diff --git a/.github/workflows/app-test-build-deploy.yaml b/.github/workflows/app-test-build-deploy.yaml index 8d0658a930e..738fa369e58 100644 --- a/.github/workflows/app-test-build-deploy.yaml +++ b/.github/workflows/app-test-build-deploy.yaml @@ -112,8 +112,6 @@ jobs: - uses: actions/setup-python@v4 with: python-version: '3.10' - - name: 'downgrade npm version' - run: npm install -g npm@6 - name: check make version run: make --version - name: 'install libudev and libsystemd' @@ -245,8 +243,6 @@ jobs: - uses: actions/setup-python@v4 with: python-version: '3.10' - - name: 'downgrade npm version' - run: npm install -g npm@6 - name: check make version run: make --version - name: 'install libudev and libsystemd' diff --git a/.github/workflows/components-test-build-deploy.yaml b/.github/workflows/components-test-build-deploy.yaml index 78e60426b3f..6b39fb3b1c8 100644 --- a/.github/workflows/components-test-build-deploy.yaml +++ b/.github/workflows/components-test-build-deploy.yaml @@ -174,10 +174,13 @@ jobs: with: node-version: '18.19.0' registry-url: 'https://registry.npmjs.org' + - name: 'install udev for usb-detection' + run: sudo apt-get update && sudo apt-get install libudev-dev - name: 'setup-js' run: | npm config set cache ./.npm-cache yarn config set cache-folder ./.yarn-cache + make setup-js - name: 'build typescript' run: make build-ts - name: 'build library' diff --git a/.github/workflows/opentrons-ai-client-test-build-deploy.yaml b/.github/workflows/opentrons-ai-client-test-build-deploy.yaml new file mode 100644 index 00000000000..072366ab0d7 --- /dev/null +++ b/.github/workflows/opentrons-ai-client-test-build-deploy.yaml @@ -0,0 +1,78 @@ +# Run tests, build the app, and deploy it cross platform + +name: 'OpentronsAI client test, build, and deploy' + +# ToDo (kk:04/16/2024) Add build and deploy task + +on: + push: + paths: + - 'Makefile' + - 'opentrons-ai-client/**/*' + - 'components/**/*' + - '*.js' + - '*.json' + - 'yarn.lock' + - '.github/workflows/app-test-build-deploy.yaml' + - '.github/workflows/utils.js' + branches: + - '**' + tags: + - 'v*' + - 'ot3@*' + pull_request: + paths: + - 'Makefile' + - 'opentrons-ai-client/**/*' + - 'components/**/*' + - '*.js' + - '*.json' + - 'yarn.lock' + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}-${{ github.ref_name != 'edge' || github.run_id}}-${{ github.ref_type != 'tag' || github.run_id }} + cancel-in-progress: true + +env: + CI: true + +jobs: + js-unit-test: + runs-on: 'ubuntu-22.04' + name: 'opentrons ai frontend unit tests' + timeout-minutes: 60 + steps: + - uses: 'actions/checkout@v3' + - uses: 'actions/setup-node@v3' + with: + node-version: '18.19.0' + - name: 'install udev' + run: sudo apt-get update && sudo apt-get install libudev-dev + - name: 'set complex environment variables' + id: 'set-vars' + uses: actions/github-script@v6 + with: + script: | + const { buildComplexEnvVars } = require(`${process.env.GITHUB_WORKSPACE}/.github/workflows/utils.js`) + buildComplexEnvVars(core, context) + - name: 'cache yarn cache' + uses: actions/cache@v3 + with: + path: | + ${{ github.workspace }}/.npm-cache/_prebuild + ${{ github.workspace }}/.yarn-cache + key: js-${{ secrets.GH_CACHE_VERSION }}-${{ runner.os }}-yarn-${{ hashFiles('yarn.lock') }} + - name: 'setup-js' + run: | + npm config set cache ${{ github.workspace }}/.npm-cache + yarn config set cache-folder ${{ github.workspace }}/.yarn-cache + make setup-js + - name: 'test frontend packages' + run: | + make -C opentrons-ai-client test-cov + - name: 'Upload coverage report' + uses: codecov/codecov-action@v3 + with: + files: ./coverage/lcov.info + flags: opentrons-ai-client diff --git a/.github/workflows/performance-metrics-test-lint.yaml b/.github/workflows/performance-metrics-test-lint.yaml new file mode 100644 index 00000000000..e57df828caf --- /dev/null +++ b/.github/workflows/performance-metrics-test-lint.yaml @@ -0,0 +1,54 @@ +# This workflow runs lint on pull requests that touch anything in the performance-metrics directory + +name: 'performance-metrics test & lint' + +on: + pull_request: + paths: + - 'performance-metrics/**' + - '.github/workflows/performance-metrics-test-lint.yaml' + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +defaults: + run: + shell: bash + +jobs: + lint: + name: 'performance-metrics test & lint' + timeout-minutes: 5 + runs-on: 'ubuntu-latest' + steps: + - name: Checkout opentrons repo + uses: 'actions/checkout@v4' + + - name: Setup Python + uses: 'actions/setup-python@v5' + with: + python-version: '3.10' + cache: 'pipenv' + cache-dependency-path: performance-metrics/Pipfile.lock + + - name: "Install Python deps" + uses: './.github/actions/python/setup' + with: + project: 'performance-metrics' + + - name: Setup + id: install + working-directory: ./performance-metrics + run: make setup + + - name: Test + if: always() && steps.install.outcome == 'success' || steps.install.outcome == 'skipped' + working-directory: ./performance-metrics + run: make test + + - name: Lint + if: always() && steps.install.outcome == 'success' || steps.install.outcome == 'skipped' + working-directory: ./performance-metrics + run: make lint diff --git a/.github/workflows/shared-data-test-lint-deploy.yaml b/.github/workflows/shared-data-test-lint-deploy.yaml index 94c56f16a56..57653337132 100644 --- a/.github/workflows/shared-data-test-lint-deploy.yaml +++ b/.github/workflows/shared-data-test-lint-deploy.yaml @@ -237,7 +237,8 @@ jobs: - name: 'js deps' run: | npm config set cache ./.npm-cache - yarn config set cache-folder ./.yarn-cache + yarn config set cache-folder ./.yarn-cache + make setup-js - name: 'build typescript' run: make build-ts - name: 'build library' diff --git a/.storybook/main.js b/.storybook/main.js index e9fc91cdf48..985486d5d4e 100644 --- a/.storybook/main.js +++ b/.storybook/main.js @@ -2,6 +2,7 @@ module.exports = { stories: [ '../components/**/*.stories.@(js|jsx|ts|tsx)', '../app/**/*.stories.@(js|jsx|ts|tsx)', + '../opentrons-ai-client/**/*.stories.@(js|jsx|ts|tsx)', ], addons: [ diff --git a/.storybook/preview.jsx b/.storybook/preview.jsx index d8537e57827..32864c9abcb 100644 --- a/.storybook/preview.jsx +++ b/.storybook/preview.jsx @@ -20,7 +20,7 @@ export const parameters = { options: { storySort: { method: 'alphabetical', - order: ['Design Tokens', 'Library', 'App', 'ODD'], + order: ['Design Tokens', 'Library', 'App', 'ODD', 'AI'], }, }, } diff --git a/abr-testing/abr_testing/google_automation/__init__.py b/abr-testing/abr_testing/automation/__init__.py similarity index 100% rename from abr-testing/abr_testing/google_automation/__init__.py rename to abr-testing/abr_testing/automation/__init__.py diff --git a/abr-testing/abr_testing/google_automation/google_drive_tool.py b/abr-testing/abr_testing/automation/google_drive_tool.py similarity index 66% rename from abr-testing/abr_testing/google_automation/google_drive_tool.py rename to abr-testing/abr_testing/automation/google_drive_tool.py index 836ba2083b0..3b65456d0ff 100644 --- a/abr-testing/abr_testing/google_automation/google_drive_tool.py +++ b/abr-testing/abr_testing/automation/google_drive_tool.py @@ -1,6 +1,8 @@ """Google Drive Tool.""" import os -from typing import Set, Any +from typing import Set, Any, Optional +import webbrowser +import mimetypes from oauth2client.service_account import ServiceAccountCredentials # type: ignore[import] from googleapiclient.discovery import build from googleapiclient.http import MediaFileUpload @@ -14,15 +16,15 @@ class google_drive: """Google Drive Tool.""" - def __init__(self, credentials: Any, folder_name: str, parent_folder: Any) -> None: + def __init__(self, credentials: Any, folder_name: str, email: str) -> None: """Connects to google drive via credentials file.""" self.scope = ["https://www.googleapis.com/auth/drive"] self.credentials = ServiceAccountCredentials.from_json_keyfile_name( credentials, self.scope ) self.drive_service = build("drive", "v3", credentials=self.credentials) - self.folder_name = folder_name - self.parent_folder = parent_folder + self.parent_folder = folder_name + self.email = email def list_folder(self, delete: Any = False) -> Set[str]: """List folders and files in Google Drive.""" @@ -72,10 +74,9 @@ def upload_file(self, file_path: str) -> str: """Upload file to Google Drive.""" file_metadata = { "name": os.path.basename(file_path), - "mimeType": "application/vnd.google-apps.folder", - "parents": [self.parent_folder] if self.parent_folder else "", + "mimeType": str(mimetypes.guess_type(file_path)[0]), + "parents": [self.parent_folder], } - media = MediaFileUpload(file_path, resumable=True) uploaded_file = ( @@ -83,15 +84,27 @@ def upload_file(self, file_path: str) -> str: .create(body=file_metadata, media_body=media, fields="id") # type: ignore .execute() ) - return uploaded_file["id"] - def upload_missing_files(self, storage_directory: str, missing_files: set) -> None: + def upload_missing_files(self, storage_directory: str) -> None: """Upload missing files to Google Drive.""" + # Read Google Drive .json files. + google_drive_files = self.list_folder() + google_drive_files_json = [ + file for file in google_drive_files if file.endswith(".json") + ] + # Read local directory. + local_files_json = set( + file for file in os.listdir(storage_directory) if file.endswith(".json") + ) + missing_files = local_files_json - set(google_drive_files_json) + print(f"Missing files: {len(missing_files)}") + # Upload missing files. uploaded_files = [] for file in missing_files: file_path = os.path.join(storage_directory, file) uploaded_file_id = google_drive.upload_file(self, file_path) + self.share_permissions(uploaded_file_id) uploaded_files.append( {"name": os.path.basename(file_path), "id": uploaded_file_id} ) @@ -108,3 +121,31 @@ def upload_missing_files(self, storage_directory: str, missing_files: set) -> No print( f"File '{this_name}' was not found in the list of files after uploading." ) + + def open_folder(self) -> Optional[str]: + """Open folder in web browser.""" + folder_metadata = ( + self.drive_service.files() + .get(fileId=self.parent_folder, fields="webViewLink") + .execute() + ) + folder_link = folder_metadata.get("webViewLink") + if folder_link: + print(f"Folder link: {folder_link}") + webbrowser.open( + folder_link + ) # Open the folder link in the default web browser + else: + print("Folder link not found.") + return folder_link + + def share_permissions(self, file_id: str) -> None: + """Share permissions with self.""" + new_permission = { + "type": "user", + "role": "writer", + "emailAddress": self.email, + } + self.drive_service.permissions().create( + fileId=file_id, body=new_permission, transferOwnership=False # type: ignore + ).execute() diff --git a/abr-testing/abr_testing/google_automation/google_sheets_tool.py b/abr-testing/abr_testing/automation/google_sheets_tool.py similarity index 95% rename from abr-testing/abr_testing/google_automation/google_sheets_tool.py rename to abr-testing/abr_testing/automation/google_sheets_tool.py index e486a28fed2..af38a39dcc0 100644 --- a/abr-testing/abr_testing/google_automation/google_sheets_tool.py +++ b/abr-testing/abr_testing/automation/google_sheets_tool.py @@ -2,6 +2,7 @@ import gspread # type: ignore[import] import socket import httplib2 +from datetime import datetime from oauth2client.service_account import ServiceAccountCredentials # type: ignore[import] from typing import Dict, List, Any, Set, Tuple @@ -57,6 +58,12 @@ def write_to_row(self, data: List) -> None: """Write data into a row in a List[] format.""" try: self.row_index += 1 + data = [ + item.strftime("%Y/%m/%d %H:%M:%S") + if isinstance(item, datetime) + else item + for item in data + ] self.worksheet.insert_row(data, index=self.row_index) except socket.gaierror: pass diff --git a/abr-testing/abr_testing/automation/jira_tool.py b/abr-testing/abr_testing/automation/jira_tool.py new file mode 100644 index 00000000000..5c0a2556dfb --- /dev/null +++ b/abr-testing/abr_testing/automation/jira_tool.py @@ -0,0 +1,165 @@ +"""JIRA Ticket Creator.""" + +import requests +from requests.auth import HTTPBasicAuth +import json +import webbrowser +import argparse +from typing import List + + +class JiraTicket: + """Connects to JIRA ticket site.""" + + def __init__(self, url: str, api_token: str, email: str) -> None: + """Connect to jira.""" + self.url = url + self.api_token = api_token + self.email = email + self.auth = HTTPBasicAuth(email, api_token) + self.headers = { + "Accept": "application/json", + "Content-Type": "application/json", + } + + def issues_on_board(self, board_id: str) -> List[str]: + """Print Issues on board.""" + response = requests.get( + f"{self.url}/rest/agile/1.0/board/{board_id}/issue", + headers=self.headers, + auth=self.auth, + ) + response.raise_for_status() + try: + board_data = response.json() + all_issues = board_data["issues"] + except json.JSONDecodeError as e: + print("Error decoding json: ", e) + issue_ids = [] + for i in all_issues: + issue_id = i.get("id") + issue_ids.append(issue_id) + return issue_ids + + def open_issue(self, issue_key: str) -> str: + """Open issue on web browser.""" + url = f"{self.url}/browse/{issue_key}" + print(f"Opening at {url}.") + webbrowser.open(url) + return url + + def create_ticket( + self, + summary: str, + description: str, + project_key: str, + reporter_id: str, + issue_type: str, + priority: str, + components: list, + affects_versions: str, + robot: str, + ) -> str: + """Create ticket.""" + data = { + "fields": { + "project": {"id": "10273", "key": project_key}, + "issuetype": {"name": issue_type}, + "summary": summary, + "reporter": {"id": reporter_id}, + "parent": {"key": robot}, + "priority": {"name": priority}, + "components": [{"name": component} for component in components], + "versions": [{"name": affects_versions}], + "description": { + "content": [ + { + "content": [{"text": description, "type": "text"}], + "type": "paragraph", + } + ], + "type": "doc", + "version": 1, + } + # Include other required fields as needed + } + } + try: + response = requests.post( + f"{self.url}/rest/api/3/issue/", + headers=self.headers, + auth=self.auth, + json=data, + ) + response.raise_for_status() + response_str = str(response.content) + issue_url = response.json().get("self") + issue_key = response.json().get("key") + print(f"issue key {issue_key}") + print(f"issue url{issue_url}") + if issue_key is None: + print("Error: Could not create issue. No key returned.") + except requests.exceptions.HTTPError: + print(f"HTTP error occurred. Response content: {response_str}") + except json.JSONDecodeError: + print(f"JSON decoding error occurred. Response content: {response_str}") + return issue_key + + def post_attachment_to_ticket(self, issue_id: str, attachment_path: str) -> None: + """Adds attachments to ticket.""" + # TODO: Ensure that file is actually uploaded. + file = {"file": open(attachment_path, "rb")} + JSON_headers = {"Accept": "application/json"} + try: + response = requests.post( + f"{self.url}/rest/api/3/issue/{issue_id}/attachments", + headers=JSON_headers, + auth=self.auth, + files=file, + ) + print(response) + except json.JSONDecodeError: + error_message = str(response.content) + print(f"JSON decoding error occurred. Response content: {error_message}.") + + +if __name__ == "__main__": + """Create ticket for specified robot.""" + parser = argparse.ArgumentParser(description="Pulls run logs from ABR robots.") + parser.add_argument( + "jira_api_token", + metavar="JIRA_API_TOKEN", + type=str, + nargs=1, + help="JIRA API Token. Get from https://id.atlassian.com/manage-profile/security.", + ) + parser.add_argument( + "email", + metavar="EMAIL", + type=str, + nargs=1, + help="Email connected to JIRA account.", + ) + # TODO: write function to get reporter_id from email. + parser.add_argument( + "reporter_id", + metavar="REPORTER_ID", + type=str, + nargs=1, + help="JIRA Reporter ID.", + ) + # TODO: improve help comment on jira board id. + parser.add_argument( + "board_id", + metavar="BOARD_ID", + type=str, + nargs=1, + help="JIRA Board ID. RABR is 217", + ) + args = parser.parse_args() + url = "https://opentrons.atlassian.net" + api_token = args.jira_api_token[0] + email = args.email[0] + board_id = args.board_id[0] + reporter_id = args.reporter_id[0] + ticket = JiraTicket(url, api_token, email) diff --git a/abr-testing/abr_testing/data_collection/abr_calibration_logs.py b/abr-testing/abr_testing/data_collection/abr_calibration_logs.py new file mode 100644 index 00000000000..4d744b5b2f5 --- /dev/null +++ b/abr-testing/abr_testing/data_collection/abr_calibration_logs.py @@ -0,0 +1,224 @@ +"""Get Calibration logs from robots.""" +from typing import Dict, Any, List, Union +import argparse +import os +import json +import gspread # type: ignore[import] +import sys +from abr_testing.data_collection import read_robot_logs +from abr_testing.automation import google_drive_tool, google_sheets_tool + + +def check_for_duplicates( + sheet_location: str, + google_sheet: Any, + col_1: int, + col_2: int, + row: List[str], + headers: List[str], +) -> Union[List[str], None]: + """Check google sheet for duplicates.""" + serials = google_sheet.get_column(col_1) + modify_dates = google_sheet.get_column(col_2) + # check for complete calibration. + if len(row[-1]) > 0: + for serial, modify_date in zip(serials, modify_dates): + if row[col_1 - 1] == serial and row[col_2 - 1] == modify_date: + print(f"Skipped row for instrument {serial}. Already on Google Sheet.") + return None + read_robot_logs.write_to_sheets(sheet_location, google_sheet, row, headers) + print(f"Writing calibration for: {serial}") + return row + + +def upload_calibration_offsets( + calibration: Dict[str, Any], storage_directory: str +) -> None: + """Upload calibration data to google_sheet.""" + # Common Headers + headers_beg = list(calibration.keys())[:4] + headers_end = list(["X", "Y", "Z", "lastModified"]) + # INSTRUMENT SHEET + instrument_headers = ( + headers_beg + list(calibration["Instruments"][0].keys())[:7] + headers_end + ) + local_instrument_file = google_sheet_name + "-Instruments" + instrument_sheet_location = read_robot_logs.create_abr_data_sheet( + storage_directory, local_instrument_file, instrument_headers + ) + # INSTRUMENTS DATA + instruments = calibration["Instruments"] + for instrument in range(len(instruments)): + one_instrument = instruments[instrument] + x = one_instrument["data"]["calibratedOffset"]["offset"].get("x", "") + y = one_instrument["data"]["calibratedOffset"]["offset"].get("y", "") + z = one_instrument["data"]["calibratedOffset"]["offset"].get("z", "") + modified = one_instrument["data"]["calibratedOffset"].get("last_modified", "") + instrument_row = ( + list(calibration.values())[:4] + + list(one_instrument.values())[:7] + + list([x, y, z, modified]) + ) + check_for_duplicates( + instrument_sheet_location, + google_sheet_instruments, + 8, + 15, + instrument_row, + instrument_headers, + ) + + # MODULE SHEET + if len(calibration.get("Modules", "")) > 0: + module_headers = ( + headers_beg + list(calibration["Modules"][0].keys())[:7] + headers_end + ) + local_modules_file = google_sheet_name + "-Modules" + modules_sheet_location = read_robot_logs.create_abr_data_sheet( + storage_directory, local_modules_file, module_headers + ) + # MODULES DATA + modules = calibration["Modules"] + for module in range(len(modules)): + one_module = modules[module] + x = one_module["moduleOffset"]["offset"].get("x", "") + y = one_module["moduleOffset"]["offset"].get("y", "") + z = one_module["moduleOffset"]["offset"].get("z", "") + modified = one_module["moduleOffset"].get("last_modified", "") + module_row = ( + list(calibration.values())[:4] + + list(one_module.values())[:7] + + list([x, y, z, modified]) + ) + check_for_duplicates( + modules_sheet_location, + google_sheet_modules, + 8, + 15, + module_row, + module_headers, + ) + # DECK SHEET + local_deck_file = google_sheet_name + "-Deck" + deck_headers = headers_beg + list(["pipetteCalibratedWith", "Slot"]) + headers_end + deck_sheet_location = read_robot_logs.create_abr_data_sheet( + storage_directory, local_deck_file, deck_headers + ) + # DECK DATA + deck = calibration["Deck"] + slots = ["D3", "D1", "A1"] + deck_modified = deck["data"].get("lastModified", "") + pipette_calibrated_with = deck["data"].get("pipetteCalibratedWith", "") + for i in range(len(deck["data"]["matrix"])): + coords = deck["data"]["matrix"][i] + x = coords[0] + y = coords[1] + z = coords[2] + deck_row = list(calibration.values())[:4] + list( + [pipette_calibrated_with, slots[i], x, y, z, deck_modified] + ) + check_for_duplicates( + deck_sheet_location, google_sheet_deck, 6, 10, deck_row, deck_headers + ) + + +if __name__ == "__main__": + """Get calibration logs.""" + parser = argparse.ArgumentParser( + description="Pulls calibration logs from ABR robots." + ) + parser.add_argument( + "storage_directory", + metavar="STORAGE_DIRECTORY", + type=str, + nargs=1, + help="Path to long term storage directory for run logs.", + ) + parser.add_argument( + "folder_name", + metavar="FOLDER_NAME", + type=str, + nargs=1, + help="Google Drive folder name. Open desired folder and copy string after drive/folders/.", + ) + parser.add_argument( + "google_sheet_name", + metavar="GOOGLE_SHEET_NAME", + type=str, + nargs=1, + help="Google sheet name.", + ) + parser.add_argument( + "email", metavar="EMAIL", type=str, nargs=1, help="opentrons gmail." + ) + parser.add_argument( + "ip_or_all", + metavar="IP_OR_ALL", + type=str, + nargs=1, + help="Enter 'ALL' to read IPs.json or type full IP address of 1 robot.", + ) + args = parser.parse_args() + storage_directory = args.storage_directory[0] + folder_name = args.folder_name[0] + google_sheet_name = args.google_sheet_name[0] + ip_or_all = args.ip_or_all[0] + email = args.email[0] + # Connect to google drive. + try: + credentials_path = os.path.join(storage_directory, "credentials.json") + except FileNotFoundError: + print(f"Add credentials.json file to: {storage_directory}.") + sys.exit() + try: + google_drive = google_drive_tool.google_drive( + credentials_path, folder_name, email + ) + # Upload calibration logs to google drive. + print("Connected to google drive.") + except json.decoder.JSONDecodeError: + print( + "Credential file is damaged. Get from https://console.cloud.google.com/apis/credentials" + ) + sys.exit() + # Connect to google sheet + try: + google_sheet_instruments = google_sheets_tool.google_sheet( + credentials_path, google_sheet_name, 0 + ) + google_sheet_modules = google_sheets_tool.google_sheet( + credentials_path, google_sheet_name, 1 + ) + google_sheet_deck = google_sheets_tool.google_sheet( + credentials_path, google_sheet_name, 2 + ) + print(f"Connected to google sheet: {google_sheet_name}") + except gspread.exceptions.APIError: + print("ERROR: Check google sheet name. Check credentials file.") + sys.exit() + ip_json_file = os.path.join(storage_directory, "IPs.json") + try: + ip_file = json.load(open(ip_json_file)) + except FileNotFoundError: + print(f"Add .json file with robot IPs to: {storage_directory}.") + sys.exit() + + if ip_or_all == "ALL": + ip_address_list = ip_file["ip_address_list"] + for ip in ip_address_list: + print(ip) + try: + saved_file_path, calibration = read_robot_logs.get_calibration_offsets( + ip, storage_directory + ) + upload_calibration_offsets(calibration, storage_directory) + except Exception: + print(f"ERROR: Failed to read IP address: {ip}") + continue + else: + saved_file_path, calibration = read_robot_logs.get_calibration_offsets( + ip_or_all, storage_directory + ) + upload_calibration_offsets(calibration, storage_directory) + + google_drive.upload_missing_files(storage_directory) diff --git a/abr-testing/abr_testing/data_collection/abr_google_drive.py b/abr-testing/abr_testing/data_collection/abr_google_drive.py index be3fe162867..a186019b35b 100644 --- a/abr-testing/abr_testing/data_collection/abr_google_drive.py +++ b/abr-testing/abr_testing/data_collection/abr_google_drive.py @@ -6,8 +6,8 @@ import gspread # type: ignore[import] from datetime import datetime, timedelta from abr_testing.data_collection import read_robot_logs -from typing import Set, Dict, Any -from abr_testing.google_automation import google_drive_tool, google_sheets_tool +from typing import Set, Dict, Any, Tuple, List, Union +from abr_testing.automation import google_drive_tool, google_sheets_tool def get_modules(file_results: Dict[str, str]) -> Dict[str, Any]: @@ -30,8 +30,10 @@ def get_modules(file_results: Dict[str, str]) -> Dict[str, Any]: def create_data_dictionary( - runs_to_save: Set[str], storage_directory: str -) -> Dict[Any, Dict[str, Any]]: + runs_to_save: Union[Set[str], str], + storage_directory: str, + issue_url: str, +) -> Tuple[Dict[Any, Dict[str, Any]], List]: """Pull data from run files and format into a dictionary.""" runs_and_robots = {} for filename in os.listdir(storage_directory): @@ -41,7 +43,9 @@ def create_data_dictionary( file_results = json.load(file) else: continue - run_id = file_results.get("run_id") + if not isinstance(file_results, dict): + continue + run_id = file_results.get("run_id", "NaN") if run_id in runs_to_save: robot = file_results.get("robot_name") protocol_name = file_results["protocol"]["metadata"].get("protocolName", "") @@ -56,6 +60,7 @@ def create_data_dictionary( error_instrument, error_level, ) = read_robot_logs.get_error_info(file_results) + all_modules = get_modules(file_results) start_time_str, complete_time_str, start_date, run_time_min = ( @@ -100,12 +105,25 @@ def create_data_dictionary( "Right Mount": right_pipette, "Extension": extension, } - row_2 = {**row, **all_modules} + tc_dict = read_robot_logs.thermocycler_commands(file_results) + hs_dict = read_robot_logs.hs_commands(file_results) + tm_dict = read_robot_logs.temperature_module_commands(file_results) + notes = {"Note1": "", "Jira Link": issue_url} + row_2 = { + **row, + **all_modules, + **notes, + **hs_dict, + **tm_dict, + **tc_dict, + } + headers = list(row_2.keys()) runs_and_robots[run_id] = row_2 else: - os.remove(file_path) - print(f"Run ID: {run_id} has a run time of 0 minutes. Run removed.") - return runs_and_robots + continue + # os.remove(file_path) + # print(f"Run ID: {run_id} has a run time of 0 minutes. Run removed.") + return runs_and_robots, headers if __name__ == "__main__": @@ -122,7 +140,7 @@ def create_data_dictionary( metavar="FOLDER_NAME", type=str, nargs=1, - help="Google Drive folder name.", + help="Google Drive folder name. Open desired folder and copy string after drive/folders/.", ) parser.add_argument( "google_sheet_name", @@ -131,11 +149,14 @@ def create_data_dictionary( nargs=1, help="Google sheet name.", ) + parser.add_argument( + "email", metavar="EMAIL", type=str, nargs=1, help="opentrons gmail." + ) args = parser.parse_args() folder_name = args.folder_name[0] storage_directory = args.storage_directory[0] google_sheet_name = args.google_sheet_name[0] - parent_folder = False + email = args.email[0] try: credentials_path = os.path.join(storage_directory, "credentials.json") except FileNotFoundError: @@ -143,7 +164,7 @@ def create_data_dictionary( sys.exit() try: google_drive = google_drive_tool.google_drive( - credentials_path, folder_name, parent_folder + credentials_path, folder_name, email ) print("Connected to google drive.") except json.decoder.JSONDecodeError: @@ -160,23 +181,19 @@ def create_data_dictionary( except gspread.exceptions.APIError: print("ERROR: Check google sheet name. Check credentials file.") sys.exit() + try: + google_sheet_lpc = google_sheets_tool.google_sheet( + credentials_path, "ABR-LPC", 0 + ) + print("Connected to google sheet ABR-LPC") + except gspread.exceptions.APIError: + print("ERROR: Check google sheet name. Check credentials file.") + sys.exit() run_ids_on_gs = google_sheet.get_column(2) run_ids_on_gs = set(run_ids_on_gs) - # Read Google Drive .json files - google_drive_files = google_drive.list_folder() - google_drive_files_json = [ - file for file in google_drive_files if file.endswith(".json") - ] - # read local directory - list_of_files = os.listdir(storage_directory) - local_files_json = set( - file for file in os.listdir(storage_directory) if file.endswith(".json") - ) - missing_files = local_files_json - set(google_drive_files_json) - print(f"Missing files: {len(missing_files)}") # Uploads files that are not in google drive directory - google_drive.upload_missing_files(storage_directory, missing_files) + google_drive.upload_missing_files(storage_directory) # Run ids in google_drive_folder run_ids_on_gd = read_robot_logs.get_run_ids_from_google_drive(google_drive) @@ -184,29 +201,9 @@ def create_data_dictionary( run_ids_on_gd, run_ids_on_gs ) # Add missing runs to google sheet - runs_and_robots = create_data_dictionary(missing_runs_from_gs, storage_directory) - headers = [ - "Robot", - "Run_ID", - "Protocol_Name", - "Software Version", - "Date", - "Start_Time", - "End_Time", - "Run_Time (min)", - "Errors", - "Error_Code", - "Error_Type", - "Error_Instrument", - "Error_Level", - "Left Mount", - "Right Mount", - "Extension", - "heaterShakerModuleV1", - "temperatureModuleV2", - "magneticBlockV1", - "thermocyclerModuleV2", - ] + runs_and_robots, headers = create_data_dictionary( + missing_runs_from_gs, storage_directory, "" + ) read_robot_logs.write_to_local_and_google_sheet( runs_and_robots, storage_directory, google_sheet_name, google_sheet, headers ) diff --git a/abr-testing/abr_testing/data_collection/abr_lpc.py b/abr-testing/abr_testing/data_collection/abr_lpc.py new file mode 100644 index 00000000000..dd880d09c37 --- /dev/null +++ b/abr-testing/abr_testing/data_collection/abr_lpc.py @@ -0,0 +1 @@ +"""Get Unique LPC Values from Run logs.""" diff --git a/abr-testing/abr_testing/data_collection/abr_robot_error.py b/abr-testing/abr_testing/data_collection/abr_robot_error.py new file mode 100644 index 00000000000..231b8077eed --- /dev/null +++ b/abr-testing/abr_testing/data_collection/abr_robot_error.py @@ -0,0 +1,216 @@ +"""Create ticket for robot with error.""" +from typing import List, Tuple +from abr_testing.data_collection import read_robot_logs, abr_google_drive, get_run_logs +import requests +import argparse +from abr_testing.automation import jira_tool, google_sheets_tool, google_drive_tool +import shutil +import os +import subprocess +import json +import sys +import gspread # type: ignore[import] + + +def get_error_runs_from_robot(ip: str) -> List[str]: + """Get runs that have errors from robot.""" + error_run_ids = [] + response = requests.get( + f"http://{ip}:31950/runs", headers={"opentrons-version": "3"} + ) + run_data = response.json() + run_list = run_data["data"] + for run in run_list: + run_id = run["id"] + num_of_errors = len(run["errors"]) + if not run["current"] and num_of_errors > 0: + error_run_ids.append(run_id) + return error_run_ids + + +def get_error_info_from_robot( + ip: str, one_run: str, storage_directory: str +) -> Tuple[str, str, str, List[str], str, str]: + """Get error information from robot to fill out ticket.""" + description = dict() + # get run information + results = get_run_logs.get_run_data(one_run, ip) + # save run information to local directory as .json file + saved_file_path = read_robot_logs.save_run_log_to_json( + ip, results, storage_directory + ) + # Error Printout + ( + num_of_errors, + error_type, + error_code, + error_instrument, + error_level, + ) = read_robot_logs.get_error_info(results) + # JIRA Ticket Fields + failure_level = "Level " + str(error_level) + " Failure" + components = [failure_level, "Flex-RABR"] + affects_version = results["API_Version"] + parent = results.get("robot_name", "") + print(parent) + summary = parent + "_" + str(one_run) + "_" + str(error_code) + "_" + error_type + # Description of error + description["protocol_name"] = results["protocol"]["metadata"].get( + "protocolName", "" + ) + description["error"] = " ".join([error_code, error_type, error_instrument]) + description["protocol_step"] = list(results["commands"])[-1] + description["right_mount"] = results.get("right", "No attachment") + description["left_mount"] = results.get("left", "No attachment") + description["gripper"] = results.get("extension", "No attachment") + all_modules = abr_google_drive.get_modules(results) + whole_description = {**description, **all_modules} + whole_description_str = ( + "{" + + "\n".join("{!r}: {!r},".format(k, v) for k, v in whole_description.items()) + + "}" + ) + + return ( + summary, + parent, + affects_version, + components, + whole_description_str, + saved_file_path, + ) + + +if __name__ == "__main__": + """Create ticket for specified robot.""" + parser = argparse.ArgumentParser(description="Pulls run logs from ABR robots.") + parser.add_argument( + "storage_directory", + metavar="STORAGE_DIRECTORY", + type=str, + nargs=1, + help="Path to long term storage directory for run logs.", + ) + parser.add_argument( + "jira_api_token", + metavar="JIRA_API_TOKEN", + type=str, + nargs=1, + help="JIRA API Token. Get from https://id.atlassian.com/manage-profile/security.", + ) + parser.add_argument( + "email", + metavar="EMAIL", + type=str, + nargs=1, + help="Email connected to JIRA account.", + ) + # TODO: write function to get reporter_id from email. + parser.add_argument( + "reporter_id", + metavar="REPORTER_ID", + type=str, + nargs=1, + help="JIRA Reporter ID.", + ) + # TODO: improve help comment on jira board id. + parser.add_argument( + "board_id", + metavar="BOARD_ID", + type=str, + nargs=1, + help="JIRA Board ID. RABR is 217", + ) + args = parser.parse_args() + storage_directory = args.storage_directory[0] + ip = str(input("Enter Robot IP: ")) + url = "https://opentrons.atlassian.net" + api_token = args.jira_api_token[0] + email = args.email[0] + board_id = args.board_id[0] + reporter_id = args.reporter_id[0] + ticket = jira_tool.JiraTicket(url, api_token, email) + try: + error_runs = get_error_runs_from_robot(ip) + except requests.exceptions.InvalidURL: + print("Invalid IP address.") + sys.exit() + one_run = error_runs[-1] # Most recent run with error. + ( + summary, + robot, + affects_version, + components, + whole_description_str, + run_log_file_path, + ) = get_error_info_from_robot(ip, one_run, storage_directory) + # Get Calibration Data + saved_file_path_calibration, calibration = read_robot_logs.get_calibration_offsets( + ip, storage_directory + ) + file_paths = read_robot_logs.get_logs(storage_directory, ip) + print(f"Making ticket for run: {one_run} on robot {robot}.") + # TODO: make argument or see if I can get rid of with using board_id. + project_key = "RABR" + parent_key = project_key + "-" + robot[-1] + # TODO: read board to see if ticket for run id already exists. + # CREATE TICKET + issue_key = ticket.create_ticket( + summary, + whole_description_str, + project_key, + reporter_id, + "Bug", + "Medium", + components, + affects_version, + parent_key, + ) + # OPEN TICKET + issue_url = ticket.open_issue(issue_key) + # MOVE FILES TO ERROR FOLDER. + error_files = [saved_file_path_calibration, run_log_file_path] + file_paths + error_folder_path = os.path.join(storage_directory, issue_key) + os.makedirs(error_folder_path, exist_ok=True) + for source_file in error_files: + destination_file = os.path.join( + error_folder_path, os.path.basename(source_file) + ) + shutil.move(source_file, destination_file) + # OPEN FOLDER DIRECTORY + subprocess.Popen(["explorer", error_folder_path]) + # CONNECT TO GOOGLE DRIVE + credentials_path = os.path.join(storage_directory, "credentials.json") + google_sheet_name = "ABR-run-data" + try: + google_drive = google_drive_tool.google_drive( + credentials_path, + "1Cvej0eadFOTZr9ILRXJ0Wg65ymOtxL4m", + "rhyann.clarke@opentrons.ocm", + ) + print("Connected to google drive.") + except json.decoder.JSONDecodeError: + print( + "Credential file is damaged. Get from https://console.cloud.google.com/apis/credentials" + ) + sys.exit() + # CONNECT TO GOOGLE SHEET + try: + google_sheet = google_sheets_tool.google_sheet( + credentials_path, google_sheet_name, 0 + ) + print(f"Connected to google sheet: {google_sheet_name}") + except gspread.exceptions.APIError: + print("ERROR: Check google sheet name. Check credentials file.") + sys.exit() + # WRITE ERRORED RUN TO GOOGLE SHEET + error_run_log = os.path.join(error_folder_path, os.path.basename(run_log_file_path)) + google_drive.upload_file(error_run_log) + run_id = os.path.basename(error_run_log).split("_")[1].split(".")[0] + runs_and_robots, headers = abr_google_drive.create_data_dictionary( + run_id, error_folder_path, issue_url + ) + read_robot_logs.write_to_local_and_google_sheet( + runs_and_robots, storage_directory, google_sheet_name, google_sheet, headers + ) + print("Wrote run to ABR-run-data") diff --git a/abr-testing/abr_testing/data_collection/error_levels.csv b/abr-testing/abr_testing/data_collection/error_levels.csv index c03cab56367..c2f54c9f09e 100644 --- a/abr-testing/abr_testing/data_collection/error_levels.csv +++ b/abr-testing/abr_testing/data_collection/error_levels.csv @@ -11,7 +11,7 @@ Prefix,Error Code,Description,Categories,Level of Failure, 2,2000,Robotics Control Error,A Robot Action Failed,3, 2,2001,Motion Failed,A Robot Action Failed,4, 2,2002,Homing Failed,A Robot Action Failed,4, -2,2003,Stall or Collision Detected,A Robot Action Failed,3-4, +2,2003,Stall or Collision Detected,A Robot Action Failed,3, 2,2004,Motion Planning Failed,A Robot Action Failed,3, 2,2005,Position Estimation Invalid,A Robot Action Failed,3, 2,2006,Move Condition Not Met,A Robot Action Failed,3, @@ -20,29 +20,29 @@ Prefix,Error Code,Description,Categories,Level of Failure, 2,2009,Early Capactivive Sense Trigger,A Robot Action Failed,4, 2,2010,Innacrruate Non Contact Sweep,A Robot Action Failed,3, 2,2011,Misaligned Gantry,A Robot Action Failed,3, -2,2012,Unmatched Tip Presence States,A Robot Action Failed,3-4, +2,2012,Unmatched Tip Presence States,A Robot Action Failed, 4, 2,2013,Position Unknown,A Robot Action Failed,4, -2,2014,Execution Cancelled,A Robot Action Failed,3-4, -2,2015,Failed Gripper Pickup Error,A Robot Action Failed,3-4, +2,2014,Execution Cancelled,A Robot Action Failed, 4, +2,2015,Failed Gripper Pickup Error,A Robot Action Failed,3, 3,3000,Robotics Interaction Error,A Robot Interaction Failed,3, -3,3001,Labware Dropped,A Robot Interaction Failed,3-4, -3,3002,Labware Not Picked Up,A Robot Interaction Failed,3-4, +3,3001,Labware Dropped,A Robot Interaction Failed, 4, +3,3002,Labware Not Picked Up,A Robot Interaction Failed,4, 3,3003,Tip Pickup Failed,A Robot Interaction Failed,4, 3,3004,Tip Drop Failed,A Robot Interaction Failed,4, 3,3005,Unexpeted Tip Removal,A Robot Interaction Failed,4, -3,3006,Pipette Overpressure,A Robot Interaction Failed,3-4, -3,3008,E-Stop Activated,A Robot Interaction Failed,Not an error, +3,3006,Pipette Overpressure,A Robot Interaction Failed,3, +3,3008,E-Stop Activated,A Robot Interaction Failed,5, Not an error, 3,3009,E-Stop Not Present,A Robot Interaction Failed,5, 3,3010,Pipette Not Present,A Robot Interaction Failed,5, 3,3011,Gripper Not Present,A Robot Interaction Failed,5, 3,3012,Unexpected Tip Attach,A Robot Interaction Failed,4, -3,3013,Firmware Update Required,A Robot Interaction Failed,Not an error, +3,3013,Firmware Update Required,A Robot Interaction Failed,5, Not an error, 3,3014,Invalid ID Actuator,A Robot Interaction Failed,3, 3,3015,Module Not Pesent,A Robot Interaction Failed,5,Not an error 3,3016,Invalid Instrument Data,A Robot Interaction Failed,3, 3,3017,Invalid Liquid Class Name,A Robot Interaction Failed,5,Not an error 3,3018,Tip Detector Not Found,A Robot Interaction Failed,3, -4,4000,General Error,A Software Error Occured,2-4,How severe does a general error get +4,4000,General Error,A Software Error Occured,4,How severe does a general error get 4,4001,Robot In Use,A Software Error Occured,5,Not an error 4,4002,API Removed,A Software Error Occured,5,used an old app on a new robot 4,4003,Not Supported On Robot Type,A Software Error Occured,5,Not an error diff --git a/abr-testing/abr_testing/data_collection/get_run_logs.py b/abr-testing/abr_testing/data_collection/get_run_logs.py index f80a4fb9f77..4034f076dc9 100644 --- a/abr-testing/abr_testing/data_collection/get_run_logs.py +++ b/abr-testing/abr_testing/data_collection/get_run_logs.py @@ -6,7 +6,7 @@ import requests import sys from abr_testing.data_collection import read_robot_logs -from abr_testing.google_automation import google_drive_tool +from abr_testing.automation import google_drive_tool def get_run_ids_from_robot(ip: str) -> Set[str]: @@ -80,9 +80,9 @@ def save_runs(runs_to_save: Set[str], ip: str, storage_directory: str) -> Set[st saved_file_paths = set() for a_run in runs_to_save: data = get_run_data(a_run, ip) - data_file_name = ip + "_" + data["run_id"] + ".json" - saved_file_path = os.path.join(storage_directory, data_file_name) - json.dump(data, open(saved_file_path, mode="w")) + saved_file_path = read_robot_logs.save_run_log_to_json( + ip, data, storage_directory + ) saved_file_paths.add(saved_file_path) print(f"Saved {len(runs_to_save)} run(s) from robot with IP address {ip}.") return saved_file_paths @@ -107,8 +107,8 @@ def get_all_run_logs(storage_directory: str) -> None: try: runs = get_run_ids_from_robot(ip) runs_to_save = read_robot_logs.get_unseen_run_ids(runs, runs_from_storage) - saved_file_paths = save_runs(runs_to_save, ip, storage_directory) - google_drive.upload_missing_files(storage_directory, saved_file_paths) + save_runs(runs_to_save, ip, storage_directory) + google_drive.upload_missing_files(storage_directory) except Exception: print(f"ERROR: Failed to read IP address: {ip}.") @@ -128,12 +128,15 @@ def get_all_run_logs(storage_directory: str) -> None: metavar="FOLDER_NAME", type=str, nargs=1, - help="Google Drive folder name.", + help="Google Drive folder name. Open desired folder and copy string after drive/folders/.", + ) + parser.add_argument( + "email", metavar="EMAIL", type=str, nargs=1, help="opentrons gmail." ) args = parser.parse_args() storage_directory = args.storage_directory[0] folder_name = args.folder_name[0] - parent_folder = False + email = args.email[0] try: credentials_path = os.path.join(storage_directory, "credentials.json") except FileNotFoundError: @@ -141,7 +144,7 @@ def get_all_run_logs(storage_directory: str) -> None: sys.exit() try: google_drive = google_drive_tool.google_drive( - credentials_path, folder_name, parent_folder + credentials_path, folder_name, email ) print("Connected to google drive.") except json.decoder.JSONDecodeError: diff --git a/abr-testing/abr_testing/data_collection/module_ramp_rates.py b/abr-testing/abr_testing/data_collection/module_ramp_rates.py new file mode 100644 index 00000000000..dc402071bb7 --- /dev/null +++ b/abr-testing/abr_testing/data_collection/module_ramp_rates.py @@ -0,0 +1,154 @@ +"""Get ramp rates of modules.""" +from abr_testing.automation import google_sheets_tool +from abr_testing.data_collection import read_robot_logs +import gspread # type: ignore[import] +import argparse +import os +import sys +import json +from datetime import datetime +from typing import Dict, Any +import requests + + +def ramp_rate(file_results: Dict[str, Any]) -> Dict[int, float]: + """Get ramp rates.""" + i = 0 + commands = file_results["commands"] + for command in commands: + commandType = command["commandType"] + if ( + commandType == "thermocycler/setTargetBlockTemperature" + or commandType == "temperatureModule/setTargetTemperature" + or commandType == "heaterShaker/setTargetTemperature" + ): + temp = command["params"].get("celsius", 0.0) + if ( + commandType == "thermocycler/waitForBlockTemperature" + or commandType == "temperatureModule/waitForTemperature" + or commandType == "heaterShaker/waitForTemperature" + ): + 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() + i += 1 + temps_and_durations[duration] = temp + ramp_rates = {} + times = list(temps_and_durations.keys()) + for i in range(len(times) - 1): + time1 = times[i] + time2 = times[i + 1] + temp1 = temps_and_durations[time1] + temp2 = temps_and_durations[time2] + ramp_rate = (temp2 - temp1) / (time2) + ramp_rates[i] = ramp_rate + return ramp_rates + + +if __name__ == "__main__": + # SCRIPT ARGUMENTS + parser = argparse.ArgumentParser(description="Read run logs on google drive.") + parser.add_argument( + "storage_directory", + metavar="STORAGE_DIRECTORY", + type=str, + nargs=1, + help="Path to long term storage directory for run logs.", + ) + parser.add_argument( + "google_sheet_name", + metavar="GOOGLE_SHEET_NAME", + type=str, + nargs=1, + help="Google sheet name.", + ) + parser.add_argument( + "email", metavar="EMAIL", type=str, nargs=1, help="opentrons gmail." + ) + args = parser.parse_args() + storage_directory = args.storage_directory[0] + google_sheet_name = args.google_sheet_name[0] + # FIND CREDENTIALS FILE + try: + credentials_path = os.path.join(storage_directory, "credentials.json") + except FileNotFoundError: + print(f"Add credentials.json file to: {storage_directory}.") + sys.exit() + # CONNECT TO GOOGLE SHEET + try: + google_sheet = google_sheets_tool.google_sheet( + credentials_path, google_sheet_name, 1 + ) + print(f"Connected to google sheet: {google_sheet_name}") + except gspread.exceptions.APIError: + print("ERROR: Check google sheet name. Check credentials file.") + sys.exit() + run_ids_on_sheet = google_sheet.get_column(2) + runs_and_robots = {} + for filename in os.listdir(storage_directory): + file_path = os.path.join(storage_directory, filename) + if file_path.endswith(".json"): + with open(file_path) as file: + file_results = json.load(file) + else: + continue + # CHECK if file is ramp rate run + run_id = file_results.get("run_id", None) + temps_and_durations: Dict[float, float] = dict() + if run_id is not None and run_id not in run_ids_on_sheet: + + ramp_rates = ramp_rate(file_results) + protocol_name = file_results["protocol"]["metadata"].get("protocolName", "") + if "Ramp Rate" in protocol_name: + ip = filename.split("_")[0] + if len(ramp_rates) > 1: + cooling_ramp_rate = abs(min(ramp_rates.values())) + heating_ramp_rate = abs(max(ramp_rates.values())) + start_time = datetime.strptime( + file_results.get("startedAt", ""), "%Y-%m-%dT%H:%M:%S.%f%z" + ) + start_date = str(start_time.date()) + module_serial_number = file_results["modules"][0].get( + "serialNumber", "NaN" + ) + try: + response = requests.get( + f"http://{ip}:31950/modules", + headers={"opentrons-version": "3"}, + ) + modules = response.json() + for module in modules["data"]: + if module["serialNumber"] == module_serial_number: + firmwareVersion = module["firmwareVersion"] + else: + firmwareVersion = "NaN" + except requests.exceptions.ConnectionError: + firmwareVersion = "NaN" + row = { + "Robot": file_results.get("robot_name", ""), + "Run_ID": run_id, + "Protocol_Name": file_results["protocol"]["metadata"].get( + "protocolName", "" + ), + "Software Version": file_results.get("API_Version", ""), + "Firmware Version": firmwareVersion, + "Date": start_date, + "Serial Number": module_serial_number, + "Approx. Average Heating Ramp Rate (C/s)": heating_ramp_rate, + "Approx. Average Cooling Ramp Rate (C/s)": cooling_ramp_rate, + } + headers = list(row.keys()) + runs_and_robots[run_id] = row + read_robot_logs.write_to_local_and_google_sheet( + runs_and_robots, + storage_directory, + google_sheet_name, + google_sheet, + headers, + ) + else: + continue diff --git a/abr-testing/abr_testing/data_collection/read_robot_logs.py b/abr-testing/abr_testing/data_collection/read_robot_logs.py index d30842b33fd..48ef1d20163 100644 --- a/abr-testing/abr_testing/data_collection/read_robot_logs.py +++ b/abr-testing/abr_testing/data_collection/read_robot_logs.py @@ -1,15 +1,250 @@ """ABR Read Robot Logs. -This library is downloading logs from robots, extracting wanted information, +This library has functions to download logs from robots, extracting wanted information, and uploading to a google sheet using credentials and google_sheets_tools module saved in a local directory. """ import csv +from datetime import datetime import os from abr_testing.data_collection.error_levels import ERROR_LEVELS_PATH from typing import List, Dict, Any, Tuple, Set import time as t import json +import requests + + +def lpc_data(file_results: Dict[str, Any], protocol_info: Dict) -> List[Dict[str, Any]]: + """Get labware offsets from one run log.""" + offsets = file_results.get("labwareOffsets", "") + all_offsets: List[Dict[str, Any]] = [] + if len(offsets) > 0: + for offset in offsets: + labware_type = offset.get("definitionUri", "") + slot = offset["location"].get("slotName", "") + module_location = offset["location"].get("moduleModel", "") + adapter = offset["location"].get("definitionUri", "") + x_offset = offset["vector"].get("x", 0.0) + y_offset = offset["vector"].get("y", 0.0) + z_offset = offset["vector"].get("z", 0.0) + created_at = offset.get("createdAt", "") + row = { + "createdAt": created_at, + "Labware Type": labware_type, + "Slot": slot, + "Module": module_location, + "Adapter": adapter, + "X": x_offset, + "Y": y_offset, + "Z": z_offset, + } + row2 = {**protocol_info, **row} + all_offsets.append(row2) + return all_offsets + + +def command_time(command: Dict[str, str]) -> Tuple[float, float]: + """Calculate total create and complete time per command.""" + try: + create_time = datetime.strptime( + command.get("createdAt", ""), "%Y-%m-%dT%H:%M:%S.%f%z" + ) + start_time = datetime.strptime( + command.get("startedAt", ""), "%Y-%m-%dT%H:%M:%S.%f%z" + ) + complete_time = datetime.strptime( + command.get("completedAt", ""), "%Y-%m-%dT%H:%M:%S.%f%z" + ) + create_to_start = (start_time - create_time).total_seconds() + start_to_complete = (complete_time - start_time).total_seconds() + except ValueError: + create_to_start = 0 + start_to_complete = 0 + return create_to_start, start_to_complete + + +def hs_commands(file_results: Dict[str, Any]) -> Dict[str, float]: + """Gets total latch engagements, homes, rotations and total on time (sec) for heater shaker.""" + # TODO: modify for cases that have more than 1 heater shaker. + commandData = file_results.get("commands", "") + hs_latch_count: float = 0.0 + hs_temp: float = 0.0 + hs_home_count: float = 0.0 + hs_speed: float = 0.0 + hs_rotations: Dict[str, float] = dict() + hs_temps: Dict[str, float] = dict() + temp_time = None + shake_time = None + for command in commandData: + commandType = command["commandType"] + # Heatershaker + # Latch count + if ( + commandType == "heaterShaker/closeLabwareLatch" + or commandType == "heaterShaker/openLabwareLatch" + ): + hs_latch_count += 1 + # Home count + elif commandType == "heaterShaker/deactivateShaker": + hs_home_count += 1 + deactivate_time = datetime.strptime( + command.get("startedAt", ""), "%Y-%m-%dT%H:%M:%S.%f%z" + ) + if temp_time is not None and deactivate_time > temp_time: + temp_duration = (deactivate_time - temp_time).total_seconds() + hs_temps[hs_temp] = hs_temps.get(hs_temp, 0.0) + temp_duration + if shake_time is not None and deactivate_time > shake_time: + shake_duration = (deactivate_time - shake_time).total_seconds() + hs_rotations[hs_speed] = hs_rotations.get(hs_speed, 0.0) + ( + (hs_speed * shake_duration) / 60 + ) + # of Rotations + elif commandType == "heaterShaker/setAndWaitForShakeSpeed": + hs_speed = command["params"]["rpm"] + shake_time = datetime.strptime( + command.get("completedAt", ""), "%Y-%m-%dT%H:%M:%S.%f%z" + ) + # On Time + elif commandType == "heaterShaker/setTargetTemperature": + # if heater shaker temp is not deactivated. + hs_temp = command["params"]["celsius"] + temp_time = datetime.strptime( + command.get("completedAt", ""), "%Y-%m-%dT%H:%M:%S.%f%z" + ) + hs_latch_sets = hs_latch_count / 2 # one set of open/close + hs_total_rotations = sum(hs_rotations.values()) + hs_total_temp_time = sum(hs_temps.values()) + hs_dict = { + "Heatershaker # of Latch Open/Close": hs_latch_sets, + "Heatershaker # of Homes": hs_home_count, + "Heatershaker # of Rotations": hs_total_rotations, + "Heatershaker Temp On Time (sec)": hs_total_temp_time, + } + return hs_dict + + +def temperature_module_commands(file_results: Dict[str, Any]) -> Dict[str, float]: + """Get # of temp changes and total temp on time for temperature module from run log.""" + # TODO: modify for cases that have more than 1 temperature module. + tm_temp_change = 0 + tm_temps: Dict[str, float] = dict() + temp_time = None + deactivate_time = None + commandData = file_results.get("commands", "") + for command in commandData: + commandType = command["commandType"] + if commandType == "temperatureModule/setTargetTemperature": + tm_temp = command["params"]["celsius"] + tm_temp_change += 1 + if commandType == "temperatureModule/waitForTemperature": + temp_time = datetime.strptime( + command.get("completedAt", ""), "%Y-%m-%dT%H:%M:%S.%f%z" + ) + if commandType == "temperatureModule/deactivate": + deactivate_time = datetime.strptime( + command.get("completedAt", ""), "%Y-%m-%dT%H:%M:%S.%f%z" + ) + if temp_time is not None and deactivate_time > temp_time: + temp_duration = (deactivate_time - temp_time).total_seconds() + tm_temps[tm_temp] = tm_temps.get(tm_temp, 0.0) + temp_duration + if temp_time is not None and deactivate_time is None: + # If temperature module is not deactivated, protocol completedAt time stamp used. + protocol_end = datetime.strptime( + file_results.get("completedAt", ""), "%Y-%m-%dT%H:%M:%S.%f%z" + ) + temp_duration = (protocol_end - temp_time).total_seconds() + tm_temps[tm_temp] = tm_temps.get(tm_temp, 0.0) + temp_duration + tm_total_temp_time = sum(tm_temps.values()) + tm_dict = { + "Temp Module # of Temp Changes": tm_temp_change, + "Temp Module Temp On Time (sec)": tm_total_temp_time, + } + return tm_dict + + +def thermocycler_commands(file_results: Dict[str, Any]) -> Dict[str, float]: + """Counts # of lid engagements, temp changes, and temp sustaining mins.""" + # TODO: modify for cases that have more than 1 thermocycler. + commandData = file_results.get("commands", "") + lid_engagements: float = 0.0 + block_temp_changes: float = 0.0 + lid_temp_changes: float = 0.0 + lid_temps: Dict[str, float] = dict() + block_temps: Dict[str, float] = dict() + lid_on_time = None + lid_off_time = None + block_on_time = None + block_off_time = None + for command in commandData: + commandType = command["commandType"] + if ( + commandType == "thermocycler/openLid" + or commandType == "thermocycler/closeLid" + ): + lid_engagements += 1 + if commandType == "thermocycler/setTargetBlockTemperature": + block_temp = command["params"]["celsius"] + block_temp_changes += 1 + block_on_time = datetime.strptime( + command.get("completedAt", ""), "%Y-%m-%dT%H:%M:%S.%f%z" + ) + if commandType == "thermocycler/setTargetLidTemperature": + lid_temp_changes += 1 + lid_temp = command["params"]["celsius"] + lid_on_time = datetime.strptime( + command.get("completedAt", ""), "%Y-%m-%dT%H:%M:%S.%f%z" + ) + if commandType == "thermocycler/deactivateLid": + lid_off_time = datetime.strptime( + command.get("completedAt", ""), "%Y-%m-%dT%H:%M:%S.%f%z" + ) + if lid_on_time is not None and lid_off_time > lid_on_time: + lid_duration = (lid_off_time - lid_on_time).total_seconds() + lid_temps[lid_temp] = lid_temps.get(lid_temp, 0.0) + lid_duration + if commandType == "thermocycler/deactivateBlock": + block_off_time = datetime.strptime( + command.get("completedAt", ""), "%Y-%m-%dT%H:%M:%S.%f%z" + ) + if block_on_time is not None and block_off_time > block_on_time: + block_duration = (block_off_time - block_on_time).total_seconds() + block_temps[block_temp] = ( + block_temps.get(block_temp, 0.0) + block_duration + ) + if commandType == "thermocycler/runProfile": + profile = command["params"]["profile"] + total_changes = len(profile) + block_temp_changes += total_changes + for cycle in profile: + block_temp = cycle["celsius"] + block_time = cycle["holdSeconds"] + block_temps[block_temp] = block_temps.get(block_temp, 0.0) + block_time + if block_on_time is not None and block_off_time is None: + # If thermocycler block not deactivated protocol completedAt time stamp used + protocol_end = datetime.strptime( + file_results.get("completedAt", ""), "%Y-%m-%dT%H:%M:%S.%f%z" + ) + temp_duration = (protocol_end - block_on_time).total_seconds() + block_temps[block_temp] = block_temps.get(block_temp, 0.0) + temp_duration + if lid_on_time is not None and lid_off_time is None: + # If thermocycler lid not deactivated protocol completedAt time stamp used + protocol_end = datetime.strptime( + file_results.get("completedAt", ""), "%Y-%m-%dT%H:%M:%S.%f%z" + ) + temp_duration = (protocol_end - lid_on_time).total_seconds() + lid_temps[lid_temp] = block_temps.get(lid_temp, 0.0) + temp_duration + + block_total_time = sum(block_temps.values()) + lid_total_time = sum(lid_temps.values()) + lid_sets = lid_engagements / 2 + tc_dict = { + "Thermocycler # of Lid Open/Close": lid_sets, + "Thermocycler Block # of Temp Changes": block_temp_changes, + "Thermocycler Block Temp On Time (sec)": block_total_time, + "Thermocycler Lid # of Temp Changes": lid_temp_changes, + "Thermocycler Lid Temp On Time (sec)": lid_total_time, + } + + return tc_dict def create_abr_data_sheet( @@ -17,7 +252,6 @@ def create_abr_data_sheet( ) -> str: """Creates csv file to log ABR data.""" file_name_csv = file_name + ".csv" - print(file_name_csv) sheet_location = os.path.join(storage_directory, file_name_csv) if os.path.exists(sheet_location): print(f"File {sheet_location} located. Not overwriting.") @@ -26,7 +260,7 @@ def create_abr_data_sheet( writer = csv.DictWriter(csvfile, fieldnames=headers) writer.writeheader() print(f"Created file. Located: {sheet_location}.") - return file_name_csv + return sheet_location def get_error_info(file_results: Dict[str, Any]) -> Tuple[int, str, str, str, str]: @@ -110,7 +344,7 @@ def read_abr_data_sheet( runs_in_sheet.add(run_id) print(f"There are {str(len(runs_in_sheet))} runs documented in the ABR sheet.") # Read Google Sheet - google_sheet.check_token() + google_sheet.token_check() google_sheet.write_header(headers) google_sheet.update_row_index() return runs_in_sheet @@ -138,6 +372,16 @@ def get_unseen_run_ids(runs: Set[str], runs_from_storage: Set[str]) -> Set[str]: return runs_to_save +def save_run_log_to_json( + ip: str, results: Dict[str, Any], storage_directory: str +) -> str: + """Save run log to local json file.""" + data_file_name = ip + "_" + results["run_id"] + ".json" + saved_file_path = os.path.join(storage_directory, data_file_name) + json.dump(results, open(saved_file_path, mode="w")) + return saved_file_path + + def get_run_ids_from_google_drive(google_drive: Any) -> Set[str]: """Get run ids in google drive folder.""" # Run ids in google_drive_folder @@ -148,3 +392,91 @@ def get_run_ids_from_google_drive(google_drive: Any) -> Set[str]: file_id = file.split(".json")[0].split("_")[1] run_ids_on_gd.add(file_id) return run_ids_on_gd + + +def write_to_sheets( + sheet_location: str, google_sheet: Any, row_list: List[Any], headers: List[str] +) -> None: + """Write list to google sheet and csv.""" + with open(sheet_location, "a", newline="") as f: + writer = csv.writer(f) + writer.writerow(row_list) + # Read Google Sheet + google_sheet.token_check() + google_sheet.write_header(headers) + google_sheet.update_row_index() + google_sheet.write_to_row(row_list) + t.sleep(5) # Sleep added to avoid API error. + + +def get_calibration_offsets( + ip: str, storage_directory: str +) -> Tuple[str, Dict[str, Any]]: + """Connect to robot via ip and get calibration data.""" + calibration = dict() + # Robot Information [Name, Software Version] + response = requests.get( + f"http://{ip}:31950/health", headers={"opentrons-version": "3"} + ) + health_data = response.json() + robot_name = health_data.get("name", "") + api_version = health_data.get("api_version", "") + pull_date_timestamp = datetime.now() + date = pull_date_timestamp.date().isoformat() + file_date = str(pull_date_timestamp).replace(":", "").split(".")[0] + calibration["Robot"] = robot_name + calibration["Software Version"] = api_version + calibration["Pull Date"] = date + calibration["Pull Timestamp"] = pull_date_timestamp.isoformat() + calibration["run_id"] = "calibration" + "_" + file_date + # Calibration [Instruments, modules, deck] + response = requests.get( + f"http://{ip}:31950/instruments", + headers={"opentrons-version": "3"}, + params={"cursor": 0, "pageLength": 0}, + ) + instruments: Dict[str, Any] = response.json() + calibration["Instruments"] = instruments.get("data", "") + response = requests.get( + f"http://{ip}:31950/modules", + headers={"opentrons-version": "3"}, + params={"cursor": 0, "pageLength": 0}, + ) + modules: Dict[str, Any] = response.json() + calibration["Modules"] = modules.get("data", "") + response = requests.get( + f"http://{ip}:31950/calibration/status", + headers={"opentrons-version": "3"}, + params={"cursor": 0, "pageLength": 0}, + ) + deck: Dict[str, Any] = response.json() + calibration["Deck"] = deck.get("deckCalibration", "") + save_name = ip + "_calibration.json" + saved_file_path = os.path.join(storage_directory, save_name) + json.dump(calibration, open(saved_file_path, mode="w")) + return saved_file_path, calibration + + +def get_logs(storage_directory: str, ip: str) -> List[str]: + """Get Robot logs.""" + log_types = ["api.log", "server.log", "serial.log", "touchscreen.log"] + all_paths = [] + for log_type in log_types: + try: + response = requests.get( + f"http://{ip}:31950/logs/{log_type}", + headers={"log_identifier": log_type}, + params={"records": 5000}, + ) + response.raise_for_status() + log_data = response.text + log_name = ip + "_" + log_type.split(".")[0] + ".json" + file_path = os.path.join(storage_directory, log_name) + with open(file_path, mode="w", encoding="utf-8") as file: + file.write(response.text) + json.dump(log_data, open(file_path, mode="w")) + except RuntimeError: + print(f"Request exception. Did not save {log_type}") + continue + all_paths.append(file_path) + return all_paths diff --git a/abr-testing/abr_testing/tools/abr_asair_sensor.py b/abr-testing/abr_testing/tools/abr_asair_sensor.py index 4183b812930..eef69329436 100644 --- a/abr-testing/abr_testing/tools/abr_asair_sensor.py +++ b/abr-testing/abr_testing/tools/abr_asair_sensor.py @@ -6,7 +6,7 @@ import time as t from typing import List import argparse -from abr_testing.google_automation import google_sheets_tool +from abr_testing.automation import google_sheets_tool class _ABRAsairSensor: diff --git a/abr-testing/abr_testing/tools/abr_scale.py b/abr-testing/abr_testing/tools/abr_scale.py index 5d253d25c70..75c887d4ecc 100644 --- a/abr-testing/abr_testing/tools/abr_scale.py +++ b/abr-testing/abr_testing/tools/abr_scale.py @@ -3,26 +3,9 @@ import datetime from hardware_testing.drivers import find_port, list_ports_and_select # type: ignore[import] from hardware_testing.drivers.radwag import RadwagScale # type: ignore[import] -from typing import Any, List import argparse -import csv from abr_testing.data_collection import read_robot_logs -from abr_testing.google_automation import google_sheets_tool - - -def write_to_sheets(file_name_csv: str, google_sheet: Any, row_list: List) -> None: - """Write list to google sheet and csv.""" - sheet_location = os.path.join(storage_directory, file_name_csv) - with open(sheet_location, "a", newline="") as f: - writer = csv.writer(f) - writer.writerow(row_list) - print(f"Written {row_list} point to {file_name_csv}") - # Read Google Sheet - google_sheet.token_check() - google_sheet.write_header(headers) - google_sheet.update_row_index() - google_sheet.write_to_row(row_list) - print(f"Written {row_list} to google sheet.") +from abr_testing.automation import google_sheets_tool if __name__ == "__main__": @@ -76,7 +59,7 @@ def write_to_sheets(file_name_csv: str, google_sheet: Any, row_list: List) -> No is_stable = False # Set up csv sheet headers = ["Robot", "Date", "Timestamp", "Labware", "Mass (g)", "Measurement Step"] - all_data_csv = read_robot_logs.create_abr_data_sheet( + sheet_location = read_robot_logs.create_abr_data_sheet( storage_directory, file_name, headers ) # Set up google sheet @@ -90,8 +73,12 @@ def write_to_sheets(file_name_csv: str, google_sheet: Any, row_list: List) -> No print("No google sheets credentials. Add credentials to storage notebook.") # Scale Loop + grams, is_stable = scale.read_mass() + grams, is_stable = scale.read_mass() + is_stable = False break_all = False while is_stable is False: + grams, is_stable = scale.read_mass() grams, is_stable = scale.read_mass() print(f"Scale reading: grams={grams}, is_stable={is_stable}") time_now = datetime.datetime.now() @@ -100,14 +87,19 @@ def write_to_sheets(file_name_csv: str, google_sheet: Any, row_list: List) -> No row_list = list(row) while is_stable is True: print("is stable") - write_to_sheets(file_name_csv, google_sheet, row_list) + read_robot_logs.write_to_sheets( + sheet_location, google_sheet, row_list, headers + ) is_stable = False y_or_no = input("Do you want to weigh another sample? (Y/N): ") if y_or_no == "Y": # Uses same storage directory and file. + grams, is_stable = scale.read_mass() + is_stable = False robot = input("Robot: ") labware = input("Labware: ") protocol_step = input("Measurement Step (1,2,3): ") + grams, is_stable = scale.read_mass() elif y_or_no == "N": break_all = True if break_all: diff --git a/api-client/src/protocols/__fixtures__/simpleAnalysisFile.json b/api-client/src/protocols/__fixtures__/simpleAnalysisFile.json index df8fcad1d98..bb6aacccd6e 100644 --- a/api-client/src/protocols/__fixtures__/simpleAnalysisFile.json +++ b/api-client/src/protocols/__fixtures__/simpleAnalysisFile.json @@ -3936,5 +3936,59 @@ "description": "", "displayColor": "#b925ff" } - ] + ], + "runTimeParameters": [ + { + "type": "int", + "displayName": "number of samples", + "variableName": "num_samples", + "description": "How many samples do you want to run?", + "value": 96, + "min": 1, + "max": 96, + "default": 96 + }, + { + "type": "float", + "displayName": "samples volume", + "variableName": "vol_sample", + "description": "What sample volume are you using?", + "value": 10.0, + "min": 1, + "max": 20.0, + "default": 10.0 + }, + { + "displayName": "Additional mix for reagent 2?", + "variableName": "extra_mix", + "description": "When on, we do an extra mix for reagent 2.", + "type": "bool", + "default": false, + "value": false + }, + { + "displayName": "Number of PCR Cycles", + "variableName": "real_mode", + "description": "Cycle map", + "type": "int", + "unit": "cycles", + "default": 15, + "value": 15, + "choices": [ + { + "displayName": "1 & 10ng (15 cycles)", + "value": 15 + }, + { + "displayName": "100ng (15 cycles)", + "value": 15 + }, + { + "displayName": "1ug (10 cycles)", + "value": 10 + } + ] + } + ], + "robotType": "OT-2 Standard" } diff --git a/api-client/src/protocols/createProtocol.ts b/api-client/src/protocols/createProtocol.ts index 64593d1a953..2bcbefe6a7b 100644 --- a/api-client/src/protocols/createProtocol.ts +++ b/api-client/src/protocols/createProtocol.ts @@ -2,15 +2,22 @@ import { POST, request } from '../request' import type { ResponsePromise } from '../request' import type { HostConfig } from '../types' import type { Protocol } from './types' +import type { RunTimeParameterCreateData } from '../runs' export function createProtocol( config: HostConfig, files: File[], - protocolKey?: string + protocolKey?: string, + runTimeParameterValues?: RunTimeParameterCreateData ): ResponsePromise { const formData = new FormData() files.forEach(file => formData.append('files', file, file.name)) if (protocolKey != null) formData.append('key', protocolKey) + if (runTimeParameterValues != null) + formData.append( + 'runTimeParameterValues', + JSON.stringify(runTimeParameterValues) + ) return request(POST, '/protocols', formData, config) } diff --git a/api-client/src/protocols/createProtocolAnalysis.ts b/api-client/src/protocols/createProtocolAnalysis.ts new file mode 100644 index 00000000000..81ab83c11af --- /dev/null +++ b/api-client/src/protocols/createProtocolAnalysis.ts @@ -0,0 +1,28 @@ +import { POST, request } from '../request' + +import type { ProtocolAnalysisSummary } from '@opentrons/shared-data' +import type { ResponsePromise } from '../request' +import type { HostConfig } from '../types' +import type { RunTimeParameterCreateData } from '../runs' + +interface CreateProtocolAnalysisData { + runTimeParameterValues: RunTimeParameterCreateData + forceReAnalyze: boolean +} + +export function createProtocolAnalysis( + config: HostConfig, + protocolKey: string, + runTimeParameterValues?: RunTimeParameterCreateData, + forceReAnalyze?: boolean +): ResponsePromise { + const data = { + runTimeParameterValues: runTimeParameterValues ?? {}, + forceReAnalyze: forceReAnalyze ?? false, + } + const response = request< + ProtocolAnalysisSummary[], + { data: CreateProtocolAnalysisData } + >(POST, `/protocols/${protocolKey}/analyses`, { data }, config) + return response +} diff --git a/api-client/src/protocols/index.ts b/api-client/src/protocols/index.ts index 6febd0795cf..f035fa000e1 100644 --- a/api-client/src/protocols/index.ts +++ b/api-client/src/protocols/index.ts @@ -3,6 +3,7 @@ export { getProtocolAnalyses } from './getProtocolAnalyses' export { getProtocolAnalysisAsDocument } from './getProtocolAnalysisAsDocument' export { deleteProtocol } from './deleteProtocol' export { createProtocol } from './createProtocol' +export { createProtocolAnalysis } from './createProtocolAnalysis' export { getProtocols } from './getProtocols' export { getProtocolIds } from './getProtocolIds' diff --git a/api-client/src/robot/getRobotSettings.ts b/api-client/src/robot/getRobotSettings.ts new file mode 100644 index 00000000000..ffe0014fcb0 --- /dev/null +++ b/api-client/src/robot/getRobotSettings.ts @@ -0,0 +1,11 @@ +import { GET, request } from '../request' + +import type { ResponsePromise } from '../request' +import type { HostConfig } from '../types' +import type { RobotSettingsResponse } from './types' + +export function getRobotSettings( + config: HostConfig +): ResponsePromise { + return request(GET, '/settings', null, config) +} diff --git a/api-client/src/robot/index.ts b/api-client/src/robot/index.ts index 96ef28165b0..55052d7b7c8 100644 --- a/api-client/src/robot/index.ts +++ b/api-client/src/robot/index.ts @@ -3,11 +3,18 @@ export { getEstopStatus } from './getEstopStatus' export { acknowledgeEstopDisengage } from './acknowledgeEstopDisengage' export { getLights } from './getLights' export { setLights } from './setLights' +export { getRobotSettings } from './getRobotSettings' +export { updateRobotSetting } from './updateRobotSetting' + export type { DoorStatus, EstopPhysicalStatus, EstopState, EstopStatus, Lights, + RobotSettings, + RobotSettingsField, + RobotSettingsResponse, SetLightsData, + UpdateRobotSettingRequest, } from './types' diff --git a/api-client/src/robot/types.ts b/api-client/src/robot/types.ts index 00d887b9c4e..41ef7f1281e 100644 --- a/api-client/src/robot/types.ts +++ b/api-client/src/robot/types.ts @@ -27,3 +27,23 @@ export interface Lights { export interface SetLightsData { on: boolean } + +export interface RobotSettingsField { + id: string + title: string + description: string + value: boolean | null + restart_required?: boolean +} + +export type RobotSettings = RobotSettingsField[] + +export interface UpdateRobotSettingRequest { + id: string + value: boolean | null +} + +export interface RobotSettingsResponse { + settings: RobotSettings + links?: { restart?: string } +} diff --git a/api-client/src/robot/updateRobotSetting.ts b/api-client/src/robot/updateRobotSetting.ts new file mode 100644 index 00000000000..a5775abaeee --- /dev/null +++ b/api-client/src/robot/updateRobotSetting.ts @@ -0,0 +1,18 @@ +import { POST, request } from '../request' + +import type { ResponsePromise } from '../request' +import type { HostConfig } from '../types' +import type { RobotSettingsResponse, UpdateRobotSettingRequest } from './types' + +export function updateRobotSetting( + config: HostConfig, + id: string, + value: boolean +): ResponsePromise { + return request( + POST, + '/settings', + { id, value }, + config + ) +} diff --git a/api-client/src/runs/constants.ts b/api-client/src/runs/constants.ts new file mode 100644 index 00000000000..9f0d8293ef6 --- /dev/null +++ b/api-client/src/runs/constants.ts @@ -0,0 +1,11 @@ +import { + RUN_STATUS_FAILED, + RUN_STATUS_STOPPED, + RUN_STATUS_SUCCEEDED, +} from './types' + +export const RUN_STATUSES_TERMINAL = [ + RUN_STATUS_SUCCEEDED, + RUN_STATUS_FAILED, + RUN_STATUS_STOPPED, +] diff --git a/api-client/src/runs/createRun.ts b/api-client/src/runs/createRun.ts index 285802d85b2..7f0fb1ad72d 100644 --- a/api-client/src/runs/createRun.ts +++ b/api-client/src/runs/createRun.ts @@ -2,11 +2,16 @@ import { POST, request } from '../request' import type { ResponsePromise } from '../request' import type { HostConfig } from '../types' -import type { Run, LabwareOffsetCreateData } from './types' +import type { + Run, + LabwareOffsetCreateData, + RunTimeParameterCreateData, +} from './types' export interface CreateRunData { protocolId?: string labwareOffsets?: LabwareOffsetCreateData[] + runTimeParameterValues?: RunTimeParameterCreateData } export function createRun( diff --git a/api-client/src/runs/index.ts b/api-client/src/runs/index.ts index fa38dade02f..1d62755d4c5 100644 --- a/api-client/src/runs/index.ts +++ b/api-client/src/runs/index.ts @@ -10,6 +10,6 @@ export { getCommands } from './commands/getCommands' export { createRunAction } from './createRunAction' export * from './createLabwareOffset' export * from './createLabwareDefinition' - +export * from './constants' export * from './types' export type { CreateRunData } from './createRun' diff --git a/api-client/src/runs/types.ts b/api-client/src/runs/types.ts index 7709e580a5e..36c5f9a3a20 100644 --- a/api-client/src/runs/types.ts +++ b/api-client/src/runs/types.ts @@ -4,6 +4,7 @@ import type { LoadedPipette, ModuleModel, RunTimeCommand, + RunTimeParameter, } from '@opentrons/shared-data' import type { ResourceLink, ErrorDetails } from '../types' export * from './commands/types' @@ -47,6 +48,7 @@ export interface LegacyGoodRunData { modules: LoadedModule[] protocolId?: string labwareOffsets?: LabwareOffset[] + runTimeParameters: RunTimeParameter[] } export interface KnownGoodRunData extends LegacyGoodRunData { @@ -125,6 +127,10 @@ export interface LabwareOffsetCreateData { vector: VectorOffset } +export interface RunTimeParameterCreateData { + [key: string]: string | boolean | number +} + export interface CommandData { data: RunTimeCommand } diff --git a/api-client/src/subsystems/types.ts b/api-client/src/subsystems/types.ts index 14f45324f62..564d59b21b2 100644 --- a/api-client/src/subsystems/types.ts +++ b/api-client/src/subsystems/types.ts @@ -6,6 +6,7 @@ export type Subsystem = | 'pipette_right' | 'gripper' | 'rear_panel' + | 'hepa_uv' type UpdateStatus = 'queued' | 'updating' | 'done' export interface SubsystemUpdateProgressData { diff --git a/api-client/src/system/createSplash.ts b/api-client/src/system/createSplash.ts new file mode 100644 index 00000000000..fd0b11bd575 --- /dev/null +++ b/api-client/src/system/createSplash.ts @@ -0,0 +1,24 @@ +import { POST, request } from '../request' +import type { ResponsePromise } from '../request' +import type { HostConfig } from '../types' + +export function createSplash( + config: HostConfig, + file: File +): ResponsePromise { + // sanitize file name to ensure no spaces + const renamedFile = new File([file], file.name.replace(' ', '_'), { + type: 'image/png', + }) + + const formData = new FormData() + formData.append('file', renamedFile) + + // eslint-disable-next-line @typescript-eslint/no-invalid-void-type + return request( + POST, + '/system/oem_mode/upload_splash', + formData, + config + ) +} diff --git a/api-client/src/system/index.ts b/api-client/src/system/index.ts index 025a303a5b5..3c63202c31f 100644 --- a/api-client/src/system/index.ts +++ b/api-client/src/system/index.ts @@ -1,4 +1,5 @@ export { createAuthorization } from './createAuthorization' export { createRegistration } from './createRegistration' +export { createSplash } from './createSplash' export { getConnections } from './getConnections' export * from './types' diff --git a/api/.flake8 b/api/.flake8 index d654020fa7f..ee1a726e611 100644 --- a/api/.flake8 +++ b/api/.flake8 @@ -33,7 +33,7 @@ per-file-ignores = src/opentrons/simulate.py:ANN,D src/opentrons/types.py:ANN,D src/opentrons/calibration_storage/*:ANN,D - src/opentrons/commands/*:D + src/opentrons/legacy_commands/*:D src/opentrons/config/*:ANN,D src/opentrons/drivers/*:ANN,D src/opentrons/hardware_control/*:ANN,D @@ -51,7 +51,7 @@ per-file-ignores = tests/opentrons/test_types.py:ANN,D tests/opentrons/conftest.py:ANN,D tests/opentrons/calibration_storage/*:ANN,D - tests/opentrons/commands/*:ANN,D + tests/opentrons/legacy_commands/*:ANN,D tests/opentrons/config/*:ANN,D tests/opentrons/data/*:ANN,D tests/opentrons/drivers/*:ANN,D diff --git a/api/Pipfile b/api/Pipfile index 710a5cb6f22..7be11b82934 100755 --- a/api/Pipfile +++ b/api/Pipfile @@ -48,3 +48,4 @@ pytest-profiling = "~=1.7.0" # TODO(mc, 2022-03-31): upgrade sphinx, remove this subdep pin jinja2 = ">=2.3,<3.1" hypothesis = "==6.96.1" +performance-metrics = {file = "../performance-metrics", editable = true} diff --git a/api/Pipfile.lock b/api/Pipfile.lock index cc9f3163e51..94643ce22a7 100644 --- a/api/Pipfile.lock +++ b/api/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "f0d4979ecb4f125cef848e0ce31e3a5e9cded69abaf773ad90d00016f6d2a65d" + "sha256": "a531665bfd7452ea19565ee95137118966532a8ab5475b7d5ee086ada333e627" }, "pipfile-spec": 6, "requires": {}, @@ -56,11 +56,11 @@ }, "idna": { "hashes": [ - "sha256:9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca", - "sha256:c05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f" + "sha256:028ff3aadf0609c1fd278d8ea3089299412a7a8b9bd005dd08b9f8285bcb5cfc", + "sha256:82fee1fc78add43492d3a1898bfa6d8a904cc97d8427f683ed8e798d07761aa0" ], "markers": "python_version >= '3.5'", - "version": "==3.6" + "version": "==3.7" }, "jsonschema": { "hashes": [ @@ -73,65 +73,65 @@ }, "msgpack": { "hashes": [ - "sha256:04ad6069c86e531682f9e1e71b71c1c3937d6014a7c3e9edd2aa81ad58842862", - "sha256:0bfdd914e55e0d2c9e1526de210f6fe8ffe9705f2b1dfcc4aecc92a4cb4b533d", - "sha256:1dc93e8e4653bdb5910aed79f11e165c85732067614f180f70534f056da97db3", - "sha256:1e2d69948e4132813b8d1131f29f9101bc2c915f26089a6d632001a5c1349672", - "sha256:235a31ec7db685f5c82233bddf9858748b89b8119bf4538d514536c485c15fe0", - "sha256:27dcd6f46a21c18fa5e5deed92a43d4554e3df8d8ca5a47bf0615d6a5f39dbc9", - "sha256:28efb066cde83c479dfe5a48141a53bc7e5f13f785b92ddde336c716663039ee", - "sha256:3476fae43db72bd11f29a5147ae2f3cb22e2f1a91d575ef130d2bf49afd21c46", - "sha256:36e17c4592231a7dbd2ed09027823ab295d2791b3b1efb2aee874b10548b7524", - "sha256:384d779f0d6f1b110eae74cb0659d9aa6ff35aaf547b3955abf2ab4c901c4819", - "sha256:38949d30b11ae5f95c3c91917ee7a6b239f5ec276f271f28638dec9156f82cfc", - "sha256:3967e4ad1aa9da62fd53e346ed17d7b2e922cba5ab93bdd46febcac39be636fc", - "sha256:3e7bf4442b310ff154b7bb9d81eb2c016b7d597e364f97d72b1acc3817a0fdc1", - "sha256:3f0c8c6dfa6605ab8ff0611995ee30d4f9fcff89966cf562733b4008a3d60d82", - "sha256:484ae3240666ad34cfa31eea7b8c6cd2f1fdaae21d73ce2974211df099a95d81", - "sha256:4a7b4f35de6a304b5533c238bee86b670b75b03d31b7797929caa7a624b5dda6", - "sha256:4cb14ce54d9b857be9591ac364cb08dc2d6a5c4318c1182cb1d02274029d590d", - "sha256:4e71bc4416de195d6e9b4ee93ad3f2f6b2ce11d042b4d7a7ee00bbe0358bd0c2", - "sha256:52700dc63a4676669b341ba33520f4d6e43d3ca58d422e22ba66d1736b0a6e4c", - "sha256:572efc93db7a4d27e404501975ca6d2d9775705c2d922390d878fcf768d92c87", - "sha256:576eb384292b139821c41995523654ad82d1916da6a60cff129c715a6223ea84", - "sha256:5b0bf0effb196ed76b7ad883848143427a73c355ae8e569fa538365064188b8e", - "sha256:5b6ccc0c85916998d788b295765ea0e9cb9aac7e4a8ed71d12e7d8ac31c23c95", - "sha256:5ed82f5a7af3697b1c4786053736f24a0efd0a1b8a130d4c7bfee4b9ded0f08f", - "sha256:6d4c80667de2e36970ebf74f42d1088cc9ee7ef5f4e8c35eee1b40eafd33ca5b", - "sha256:730076207cb816138cf1af7f7237b208340a2c5e749707457d70705715c93b93", - "sha256:7687e22a31e976a0e7fc99c2f4d11ca45eff652a81eb8c8085e9609298916dcf", - "sha256:822ea70dc4018c7e6223f13affd1c5c30c0f5c12ac1f96cd8e9949acddb48a61", - "sha256:84b0daf226913133f899ea9b30618722d45feffa67e4fe867b0b5ae83a34060c", - "sha256:85765fdf4b27eb5086f05ac0491090fc76f4f2b28e09d9350c31aac25a5aaff8", - "sha256:8dd178c4c80706546702c59529ffc005681bd6dc2ea234c450661b205445a34d", - "sha256:8f5b234f567cf76ee489502ceb7165c2a5cecec081db2b37e35332b537f8157c", - "sha256:98bbd754a422a0b123c66a4c341de0474cad4a5c10c164ceed6ea090f3563db4", - "sha256:993584fc821c58d5993521bfdcd31a4adf025c7d745bbd4d12ccfecf695af5ba", - "sha256:a40821a89dc373d6427e2b44b572efc36a2778d3f543299e2f24eb1a5de65415", - "sha256:b291f0ee7961a597cbbcc77709374087fa2a9afe7bdb6a40dbbd9b127e79afee", - "sha256:b573a43ef7c368ba4ea06050a957c2a7550f729c31f11dd616d2ac4aba99888d", - "sha256:b610ff0f24e9f11c9ae653c67ff8cc03c075131401b3e5ef4b82570d1728f8a9", - "sha256:bdf38ba2d393c7911ae989c3bbba510ebbcdf4ecbdbfec36272abe350c454075", - "sha256:bfef2bb6ef068827bbd021017a107194956918ab43ce4d6dc945ffa13efbc25f", - "sha256:cab3db8bab4b7e635c1c97270d7a4b2a90c070b33cbc00c99ef3f9be03d3e1f7", - "sha256:cb70766519500281815dfd7a87d3a178acf7ce95390544b8c90587d76b227681", - "sha256:cca1b62fe70d761a282496b96a5e51c44c213e410a964bdffe0928e611368329", - "sha256:ccf9a39706b604d884d2cb1e27fe973bc55f2890c52f38df742bc1d79ab9f5e1", - "sha256:dc43f1ec66eb8440567186ae2f8c447d91e0372d793dfe8c222aec857b81a8cf", - "sha256:dd632777ff3beaaf629f1ab4396caf7ba0bdd075d948a69460d13d44357aca4c", - "sha256:e45ae4927759289c30ccba8d9fdce62bb414977ba158286b5ddaf8df2cddb5c5", - "sha256:e50ebce52f41370707f1e21a59514e3375e3edd6e1832f5e5235237db933c98b", - "sha256:ebbbba226f0a108a7366bf4b59bf0f30a12fd5e75100c630267d94d7f0ad20e5", - "sha256:ec79ff6159dffcc30853b2ad612ed572af86c92b5168aa3fc01a67b0fa40665e", - "sha256:f0936e08e0003f66bfd97e74ee530427707297b0d0361247e9b4f59ab78ddc8b", - "sha256:f26a07a6e877c76a88e3cecac8531908d980d3d5067ff69213653649ec0f60ad", - "sha256:f64e376cd20d3f030190e8c32e1c64582eba56ac6dc7d5b0b49a9d44021b52fd", - "sha256:f6ffbc252eb0d229aeb2f9ad051200668fc3a9aaa8994e49f0cb2ffe2b7867e7", - "sha256:f9a7c509542db4eceed3dcf21ee5267ab565a83555c9b88a8109dcecc4709002", - "sha256:ff1d0899f104f3921d94579a5638847f783c9b04f2d5f229392ca77fba5b82fc" + "sha256:00e073efcba9ea99db5acef3959efa45b52bc67b61b00823d2a1a6944bf45982", + "sha256:0726c282d188e204281ebd8de31724b7d749adebc086873a59efb8cf7ae27df3", + "sha256:0ceea77719d45c839fd73abcb190b8390412a890df2f83fb8cf49b2a4b5c2f40", + "sha256:114be227f5213ef8b215c22dde19532f5da9652e56e8ce969bf0a26d7c419fee", + "sha256:13577ec9e247f8741c84d06b9ece5f654920d8365a4b636ce0e44f15e07ec693", + "sha256:1876b0b653a808fcd50123b953af170c535027bf1d053b59790eebb0aeb38950", + "sha256:1ab0bbcd4d1f7b6991ee7c753655b481c50084294218de69365f8f1970d4c151", + "sha256:1cce488457370ffd1f953846f82323cb6b2ad2190987cd4d70b2713e17268d24", + "sha256:26ee97a8261e6e35885c2ecd2fd4a6d38252246f94a2aec23665a4e66d066305", + "sha256:3528807cbbb7f315bb81959d5961855e7ba52aa60a3097151cb21956fbc7502b", + "sha256:374a8e88ddab84b9ada695d255679fb99c53513c0a51778796fcf0944d6c789c", + "sha256:376081f471a2ef24828b83a641a02c575d6103a3ad7fd7dade5486cad10ea659", + "sha256:3923a1778f7e5ef31865893fdca12a8d7dc03a44b33e2a5f3295416314c09f5d", + "sha256:4916727e31c28be8beaf11cf117d6f6f188dcc36daae4e851fee88646f5b6b18", + "sha256:493c5c5e44b06d6c9268ce21b302c9ca055c1fd3484c25ba41d34476c76ee746", + "sha256:505fe3d03856ac7d215dbe005414bc28505d26f0c128906037e66d98c4e95868", + "sha256:5845fdf5e5d5b78a49b826fcdc0eb2e2aa7191980e3d2cfd2a30303a74f212e2", + "sha256:5c330eace3dd100bdb54b5653b966de7f51c26ec4a7d4e87132d9b4f738220ba", + "sha256:5dbf059fb4b7c240c873c1245ee112505be27497e90f7c6591261c7d3c3a8228", + "sha256:5e390971d082dba073c05dbd56322427d3280b7cc8b53484c9377adfbae67dc2", + "sha256:5fbb160554e319f7b22ecf530a80a3ff496d38e8e07ae763b9e82fadfe96f273", + "sha256:64d0fcd436c5683fdd7c907eeae5e2cbb5eb872fafbc03a43609d7941840995c", + "sha256:69284049d07fce531c17404fcba2bb1df472bc2dcdac642ae71a2d079d950653", + "sha256:6a0e76621f6e1f908ae52860bdcb58e1ca85231a9b0545e64509c931dd34275a", + "sha256:73ee792784d48aa338bba28063e19a27e8d989344f34aad14ea6e1b9bd83f596", + "sha256:74398a4cf19de42e1498368c36eed45d9528f5fd0155241e82c4082b7e16cffd", + "sha256:7938111ed1358f536daf311be244f34df7bf3cdedb3ed883787aca97778b28d8", + "sha256:82d92c773fbc6942a7a8b520d22c11cfc8fd83bba86116bfcf962c2f5c2ecdaa", + "sha256:83b5c044f3eff2a6534768ccfd50425939e7a8b5cf9a7261c385de1e20dcfc85", + "sha256:8db8e423192303ed77cff4dce3a4b88dbfaf43979d280181558af5e2c3c71afc", + "sha256:9517004e21664f2b5a5fd6333b0731b9cf0817403a941b393d89a2f1dc2bd836", + "sha256:95c02b0e27e706e48d0e5426d1710ca78e0f0628d6e89d5b5a5b91a5f12274f3", + "sha256:99881222f4a8c2f641f25703963a5cefb076adffd959e0558dc9f803a52d6a58", + "sha256:9ee32dcb8e531adae1f1ca568822e9b3a738369b3b686d1477cbc643c4a9c128", + "sha256:a22e47578b30a3e199ab067a4d43d790249b3c0587d9a771921f86250c8435db", + "sha256:b5505774ea2a73a86ea176e8a9a4a7c8bf5d521050f0f6f8426afe798689243f", + "sha256:bd739c9251d01e0279ce729e37b39d49a08c0420d3fee7f2a4968c0576678f77", + "sha256:d16a786905034e7e34098634b184a7d81f91d4c3d246edc6bd7aefb2fd8ea6ad", + "sha256:d3420522057ebab1728b21ad473aa950026d07cb09da41103f8e597dfbfaeb13", + "sha256:d56fd9f1f1cdc8227d7b7918f55091349741904d9520c65f0139a9755952c9e8", + "sha256:d661dc4785affa9d0edfdd1e59ec056a58b3dbb9f196fa43587f3ddac654ac7b", + "sha256:dfe1f0f0ed5785c187144c46a292b8c34c1295c01da12e10ccddfc16def4448a", + "sha256:e1dd7839443592d00e96db831eddb4111a2a81a46b028f0facd60a09ebbdd543", + "sha256:e2872993e209f7ed04d963e4b4fbae72d034844ec66bc4ca403329db2074377b", + "sha256:e2f879ab92ce502a1e65fce390eab619774dda6a6ff719718069ac94084098ce", + "sha256:e3aa7e51d738e0ec0afbed661261513b38b3014754c9459508399baf14ae0c9d", + "sha256:e532dbd6ddfe13946de050d7474e3f5fb6ec774fbb1a188aaf469b08cf04189a", + "sha256:e6b7842518a63a9f17107eb176320960ec095a8ee3b4420b5f688e24bf50c53c", + "sha256:e75753aeda0ddc4c28dce4c32ba2f6ec30b1b02f6c0b14e547841ba5b24f753f", + "sha256:eadb9f826c138e6cf3c49d6f8de88225a3c0ab181a9b4ba792e006e5292d150e", + "sha256:ed59dd52075f8fc91da6053b12e8c89e37aa043f8986efd89e61fae69dc1b011", + "sha256:ef254a06bcea461e65ff0373d8a0dd1ed3aa004af48839f002a0c994a6f72d04", + "sha256:f3709997b228685fe53e8c433e2df9f0cdb5f4542bd5114ed17ac3c0129b0480", + "sha256:f51bab98d52739c50c56658cc303f190785f9a2cd97b823357e7aeae54c8f68a", + "sha256:f9904e24646570539a8950400602d66d2b2c492b9010ea7e965025cb71d0c86d", + "sha256:f9af38a89b6a5c04b7d18c492c8ccf2aee7048aff1ce8437c4683bb5a1df893d" ], "markers": "platform_system != 'Windows'", - "version": "==1.0.7" + "version": "==1.0.8" }, "numpy": { "hashes": [ @@ -162,6 +162,7 @@ }, "opentrons": { "editable": true, + "markers": "python_version >= '3.8'", "path": "." }, "opentrons-hardware": { @@ -173,15 +174,16 @@ }, "opentrons-shared-data": { "editable": true, + "markers": "python_version >= '3.8'", "path": "../shared-data/python" }, "packaging": { "hashes": [ - "sha256:048fb0e9405036518eaaf48a55953c750c11e1a1b68e0dd1a9d62ed0c092cfc5", - "sha256:8c491190033a9af7e1d931d0b5dacc2ef47509b34dd0de67ed209b5203fc88c7" + "sha256:2ddfb553fdf02fb784c234c7ba6ccc288296ceabec964ad2eae3777778130bc5", + "sha256:eb82c5e3e56209074766e6885bb04b8c38a0c015d0a30036ebe7ece34c9989e9" ], "markers": "python_version >= '3.7'", - "version": "==23.2" + "version": "==24.0" }, "pydantic": { "hashes": [ @@ -276,32 +278,31 @@ "sha256:6ad50f4613289f3c4d276b6d2ac8901d776dcb929994cce93f55a69e858c595f", "sha256:7eea9b81b0ff908000a825db024313f622895bd578e8a17433e0474cd7d2da83" ], - "markers": "python_version >= '3.7'", "version": "==4.2.2" }, "setuptools": { "hashes": [ - "sha256:385eb4edd9c9d5c17540511303e39a147ce2fc04bc55289c322b9e5904fe2c05", - "sha256:be1af57fc409f93647f2e8e4573a142ed38724b8cdd389706a867bb4efcf1e78" + "sha256:6c1fccdac05a97e598fb0ae3bbed5904ccb317337a51139dcd51453611bbb987", + "sha256:c636ac361bc47580504644275c9ad802c50415c7522212252c033bd15f301f32" ], "markers": "python_version >= '3.8'", - "version": "==69.0.3" + "version": "==69.5.1" }, "sniffio": { "hashes": [ - "sha256:e60305c5e5d314f5389259b7f22aaa33d8f7dee49763119234af3755c55b9101", - "sha256:eecefdce1e5bbfb7ad2eeaabf7c1eeb404d7757c379bd1f7e5cce9d8bf425384" + "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", + "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc" ], "markers": "python_version >= '3.7'", - "version": "==1.3.0" + "version": "==1.3.1" }, "typing-extensions": { "hashes": [ - "sha256:23478f88c37f27d76ac8aee6c905017a143b0b1b886c3c9f66bc2fd94f9f5783", - "sha256:af72aea155e91adfc61c3ae9e0e342dbc0cba726d6cba4b6c72c1f34e47291cd" + "sha256:83f085bd5ca59c80295fc2a82ab5dac679cbe02b9f33f7d83af68e241bea51b0", + "sha256:c1f94d72897edaf4ce775bb7558d5b79d8126906a14ea5ed1635921406c0387a" ], "markers": "python_version >= '3.8'", - "version": "==4.9.0" + "version": "==4.11.0" }, "wrapt": { "hashes": [ @@ -413,6 +414,14 @@ "markers": "python_version >= '3.7'", "version": "==2.14.0" }, + "backports.tarfile": { + "hashes": [ + "sha256:2688f159c21afd56a07b75f01306f9f52c79aebcc5f4a117fb8fbb4445352c75", + "sha256:bcd36290d9684beb524d3fe74f4a2db056824c47746583f090b8e55daf0776e4" + ], + "markers": "python_version < '3.12'", + "version": "==1.0.0" + }, "black": { "hashes": [ "sha256:06f9d8846f2340dfac80ceb20200ea5d1b3f181dd0556b47af4e8e0b24fa0a6b", @@ -445,11 +454,69 @@ }, "certifi": { "hashes": [ - "sha256:9b469f3a900bf28dc19b8cfbf8019bf47f7fdd1a65a1d4ffb98fc14166beb4d1", - "sha256:e036ab49d5b79556f99cfc2d9320b34cfbe5be05c5871b51de9329f0603b0474" + "sha256:0569859f95fc761b18b45ef421b1290a0f65f147e92a1e5eb3e635f9a5e4e66f", + "sha256:dc383c07b76109f368f6106eee2b593b04a011ea4d55f652c6ca24a754d1cdd1" ], "markers": "python_version >= '3.6'", - "version": "==2023.11.17" + "version": "==2024.2.2" + }, + "cffi": { + "hashes": [ + "sha256:0c9ef6ff37e974b73c25eecc13952c55bceed9112be2d9d938ded8e856138bcc", + "sha256:131fd094d1065b19540c3d72594260f118b231090295d8c34e19a7bbcf2e860a", + "sha256:1b8ebc27c014c59692bb2664c7d13ce7a6e9a629be20e54e7271fa696ff2b417", + "sha256:2c56b361916f390cd758a57f2e16233eb4f64bcbeee88a4881ea90fca14dc6ab", + "sha256:2d92b25dbf6cae33f65005baf472d2c245c050b1ce709cc4588cdcdd5495b520", + "sha256:31d13b0f99e0836b7ff893d37af07366ebc90b678b6664c955b54561fc36ef36", + "sha256:32c68ef735dbe5857c810328cb2481e24722a59a2003018885514d4c09af9743", + "sha256:3686dffb02459559c74dd3d81748269ffb0eb027c39a6fc99502de37d501faa8", + "sha256:582215a0e9adbe0e379761260553ba11c58943e4bbe9c36430c4ca6ac74b15ed", + "sha256:5b50bf3f55561dac5438f8e70bfcdfd74543fd60df5fa5f62d94e5867deca684", + "sha256:5bf44d66cdf9e893637896c7faa22298baebcd18d1ddb6d2626a6e39793a1d56", + "sha256:6602bc8dc6f3a9e02b6c22c4fc1e47aa50f8f8e6d3f78a5e16ac33ef5fefa324", + "sha256:673739cb539f8cdaa07d92d02efa93c9ccf87e345b9a0b556e3ecc666718468d", + "sha256:68678abf380b42ce21a5f2abde8efee05c114c2fdb2e9eef2efdb0257fba1235", + "sha256:68e7c44931cc171c54ccb702482e9fc723192e88d25a0e133edd7aff8fcd1f6e", + "sha256:6b3d6606d369fc1da4fd8c357d026317fbb9c9b75d36dc16e90e84c26854b088", + "sha256:748dcd1e3d3d7cd5443ef03ce8685043294ad6bd7c02a38d1bd367cfd968e000", + "sha256:7651c50c8c5ef7bdb41108b7b8c5a83013bfaa8a935590c5d74627c047a583c7", + "sha256:7b78010e7b97fef4bee1e896df8a4bbb6712b7f05b7ef630f9d1da00f6444d2e", + "sha256:7e61e3e4fa664a8588aa25c883eab612a188c725755afff6289454d6362b9673", + "sha256:80876338e19c951fdfed6198e70bc88f1c9758b94578d5a7c4c91a87af3cf31c", + "sha256:8895613bcc094d4a1b2dbe179d88d7fb4a15cee43c052e8885783fac397d91fe", + "sha256:88e2b3c14bdb32e440be531ade29d3c50a1a59cd4e51b1dd8b0865c54ea5d2e2", + "sha256:8f8e709127c6c77446a8c0a8c8bf3c8ee706a06cd44b1e827c3e6a2ee6b8c098", + "sha256:9cb4a35b3642fc5c005a6755a5d17c6c8b6bcb6981baf81cea8bfbc8903e8ba8", + "sha256:9f90389693731ff1f659e55c7d1640e2ec43ff725cc61b04b2f9c6d8d017df6a", + "sha256:a09582f178759ee8128d9270cd1344154fd473bb77d94ce0aeb2a93ebf0feaf0", + "sha256:a6a14b17d7e17fa0d207ac08642c8820f84f25ce17a442fd15e27ea18d67c59b", + "sha256:a72e8961a86d19bdb45851d8f1f08b041ea37d2bd8d4fd19903bc3083d80c896", + "sha256:abd808f9c129ba2beda4cfc53bde801e5bcf9d6e0f22f095e45327c038bfe68e", + "sha256:ac0f5edd2360eea2f1daa9e26a41db02dd4b0451b48f7c318e217ee092a213e9", + "sha256:b29ebffcf550f9da55bec9e02ad430c992a87e5f512cd63388abb76f1036d8d2", + "sha256:b2ca4e77f9f47c55c194982e10f058db063937845bb2b7a86c84a6cfe0aefa8b", + "sha256:b7be2d771cdba2942e13215c4e340bfd76398e9227ad10402a8767ab1865d2e6", + "sha256:b84834d0cf97e7d27dd5b7f3aca7b6e9263c56308ab9dc8aae9784abb774d404", + "sha256:b86851a328eedc692acf81fb05444bdf1891747c25af7529e39ddafaf68a4f3f", + "sha256:bcb3ef43e58665bbda2fb198698fcae6776483e0c4a631aa5647806c25e02cc0", + "sha256:c0f31130ebc2d37cdd8e44605fb5fa7ad59049298b3f745c74fa74c62fbfcfc4", + "sha256:c6a164aa47843fb1b01e941d385aab7215563bb8816d80ff3a363a9f8448a8dc", + "sha256:d8a9d3ebe49f084ad71f9269834ceccbf398253c9fac910c4fd7053ff1386936", + "sha256:db8e577c19c0fda0beb7e0d4e09e0ba74b1e4c092e0e40bfa12fe05b6f6d75ba", + "sha256:dc9b18bf40cc75f66f40a7379f6a9513244fe33c0e8aa72e2d56b0196a7ef872", + "sha256:e09f3ff613345df5e8c3667da1d918f9149bd623cd9070c983c013792a9a62eb", + "sha256:e4108df7fe9b707191e55f33efbcb2d81928e10cea45527879a4749cbe472614", + "sha256:e6024675e67af929088fda399b2094574609396b1decb609c55fa58b028a32a1", + "sha256:e70f54f1796669ef691ca07d046cd81a29cb4deb1e5f942003f401c0c4a2695d", + "sha256:e715596e683d2ce000574bae5d07bd522c781a822866c20495e52520564f0969", + "sha256:e760191dd42581e023a68b758769e2da259b5d52e3103c6060ddc02c9edb8d7b", + "sha256:ed86a35631f7bfbb28e108dd96773b9d5a6ce4811cf6ea468bb6a359b256b1e4", + "sha256:ee07e47c12890ef248766a6e55bd38ebfb2bb8edd4142d56db91b21ea68b7627", + "sha256:fa3a0128b152627161ce47201262d3140edb5a5c3da88d73a1b790a959126956", + "sha256:fcc8eb6d5902bb1cf6dc4f187ee3ea80a1eba0a89aba40a5cb20a5087d961357" + ], + "markers": "platform_python_implementation != 'PyPy'", + "version": "==1.16.0" }, "charset-normalizer": { "hashes": [ @@ -565,53 +632,53 @@ }, "contourpy": { "hashes": [ - "sha256:0274c1cb63625972c0c007ab14dd9ba9e199c36ae1a231ce45d725cbcbfd10a8", - "sha256:0d7e03c0f9a4f90dc18d4e77e9ef4ec7b7bbb437f7f675be8e530d65ae6ef956", - "sha256:11f8d2554e52f459918f7b8e6aa20ec2a3bce35ce95c1f0ef4ba36fbda306df5", - "sha256:139d8d2e1c1dd52d78682f505e980f592ba53c9f73bd6be102233e358b401063", - "sha256:16a7380e943a6d52472096cb7ad5264ecee36ed60888e2a3d3814991a0107286", - "sha256:171f311cb758de7da13fc53af221ae47a5877be5a0843a9fe150818c51ed276a", - "sha256:18fc2b4ed8e4a8fe849d18dce4bd3c7ea637758c6343a1f2bae1e9bd4c9f4686", - "sha256:1c203f617abc0dde5792beb586f827021069fb6d403d7f4d5c2b543d87edceb9", - "sha256:1c2559d6cffc94890b0529ea7eeecc20d6fadc1539273aa27faf503eb4656d8f", - "sha256:1c88dfb9e0c77612febebb6ac69d44a8d81e3dc60f993215425b62c1161353f4", - "sha256:1e9dc350fb4c58adc64df3e0703ab076f60aac06e67d48b3848c23647ae4310e", - "sha256:247b9d16535acaa766d03037d8e8fb20866d054d3c7fbf6fd1f993f11fc60ca0", - "sha256:266270c6f6608340f6c9836a0fb9b367be61dde0c9a9a18d5ece97774105ff3e", - "sha256:34b9071c040d6fe45d9826cbbe3727d20d83f1b6110d219b83eb0e2a01d79488", - "sha256:3d7d1f8871998cdff5d2ff6a087e5e1780139abe2838e85b0b46b7ae6cc25399", - "sha256:461e3ae84cd90b30f8d533f07d87c00379644205b1d33a5ea03381edc4b69431", - "sha256:464b423bc2a009088f19bdf1f232299e8b6917963e2b7e1d277da5041f33a779", - "sha256:491b1917afdd8638a05b611a56d46587d5a632cabead889a5440f7c638bc6ed9", - "sha256:4a1b1208102be6e851f20066bf0e7a96b7d48a07c9b0cfe6d0d4545c2f6cadab", - "sha256:575bcaf957a25d1194903a10bc9f316c136c19f24e0985a2b9b5608bdf5dbfe0", - "sha256:5c6b28956b7b232ae801406e529ad7b350d3f09a4fde958dfdf3c0520cdde0dd", - "sha256:5d16edfc3fc09968e09ddffada434b3bf989bf4911535e04eada58469873e28e", - "sha256:5fd1810973a375ca0e097dee059c407913ba35723b111df75671a1976efa04bc", - "sha256:67b7f17679fa62ec82b7e3e611c43a016b887bd64fb933b3ae8638583006c6d6", - "sha256:68ce4788b7d93e47f84edd3f1f95acdcd142ae60bc0e5493bfd120683d2d4316", - "sha256:6d3364b999c62f539cd403f8123ae426da946e142312a514162adb2addd8d808", - "sha256:6e739530c662a8d6d42c37c2ed52a6f0932c2d4a3e8c1f90692ad0ce1274abe0", - "sha256:6fdd887f17c2f4572ce548461e4f96396681212d858cae7bd52ba3310bc6f00f", - "sha256:78e6ad33cf2e2e80c5dfaaa0beec3d61face0fb650557100ee36db808bfa6843", - "sha256:884c3f9d42d7218304bc74a8a7693d172685c84bd7ab2bab1ee567b769696df9", - "sha256:8d8faf05be5ec8e02a4d86f616fc2a0322ff4a4ce26c0f09d9f7fb5330a35c95", - "sha256:999c71939aad2780f003979b25ac5b8f2df651dac7b38fb8ce6c46ba5abe6ae9", - "sha256:99ad97258985328b4f207a5e777c1b44a83bfe7cf1f87b99f9c11d4ee477c4de", - "sha256:9e6c93b5b2dbcedad20a2f18ec22cae47da0d705d454308063421a3b290d9ea4", - "sha256:ab459a1cbbf18e8698399c595a01f6dcc5c138220ca3ea9e7e6126232d102bb4", - "sha256:b69303ceb2e4d4f146bf82fda78891ef7bcd80c41bf16bfca3d0d7eb545448aa", - "sha256:b7caf9b241464c404613512d5594a6e2ff0cc9cb5615c9475cc1d9b514218ae8", - "sha256:b95a225d4948b26a28c08307a60ac00fb8671b14f2047fc5476613252a129776", - "sha256:bd2f1ae63998da104f16a8b788f685e55d65760cd1929518fd94cd682bf03e41", - "sha256:be16975d94c320432657ad2402f6760990cb640c161ae6da1363051805fa8108", - "sha256:ce96dd400486e80ac7d195b2d800b03e3e6a787e2a522bfb83755938465a819e", - "sha256:dbd50d0a0539ae2e96e537553aff6d02c10ed165ef40c65b0e27e744a0f10af8", - "sha256:dd10c26b4eadae44783c45ad6655220426f971c61d9b239e6f7b16d5cdaaa727", - "sha256:ebeac59e9e1eb4b84940d076d9f9a6cec0064e241818bcb6e32124cc5c3e377a" + "sha256:00e5388f71c1a0610e6fe56b5c44ab7ba14165cdd6d695429c5cd94021e390b2", + "sha256:10a37ae557aabf2509c79715cd20b62e4c7c28b8cd62dd7d99e5ed3ce28c3fd9", + "sha256:11959f0ce4a6f7b76ec578576a0b61a28bdc0696194b6347ba3f1c53827178b9", + "sha256:187fa1d4c6acc06adb0fae5544c59898ad781409e61a926ac7e84b8f276dcef4", + "sha256:1a07fc092a4088ee952ddae19a2b2a85757b923217b7eed584fdf25f53a6e7ce", + "sha256:1cac0a8f71a041aa587410424ad46dfa6a11f6149ceb219ce7dd48f6b02b87a7", + "sha256:1d59e739ab0e3520e62a26c60707cc3ab0365d2f8fecea74bfe4de72dc56388f", + "sha256:2855c8b0b55958265e8b5888d6a615ba02883b225f2227461aa9127c578a4922", + "sha256:2e785e0f2ef0d567099b9ff92cbfb958d71c2d5b9259981cd9bee81bd194c9a4", + "sha256:309be79c0a354afff9ff7da4aaed7c3257e77edf6c1b448a779329431ee79d7e", + "sha256:39f3ecaf76cd98e802f094e0d4fbc6dc9c45a8d0c4d185f0f6c2234e14e5f75b", + "sha256:457499c79fa84593f22454bbd27670227874cd2ff5d6c84e60575c8b50a69619", + "sha256:49e70d111fee47284d9dd867c9bb9a7058a3c617274900780c43e38d90fe1205", + "sha256:4c75507d0a55378240f781599c30e7776674dbaf883a46d1c90f37e563453480", + "sha256:4c863140fafc615c14a4bf4efd0f4425c02230eb8ef02784c9a156461e62c965", + "sha256:4d8908b3bee1c889e547867ca4cdc54e5ab6be6d3e078556814a22457f49423c", + "sha256:5b9eb0ca724a241683c9685a484da9d35c872fd42756574a7cfbf58af26677fd", + "sha256:6022cecf8f44e36af10bd9118ca71f371078b4c168b6e0fab43d4a889985dbb5", + "sha256:6150ffa5c767bc6332df27157d95442c379b7dce3a38dff89c0f39b63275696f", + "sha256:62828cada4a2b850dbef89c81f5a33741898b305db244904de418cc957ff05dc", + "sha256:7b4182299f251060996af5249c286bae9361fa8c6a9cda5efc29fe8bfd6062ec", + "sha256:94b34f32646ca0414237168d68a9157cb3889f06b096612afdd296003fdd32fd", + "sha256:9ce6889abac9a42afd07a562c2d6d4b2b7134f83f18571d859b25624a331c90b", + "sha256:9cffe0f850e89d7c0012a1fb8730f75edd4320a0a731ed0c183904fe6ecfc3a9", + "sha256:a12a813949e5066148712a0626895c26b2578874e4cc63160bb007e6df3436fe", + "sha256:a1eea9aecf761c661d096d39ed9026574de8adb2ae1c5bd7b33558af884fb2ce", + "sha256:a31f94983fecbac95e58388210427d68cd30fe8a36927980fab9c20062645609", + "sha256:ac58bdee53cbeba2ecad824fa8159493f0bf3b8ea4e93feb06c9a465d6c87da8", + "sha256:af3f4485884750dddd9c25cb7e3915d83c2db92488b38ccb77dd594eac84c4a0", + "sha256:b33d2bc4f69caedcd0a275329eb2198f560b325605810895627be5d4b876bf7f", + "sha256:b59c0ffceff8d4d3996a45f2bb6f4c207f94684a96bf3d9728dbb77428dd8cb8", + "sha256:bb6834cbd983b19f06908b45bfc2dad6ac9479ae04abe923a275b5f48f1a186b", + "sha256:bd3db01f59fdcbce5b22afad19e390260d6d0222f35a1023d9adc5690a889364", + "sha256:bd7c23df857d488f418439686d3b10ae2fbf9bc256cd045b37a8c16575ea1040", + "sha256:c2528d60e398c7c4c799d56f907664673a807635b857df18f7ae64d3e6ce2d9f", + "sha256:d31a63bc6e6d87f77d71e1abbd7387ab817a66733734883d1fc0021ed9bfa083", + "sha256:d4492d82b3bc7fbb7e3610747b159869468079fe149ec5c4d771fa1f614a14df", + "sha256:ddcb8581510311e13421b1f544403c16e901c4e8f09083c881fab2be80ee31ba", + "sha256:e1d59258c3c67c865435d8fbeb35f8c59b8bef3d6f46c1f29f6123556af28445", + "sha256:eb3315a8a236ee19b6df481fc5f997436e8ade24a9f03dfdc6bd490fea20c6da", + "sha256:ef2b055471c0eb466033760a521efb9d8a32b99ab907fc8358481a1dd29e3bd3", + "sha256:ef5adb9a3b1d0c645ff694f9bca7702ec2c70f4d734f9922ea34de02294fdf72", + "sha256:f32c38afb74bd98ce26de7cc74a67b40afb7b05aae7b42924ea990d51e4dac02", + "sha256:fe0ccca550bb8e5abc22f530ec0466136379c01321fd94f30a22231e8a48d985" ], "markers": "python_version >= '3.9'", - "version": "==1.2.0" + "version": "==1.2.1" }, "coverage": { "extras": [ @@ -675,6 +742,44 @@ "markers": "python_version >= '3.8'", "version": "==7.4.1" }, + "cryptography": { + "hashes": [ + "sha256:0270572b8bd2c833c3981724b8ee9747b3ec96f699a9665470018594301439ee", + "sha256:111a0d8553afcf8eb02a4fea6ca4f59d48ddb34497aa8706a6cf536f1a5ec576", + "sha256:16a48c23a62a2f4a285699dba2e4ff2d1cff3115b9df052cdd976a18856d8e3d", + "sha256:1b95b98b0d2af784078fa69f637135e3c317091b615cd0905f8b8a087e86fa30", + "sha256:1f71c10d1e88467126f0efd484bd44bca5e14c664ec2ede64c32f20875c0d413", + "sha256:2424ff4c4ac7f6b8177b53c17ed5d8fa74ae5955656867f5a8affaca36a27abb", + "sha256:2bce03af1ce5a5567ab89bd90d11e7bbdff56b8af3acbbec1faded8f44cb06da", + "sha256:329906dcc7b20ff3cad13c069a78124ed8247adcac44b10bea1130e36caae0b4", + "sha256:37dd623507659e08be98eec89323469e8c7b4c1407c85112634ae3dbdb926fdd", + "sha256:3eaafe47ec0d0ffcc9349e1708be2aaea4c6dd4978d76bf6eb0cb2c13636c6fc", + "sha256:5e6275c09d2badf57aea3afa80d975444f4be8d3bc58f7f80d2a484c6f9485c8", + "sha256:6fe07eec95dfd477eb9530aef5bead34fec819b3aaf6c5bd6d20565da607bfe1", + "sha256:7367d7b2eca6513681127ebad53b2582911d1736dc2ffc19f2c3ae49997496bc", + "sha256:7cde5f38e614f55e28d831754e8a3bacf9ace5d1566235e39d91b35502d6936e", + "sha256:9481ffe3cf013b71b2428b905c4f7a9a4f76ec03065b05ff499bb5682a8d9ad8", + "sha256:98d8dc6d012b82287f2c3d26ce1d2dd130ec200c8679b6213b3c73c08b2b7940", + "sha256:a011a644f6d7d03736214d38832e030d8268bcff4a41f728e6030325fea3e400", + "sha256:a2913c5375154b6ef2e91c10b5720ea6e21007412f6437504ffea2109b5a33d7", + "sha256:a30596bae9403a342c978fb47d9b0ee277699fa53bbafad14706af51fe543d16", + "sha256:b03c2ae5d2f0fc05f9a2c0c997e1bc18c8229f392234e8a0194f202169ccd278", + "sha256:b6cd2203306b63e41acdf39aa93b86fb566049aeb6dc489b70e34bcd07adca74", + "sha256:b7ffe927ee6531c78f81aa17e684e2ff617daeba7f189f911065b2ea2d526dec", + "sha256:b8cac287fafc4ad485b8a9b67d0ee80c66bf3574f655d3b97ef2e1082360faf1", + "sha256:ba334e6e4b1d92442b75ddacc615c5476d4ad55cc29b15d590cc6b86efa487e2", + "sha256:ba3e4a42397c25b7ff88cdec6e2a16c2be18720f317506ee25210f6d31925f9c", + "sha256:c41fb5e6a5fe9ebcd58ca3abfeb51dffb5d83d6775405305bfa8715b76521922", + "sha256:cd2030f6650c089aeb304cf093f3244d34745ce0cfcc39f20c6fbfe030102e2a", + "sha256:cd65d75953847815962c84a4654a84850b2bb4aed3f26fadcc1c13892e1e29f6", + "sha256:e4985a790f921508f36f81831817cbc03b102d643b5fcb81cd33df3fa291a1a1", + "sha256:e807b3188f9eb0eaa7bbb579b462c5ace579f1cedb28107ce8b48a9f7ad3679e", + "sha256:f12764b8fffc7a123f641d7d049d382b73f96a34117e0b637b80643169cec8ac", + "sha256:f8837fe1d6ac4a8052a9a8ddab256bc006242696f03368a4009be7ee3075cdb7" + ], + "markers": "python_version >= '3.7'", + "version": "==42.0.5" + }, "cycler": { "hashes": [ "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", @@ -710,11 +815,11 @@ }, "execnet": { "hashes": [ - "sha256:88256416ae766bc9e8895c76a87928c0012183da3cc4fc18016e6f050e025f41", - "sha256:cc59bc4423742fd71ad227122eb0dd44db51efb3dc4095b45ac9a08c770096af" + "sha256:26dee51f1b80cebd6d0ca8e74dd8745419761d3bef34163928cbebbdc4749fdc", + "sha256:5189b52c6121c24feae288166ab41b32549c7e2348652736540b9e6e7d4e72e3" ], - "markers": "python_version >= '3.7'", - "version": "==2.0.2" + "markers": "python_version >= '3.8'", + "version": "==2.1.1" }, "flake8": { "hashes": [ @@ -754,51 +859,51 @@ }, "fonttools": { "hashes": [ - "sha256:0255dbc128fee75fb9be364806b940ed450dd6838672a150d501ee86523ac61e", - "sha256:0a00bd0e68e88987dcc047ea31c26d40a3c61185153b03457956a87e39d43c37", - "sha256:0a1d313a415eaaba2b35d6cd33536560deeebd2ed758b9bfb89ab5d97dc5deac", - "sha256:0f750037e02beb8b3569fbff701a572e62a685d2a0e840d75816592280e5feae", - "sha256:13819db8445a0cec8c3ff5f243af6418ab19175072a9a92f6cc8ca7d1452754b", - "sha256:254d9a6f7be00212bf0c3159e0a420eb19c63793b2c05e049eb337f3023c5ecc", - "sha256:29495d6d109cdbabe73cfb6f419ce67080c3ef9ea1e08d5750240fd4b0c4763b", - "sha256:32ab2e9702dff0dd4510c7bb958f265a8d3dd5c0e2547e7b5f7a3df4979abb07", - "sha256:3480eeb52770ff75140fe7d9a2ec33fb67b07efea0ab5129c7e0c6a639c40c70", - "sha256:3a808f3c1d1df1f5bf39be869b6e0c263570cdafb5bdb2df66087733f566ea71", - "sha256:3b629108351d25512d4ea1a8393a2dba325b7b7d7308116b605ea3f8e1be88df", - "sha256:3d71606c9321f6701642bd4746f99b6089e53d7e9817fc6b964e90d9c5f0ecc6", - "sha256:3e2b95dce2ead58fb12524d0ca7d63a63459dd489e7e5838c3cd53557f8933e1", - "sha256:4a5a5318ba5365d992666ac4fe35365f93004109d18858a3e18ae46f67907670", - "sha256:4c811d3c73b6abac275babb8aa439206288f56fdb2c6f8835e3d7b70de8937a7", - "sha256:4e743935139aa485fe3253fc33fe467eab6ea42583fa681223ea3f1a93dd01e6", - "sha256:4ec558c543609e71b2275c4894e93493f65d2f41c15fe1d089080c1d0bb4d635", - "sha256:5465df494f20a7d01712b072ae3ee9ad2887004701b95cb2cc6dcb9c2c97a899", - "sha256:5b60e3afa9635e3dfd3ace2757039593e3bd3cf128be0ddb7a1ff4ac45fa5a50", - "sha256:63fbed184979f09a65aa9c88b395ca539c94287ba3a364517698462e13e457c9", - "sha256:69731e8bea0578b3c28fdb43dbf95b9386e2d49a399e9a4ad736b8e479b08085", - "sha256:6dd58cc03016b281bd2c74c84cdaa6bd3ce54c5a7f47478b7657b930ac3ed8eb", - "sha256:740947906590a878a4bde7dd748e85fefa4d470a268b964748403b3ab2aeed6c", - "sha256:7df26dd3650e98ca45f1e29883c96a0b9f5bb6af8d632a6a108bc744fa0bd9b3", - "sha256:7eb7ad665258fba68fd22228a09f347469d95a97fb88198e133595947a20a184", - "sha256:7ee48bd9d6b7e8f66866c9090807e3a4a56cf43ffad48962725a190e0dd774c8", - "sha256:86e0427864c6c91cf77f16d1fb9bf1bbf7453e824589e8fb8461b6ee1144f506", - "sha256:8f57ecd742545362a0f7186774b2d1c53423ed9ece67689c93a1055b236f638c", - "sha256:90f898cdd67f52f18049250a6474185ef6544c91f27a7bee70d87d77a8daf89c", - "sha256:94208ea750e3f96e267f394d5588579bb64cc628e321dbb1d4243ffbc291b18b", - "sha256:a1c154bb85dc9a4cf145250c88d112d88eb414bad81d4cb524d06258dea1bdc0", - "sha256:a5d77479fb885ef38a16a253a2f4096bc3d14e63a56d6246bfdb56365a12b20c", - "sha256:a86a5ab2873ed2575d0fcdf1828143cfc6b977ac448e3dc616bb1e3d20efbafa", - "sha256:ac71e2e201df041a2891067dc36256755b1229ae167edbdc419b16da78732c2f", - "sha256:b3e1304e5f19ca861d86a72218ecce68f391646d85c851742d265787f55457a4", - "sha256:b8be28c036b9f186e8c7eaf8a11b42373e7e4949f9e9f370202b9da4c4c3f56c", - "sha256:c19044256c44fe299d9a73456aabee4b4d06c6b930287be93b533b4737d70aa1", - "sha256:d49ce3ea7b7173faebc5664872243b40cf88814ca3eb135c4a3cdff66af71946", - "sha256:e040f905d542362e07e72e03612a6270c33d38281fd573160e1003e43718d68d", - "sha256:eabae77a07c41ae0b35184894202305c3ad211a93b2eb53837c2a1143c8bc952", - "sha256:f791446ff297fd5f1e2247c188de53c1bfb9dd7f0549eba55b73a3c2087a2703", - "sha256:f83a4daef6d2a202acb9bf572958f91cfde5b10c8ee7fb1d09a4c81e5d851fd8" + "sha256:0118ef998a0699a96c7b28457f15546815015a2710a1b23a7bf6c1be60c01636", + "sha256:0d145976194a5242fdd22df18a1b451481a88071feadf251221af110ca8f00ce", + "sha256:0e19bd9e9964a09cd2433a4b100ca7f34e34731e0758e13ba9a1ed6e5468cc0f", + "sha256:0f08c901d3866a8905363619e3741c33f0a83a680d92a9f0e575985c2634fcc1", + "sha256:1250e818b5f8a679ad79660855528120a8f0288f8f30ec88b83db51515411fcc", + "sha256:15c94eeef6b095831067f72c825eb0e2d48bb4cea0647c1b05c981ecba2bf39f", + "sha256:1621ee57da887c17312acc4b0e7ac30d3a4fb0fec6174b2e3754a74c26bbed1e", + "sha256:180194c7fe60c989bb627d7ed5011f2bef1c4d36ecf3ec64daec8302f1ae0716", + "sha256:278e50f6b003c6aed19bae2242b364e575bcb16304b53f2b64f6551b9c000e15", + "sha256:32b17504696f605e9e960647c5f64b35704782a502cc26a37b800b4d69ff3c77", + "sha256:3bee3f3bd9fa1d5ee616ccfd13b27ca605c2b4270e45715bd2883e9504735034", + "sha256:4060acc2bfa2d8e98117828a238889f13b6f69d59f4f2d5857eece5277b829ba", + "sha256:54dcf21a2f2d06ded676e3c3f9f74b2bafded3a8ff12f0983160b13e9f2fb4a7", + "sha256:56fc244f2585d6c00b9bcc59e6593e646cf095a96fe68d62cd4da53dd1287b55", + "sha256:599bdb75e220241cedc6faebfafedd7670335d2e29620d207dd0378a4e9ccc5a", + "sha256:5f6bc991d1610f5c3bbe997b0233cbc234b8e82fa99fc0b2932dc1ca5e5afec0", + "sha256:60a3409c9112aec02d5fb546f557bca6efa773dcb32ac147c6baf5f742e6258b", + "sha256:68b3fb7775a923be73e739f92f7e8a72725fd333eab24834041365d2278c3671", + "sha256:76f1777d8b3386479ffb4a282e74318e730014d86ce60f016908d9801af9ca2a", + "sha256:806e7912c32a657fa39d2d6eb1d3012d35f841387c8fc6cf349ed70b7c340039", + "sha256:84d7751f4468dd8cdd03ddada18b8b0857a5beec80bce9f435742abc9a851a74", + "sha256:865a58b6e60b0938874af0968cd0553bcd88e0b2cb6e588727117bd099eef836", + "sha256:8ac27f436e8af7779f0bb4d5425aa3535270494d3bc5459ed27de3f03151e4c2", + "sha256:8b4850fa2ef2cfbc1d1f689bc159ef0f45d8d83298c1425838095bf53ef46308", + "sha256:8b5ad456813d93b9c4b7ee55302208db2b45324315129d85275c01f5cb7e61a2", + "sha256:8e2f1a4499e3b5ee82c19b5ee57f0294673125c65b0a1ff3764ea1f9db2f9ef5", + "sha256:9696fe9f3f0c32e9a321d5268208a7cc9205a52f99b89479d1b035ed54c923f1", + "sha256:96a48e137c36be55e68845fc4284533bda2980f8d6f835e26bca79d7e2006438", + "sha256:a8feca65bab31479d795b0d16c9a9852902e3a3c0630678efb0b2b7941ea9c74", + "sha256:aefa011207ed36cd280babfaa8510b8176f1a77261833e895a9d96e57e44802f", + "sha256:b2b92381f37b39ba2fc98c3a45a9d6383bfc9916a87d66ccb6553f7bdd129097", + "sha256:b3c61423f22165541b9403ee39874dcae84cd57a9078b82e1dce8cb06b07fa2e", + "sha256:b5b48a1121117047d82695d276c2af2ee3a24ffe0f502ed581acc2673ecf1037", + "sha256:c18b49adc721a7d0b8dfe7c3130c89b8704baf599fb396396d07d4aa69b824a1", + "sha256:c5b8cab0c137ca229433570151b5c1fc6af212680b58b15abd797dcdd9dd5051", + "sha256:c7e91abdfae1b5c9e3a543f48ce96013f9a08c6c9668f1e6be0beabf0a569c1b", + "sha256:cadf4e12a608ef1d13e039864f484c8a968840afa0258b0b843a0556497ea9ed", + "sha256:dc0673361331566d7a663d7ce0f6fdcbfbdc1f59c6e3ed1165ad7202ca183c68", + "sha256:de7c29bdbdd35811f14493ffd2534b88f0ce1b9065316433b22d63ca1cd21f14", + "sha256:e9d9298be7a05bb4801f558522adbe2feea1b0b103d5294ebf24a92dd49b78e5", + "sha256:ee1af4be1c5afe4c96ca23badd368d8dc75f611887fb0c0dac9f71ee5d6f110e", + "sha256:f7e89853d8bea103c8e3514b9f9dc86b5b4120afb4583b57eb10dfa5afbe0936" ], "markers": "python_version >= '3.8'", - "version": "==4.47.2" + "version": "==4.51.0" }, "gprof2dot": { "hashes": [ @@ -819,11 +924,11 @@ }, "idna": { "hashes": [ - "sha256:9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca", - "sha256:c05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f" + "sha256:028ff3aadf0609c1fd278d8ea3089299412a7a8b9bd005dd08b9f8285bcb5cfc", + "sha256:82fee1fc78add43492d3a1898bfa6d8a904cc97d8427f683ed8e798d07761aa0" ], "markers": "python_version >= '3.5'", - "version": "==3.6" + "version": "==3.7" }, "imagesize": { "hashes": [ @@ -835,11 +940,11 @@ }, "importlib-metadata": { "hashes": [ - "sha256:4805911c3a4ec7c3966410053e9ec6a1fecd629117df5adee56dfc9432a1081e", - "sha256:f238736bb06590ae52ac1fab06a3a9ef1d8dce2b7a35b5ab329371d6c8f5d2cc" + "sha256:30962b96c0c223483ed6cc7280e7f0199feb01a0e40cfae4d4450fc6fab1f570", + "sha256:b78938b926ee8d5f020fc4772d487045805a55ddbad2ecf21c6d60938dc7fcd2" ], "markers": "python_version >= '3.8'", - "version": "==7.0.1" + "version": "==7.1.0" }, "iniconfig": { "hashes": [ @@ -851,11 +956,35 @@ }, "jaraco.classes": { "hashes": [ - "sha256:10afa92b6743f25c0cf5f37c6bb6e18e2c5bb84a16527ccfc0040ea377e7aaeb", - "sha256:c063dd08e89217cee02c8d5e5ec560f2c8ce6cdc2fcdc2e68f7b2e5547ed3621" + "sha256:47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd", + "sha256:f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790" ], "markers": "python_version >= '3.8'", - "version": "==3.3.0" + "version": "==3.4.0" + }, + "jaraco.context": { + "hashes": [ + "sha256:3e16388f7da43d384a1a7cd3452e72e14732ac9fe459678773a3608a812bf266", + "sha256:c2f67165ce1f9be20f32f650f25d8edfc1646a8aeee48ae06fb35f90763576d2" + ], + "markers": "python_version >= '3.8'", + "version": "==5.3.0" + }, + "jaraco.functools": { + "hashes": [ + "sha256:c279cb24c93d694ef7270f970d499cab4d3813f4e08273f95398651a634f0925", + "sha256:daf276ddf234bea897ef14f43c4e1bf9eefeac7b7a82a4dd69228ac20acff68d" + ], + "markers": "python_version >= '3.8'", + "version": "==4.0.0" + }, + "jeepney": { + "hashes": [ + "sha256:5efe48d255973902f6badc3ce55e2aa6c5c3b3bc642059ef3a91247bcfcc5806", + "sha256:c0a454ad016ca575060802ee4d590dd912e35c122fa04e70306de3d076cce755" + ], + "markers": "sys_platform == 'linux'", + "version": "==0.8.0" }, "jinja2": { "hashes": [ @@ -866,13 +995,22 @@ "markers": "python_version >= '3.6'", "version": "==3.0.3" }, + "jsonschema": { + "hashes": [ + "sha256:0f864437ab8b6076ba6707453ef8f98a6a0d512a80e93f8abdb676f737ecb60d", + "sha256:a870ad254da1a8ca84b6a2905cac29d265f805acc57af304784962a2aa6508f6" + ], + "index": "pypi", + "markers": "python_version >= '3.7'", + "version": "==4.17.3" + }, "keyring": { "hashes": [ - "sha256:4446d35d636e6a10b8bce7caa66913dd9eca5fd222ca03a3d42c38608ac30836", - "sha256:e730ecffd309658a08ee82535a3b5ec4b4c8669a9be11efb66249d8e0aeb9a25" + "sha256:26fc12e6a329d61d24aa47b22a7c5c3f35753df7d8f2860973cf94f4e1fb3427", + "sha256:7230ea690525133f6ad536a9b5def74a4bd52642abe594761028fc044d7c7893" ], "markers": "python_version >= '3.8'", - "version": "==24.3.0" + "version": "==25.1.0" }, "kiwisolver": { "hashes": [ @@ -994,103 +1132,103 @@ }, "markupsafe": { "hashes": [ - "sha256:0042d6a9880b38e1dd9ff83146cc3c9c18a059b9360ceae207805567aacccc69", - "sha256:0c26f67b3fe27302d3a412b85ef696792c4a2386293c53ba683a89562f9399b0", - "sha256:0fbad3d346df8f9d72622ac71b69565e621ada2ce6572f37c2eae8dacd60385d", - "sha256:15866d7f2dc60cfdde12ebb4e75e41be862348b4728300c36cdf405e258415ec", - "sha256:1c98c33ffe20e9a489145d97070a435ea0679fddaabcafe19982fe9c971987d5", - "sha256:21e7af8091007bf4bebf4521184f4880a6acab8df0df52ef9e513d8e5db23411", - "sha256:23984d1bdae01bee794267424af55eef4dfc038dc5d1272860669b2aa025c9e3", - "sha256:31f57d64c336b8ccb1966d156932f3daa4fee74176b0fdc48ef580be774aae74", - "sha256:3583a3a3ab7958e354dc1d25be74aee6228938312ee875a22330c4dc2e41beb0", - "sha256:36d7626a8cca4d34216875aee5a1d3d654bb3dac201c1c003d182283e3205949", - "sha256:396549cea79e8ca4ba65525470d534e8a41070e6b3500ce2414921099cb73e8d", - "sha256:3a66c36a3864df95e4f62f9167c734b3b1192cb0851b43d7cc08040c074c6279", - "sha256:3aae9af4cac263007fd6309c64c6ab4506dd2b79382d9d19a1994f9240b8db4f", - "sha256:3ab3a886a237f6e9c9f4f7d272067e712cdb4efa774bef494dccad08f39d8ae6", - "sha256:47bb5f0142b8b64ed1399b6b60f700a580335c8e1c57f2f15587bd072012decc", - "sha256:49a3b78a5af63ec10d8604180380c13dcd870aba7928c1fe04e881d5c792dc4e", - "sha256:4df98d4a9cd6a88d6a585852f56f2155c9cdb6aec78361a19f938810aa020954", - "sha256:5045e892cfdaecc5b4c01822f353cf2c8feb88a6ec1c0adef2a2e705eef0f656", - "sha256:5244324676254697fe5c181fc762284e2c5fceeb1c4e3e7f6aca2b6f107e60dc", - "sha256:54635102ba3cf5da26eb6f96c4b8c53af8a9c0d97b64bdcb592596a6255d8518", - "sha256:54a7e1380dfece8847c71bf7e33da5d084e9b889c75eca19100ef98027bd9f56", - "sha256:55d03fea4c4e9fd0ad75dc2e7e2b6757b80c152c032ea1d1de487461d8140efc", - "sha256:698e84142f3f884114ea8cf83e7a67ca8f4ace8454e78fe960646c6c91c63bfa", - "sha256:6aa5e2e7fc9bc042ae82d8b79d795b9a62bd8f15ba1e7594e3db243f158b5565", - "sha256:7653fa39578957bc42e5ebc15cf4361d9e0ee4b702d7d5ec96cdac860953c5b4", - "sha256:765f036a3d00395a326df2835d8f86b637dbaf9832f90f5d196c3b8a7a5080cb", - "sha256:78bc995e004681246e85e28e068111a4c3f35f34e6c62da1471e844ee1446250", - "sha256:7a07f40ef8f0fbc5ef1000d0c78771f4d5ca03b4953fc162749772916b298fc4", - "sha256:8b570a1537367b52396e53325769608f2a687ec9a4363647af1cded8928af959", - "sha256:987d13fe1d23e12a66ca2073b8d2e2a75cec2ecb8eab43ff5624ba0ad42764bc", - "sha256:9896fca4a8eb246defc8b2a7ac77ef7553b638e04fbf170bff78a40fa8a91474", - "sha256:9e9e3c4020aa2dc62d5dd6743a69e399ce3de58320522948af6140ac959ab863", - "sha256:a0b838c37ba596fcbfca71651a104a611543077156cb0a26fe0c475e1f152ee8", - "sha256:a4d176cfdfde84f732c4a53109b293d05883e952bbba68b857ae446fa3119b4f", - "sha256:a76055d5cb1c23485d7ddae533229039b850db711c554a12ea64a0fd8a0129e2", - "sha256:a76cd37d229fc385738bd1ce4cba2a121cf26b53864c1772694ad0ad348e509e", - "sha256:a7cc49ef48a3c7a0005a949f3c04f8baa5409d3f663a1b36f0eba9bfe2a0396e", - "sha256:abf5ebbec056817057bfafc0445916bb688a255a5146f900445d081db08cbabb", - "sha256:b0fe73bac2fed83839dbdbe6da84ae2a31c11cfc1c777a40dbd8ac8a6ed1560f", - "sha256:b6f14a9cd50c3cb100eb94b3273131c80d102e19bb20253ac7bd7336118a673a", - "sha256:b83041cda633871572f0d3c41dddd5582ad7d22f65a72eacd8d3d6d00291df26", - "sha256:b835aba863195269ea358cecc21b400276747cc977492319fd7682b8cd2c253d", - "sha256:bf1196dcc239e608605b716e7b166eb5faf4bc192f8a44b81e85251e62584bd2", - "sha256:c669391319973e49a7c6230c218a1e3044710bc1ce4c8e6eb71f7e6d43a2c131", - "sha256:c7556bafeaa0a50e2fe7dc86e0382dea349ebcad8f010d5a7dc6ba568eaaa789", - "sha256:c8f253a84dbd2c63c19590fa86a032ef3d8cc18923b8049d91bcdeeb2581fbf6", - "sha256:d18b66fe626ac412d96c2ab536306c736c66cf2a31c243a45025156cc190dc8a", - "sha256:d5291d98cd3ad9a562883468c690a2a238c4a6388ab3bd155b0c75dd55ece858", - "sha256:d5c31fe855c77cad679b302aabc42d724ed87c043b1432d457f4976add1c2c3e", - "sha256:d6e427c7378c7f1b2bef6a344c925b8b63623d3321c09a237b7cc0e77dd98ceb", - "sha256:dac1ebf6983148b45b5fa48593950f90ed6d1d26300604f321c74a9ca1609f8e", - "sha256:de8153a7aae3835484ac168a9a9bdaa0c5eee4e0bc595503c95d53b942879c84", - "sha256:e1a0d1924a5013d4f294087e00024ad25668234569289650929ab871231668e7", - "sha256:e7902211afd0af05fbadcc9a312e4cf10f27b779cf1323e78d52377ae4b72bea", - "sha256:e888ff76ceb39601c59e219f281466c6d7e66bd375b4ec1ce83bcdc68306796b", - "sha256:f06e5a9e99b7df44640767842f414ed5d7bedaaa78cd817ce04bbd6fd86e2dd6", - "sha256:f6be2d708a9d0e9b0054856f07ac7070fbe1754be40ca8525d5adccdbda8f475", - "sha256:f9917691f410a2e0897d1ef99619fd3f7dd503647c8ff2475bf90c3cf222ad74", - "sha256:fc1a75aa8f11b87910ffd98de62b29d6520b6d6e8a3de69a70ca34dea85d2a8a", - "sha256:fe8512ed897d5daf089e5bd010c3dc03bb1bdae00b35588c49b98268d4a01e00" + "sha256:00e046b6dd71aa03a41079792f8473dc494d564611a8f89bbbd7cb93295ebdcf", + "sha256:075202fa5b72c86ad32dc7d0b56024ebdbcf2048c0ba09f1cde31bfdd57bcfff", + "sha256:0e397ac966fdf721b2c528cf028494e86172b4feba51d65f81ffd65c63798f3f", + "sha256:17b950fccb810b3293638215058e432159d2b71005c74371d784862b7e4683f3", + "sha256:1f3fbcb7ef1f16e48246f704ab79d79da8a46891e2da03f8783a5b6fa41a9532", + "sha256:2174c595a0d73a3080ca3257b40096db99799265e1c27cc5a610743acd86d62f", + "sha256:2b7c57a4dfc4f16f7142221afe5ba4e093e09e728ca65c51f5620c9aaeb9a617", + "sha256:2d2d793e36e230fd32babe143b04cec8a8b3eb8a3122d2aceb4a371e6b09b8df", + "sha256:30b600cf0a7ac9234b2638fbc0fb6158ba5bdcdf46aeb631ead21248b9affbc4", + "sha256:397081c1a0bfb5124355710fe79478cdbeb39626492b15d399526ae53422b906", + "sha256:3a57fdd7ce31c7ff06cdfbf31dafa96cc533c21e443d57f5b1ecc6cdc668ec7f", + "sha256:3c6b973f22eb18a789b1460b4b91bf04ae3f0c4234a0a6aa6b0a92f6f7b951d4", + "sha256:3e53af139f8579a6d5f7b76549125f0d94d7e630761a2111bc431fd820e163b8", + "sha256:4096e9de5c6fdf43fb4f04c26fb114f61ef0bf2e5604b6ee3019d51b69e8c371", + "sha256:4275d846e41ecefa46e2015117a9f491e57a71ddd59bbead77e904dc02b1bed2", + "sha256:4c31f53cdae6ecfa91a77820e8b151dba54ab528ba65dfd235c80b086d68a465", + "sha256:4f11aa001c540f62c6166c7726f71f7573b52c68c31f014c25cc7901deea0b52", + "sha256:5049256f536511ee3f7e1b3f87d1d1209d327e818e6ae1365e8653d7e3abb6a6", + "sha256:58c98fee265677f63a4385256a6d7683ab1832f3ddd1e66fe948d5880c21a169", + "sha256:598e3276b64aff0e7b3451b72e94fa3c238d452e7ddcd893c3ab324717456bad", + "sha256:5b7b716f97b52c5a14bffdf688f971b2d5ef4029127f1ad7a513973cfd818df2", + "sha256:5dedb4db619ba5a2787a94d877bc8ffc0566f92a01c0ef214865e54ecc9ee5e0", + "sha256:619bc166c4f2de5caa5a633b8b7326fbe98e0ccbfacabd87268a2b15ff73a029", + "sha256:629ddd2ca402ae6dbedfceeba9c46d5f7b2a61d9749597d4307f943ef198fc1f", + "sha256:656f7526c69fac7f600bd1f400991cc282b417d17539a1b228617081106feb4a", + "sha256:6ec585f69cec0aa07d945b20805be741395e28ac1627333b1c5b0105962ffced", + "sha256:72b6be590cc35924b02c78ef34b467da4ba07e4e0f0454a2c5907f473fc50ce5", + "sha256:7502934a33b54030eaf1194c21c692a534196063db72176b0c4028e140f8f32c", + "sha256:7a68b554d356a91cce1236aa7682dc01df0edba8d043fd1ce607c49dd3c1edcf", + "sha256:7b2e5a267c855eea6b4283940daa6e88a285f5f2a67f2220203786dfa59b37e9", + "sha256:823b65d8706e32ad2df51ed89496147a42a2a6e01c13cfb6ffb8b1e92bc910bb", + "sha256:8590b4ae07a35970728874632fed7bd57b26b0102df2d2b233b6d9d82f6c62ad", + "sha256:8dd717634f5a044f860435c1d8c16a270ddf0ef8588d4887037c5028b859b0c3", + "sha256:8dec4936e9c3100156f8a2dc89c4b88d5c435175ff03413b443469c7c8c5f4d1", + "sha256:97cafb1f3cbcd3fd2b6fbfb99ae11cdb14deea0736fc2b0952ee177f2b813a46", + "sha256:a17a92de5231666cfbe003f0e4b9b3a7ae3afb1ec2845aadc2bacc93ff85febc", + "sha256:a549b9c31bec33820e885335b451286e2969a2d9e24879f83fe904a5ce59d70a", + "sha256:ac07bad82163452a6884fe8fa0963fb98c2346ba78d779ec06bd7a6262132aee", + "sha256:ae2ad8ae6ebee9d2d94b17fb62763125f3f374c25618198f40cbb8b525411900", + "sha256:b91c037585eba9095565a3556f611e3cbfaa42ca1e865f7b8015fe5c7336d5a5", + "sha256:bc1667f8b83f48511b94671e0e441401371dfd0f0a795c7daa4a3cd1dde55bea", + "sha256:bec0a414d016ac1a18862a519e54b2fd0fc8bbfd6890376898a6c0891dd82e9f", + "sha256:bf50cd79a75d181c9181df03572cdce0fbb75cc353bc350712073108cba98de5", + "sha256:bff1b4290a66b490a2f4719358c0cdcd9bafb6b8f061e45c7a2460866bf50c2e", + "sha256:c061bb86a71b42465156a3ee7bd58c8c2ceacdbeb95d05a99893e08b8467359a", + "sha256:c8b29db45f8fe46ad280a7294f5c3ec36dbac9491f2d1c17345be8e69cc5928f", + "sha256:ce409136744f6521e39fd8e2a24c53fa18ad67aa5bc7c2cf83645cce5b5c4e50", + "sha256:d050b3361367a06d752db6ead6e7edeb0009be66bc3bae0ee9d97fb326badc2a", + "sha256:d283d37a890ba4c1ae73ffadf8046435c76e7bc2247bbb63c00bd1a709c6544b", + "sha256:d9fad5155d72433c921b782e58892377c44bd6252b5af2f67f16b194987338a4", + "sha256:daa4ee5a243f0f20d528d939d06670a298dd39b1ad5f8a72a4275124a7819eff", + "sha256:db0b55e0f3cc0be60c1f19efdde9a637c32740486004f20d1cff53c3c0ece4d2", + "sha256:e61659ba32cf2cf1481e575d0462554625196a1f2fc06a1c777d3f48e8865d46", + "sha256:ea3d8a3d18833cf4304cd2fc9cbb1efe188ca9b5efef2bdac7adc20594a0e46b", + "sha256:ec6a563cff360b50eed26f13adc43e61bc0c04d94b8be985e6fb24b81f6dcfdf", + "sha256:f5dfb42c4604dddc8e4305050aa6deb084540643ed5804d7455b5df8fe16f5e5", + "sha256:fa173ec60341d6bb97a89f5ea19c85c5643c1e7dedebc22f5181eb73573142c5", + "sha256:fa9db3f79de01457b03d4f01b34cf91bc0048eb2c3846ff26f66687c2f6d16ab", + "sha256:fce659a462a1be54d2ffcacea5e3ba2d74daa74f30f5f143fe0c58636e355fdd", + "sha256:ffee1f21e5ef0d712f9033568f8344d5da8cc2869dbd08d87c84656e6a2d2f68" ], "markers": "python_version >= '3.7'", - "version": "==2.1.4" + "version": "==2.1.5" }, "matplotlib": { "hashes": [ - "sha256:01a978b871b881ee76017152f1f1a0cbf6bd5f7b8ff8c96df0df1bd57d8755a1", - "sha256:03f9d160a29e0b65c0790bb07f4f45d6a181b1ac33eb1bb0dd225986450148f0", - "sha256:091275d18d942cf1ee9609c830a1bc36610607d8223b1b981c37d5c9fc3e46a4", - "sha256:09796f89fb71a0c0e1e2f4bdaf63fb2cefc84446bb963ecdeb40dfee7dfa98c7", - "sha256:0f4fc5d72b75e2c18e55eb32292659cf731d9d5b312a6eb036506304f4675630", - "sha256:172f4d0fbac3383d39164c6caafd3255ce6fa58f08fc392513a0b1d3b89c4f89", - "sha256:1b0f3b8ea0e99e233a4bcc44590f01604840d833c280ebb8fe5554fd3e6cfe8d", - "sha256:3773002da767f0a9323ba1a9b9b5d00d6257dbd2a93107233167cfb581f64717", - "sha256:46a569130ff53798ea5f50afce7406e91fdc471ca1e0e26ba976a8c734c9427a", - "sha256:4c318c1e95e2f5926fba326f68177dee364aa791d6df022ceb91b8221bd0a627", - "sha256:4e208f46cf6576a7624195aa047cb344a7f802e113bb1a06cfd4bee431de5e31", - "sha256:533b0e3b0c6768eef8cbe4b583731ce25a91ab54a22f830db2b031e83cca9213", - "sha256:5864bdd7da445e4e5e011b199bb67168cdad10b501750367c496420f2ad00843", - "sha256:5ba9cbd8ac6cf422f3102622b20f8552d601bf8837e49a3afed188d560152788", - "sha256:6f9c6976748a25e8b9be51ea028df49b8e561eed7809146da7a47dbecebab367", - "sha256:7c48d9e221b637c017232e3760ed30b4e8d5dfd081daf327e829bf2a72c731b4", - "sha256:830f00640c965c5b7f6bc32f0d4ce0c36dfe0379f7dd65b07a00c801713ec40a", - "sha256:9a5430836811b7652991939012f43d2808a2db9b64ee240387e8c43e2e5578c8", - "sha256:aa11b3c6928a1e496c1a79917d51d4cd5d04f8a2e75f21df4949eeefdf697f4b", - "sha256:b78e4f2cedf303869b782071b55fdde5987fda3038e9d09e58c91cc261b5ad18", - "sha256:b9576723858a78751d5aacd2497b8aef29ffea6d1c95981505877f7ac28215c6", - "sha256:bddfb1db89bfaa855912261c805bd0e10218923cc262b9159a49c29a7a1c1afa", - "sha256:c7d36c2209d9136cd8e02fab1c0ddc185ce79bc914c45054a9f514e44c787917", - "sha256:d1095fecf99eeb7384dabad4bf44b965f929a5f6079654b681193edf7169ec20", - "sha256:d7b1704a530395aaf73912be741c04d181f82ca78084fbd80bc737be04848331", - "sha256:d86593ccf546223eb75a39b44c32788e6f6440d13cfc4750c1c15d0fcb850b63", - "sha256:deaed9ad4da0b1aea77fe0aa0cebb9ef611c70b3177be936a95e5d01fa05094f", - "sha256:ef8345b48e95cee45ff25192ed1f4857273117917a4dcd48e3905619bcd9c9b8" + "sha256:1c13f041a7178f9780fb61cc3a2b10423d5e125480e4be51beaf62b172413b67", + "sha256:232ce322bfd020a434caaffbd9a95333f7c2491e59cfc014041d95e38ab90d1c", + "sha256:493e9f6aa5819156b58fce42b296ea31969f2aab71c5b680b4ea7a3cb5c07d94", + "sha256:50bac6e4d77e4262c4340d7a985c30912054745ec99756ce213bfbc3cb3808eb", + "sha256:606e3b90897554c989b1e38a258c626d46c873523de432b1462f295db13de6f9", + "sha256:6209e5c9aaccc056e63b547a8152661324404dd92340a6e479b3a7f24b42a5d0", + "sha256:6485ac1f2e84676cff22e693eaa4fbed50ef5dc37173ce1f023daef4687df616", + "sha256:6addbd5b488aedb7f9bc19f91cd87ea476206f45d7116fcfe3d31416702a82fa", + "sha256:72f9322712e4562e792b2961971891b9fbbb0e525011e09ea0d1f416c4645661", + "sha256:7a6769f58ce51791b4cb8b4d7642489df347697cd3e23d88266aaaee93b41d9a", + "sha256:8080d5081a86e690d7688ffa542532e87f224c38a6ed71f8fbed34dd1d9fedae", + "sha256:843cbde2f0946dadd8c5c11c6d91847abd18ec76859dc319362a0964493f0ba6", + "sha256:8aac397d5e9ec158960e31c381c5ffc52ddd52bd9a47717e2a694038167dffea", + "sha256:8f65c9f002d281a6e904976007b2d46a1ee2bcea3a68a8c12dda24709ddc9106", + "sha256:90df07db7b599fe7035d2f74ab7e438b656528c68ba6bb59b7dc46af39ee48ef", + "sha256:9bb0189011785ea794ee827b68777db3ca3f93f3e339ea4d920315a0e5a78d54", + "sha256:a0e47eda4eb2614300fc7bb4657fced3e83d6334d03da2173b09e447418d499f", + "sha256:abc9d838f93583650c35eca41cfcec65b2e7cb50fd486da6f0c49b5e1ed23014", + "sha256:ac24233e8f2939ac4fd2919eed1e9c0871eac8057666070e94cbf0b33dd9c338", + "sha256:b12ba985837e4899b762b81f5b2845bd1a28f4fdd1a126d9ace64e9c4eb2fb25", + "sha256:b7a2a253d3b36d90c8993b4620183b55665a429da8357a4f621e78cd48b2b30b", + "sha256:c7064120a59ce6f64103c9cefba8ffe6fba87f2c61d67c401186423c9a20fd35", + "sha256:c89ee9314ef48c72fe92ce55c4e95f2f39d70208f9f1d9db4e64079420d8d732", + "sha256:cc4ccdc64e3039fc303defd119658148f2349239871db72cd74e2eeaa9b80b71", + "sha256:ce1edd9f5383b504dbc26eeea404ed0a00656c526638129028b758fd43fc5f10", + "sha256:ecd79298550cba13a43c340581a3ec9c707bd895a6a061a78fa2524660482fc0", + "sha256:f51c4c869d4b60d769f7b4406eec39596648d9d70246428745a681c327a8ad30", + "sha256:fb44f53af0a62dc80bba4443d9b27f2fde6acfdac281d95bc872dc148a6509cc" ], "markers": "python_version >= '3.9'", - "version": "==3.8.2" + "version": "==3.8.4" }, "mccabe": { "hashes": [ @@ -1169,24 +1307,24 @@ }, "nh3": { "hashes": [ - "sha256:0d02d0ff79dfd8208ed25a39c12cbda092388fff7f1662466e27d97ad011b770", - "sha256:3277481293b868b2715907310c7be0f1b9d10491d5adf9fce11756a97e97eddf", - "sha256:3b803a5875e7234907f7d64777dfde2b93db992376f3d6d7af7f3bc347deb305", - "sha256:427fecbb1031db085eaac9931362adf4a796428ef0163070c484b5a768e71601", - "sha256:5f0d77272ce6d34db6c87b4f894f037d55183d9518f948bba236fe81e2bb4e28", - "sha256:60684857cfa8fdbb74daa867e5cad3f0c9789415aba660614fe16cd66cbb9ec7", - "sha256:6f42f99f0cf6312e470b6c09e04da31f9abaadcd3eb591d7d1a88ea931dca7f3", - "sha256:86e447a63ca0b16318deb62498db4f76fc60699ce0a1231262880b38b6cff911", - "sha256:8d595df02413aa38586c24811237e95937ef18304e108b7e92c890a06793e3bf", - "sha256:9c0d415f6b7f2338f93035bba5c0d8c1b464e538bfbb1d598acd47d7969284f0", - "sha256:a5167a6403d19c515217b6bcaaa9be420974a6ac30e0da9e84d4fc67a5d474c5", - "sha256:ac19c0d68cd42ecd7ead91a3a032fdfff23d29302dbb1311e641a130dfefba97", - "sha256:b1e97221cedaf15a54f5243f2c5894bb12ca951ae4ddfd02a9d4ea9df9e1a29d", - "sha256:bc2d086fb540d0fa52ce35afaded4ea526b8fc4d3339f783db55c95de40ef02e", - "sha256:d1e30ff2d8d58fb2a14961f7aac1bbb1c51f9bdd7da727be35c63826060b0bf3", - "sha256:f3b53ba93bb7725acab1e030bc2ecd012a817040fd7851b332f86e2f9bb98dc6" - ], - "version": "==0.2.15" + "sha256:0316c25b76289cf23be6b66c77d3608a4fdf537b35426280032f432f14291b9a", + "sha256:1a814dd7bba1cb0aba5bcb9bebcc88fd801b63e21e2450ae6c52d3b3336bc911", + "sha256:1aa52a7def528297f256de0844e8dd680ee279e79583c76d6fa73a978186ddfb", + "sha256:22c26e20acbb253a5bdd33d432a326d18508a910e4dcf9a3316179860d53345a", + "sha256:40015514022af31975c0b3bca4014634fa13cb5dc4dbcbc00570acc781316dcc", + "sha256:40d0741a19c3d645e54efba71cb0d8c475b59135c1e3c580f879ad5514cbf028", + "sha256:551672fd71d06cd828e282abdb810d1be24e1abb7ae2543a8fa36a71c1006fe9", + "sha256:66f17d78826096291bd264f260213d2b3905e3c7fae6dfc5337d49429f1dc9f3", + "sha256:85cdbcca8ef10733bd31f931956f7fbb85145a4d11ab9e6742bbf44d88b7e351", + "sha256:a3f55fabe29164ba6026b5ad5c3151c314d136fd67415a17660b4aaddacf1b10", + "sha256:b4427ef0d2dfdec10b641ed0bdaf17957eb625b2ec0ea9329b3d28806c153d71", + "sha256:ba73a2f8d3a1b966e9cdba7b211779ad8a2561d2dba9674b8a19ed817923f65f", + "sha256:c21bac1a7245cbd88c0b0e4a420221b7bfa838a2814ee5bb924e9c2f10a1120b", + "sha256:c551eb2a3876e8ff2ac63dff1585236ed5dfec5ffd82216a7a174f7c5082a78a", + "sha256:c790769152308421283679a142dbdb3d1c46c79c823008ecea8e8141db1a2062", + "sha256:d7a25fd8c86657f5d9d576268e3b3767c5cd4f42867c9383618be8517f0f022a" + ], + "version": "==0.2.17" }, "numpy": { "hashes": [ @@ -1222,13 +1360,18 @@ "index": "pypi", "version": "==0.9.1" }, + "opentrons-shared-data": { + "editable": true, + "markers": "python_version >= '3.8'", + "path": "../shared-data/python" + }, "packaging": { "hashes": [ - "sha256:048fb0e9405036518eaaf48a55953c750c11e1a1b68e0dd1a9d62ed0c092cfc5", - "sha256:8c491190033a9af7e1d931d0b5dacc2ef47509b34dd0de67ed209b5203fc88c7" + "sha256:2ddfb553fdf02fb784c234c7ba6ccc288296ceabec964ad2eae3777778130bc5", + "sha256:eb82c5e3e56209074766e6885bb04b8c38a0c015d0a30036ebe7ece34c9989e9" ], "markers": "python_version >= '3.7'", - "version": "==23.2" + "version": "==24.0" }, "pathspec": { "hashes": [ @@ -1238,87 +1381,92 @@ "markers": "python_version >= '3.8'", "version": "==0.12.1" }, + "performance-metrics": { + "editable": true, + "file": "../performance-metrics" + }, "pillow": { "hashes": [ - "sha256:0304004f8067386b477d20a518b50f3fa658a28d44e4116970abfcd94fac34a8", - "sha256:0689b5a8c5288bc0504d9fcee48f61a6a586b9b98514d7d29b840143d6734f39", - "sha256:0eae2073305f451d8ecacb5474997c08569fb4eb4ac231ffa4ad7d342fdc25ac", - "sha256:0fb3e7fc88a14eacd303e90481ad983fd5b69c761e9e6ef94c983f91025da869", - "sha256:11fa2e5984b949b0dd6d7a94d967743d87c577ff0b83392f17cb3990d0d2fd6e", - "sha256:127cee571038f252a552760076407f9cff79761c3d436a12af6000cd182a9d04", - "sha256:154e939c5f0053a383de4fd3d3da48d9427a7e985f58af8e94d0b3c9fcfcf4f9", - "sha256:15587643b9e5eb26c48e49a7b33659790d28f190fc514a322d55da2fb5c2950e", - "sha256:170aeb00224ab3dc54230c797f8404507240dd868cf52066f66a41b33169bdbe", - "sha256:1b5e1b74d1bd1b78bc3477528919414874748dd363e6272efd5abf7654e68bef", - "sha256:1da3b2703afd040cf65ec97efea81cfba59cdbed9c11d8efc5ab09df9509fc56", - "sha256:1e23412b5c41e58cec602f1135c57dfcf15482013ce6e5f093a86db69646a5aa", - "sha256:2247178effb34a77c11c0e8ac355c7a741ceca0a732b27bf11e747bbc950722f", - "sha256:257d8788df5ca62c980314053197f4d46eefedf4e6175bc9412f14412ec4ea2f", - "sha256:3031709084b6e7852d00479fd1d310b07d0ba82765f973b543c8af5061cf990e", - "sha256:322209c642aabdd6207517e9739c704dc9f9db943015535783239022002f054a", - "sha256:322bdf3c9b556e9ffb18f93462e5f749d3444ce081290352c6070d014c93feb2", - "sha256:33870dc4653c5017bf4c8873e5488d8f8d5f8935e2f1fb9a2208c47cdd66efd2", - "sha256:35bb52c37f256f662abdfa49d2dfa6ce5d93281d323a9af377a120e89a9eafb5", - "sha256:3c31822339516fb3c82d03f30e22b1d038da87ef27b6a78c9549888f8ceda39a", - "sha256:3eedd52442c0a5ff4f887fab0c1c0bb164d8635b32c894bc1faf4c618dd89df2", - "sha256:3ff074fc97dd4e80543a3e91f69d58889baf2002b6be64347ea8cf5533188213", - "sha256:47c0995fc4e7f79b5cfcab1fc437ff2890b770440f7696a3ba065ee0fd496563", - "sha256:49d9ba1ed0ef3e061088cd1e7538a0759aab559e2e0a80a36f9fd9d8c0c21591", - "sha256:51f1a1bffc50e2e9492e87d8e09a17c5eea8409cda8d3f277eb6edc82813c17c", - "sha256:52a50aa3fb3acb9cf7213573ef55d31d6eca37f5709c69e6858fe3bc04a5c2a2", - "sha256:54f1852cd531aa981bc0965b7d609f5f6cc8ce8c41b1139f6ed6b3c54ab82bfb", - "sha256:609448742444d9290fd687940ac0b57fb35e6fd92bdb65386e08e99af60bf757", - "sha256:69ffdd6120a4737710a9eee73e1d2e37db89b620f702754b8f6e62594471dee0", - "sha256:6fad5ff2f13d69b7e74ce5b4ecd12cc0ec530fcee76356cac6742785ff71c452", - "sha256:7049e301399273a0136ff39b84c3678e314f2158f50f517bc50285fb5ec847ad", - "sha256:70c61d4c475835a19b3a5aa42492409878bbca7438554a1f89d20d58a7c75c01", - "sha256:716d30ed977be8b37d3ef185fecb9e5a1d62d110dfbdcd1e2a122ab46fddb03f", - "sha256:753cd8f2086b2b80180d9b3010dd4ed147efc167c90d3bf593fe2af21265e5a5", - "sha256:773efe0603db30c281521a7c0214cad7836c03b8ccff897beae9b47c0b657d61", - "sha256:7823bdd049099efa16e4246bdf15e5a13dbb18a51b68fa06d6c1d4d8b99a796e", - "sha256:7c8f97e8e7a9009bcacbe3766a36175056c12f9a44e6e6f2d5caad06dcfbf03b", - "sha256:823ef7a27cf86df6597fa0671066c1b596f69eba53efa3d1e1cb8b30f3533068", - "sha256:8373c6c251f7ef8bda6675dd6d2b3a0fcc31edf1201266b5cf608b62a37407f9", - "sha256:83b2021f2ade7d1ed556bc50a399127d7fb245e725aa0113ebd05cfe88aaf588", - "sha256:870ea1ada0899fd0b79643990809323b389d4d1d46c192f97342eeb6ee0b8483", - "sha256:8d12251f02d69d8310b046e82572ed486685c38f02176bd08baf216746eb947f", - "sha256:9c23f307202661071d94b5e384e1e1dc7dfb972a28a2310e4ee16103e66ddb67", - "sha256:9d189550615b4948f45252d7f005e53c2040cea1af5b60d6f79491a6e147eef7", - "sha256:a086c2af425c5f62a65e12fbf385f7c9fcb8f107d0849dba5839461a129cf311", - "sha256:a2b56ba36e05f973d450582fb015594aaa78834fefe8dfb8fcd79b93e64ba4c6", - "sha256:aebb6044806f2e16ecc07b2a2637ee1ef67a11840a66752751714a0d924adf72", - "sha256:b1b3020d90c2d8e1dae29cf3ce54f8094f7938460fb5ce8bc5c01450b01fbaf6", - "sha256:b4b6b1e20608493548b1f32bce8cca185bf0480983890403d3b8753e44077129", - "sha256:b6f491cdf80ae540738859d9766783e3b3c8e5bd37f5dfa0b76abdecc5081f13", - "sha256:b792a349405fbc0163190fde0dc7b3fef3c9268292586cf5645598b48e63dc67", - "sha256:b7c2286c23cd350b80d2fc9d424fc797575fb16f854b831d16fd47ceec078f2c", - "sha256:babf5acfede515f176833ed6028754cbcd0d206f7f614ea3447d67c33be12516", - "sha256:c365fd1703040de1ec284b176d6af5abe21b427cb3a5ff68e0759e1e313a5e7e", - "sha256:c4225f5220f46b2fde568c74fca27ae9771536c2e29d7c04f4fb62c83275ac4e", - "sha256:c570f24be1e468e3f0ce7ef56a89a60f0e05b30a3669a459e419c6eac2c35364", - "sha256:c6dafac9e0f2b3c78df97e79af707cdc5ef8e88208d686a4847bab8266870023", - "sha256:c8de2789052ed501dd829e9cae8d3dcce7acb4777ea4a479c14521c942d395b1", - "sha256:cb28c753fd5eb3dd859b4ee95de66cc62af91bcff5db5f2571d32a520baf1f04", - "sha256:cb4c38abeef13c61d6916f264d4845fab99d7b711be96c326b84df9e3e0ff62d", - "sha256:d1b35bcd6c5543b9cb547dee3150c93008f8dd0f1fef78fc0cd2b141c5baf58a", - "sha256:d8e6aeb9201e655354b3ad049cb77d19813ad4ece0df1249d3c793de3774f8c7", - "sha256:d8ecd059fdaf60c1963c58ceb8997b32e9dc1b911f5da5307aab614f1ce5c2fb", - "sha256:da2b52b37dad6d9ec64e653637a096905b258d2fc2b984c41ae7d08b938a67e4", - "sha256:e87f0b2c78157e12d7686b27d63c070fd65d994e8ddae6f328e0dcf4a0cd007e", - "sha256:edca80cbfb2b68d7b56930b84a0e45ae1694aeba0541f798e908a49d66b837f1", - "sha256:f379abd2f1e3dddb2b61bc67977a6b5a0a3f7485538bcc6f39ec76163891ee48", - "sha256:fe4c15f6c9285dc54ce6553a3ce908ed37c8f3825b5a51a15c91442bb955b868" + "sha256:048ad577748b9fa4a99a0548c64f2cb8d672d5bf2e643a739ac8faff1164238c", + "sha256:048eeade4c33fdf7e08da40ef402e748df113fd0b4584e32c4af74fe78baaeb2", + "sha256:0ba26351b137ca4e0db0342d5d00d2e355eb29372c05afd544ebf47c0956ffeb", + "sha256:0ea2a783a2bdf2a561808fe4a7a12e9aa3799b701ba305de596bc48b8bdfce9d", + "sha256:1530e8f3a4b965eb6a7785cf17a426c779333eb62c9a7d1bbcf3ffd5bf77a4aa", + "sha256:16563993329b79513f59142a6b02055e10514c1a8e86dca8b48a893e33cf91e3", + "sha256:19aeb96d43902f0a783946a0a87dbdad5c84c936025b8419da0a0cd7724356b1", + "sha256:1a1d1915db1a4fdb2754b9de292642a39a7fb28f1736699527bb649484fb966a", + "sha256:1b87bd9d81d179bd8ab871603bd80d8645729939f90b71e62914e816a76fc6bd", + "sha256:1dfc94946bc60ea375cc39cff0b8da6c7e5f8fcdc1d946beb8da5c216156ddd8", + "sha256:2034f6759a722da3a3dbd91a81148cf884e91d1b747992ca288ab88c1de15999", + "sha256:261ddb7ca91fcf71757979534fb4c128448b5b4c55cb6152d280312062f69599", + "sha256:2ed854e716a89b1afcedea551cd85f2eb2a807613752ab997b9974aaa0d56936", + "sha256:3102045a10945173d38336f6e71a8dc71bcaeed55c3123ad4af82c52807b9375", + "sha256:339894035d0ede518b16073bdc2feef4c991ee991a29774b33e515f1d308e08d", + "sha256:412444afb8c4c7a6cc11a47dade32982439925537e483be7c0ae0cf96c4f6a0b", + "sha256:4203efca580f0dd6f882ca211f923168548f7ba334c189e9eab1178ab840bf60", + "sha256:45ebc7b45406febf07fef35d856f0293a92e7417ae7933207e90bf9090b70572", + "sha256:4b5ec25d8b17217d635f8935dbc1b9aa5907962fae29dff220f2659487891cd3", + "sha256:4c8e73e99da7db1b4cad7f8d682cf6abad7844da39834c288fbfa394a47bbced", + "sha256:4e6f7d1c414191c1199f8996d3f2282b9ebea0945693fb67392c75a3a320941f", + "sha256:4eaa22f0d22b1a7e93ff0a596d57fdede2e550aecffb5a1ef1106aaece48e96b", + "sha256:50b8eae8f7334ec826d6eeffaeeb00e36b5e24aa0b9df322c247539714c6df19", + "sha256:50fd3f6b26e3441ae07b7c979309638b72abc1a25da31a81a7fbd9495713ef4f", + "sha256:51243f1ed5161b9945011a7360e997729776f6e5d7005ba0c6879267d4c5139d", + "sha256:5d512aafa1d32efa014fa041d38868fda85028e3f930a96f85d49c7d8ddc0383", + "sha256:5f77cf66e96ae734717d341c145c5949c63180842a545c47a0ce7ae52ca83795", + "sha256:6b02471b72526ab8a18c39cb7967b72d194ec53c1fd0a70b050565a0f366d355", + "sha256:6fb1b30043271ec92dc65f6d9f0b7a830c210b8a96423074b15c7bc999975f57", + "sha256:7161ec49ef0800947dc5570f86568a7bb36fa97dd09e9827dc02b718c5643f09", + "sha256:72d622d262e463dfb7595202d229f5f3ab4b852289a1cd09650362db23b9eb0b", + "sha256:74d28c17412d9caa1066f7a31df8403ec23d5268ba46cd0ad2c50fb82ae40462", + "sha256:78618cdbccaa74d3f88d0ad6cb8ac3007f1a6fa5c6f19af64b55ca170bfa1edf", + "sha256:793b4e24db2e8742ca6423d3fde8396db336698c55cd34b660663ee9e45ed37f", + "sha256:798232c92e7665fe82ac085f9d8e8ca98826f8e27859d9a96b41d519ecd2e49a", + "sha256:81d09caa7b27ef4e61cb7d8fbf1714f5aec1c6b6c5270ee53504981e6e9121ad", + "sha256:8ab74c06ffdab957d7670c2a5a6e1a70181cd10b727cd788c4dd9005b6a8acd9", + "sha256:8eb0908e954d093b02a543dc963984d6e99ad2b5e36503d8a0aaf040505f747d", + "sha256:90b9e29824800e90c84e4022dd5cc16eb2d9605ee13f05d47641eb183cd73d45", + "sha256:9797a6c8fe16f25749b371c02e2ade0efb51155e767a971c61734b1bf6293994", + "sha256:9d2455fbf44c914840c793e89aa82d0e1763a14253a000743719ae5946814b2d", + "sha256:9d3bea1c75f8c53ee4d505c3e67d8c158ad4df0d83170605b50b64025917f338", + "sha256:9e2ec1e921fd07c7cda7962bad283acc2f2a9ccc1b971ee4b216b75fad6f0463", + "sha256:9e91179a242bbc99be65e139e30690e081fe6cb91a8e77faf4c409653de39451", + "sha256:a0eaa93d054751ee9964afa21c06247779b90440ca41d184aeb5d410f20ff591", + "sha256:a2c405445c79c3f5a124573a051062300936b0281fee57637e706453e452746c", + "sha256:aa7e402ce11f0885305bfb6afb3434b3cd8f53b563ac065452d9d5654c7b86fd", + "sha256:aff76a55a8aa8364d25400a210a65ff59d0168e0b4285ba6bf2bd83cf675ba32", + "sha256:b09b86b27a064c9624d0a6c54da01c1beaf5b6cadfa609cf63789b1d08a797b9", + "sha256:b14f16f94cbc61215115b9b1236f9c18403c15dd3c52cf629072afa9d54c1cbf", + "sha256:b50811d664d392f02f7761621303eba9d1b056fb1868c8cdf4231279645c25f5", + "sha256:b7bc2176354defba3edc2b9a777744462da2f8e921fbaf61e52acb95bafa9828", + "sha256:c78e1b00a87ce43bb37642c0812315b411e856a905d58d597750eb79802aaaa3", + "sha256:c83341b89884e2b2e55886e8fbbf37c3fa5efd6c8907124aeb72f285ae5696e5", + "sha256:ca2870d5d10d8726a27396d3ca4cf7976cec0f3cb706debe88e3a5bd4610f7d2", + "sha256:ccce24b7ad89adb5a1e34a6ba96ac2530046763912806ad4c247356a8f33a67b", + "sha256:cd5e14fbf22a87321b24c88669aad3a51ec052eb145315b3da3b7e3cc105b9a2", + "sha256:ce49c67f4ea0609933d01c0731b34b8695a7a748d6c8d186f95e7d085d2fe475", + "sha256:d33891be6df59d93df4d846640f0e46f1a807339f09e79a8040bc887bdcd7ed3", + "sha256:d3b2348a78bc939b4fed6552abfd2e7988e0f81443ef3911a4b8498ca084f6eb", + "sha256:d886f5d353333b4771d21267c7ecc75b710f1a73d72d03ca06df49b09015a9ef", + "sha256:d93480005693d247f8346bc8ee28c72a2191bdf1f6b5db469c096c0c867ac015", + "sha256:dc1a390a82755a8c26c9964d457d4c9cbec5405896cba94cf51f36ea0d855002", + "sha256:dd78700f5788ae180b5ee8902c6aea5a5726bac7c364b202b4b3e3ba2d293170", + "sha256:e46f38133e5a060d46bd630faa4d9fa0202377495df1f068a8299fd78c84de84", + "sha256:e4b878386c4bf293578b48fc570b84ecfe477d3b77ba39a6e87150af77f40c57", + "sha256:f0d0591a0aeaefdaf9a5e545e7485f89910c977087e7de2b6c388aec32011e9f", + "sha256:fdcbb4068117dfd9ce0138d068ac512843c52295ed996ae6dd1faf537b6dbc27", + "sha256:ff61bfd9253c3915e6d41c651d5f962da23eda633cf02262990094a18a55371a" ], "markers": "python_version >= '3.8'", - "version": "==10.2.0" + "version": "==10.3.0" }, "pkginfo": { "hashes": [ - "sha256:4b7a555a6d5a22169fcc9cf7bfd78d296b0361adad412a346c1226849af5e546", - "sha256:8fd5896e8718a4372f0ea9cc9d96f6417c9b986e23a4d116dda26b62cc29d046" + "sha256:5df73835398d10db79f8eecd5cd86b1f6d29317589ea70796994d49399af6297", + "sha256:889a6da2ed7ffc58ab5b900d888ddce90bce912f2d2de1dc1c26f4cb9fe65097" ], "markers": "python_version >= '3.6'", - "version": "==1.9.6" + "version": "==1.10.0" }, "platformdirs": { "hashes": [ @@ -1352,6 +1500,57 @@ "markers": "python_version >= '3.8'", "version": "==2.11.1" }, + "pycparser": { + "hashes": [ + "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6", + "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc" + ], + "markers": "python_version >= '3.8'", + "version": "==2.22" + }, + "pydantic": { + "hashes": [ + "sha256:0fe8a415cea8f340e7a9af9c54fc71a649b43e8ca3cc732986116b3cb135d303", + "sha256:1289c180abd4bd4555bb927c42ee42abc3aee02b0fb2d1223fb7c6e5bef87dbe", + "sha256:1eb2085c13bce1612da8537b2d90f549c8cbb05c67e8f22854e201bde5d98a47", + "sha256:2031de0967c279df0d8a1c72b4ffc411ecd06bac607a212892757db7462fc494", + "sha256:2a7bac939fa326db1ab741c9d7f44c565a1d1e80908b3797f7f81a4f86bc8d33", + "sha256:2d5a58feb9a39f481eda4d5ca220aa8b9d4f21a41274760b9bc66bfd72595b86", + "sha256:2f9a6fab5f82ada41d56b0602606a5506aab165ca54e52bc4545028382ef1c5d", + "sha256:2fcfb5296d7877af406ba1547dfde9943b1256d8928732267e2653c26938cd9c", + "sha256:549a8e3d81df0a85226963611950b12d2d334f214436a19537b2efed61b7639a", + "sha256:598da88dfa127b666852bef6d0d796573a8cf5009ffd62104094a4fe39599565", + "sha256:5d1197e462e0364906cbc19681605cb7c036f2475c899b6f296104ad42b9f5fb", + "sha256:69328e15cfda2c392da4e713443c7dbffa1505bc9d566e71e55abe14c97ddc62", + "sha256:6a9dfa722316f4acf4460afdf5d41d5246a80e249c7ff475c43a3a1e9d75cf62", + "sha256:6b30bcb8cbfccfcf02acb8f1a261143fab622831d9c0989707e0e659f77a18e0", + "sha256:6c076be61cd0177a8433c0adcb03475baf4ee91edf5a4e550161ad57fc90f523", + "sha256:771735dc43cf8383959dc9b90aa281f0b6092321ca98677c5fb6125a6f56d58d", + "sha256:795e34e6cc065f8f498c89b894a3c6da294a936ee71e644e4bd44de048af1405", + "sha256:87afda5539d5140cb8ba9e8b8c8865cb5b1463924d38490d73d3ccfd80896b3f", + "sha256:8fb2aa3ab3728d950bcc885a2e9eff6c8fc40bc0b7bb434e555c215491bcf48b", + "sha256:a1fcb59f2f355ec350073af41d927bf83a63b50e640f4dbaa01053a28b7a7718", + "sha256:a5e7add47a5b5a40c49b3036d464e3c7802f8ae0d1e66035ea16aa5b7a3923ed", + "sha256:a73f489aebd0c2121ed974054cb2759af8a9f747de120acd2c3394cf84176ccb", + "sha256:ab26038b8375581dc832a63c948f261ae0aa21f1d34c1293469f135fa92972a5", + "sha256:b0d191db0f92dfcb1dec210ca244fdae5cbe918c6050b342d619c09d31eea0cc", + "sha256:b749a43aa51e32839c9d71dc67eb1e4221bb04af1033a32e3923d46f9effa942", + "sha256:b7ccf02d7eb340b216ec33e53a3a629856afe1c6e0ef91d84a4e6f2fb2ca70fe", + "sha256:ba5b2e6fe6ca2b7e013398bc7d7b170e21cce322d266ffcd57cca313e54fb246", + "sha256:ba5c4a8552bff16c61882db58544116d021d0b31ee7c66958d14cf386a5b5350", + "sha256:c79e6a11a07da7374f46970410b41d5e266f7f38f6a17a9c4823db80dadf4303", + "sha256:ca48477862372ac3770969b9d75f1bf66131d386dba79506c46d75e6b48c1e09", + "sha256:dea7adcc33d5d105896401a1f37d56b47d443a2b2605ff8a969a0ed5543f7e33", + "sha256:e0a16d274b588767602b7646fa05af2782576a6cf1022f4ba74cbb4db66f6ca8", + "sha256:e4129b528c6baa99a429f97ce733fff478ec955513630e61b49804b6cf9b224a", + "sha256:e5f805d2d5d0a41633651a73fa4ecdd0b3d7a49de4ec3fadf062fe16501ddbf1", + "sha256:ef6c96b2baa2100ec91a4b428f80d8f28a3c9e53568219b6c298c1125572ebc6", + "sha256:fdbdd1d630195689f325c9ef1a12900524dceb503b00a987663ff4f58669b93d" + ], + "index": "pypi", + "markers": "python_version >= '3.7'", + "version": "==1.10.12" + }, "pydocstyle": { "hashes": [ "sha256:118762d452a49d6b05e194ef344a55822987a462831ade91ec5c06fd2169d019", @@ -1378,11 +1577,49 @@ }, "pyparsing": { "hashes": [ - "sha256:32c7c0b711493c72ff18a981d24f28aaf9c1fb7ed5e9667c9e84e3db623bdbfb", - "sha256:ede28a1a32462f5a9705e07aea48001a08f7cf81a021585011deba701581a0db" + "sha256:a1bac0ce561155ecc3ed78ca94d3c9378656ad4c94c1270de543f621420f94ad", + "sha256:f9db75911801ed778fe61bb643079ff86601aca99fcae6345aa67292038fb742" ], "markers": "python_full_version >= '3.6.8'", - "version": "==3.1.1" + "version": "==3.1.2" + }, + "pyrsistent": { + "hashes": [ + "sha256:0724c506cd8b63c69c7f883cc233aac948c1ea946ea95996ad8b1380c25e1d3f", + "sha256:09848306523a3aba463c4b49493a760e7a6ca52e4826aa100ee99d8d39b7ad1e", + "sha256:0f3b1bcaa1f0629c978b355a7c37acd58907390149b7311b5db1b37648eb6958", + "sha256:21cc459636983764e692b9eba7144cdd54fdec23ccdb1e8ba392a63666c60c34", + "sha256:2e14c95c16211d166f59c6611533d0dacce2e25de0f76e4c140fde250997b3ca", + "sha256:2e2c116cc804d9b09ce9814d17df5edf1df0c624aba3b43bc1ad90411487036d", + "sha256:4021a7f963d88ccd15b523787d18ed5e5269ce57aa4037146a2377ff607ae87d", + "sha256:4c48f78f62ab596c679086084d0dd13254ae4f3d6c72a83ffdf5ebdef8f265a4", + "sha256:4f5c2d012671b7391803263419e31b5c7c21e7c95c8760d7fc35602353dee714", + "sha256:58b8f6366e152092194ae68fefe18b9f0b4f89227dfd86a07770c3d86097aebf", + "sha256:59a89bccd615551391f3237e00006a26bcf98a4d18623a19909a2c48b8e986ee", + "sha256:5cdd7ef1ea7a491ae70d826b6cc64868de09a1d5ff9ef8d574250d0940e275b8", + "sha256:6288b3fa6622ad8a91e6eb759cfc48ff3089e7c17fb1d4c59a919769314af224", + "sha256:6d270ec9dd33cdb13f4d62c95c1a5a50e6b7cdd86302b494217137f760495b9d", + "sha256:79ed12ba79935adaac1664fd7e0e585a22caa539dfc9b7c7c6d5ebf91fb89054", + "sha256:7d29c23bdf6e5438c755b941cef867ec2a4a172ceb9f50553b6ed70d50dfd656", + "sha256:8441cf9616d642c475684d6cf2520dd24812e996ba9af15e606df5f6fd9d04a7", + "sha256:881bbea27bbd32d37eb24dd320a5e745a2a5b092a17f6debc1349252fac85423", + "sha256:8c3aba3e01235221e5b229a6c05f585f344734bd1ad42a8ac51493d74722bbce", + "sha256:a14798c3005ec892bbada26485c2eea3b54109cb2533713e355c806891f63c5e", + "sha256:b14decb628fac50db5e02ee5a35a9c0772d20277824cfe845c8a8b717c15daa3", + "sha256:b318ca24db0f0518630e8b6f3831e9cba78f099ed5c1d65ffe3e023003043ba0", + "sha256:c1beb78af5423b879edaf23c5591ff292cf7c33979734c99aa66d5914ead880f", + "sha256:c55acc4733aad6560a7f5f818466631f07efc001fd023f34a6c203f8b6df0f0b", + "sha256:ca52d1ceae015859d16aded12584c59eb3825f7b50c6cfd621d4231a6cc624ce", + "sha256:cae40a9e3ce178415040a0383f00e8d68b569e97f31928a3a8ad37e3fde6df6a", + "sha256:e78d0c7c1e99a4a45c99143900ea0546025e41bb59ebc10182e947cf1ece9174", + "sha256:ef3992833fbd686ee783590639f4b8343a57f1f75de8633749d984dc0eb16c86", + "sha256:f058a615031eea4ef94ead6456f5ec2026c19fb5bd6bfe86e9665c4158cf802f", + "sha256:f5ac696f02b3fc01a710427585c855f65cd9c640e14f52abe52020722bb4906b", + "sha256:f920385a11207dc372a028b3f1e1038bb244b3ec38d448e6d8e43c6b3ba20e98", + "sha256:fed2c3216a605dc9a6ea50c7e84c82906e3684c4e80d2908208f662a6cbf9022" + ], + "markers": "python_version >= '3.8'", + "version": "==0.20.0" }, "pytest": { "hashes": [ @@ -1395,12 +1632,12 @@ }, "pytest-asyncio": { "hashes": [ - "sha256:2143d9d9375bf372a73260e4114541485e84fca350b0b6b92674ca56ff5f7ea2", - "sha256:b0079dfac14b60cd1ce4691fbfb1748fe939db7d0234b5aba97197d10fbe0fef" + "sha256:68516fdd1018ac57b846c9846b954f0393b26f094764a28c955eabb0536a4e8a", + "sha256:ffe523a89c1c222598c76856e76852b787504ddb72dd5d9b6617ffa8aa2cde5f" ], "index": "pypi", "markers": "python_version >= '3.8'", - "version": "==0.23.4" + "version": "==0.23.6" }, "pytest-cov": { "hashes": [ @@ -1448,19 +1685,19 @@ }, "python-dateutil": { "hashes": [ - "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86", - "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9" + "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", + "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427" ], "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==2.8.2" + "version": "==2.9.0.post0" }, "readme-renderer": { "hashes": [ - "sha256:13d039515c1f24de668e2c93f2e877b9dbe6c6c32328b90a40a49d8b2b85f36d", - "sha256:2d55489f83be4992fe4454939d1a051c33edbab778e82761d060c9fc6b308cd1" + "sha256:1818dd28140813509eeed8d62687f7cd4f7bad90d4db586001c5dc09d4fde311", + "sha256:19db308d86ecd60e5affa3b2a98f017af384678c63c88e5d4556a380e674f3f9" ], "markers": "python_version >= '3.8'", - "version": "==42.0" + "version": "==43.0" }, "requests": { "hashes": [ @@ -1488,11 +1725,19 @@ }, "rich": { "hashes": [ - "sha256:5cb5123b5cf9ee70584244246816e9114227e0b98ad9176eede6ad54bf5403fa", - "sha256:6da14c108c4866ee9520bbffa71f6fe3962e193b7da68720583850cd4548e235" + "sha256:4edbae314f59eb482f54e9e30bf00d33350aaa94f4bfcd4e9e3110e64d0d7222", + "sha256:9be308cb1fe2f1f57d67ce99e95af38a1e2bc71ad9813b0e247cf7ffbcc3a432" ], "markers": "python_full_version >= '3.7.0'", - "version": "==13.7.0" + "version": "==13.7.1" + }, + "secretstorage": { + "hashes": [ + "sha256:2403533ef369eca6d2ba81718576c5e0f564d5cca1b58f73a8b23e7d4eeebd77", + "sha256:f356e6628222568e3af06f2eba8df495efa13b3b63081dafd4f7d9a7b7bc9f99" + ], + "markers": "sys_platform == 'linux'", + "version": "==3.3.3" }, "six": { "hashes": [ @@ -1633,12 +1878,12 @@ }, "types-mock": { "hashes": [ - "sha256:13ca379d5710ccb3f18f69ade5b08881874cb83383d8fb49b1d4dac9d5c5d090", - "sha256:3d116955495935b0bcba14954b38d97e507cd43eca3e3700fc1b8e4f5c6bf2c7" + "sha256:0769cb376dfc75b45215619f17a9fd6333d771cc29ce4a38937f060b1e45530f", + "sha256:7472797986d83016f96fde7f73577d129b0cd8a8d0b783487a7be330d57ba431" ], "index": "pypi", "markers": "python_version >= '3.8'", - "version": "==5.1.0.20240106" + "version": "==5.1.0.20240311" }, "types-setuptools": { "hashes": [ @@ -1650,19 +1895,19 @@ }, "typing-extensions": { "hashes": [ - "sha256:23478f88c37f27d76ac8aee6c905017a143b0b1b886c3c9f66bc2fd94f9f5783", - "sha256:af72aea155e91adfc61c3ae9e0e342dbc0cba726d6cba4b6c72c1f34e47291cd" + "sha256:83f085bd5ca59c80295fc2a82ab5dac679cbe02b9f33f7d83af68e241bea51b0", + "sha256:c1f94d72897edaf4ce775bb7558d5b79d8126906a14ea5ed1635921406c0387a" ], "markers": "python_version >= '3.8'", - "version": "==4.9.0" + "version": "==4.11.0" }, "urllib3": { "hashes": [ - "sha256:051d961ad0c62a94e50ecf1af379c3aba230c66c710493493560c0c223c49f20", - "sha256:ce3711610ddce217e6d113a2732fafad960a03fd0318c91faa79481e35c11224" + "sha256:450b20ec296a467077128bff42b73080516e71b56ff59a60a02bef2232c4fa9d", + "sha256:d0570876c61ab9e520d776c38acbbb5b05a776d3f9ff98a5c8fd5162a444cf19" ], "markers": "python_version >= '3.8'", - "version": "==2.2.0" + "version": "==2.2.1" }, "wheel": { "hashes": [ @@ -1675,11 +1920,11 @@ }, "zipp": { "hashes": [ - "sha256:0e923e726174922dce09c53c59ad483ff7bbb8e572e00c7f7c46b88556409f31", - "sha256:84e64a1c28cf7e91ed2078bb8cc8c259cb19b76942096c8d7b84947690cabaf0" + "sha256:206f5a15f2af3dbaee80769fb7dc6f249695e940acca08dfb2a4769fe61e538b", + "sha256:2884ed22e7d8961de1c9a05142eb69a247f120291bc0206a00a7642f09b5b715" ], "markers": "python_version >= '3.8'", - "version": "==3.17.0" + "version": "==3.18.1" } } } diff --git a/api/docs/v2/index.rst b/api/docs/v2/index.rst index 376d483f33b..5e29296241d 100644 --- a/api/docs/v2/index.rst +++ b/api/docs/v2/index.rst @@ -171,17 +171,17 @@ More Resources Opentrons App +++++++++++++ -The `Opentrons App `__ is the easiest way to run your Python protocols. The app `supports `_ the latest versions of macOS, Windows, and Ubuntu. +The `Opentrons App `__ is the easiest way to run your Python protocols. The app runs on the latest versions of macOS, Windows, and Ubuntu. Support +++++++ -Questions about setting up your robot, using Opentrons software, or troubleshooting? Check out our `support articles `_ or `get in touch directly `_ with Opentrons Support. +Questions about setting up your robot, using Opentrons software, or troubleshooting? Check out our `support articles `_ or `contact Opentrons Support directly `_. Custom Protocol Service +++++++++++++++++++++++ -Don't have the time or resources to write your own protocols? The `Opentrons Custom Protocols `_ service can get you set up in as little as a week. +Don't have the time or resources to write your own protocols? Our `custom protocol development service `_ can get you set up in two weeks. Contributing ++++++++++++ diff --git a/api/release-notes-internal.md b/api/release-notes-internal.md index f05cd2e2f1e..261d55e2100 100644 --- a/api/release-notes-internal.md +++ b/api/release-notes-internal.md @@ -2,6 +2,32 @@ For more details about this release, please see the full [technical change log][ [technical change log]: https://github.com/Opentrons/opentrons/releases +## Internal Release 1.4.0-alpha.1 + +This internal release is from the `edge` branch to contain rapid dev on new features for 7.3.0. This release is for internal testing purposes and if used may require a factory reset of the robot to return to a stable version. + +This release is primarily to unblock Flex runs. That fix is in + +### All changes + + + +--- + +## Internal Release 1.4.0-alpha.0 + +This internal release is from the `edge` branch to contain rapid dev on new features for 7.3.0. This release is for internal testing purposes and if used may require a factory reset of the robot to return to a stable version. + + + +--- + +## Internal Release 1.3.0-alpha.0 + +This internal release is from the `edge` branch to contain rapid dev on new features for 7.3.0. This release is for internal testing purposes and if used may require a factory reset of the robot to return to a stable version. + + + --- # Internal Release 1.1.0 diff --git a/api/release-notes.md b/api/release-notes.md index ff193247459..ca9523121b4 100644 --- a/api/release-notes.md +++ b/api/release-notes.md @@ -6,6 +6,21 @@ log][]. For a list of currently known issues, please see the [Opentrons issue tr --- +## Opentrons Robot Software Changes in 7.2.2 + +Welcome to the v7.2.2 release of the Opentrons robot software! + +### Improved Features + +- Improved the low-volume performance of recently produced Flex 96-Channel Pipettes. + +### Bug Fixes + +- Restores the ability to use the speaker and camera on OT-2. +- Restores the ability to use the camera on Flex. + +--- + ## Opentrons Robot Software Changes in 7.2.1 Welcome to the v7.2.1 release of the Opentrons robot software! diff --git a/api/setup.py b/api/setup.py index ae53321ca22..1811b6b4e2d 100755 --- a/api/setup.py +++ b/api/setup.py @@ -46,8 +46,6 @@ def get_version(): "Intended Audience :: Science/Research", "Programming Language :: Python", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Topic :: Scientific/Engineering", ] @@ -87,7 +85,7 @@ def read(*parts): if __name__ == "__main__": setup( - python_requires=">=3.8", + python_requires=">=3.10", name=DISTNAME, description=DESCRIPTION, license=LICENSE, diff --git a/api/src/opentrons/calibration_storage/deck_configuration.py b/api/src/opentrons/calibration_storage/deck_configuration.py index 31410403d35..a627fce73c9 100644 --- a/api/src/opentrons/calibration_storage/deck_configuration.py +++ b/api/src/opentrons/calibration_storage/deck_configuration.py @@ -10,6 +10,7 @@ class _CutoutFixturePlacementModel(pydantic.BaseModel): cutoutId: str cutoutFixtureId: str + opentronsModuleSerialNumber: Optional[str] class _DeckConfigurationModel(pydantic.BaseModel): @@ -26,7 +27,9 @@ def serialize_deck_configuration( data = _DeckConfigurationModel.construct( cutoutFixtures=[ _CutoutFixturePlacementModel.construct( - cutoutId=e.cutout_id, cutoutFixtureId=e.cutout_fixture_id + cutoutId=e.cutout_id, + cutoutFixtureId=e.cutout_fixture_id, + opentronsModuleSerialNumber=e.opentrons_module_serial_number, ) for e in cutout_fixture_placements ], @@ -50,7 +53,9 @@ def deserialize_deck_configuration( else: cutout_fixture_placements = [ CutoutFixturePlacement( - cutout_id=e.cutoutId, cutout_fixture_id=e.cutoutFixtureId + cutout_id=e.cutoutId, + cutout_fixture_id=e.cutoutFixtureId, + opentrons_module_serial_number=e.opentronsModuleSerialNumber, ) for e in parsed.cutoutFixtures ] diff --git a/api/src/opentrons/calibration_storage/types.py b/api/src/opentrons/calibration_storage/types.py index fd1bfbd5e2e..bd80af33719 100644 --- a/api/src/opentrons/calibration_storage/types.py +++ b/api/src/opentrons/calibration_storage/types.py @@ -42,3 +42,4 @@ class UriDetails: class CutoutFixturePlacement: cutout_fixture_id: str cutout_id: str + opentrons_module_serial_number: typing.Optional[str] diff --git a/api/src/opentrons/cli/analyze.py b/api/src/opentrons/cli/analyze.py index a42a4f5f868..42ca29a2b81 100644 --- a/api/src/opentrons/cli/analyze.py +++ b/api/src/opentrons/cli/analyze.py @@ -28,6 +28,7 @@ ) from opentrons_shared_data.robot.dev_types import RobotType +from opentrons.util.performance_helpers import track_analysis @click.command() @@ -63,6 +64,7 @@ def _get_input_files(files_and_dirs: Sequence[Path]) -> List[Path]: return results +@track_analysis async def _analyze( files_and_dirs: Sequence[Path], json_output: Optional[AsyncPath], diff --git a/api/src/opentrons/config/__init__.py b/api/src/opentrons/config/__init__.py index ce867677777..a4571521211 100644 --- a/api/src/opentrons/config/__init__.py +++ b/api/src/opentrons/config/__init__.py @@ -284,6 +284,13 @@ class ConfigElement(NamedTuple): ConfigElementType.DIR, "The dir where module calibration is stored", ), + ConfigElement( + "performance_metrics_dir", + "Performance Metrics Directory", + Path("performance_metrics_data"), + ConfigElementType.DIR, + "The dir where performance metrics are stored", + ), ) #: The available configuration file elements to modify. All of these can be #: changed by editing opentrons.json, where the keys are the name elements, @@ -602,3 +609,7 @@ def get_tip_length_cal_path() -> Path: def get_custom_tiprack_def_path() -> Path: return get_opentrons_path("custom_tiprack_dir") + + +def get_performance_metrics_data_dir() -> Path: + return get_opentrons_path("performance_metrics_dir") diff --git a/api/src/opentrons/config/advanced_settings.py b/api/src/opentrons/config/advanced_settings.py index 191c0d69ccc..6a6076a8432 100644 --- a/api/src/opentrons/config/advanced_settings.py +++ b/api/src/opentrons/config/advanced_settings.py @@ -238,6 +238,16 @@ class Setting(NamedTuple): title="Enable OEM Mode", description="This setting anonymizes Opentrons branding in the ODD app.", robot_type=[RobotTypeEnum.FLEX], + ), + SettingDefinition( + _id="enablePerformanceMetrics", + title="Enable performance metrics", + description=( + "Do not enable." + " This is an Opentrons internal setting to collect performance metrics." + " Do not turn this on unless you are playing with the performance metrics system." + ), + robot_type=[RobotTypeEnum.OT2, RobotTypeEnum.FLEX], internal_only=True, ), ] @@ -709,6 +719,16 @@ def _migrate31to32(previous: SettingsMap) -> SettingsMap: return newmap +def _migrate32to33(previous: SettingsMap) -> SettingsMap: + """Migrate to version 33 of the feature flags file. + + - Adds the enablePerformanceMetrics config element. + """ + newmap = {k: v for k, v in previous.items()} + newmap["enablePerformanceMetrics"] = None + return newmap + + _MIGRATIONS = [ _migrate0to1, _migrate1to2, @@ -742,6 +762,7 @@ def _migrate31to32(previous: SettingsMap) -> SettingsMap: _migrate29to30, _migrate30to31, _migrate31to32, + _migrate32to33, ] """ List of all migrations to apply, indexed by (version - 1). See _migrate below diff --git a/api/src/opentrons/config/defaults_ot3.py b/api/src/opentrons/config/defaults_ot3.py index ba4ed09d078..0b2499feaab 100644 --- a/api/src/opentrons/config/defaults_ot3.py +++ b/api/src/opentrons/config/defaults_ot3.py @@ -1,8 +1,8 @@ -from typing import Any, Dict, cast, List, Iterable, Tuple +from typing import Any, Dict, cast, List, Iterable, Tuple, Optional from typing_extensions import Final from dataclasses import asdict -from opentrons.hardware_control.types import OT3AxisKind +from opentrons.hardware_control.types import OT3AxisKind, InstrumentProbeType from .types import ( OT3Config, ByGantryLoad, @@ -34,7 +34,7 @@ aspirate_while_sensing=False, auto_zero_sensor=True, num_baseline_reads=10, - data_file="/var/pressure_sensor_data.csv", + data_files={InstrumentProbeType.PRIMARY: "/data/pressure_sensor_data.csv"}, ) DEFAULT_CALIBRATION_SETTINGS: Final[OT3CalibrationSettings] = OT3CalibrationSettings( @@ -194,6 +194,49 @@ ) +def _build_output_option_with_default( + from_conf: Any, default: OutputOptions +) -> OutputOptions: + if from_conf is None: + return default + else: + if isinstance(from_conf, OutputOptions): + return from_conf + else: + try: + enumval = OutputOptions[from_conf] + except KeyError: # not an enum entry + return default + else: + return enumval + + +def _build_log_files_with_default( + from_conf: Any, + default: Optional[Dict[InstrumentProbeType, str]], +) -> Optional[Dict[InstrumentProbeType, str]]: + print(f"from_conf {from_conf} default {default}") + if not isinstance(from_conf, dict): + if default is None: + return None + else: + return {k: v for k, v in default.items()} + else: + validated: Dict[InstrumentProbeType, str] = {} + for k, v in from_conf.items(): + if isinstance(k, InstrumentProbeType): + validated[k] = v + else: + try: + enumval = InstrumentProbeType[k] + except KeyError: # not an enum entry + pass + else: + validated[enumval] = v + print(f"result {validated}") + return validated + + def _build_dict_with_default( from_conf: Any, default: Dict[OT3AxisKind, float], @@ -278,6 +321,17 @@ def _build_default_cap_pass( def _build_default_liquid_probe( from_conf: Any, default: LiquidProbeSettings ) -> LiquidProbeSettings: + output_option = _build_output_option_with_default( + from_conf.get("output_option", None), default.output_option + ) + data_files: Optional[Dict[InstrumentProbeType, str]] = None + if ( + output_option is OutputOptions.sync_buffer_to_csv + or output_option is OutputOptions.stream_to_csv + ): + data_files = _build_log_files_with_default( + from_conf.get("data_files", {}), default.data_files + ) return LiquidProbeSettings( starting_mount_height=from_conf.get( "starting_mount_height", default.starting_mount_height @@ -302,7 +356,7 @@ def _build_default_liquid_probe( num_baseline_reads=from_conf.get( "num_baseline_reads", default.num_baseline_reads ), - data_file=from_conf.get("data_file", default.data_file), + data_files=data_files, ) @@ -412,7 +466,7 @@ def build_with_defaults(robot_settings: Dict[str, Any]) -> OT3Config: def serialize(config: OT3Config) -> Dict[str, Any]: def _build_dict(pairs: Iterable[Tuple[Any, Any]]) -> Dict[str, Any]: def _normalize_key(key: Any) -> Any: - if isinstance(key, OT3AxisKind): + if isinstance(key, OT3AxisKind) or isinstance(key, InstrumentProbeType): return key.name return key diff --git a/api/src/opentrons/config/feature_flags.py b/api/src/opentrons/config/feature_flags.py index 4a1161a2391..65984dd7ab9 100644 --- a/api/src/opentrons/config/feature_flags.py +++ b/api/src/opentrons/config/feature_flags.py @@ -76,3 +76,11 @@ def enable_error_recovery_experiments() -> bool: return advs.get_setting_with_env_overload( "enableErrorRecoveryExperiments", RobotTypeEnum.FLEX ) + + +def enable_performance_metrics(robot_type: RobotTypeEnum) -> bool: + return advs.get_setting_with_env_overload("enablePerformanceMetrics", robot_type) + + +def oem_mode_enabled() -> bool: + return advs.get_setting_with_env_overload("enableOEMMode", RobotTypeEnum.FLEX) diff --git a/api/src/opentrons/config/types.py b/api/src/opentrons/config/types.py index 0a526ee5336..f13d5a5e6e3 100644 --- a/api/src/opentrons/config/types.py +++ b/api/src/opentrons/config/types.py @@ -2,7 +2,7 @@ from dataclasses import dataclass, asdict, fields from typing import Dict, Tuple, TypeVar, Generic, List, cast, Optional from typing_extensions import TypedDict, Literal -from opentrons.hardware_control.types import OT3AxisKind +from opentrons.hardware_control.types import OT3AxisKind, InstrumentProbeType class AxisDict(TypedDict): @@ -139,7 +139,7 @@ class LiquidProbeSettings: aspirate_while_sensing: bool auto_zero_sensor: bool num_baseline_reads: int - data_file: Optional[str] + data_files: Optional[Dict[InstrumentProbeType, str]] @dataclass(frozen=True) diff --git a/api/src/opentrons/execute.py b/api/src/opentrons/execute.py index a35f4a91d8d..e851d8a44f0 100644 --- a/api/src/opentrons/execute.py +++ b/api/src/opentrons/execute.py @@ -28,7 +28,7 @@ from opentrons import protocol_api, __version__, should_use_ot3 -from opentrons.commands import types as command_types +from opentrons.legacy_commands import types as command_types from opentrons.hardware_control import ( API as OT2API, @@ -333,7 +333,7 @@ def execute( # noqa: C901 'text': string_command_text, # The rest of this struct is # command-dependent; see - # opentrons.commands.commands. + # opentrons.legacy_commands.commands. } } diff --git a/api/src/opentrons/hardware_control/backends/flex_protocol.py b/api/src/opentrons/hardware_control/backends/flex_protocol.py index 1a63ec04f08..7bd2969de6b 100644 --- a/api/src/opentrons/hardware_control/backends/flex_protocol.py +++ b/api/src/opentrons/hardware_control/backends/flex_protocol.py @@ -147,7 +147,7 @@ async def liquid_probe( plunger_speed: float, threshold_pascals: float, output_format: OutputOptions = OutputOptions.can_bus_only, - data_file: Optional[str] = None, + data_files: Optional[Dict[InstrumentProbeType, str]] = None, auto_zero_sensor: bool = True, num_baseline_reads: int = 10, probe: InstrumentProbeType = InstrumentProbeType.PRIMARY, @@ -383,7 +383,9 @@ async def capacitive_pass( def subsystems(self) -> Dict[SubSystem, SubSystemState]: ... - async def get_tip_status(self, mount: OT3Mount) -> TipStateType: + async def get_tip_status( + self, mount: OT3Mount, ht_operation_sensor: Optional[InstrumentProbeType] = None + ) -> TipStateType: ... def current_tip_state(self, mount: OT3Mount) -> Optional[bool]: diff --git a/api/src/opentrons/hardware_control/backends/ot3controller.py b/api/src/opentrons/hardware_control/backends/ot3controller.py index 83439c0896b..ea0b610f8b4 100644 --- a/api/src/opentrons/hardware_control/backends/ot3controller.py +++ b/api/src/opentrons/hardware_control/backends/ot3controller.py @@ -1351,7 +1351,7 @@ async def liquid_probe( plunger_speed: float, threshold_pascals: float, output_option: OutputOptions = OutputOptions.can_bus_only, - data_file: Optional[str] = None, + data_files: Optional[Dict[InstrumentProbeType, str]] = None, auto_zero_sensor: bool = True, num_baseline_reads: int = 10, probe: InstrumentProbeType = InstrumentProbeType.PRIMARY, @@ -1372,6 +1372,14 @@ async def liquid_probe( can_bus_only_output = bool( output_option.value & OutputOptions.can_bus_only.value ) + data_files_transposed = ( + None + if data_files is None + else { + sensor_id_for_instrument(probe): data_files[probe] + for probe in data_files.keys() + } + ) positions = await liquid_probe( messenger=self._messenger, tool=tool, @@ -1383,7 +1391,7 @@ async def liquid_probe( csv_output=csv_output, sync_buffer_output=sync_buffer_output, can_bus_only_output=can_bus_only_output, - data_file=data_file, + data_files=data_files_transposed, auto_zero_sensor=auto_zero_sensor, num_baseline_reads=num_baseline_reads, sensor_id=sensor_id_for_instrument(probe), @@ -1513,8 +1521,14 @@ async def update_tip_detector(self, mount: OT3Mount, sensor_count: int) -> None: async def teardown_tip_detector(self, mount: OT3Mount) -> None: await self._tip_presence_manager.clear_detector(mount) - async def get_tip_status(self, mount: OT3Mount) -> TipStateType: - return await self.tip_presence_manager.get_tip_status(mount) + async def get_tip_status( + self, + mount: OT3Mount, + ht_operational_sensor: Optional[InstrumentProbeType] = None, + ) -> TipStateType: + return await self.tip_presence_manager.get_tip_status( + mount, ht_operational_sensor + ) def current_tip_state(self, mount: OT3Mount) -> Optional[bool]: return self.tip_presence_manager.current_tip_state(mount) @@ -1647,3 +1661,8 @@ async def get_hepa_uv_state(self) -> Optional[HepaUVState]: if res else None ) + + def _update_tip_state(self, mount: OT3Mount, status: bool) -> None: + """This is something we only use in the simulator. + It is required so that PE simulations using ot3api don't break.""" + pass diff --git a/api/src/opentrons/hardware_control/backends/ot3simulator.py b/api/src/opentrons/hardware_control/backends/ot3simulator.py index 638b0094a85..26d6237e9a3 100644 --- a/api/src/opentrons/hardware_control/backends/ot3simulator.py +++ b/api/src/opentrons/hardware_control/backends/ot3simulator.py @@ -346,7 +346,7 @@ async def liquid_probe( plunger_speed: float, threshold_pascals: float, output_format: OutputOptions = OutputOptions.can_bus_only, - data_file: Optional[str] = None, + data_files: Optional[Dict[InstrumentProbeType, str]] = None, auto_zero_sensor: bool = True, num_baseline_reads: int = 10, probe: InstrumentProbeType = InstrumentProbeType.PRIMARY, @@ -506,13 +506,20 @@ def _attached_pipette_to_mount( ), "id": None, } - if found_model and expected_instr or found_model: + if found_model and init_instr["id"] is not None: # Instrument detected matches instrument expected (note: # "instrument detected" means passed as an argument to the # constructor of this class) # OR Instrument detected and no expected instrument specified - converted_name = pipette_load_name.convert_pipette_model(found_model) + + found_model_version = "" + if found_model.find("flex") > -1: + found_model = found_model.replace("_flex", "") # type: ignore + found_model_version = f"{init_instr['id'][4]}.{init_instr['id'][5]}" + converted_name = pipette_load_name.convert_pipette_model( + found_model, found_model_version + ) return { "config": load_pipette_data.load_definition( converted_name.pipette_type, @@ -773,7 +780,11 @@ def subsystems(self) -> Dict[SubSystem, SubSystemState]: for axis in self._present_axes } - async def get_tip_status(self, mount: OT3Mount) -> TipStateType: + async def get_tip_status( + self, + mount: OT3Mount, + ht_operational_sensor: Optional[InstrumentProbeType] = None, + ) -> TipStateType: return TipStateType(self._sim_tip_state[mount]) def current_tip_state(self, mount: OT3Mount) -> Optional[bool]: @@ -843,3 +854,8 @@ async def set_hepa_uv_state(self, light_on: bool, timeout_s: int) -> bool: async def get_hepa_uv_state(self) -> Optional[HepaUVState]: return None + + def _update_tip_state(self, mount: OT3Mount, status: bool) -> None: + """This is something we only use in the simulator. + It is required so that PE simulations using ot3api don't break.""" + self._sim_tip_state[mount] = status diff --git a/api/src/opentrons/hardware_control/backends/ot3utils.py b/api/src/opentrons/hardware_control/backends/ot3utils.py index d585a48f99d..a9108c2365e 100644 --- a/api/src/opentrons/hardware_control/backends/ot3utils.py +++ b/api/src/opentrons/hardware_control/backends/ot3utils.py @@ -544,6 +544,7 @@ def sensor_node_for_pipette(mount: OT3Mount) -> PipetteProbeTarget: _instr_sensor_id_lookup: Dict[InstrumentProbeType, SensorId] = { InstrumentProbeType.PRIMARY: SensorId.S0, InstrumentProbeType.SECONDARY: SensorId.S1, + InstrumentProbeType.BOTH: SensorId.BOTH, } diff --git a/api/src/opentrons/hardware_control/backends/tip_presence_manager.py b/api/src/opentrons/hardware_control/backends/tip_presence_manager.py index 9d2be3901da..0e46d713955 100644 --- a/api/src/opentrons/hardware_control/backends/tip_presence_manager.py +++ b/api/src/opentrons/hardware_control/backends/tip_presence_manager.py @@ -3,7 +3,7 @@ from typing import cast, Callable, Optional, List, Set from typing_extensions import TypedDict, Literal -from opentrons.hardware_control.types import TipStateType, OT3Mount +from opentrons.hardware_control.types import TipStateType, OT3Mount, InstrumentProbeType from opentrons_hardware.drivers.can_bus import CanMessenger from opentrons_hardware.firmware_bindings.constants import NodeId @@ -14,8 +14,11 @@ from opentrons_shared_data.errors.exceptions import ( TipDetectorNotFound, UnmatchedTipPresenceStates, + GeneralError, ) +from .ot3utils import sensor_id_for_instrument + log = logging.getLogger(__name__) TipListener = Callable[[OT3Mount, bool], None] @@ -111,7 +114,24 @@ def current_tip_state(self, mount: OT3Mount) -> Optional[bool]: return state @staticmethod - def _get_tip_presence(results: List[tip_types.TipNotification]) -> TipStateType: + def _get_tip_presence( + results: List[tip_types.TipNotification], + ht_operational_sensor: Optional[InstrumentProbeType] = None, + ) -> TipStateType: + """ + We can use ht_operational_sensor used to specify that we only care + about the status of one tip presence sensor on a high throughput + pipette, and the other is allowed to be different. + """ + if ht_operational_sensor: + target_sensor_id = sensor_id_for_instrument(ht_operational_sensor) + for r in results: + if r.sensor == target_sensor_id: + return TipStateType(r.presence) + # raise an error if requested sensor response isn't found + raise GeneralError( + message=f"Requested status for sensor {ht_operational_sensor} not found." + ) # more than one sensor reported, we have to check if their states match if len(set(r.presence for r in results)) > 1: raise UnmatchedTipPresenceStates( @@ -119,9 +139,15 @@ def _get_tip_presence(results: List[tip_types.TipNotification]) -> TipStateType: ) return TipStateType(results[0].presence) - async def get_tip_status(self, mount: OT3Mount) -> TipStateType: + async def get_tip_status( + self, + mount: OT3Mount, + ht_operational_sensor: Optional[InstrumentProbeType] = None, + ) -> TipStateType: detector = self.get_detector(mount) - return self._get_tip_presence(await detector.request_tip_status()) + return self._get_tip_presence( + await detector.request_tip_status(), ht_operational_sensor + ) def get_detector(self, mount: OT3Mount) -> TipDetector: detector = self._detectors[self._get_key(mount)] diff --git a/api/src/opentrons/hardware_control/instruments/ot2/pipette.py b/api/src/opentrons/hardware_control/instruments/ot2/pipette.py index 2d20a4f592a..f8a9d48da60 100644 --- a/api/src/opentrons/hardware_control/instruments/ot2/pipette.py +++ b/api/src/opentrons/hardware_control/instruments/ot2/pipette.py @@ -26,6 +26,11 @@ InvalidLiquidClassName, CommandPreconditionViolated, ) +from opentrons_shared_data.pipette.ul_per_mm import ( + piecewise_volume_conversion, + PIPETTING_FUNCTION_FALLBACK_VERSION, + PIPETTING_FUNCTION_LATEST_VERSION, +) from opentrons.types import Point, Mount @@ -33,11 +38,7 @@ from opentrons.config.types import RobotConfig from opentrons.drivers.types import MoveSplit from ..instrument_abc import AbstractInstrument -from ..instrument_helpers import ( - piecewise_volume_conversion, - PIPETTING_FUNCTION_FALLBACK_VERSION, - PIPETTING_FUNCTION_LATEST_VERSION, -) + from .instrument_calibration import ( PipetteOffsetByPipetteMount, load_pipette_offset, diff --git a/api/src/opentrons/hardware_control/instruments/ot3/instrument_calibration.py b/api/src/opentrons/hardware_control/instruments/ot3/instrument_calibration.py index 7e7352170b9..b7eae1aa1fc 100644 --- a/api/src/opentrons/hardware_control/instruments/ot3/instrument_calibration.py +++ b/api/src/opentrons/hardware_control/instruments/ot3/instrument_calibration.py @@ -21,7 +21,7 @@ ) from opentrons.hardware_control.types import OT3Mount -PIPETTE_OFFSET_CONSISTENCY_LIMIT: Final = 1.5 +PIPETTE_OFFSET_CONSISTENCY_LIMIT: Final = 4.0 # These type aliases aid typechecking in tests that work the same on this and # the hardware_control.instruments.ot2 variant diff --git a/api/src/opentrons/hardware_control/instruments/ot3/pipette.py b/api/src/opentrons/hardware_control/instruments/ot3/pipette.py index b2dc7f01c02..7d72058d1ce 100644 --- a/api/src/opentrons/hardware_control/instruments/ot3/pipette.py +++ b/api/src/opentrons/hardware_control/instruments/ot3/pipette.py @@ -25,12 +25,12 @@ CommandPreconditionViolated, PythonException, ) -from ..instrument_abc import AbstractInstrument -from ..instrument_helpers import ( +from opentrons_shared_data.pipette.ul_per_mm import ( piecewise_volume_conversion, PIPETTING_FUNCTION_FALLBACK_VERSION, PIPETTING_FUNCTION_LATEST_VERSION, ) +from ..instrument_abc import AbstractInstrument from .instrument_calibration import ( save_pipette_offset_calibration, load_pipette_offset, diff --git a/api/src/opentrons/hardware_control/modules/mod_abc.py b/api/src/opentrons/hardware_control/modules/mod_abc.py index c6ea41437eb..9d5527991f6 100644 --- a/api/src/opentrons/hardware_control/modules/mod_abc.py +++ b/api/src/opentrons/hardware_control/modules/mod_abc.py @@ -2,9 +2,8 @@ import asyncio import logging import re -from pkg_resources import parse_version -from typing import ClassVar, Mapping, Optional, cast, TypeVar - +from typing import ClassVar, Mapping, Optional, TypeVar +from packaging.version import InvalidVersion, parse, Version from opentrons.config import IS_ROBOT, ROBOT_FIRMWARE_DIR from opentrons.drivers.rpi_drivers.types import USBPort @@ -16,6 +15,14 @@ TaskPayload = TypeVar("TaskPayload") +def parse_fw_version(version: str) -> Version: + try: + device_version = parse(version) + except InvalidVersion: + device_version = parse("v0.0.0") + return device_version + + class AbstractModule(abc.ABC): """Defines the common methods of a module.""" @@ -88,9 +95,9 @@ def get_bundled_fw(self) -> Optional[BundledFirmware]: def has_available_update(self) -> bool: """Return whether a newer firmware file is available""" if self.device_info and self._bundled_fw: - device_version = parse_version(self.device_info["version"]) - available_version = parse_version(self._bundled_fw.version) - return cast(bool, available_version > device_version) + device_version = parse_fw_version(self.device_info["version"]) + available_version = parse_fw_version(self._bundled_fw.version) + return available_version > device_version return False async def wait_for_is_running(self) -> None: diff --git a/api/src/opentrons/hardware_control/modules/types.py b/api/src/opentrons/hardware_control/modules/types.py index 1a87d60d35e..eb8054a87ee 100644 --- a/api/src/opentrons/hardware_control/modules/types.py +++ b/api/src/opentrons/hardware_control/modules/types.py @@ -64,6 +64,22 @@ def from_model(cls, model: ModuleModel) -> ModuleType: if isinstance(model, MagneticBlockModel): return cls.MAGNETIC_BLOCK + @classmethod + def to_module_fixture_id(cls, module_type: ModuleType) -> str: + if module_type == ModuleType.THERMOCYCLER: + # Thermocyclers are "loaded" in B1 only + return "thermocyclerModuleV2Front" + if module_type == ModuleType.TEMPERATURE: + return "temperatureModuleV2" + if module_type == ModuleType.HEATER_SHAKER: + return "heaterShakerModuleV1" + if module_type == ModuleType.MAGNETIC_BLOCK: + return "magneticBlockV1" + else: + raise ValueError( + f"Module Type {module_type} does not have a related fixture ID." + ) + class MagneticModuleModel(str, Enum): MAGNETIC_V1: str = "magneticModuleV1" diff --git a/api/src/opentrons/hardware_control/ot3api.py b/api/src/opentrons/hardware_control/ot3api.py index ae7be339673..dbc76181f24 100644 --- a/api/src/opentrons/hardware_control/ot3api.py +++ b/api/src/opentrons/hardware_control/ot3api.py @@ -1533,6 +1533,12 @@ async def _home_axis(self, axis: Axis) -> None: await self._set_plunger_current_and_home(axis, motor_ok, encoder_ok) return + # TODO: (ba, 2024-04-19): We need to explictly engage the axis and enable + # the motor when we are attempting to move. This should be already + # happening but something on the firmware is either not enabling the motor or + # disabling the motor. + await self.engage_axes([axis]) + # we can move to safe home distance! if encoder_ok and motor_ok: origin, target_pos = await self._retrieve_home_position(axis) @@ -1649,16 +1655,24 @@ async def retract_axis(self, axis: Axis) -> None: motor_ok = self._backend.check_motor_status([axis]) encoder_ok = self._backend.check_encoder_status([axis]) - if motor_ok and encoder_ok: - # we can move to the home position without checking the limit switch - origin = await self._backend.update_position() - target_pos = {axis: self._backend.home_position()[axis]} - await self._backend.move(origin, target_pos, 400, HWStopCondition.none) - else: - # home the axis - await self._home_axis(axis) - await self._cache_current_position() - await self._cache_encoder_position() + async with self._motion_lock: + if motor_ok and encoder_ok: + # TODO: (ba, 2024-04-19): We need to explictly engage the axis and enable + # the motor when we are attempting to move. This should be already + # happening but something on the firmware is either not enabling the motor or + # disabling the motor. + await self.engage_axes([axis]) + + # we can move to the home position without checking the limit switch + origin = await self._backend.update_position() + target_pos = {axis: self._backend.home_position()[axis]} + await self._backend.move(origin, target_pos, 400, HWStopCondition.none) + else: + # home the axis + await self._home_axis(axis) + + await self._cache_current_position() + await self._cache_encoder_position() # Gantry/frame (i.e. not pipette) config API @property @@ -2058,6 +2072,7 @@ async def _high_throughput_check_tip(self) -> AsyncIterator[None]: async def get_tip_presence_status( self, mount: Union[top_types.Mount, OT3Mount], + ht_operational_sensor: Optional[InstrumentProbeType] = None, ) -> TipStateType: """ Check tip presence status. If a high throughput pipette is present, @@ -2071,14 +2086,19 @@ async def get_tip_presence_status( and self._gantry_load == GantryLoad.HIGH_THROUGHPUT ): await stack.enter_async_context(self._high_throughput_check_tip()) - result = await self._backend.get_tip_status(real_mount) + result = await self._backend.get_tip_status( + real_mount, ht_operational_sensor + ) return result async def verify_tip_presence( - self, mount: Union[top_types.Mount, OT3Mount], expected: TipStateType + self, + mount: Union[top_types.Mount, OT3Mount], + expected: TipStateType, + ht_operational_sensor: Optional[InstrumentProbeType] = None, ) -> None: real_mount = OT3Mount.from_mount(mount) - status = await self.get_tip_presence_status(real_mount) + status = await self.get_tip_presence_status(real_mount, ht_operational_sensor) if status != expected: raise FailedTipStateCheck(expected, status.value) @@ -2087,14 +2107,17 @@ async def _force_pick_up_tip( ) -> None: for press in pipette_spec.tip_action_moves: async with self._backend.motor_current(run_currents=press.currents): - target_down = target_position_from_relative( + target = target_position_from_relative( mount, top_types.Point(z=press.distance), self._current_position ) - await self._move(target_down, speed=press.speed, expect_stalls=True) - if press.distance < 0: - # we expect a stall has happened during a downward movement into the tiprack, so - # we want to update the motor estimation - await self._update_position_estimation([Axis.by_mount(mount)]) + if press.distance < 0: + # we expect a stall has happened during a downward movement into the tiprack, so + # we want to update the motor estimation + await self._move(target, speed=press.speed, expect_stalls=True) + await self._update_position_estimation([Axis.by_mount(mount)]) + else: + # we should not ignore stalls that happen during the retract part of the routine + await self._move(target, speed=press.speed, expect_stalls=False) async def _tip_motor_action( self, mount: OT3Mount, pipette_spec: List[TipActionMoveSpec] @@ -2133,6 +2156,8 @@ async def pick_up_tip( def add_tip_to_instr() -> None: instrument.add_tip(tip_length=tip_length) instrument.set_current_volume(0) + if isinstance(self._backend, OT3Simulator): + self._backend._update_tip_state(realmount, True) await self._move_to_plunger_bottom(realmount, rate=1.0) if ( @@ -2233,6 +2258,9 @@ def _remove_tips() -> None: await self._home([Axis.by_mount(mount)]) _remove_tips() + # call this in case we're simulating + if isinstance(self._backend, OT3Simulator): + self._backend._update_tip_state(realmount, False) async def clean_up(self) -> None: """Get the API ready to stop cleanly.""" @@ -2573,7 +2601,7 @@ async def liquid_probe( (probe_settings.plunger_speed * plunger_direction), probe_settings.sensor_threshold_pascals, probe_settings.output_option, - probe_settings.data_file, + probe_settings.data_files, probe_settings.auto_zero_sensor, probe_settings.num_baseline_reads, probe=probe if probe else InstrumentProbeType.PRIMARY, diff --git a/api/src/opentrons/hardware_control/types.py b/api/src/opentrons/hardware_control/types.py index 9a153a447d5..1ea79652f34 100644 --- a/api/src/opentrons/hardware_control/types.py +++ b/api/src/opentrons/hardware_control/types.py @@ -624,6 +624,7 @@ class GripperJawState(enum.Enum): class InstrumentProbeType(enum.Enum): PRIMARY = enum.auto() SECONDARY = enum.auto() + BOTH = enum.auto() class GripperProbe(enum.Enum): diff --git a/api/src/opentrons/legacy_broker.py b/api/src/opentrons/legacy_broker.py index 838a75b7759..b58a779134e 100644 --- a/api/src/opentrons/legacy_broker.py +++ b/api/src/opentrons/legacy_broker.py @@ -5,7 +5,7 @@ from typing import Callable, Dict, List from typing_extensions import Literal -from opentrons.commands import types +from opentrons.legacy_commands import types MODULE_LOG = logging.getLogger(__name__) @@ -16,7 +16,7 @@ class LegacyBroker: Deprecated: Use the newer, more generic `opentrons.utils.Broker` class instead. - This class is coupled to old types from `opentrons.commands`. + This class is coupled to old types from `opentrons.legacy_commands`. https://opentrons.atlassian.net/browse/RSS-270 """ diff --git a/api/src/opentrons/legacy_commands/__init__.py b/api/src/opentrons/legacy_commands/__init__.py new file mode 100644 index 00000000000..558ad9b87c0 --- /dev/null +++ b/api/src/opentrons/legacy_commands/__init__.py @@ -0,0 +1 @@ +"""Command models from before v5.0, before Protocol Engine.""" diff --git a/api/src/opentrons/commands/commands.py b/api/src/opentrons/legacy_commands/commands.py similarity index 100% rename from api/src/opentrons/commands/commands.py rename to api/src/opentrons/legacy_commands/commands.py diff --git a/api/src/opentrons/commands/helpers.py b/api/src/opentrons/legacy_commands/helpers.py similarity index 100% rename from api/src/opentrons/commands/helpers.py rename to api/src/opentrons/legacy_commands/helpers.py diff --git a/api/src/opentrons/commands/module_commands.py b/api/src/opentrons/legacy_commands/module_commands.py similarity index 100% rename from api/src/opentrons/commands/module_commands.py rename to api/src/opentrons/legacy_commands/module_commands.py diff --git a/api/src/opentrons/commands/protocol_commands.py b/api/src/opentrons/legacy_commands/protocol_commands.py similarity index 100% rename from api/src/opentrons/commands/protocol_commands.py rename to api/src/opentrons/legacy_commands/protocol_commands.py diff --git a/api/src/opentrons/commands/publisher.py b/api/src/opentrons/legacy_commands/publisher.py similarity index 100% rename from api/src/opentrons/commands/publisher.py rename to api/src/opentrons/legacy_commands/publisher.py diff --git a/api/src/opentrons/commands/types.py b/api/src/opentrons/legacy_commands/types.py similarity index 100% rename from api/src/opentrons/commands/types.py rename to api/src/opentrons/legacy_commands/types.py diff --git a/api/src/opentrons/protocol_api/_parameter_context.py b/api/src/opentrons/protocol_api/_parameter_context.py index e16273b2a33..8c9debd882c 100644 --- a/api/src/opentrons/protocol_api/_parameter_context.py +++ b/api/src/opentrons/protocol_api/_parameter_context.py @@ -52,6 +52,7 @@ def add_int( description: A description of the parameter as it will show up on the frontend. unit: An optional unit to be appended to the end of the integer as it shown on the frontend. """ + validation.validate_variable_name_unique(variable_name, set(self._parameters)) parameter = parameter_definition.create_int_parameter( display_name=display_name, variable_name=variable_name, @@ -88,13 +89,14 @@ def add_float( description: A description of the parameter as it will show up on the frontend. unit: An optional unit to be appended to the end of the float as it shown on the frontend. """ + validation.validate_variable_name_unique(variable_name, set(self._parameters)) parameter = parameter_definition.create_float_parameter( display_name=display_name, variable_name=variable_name, - default=default, - minimum=minimum, - maximum=maximum, - choices=choices, + default=validation.ensure_float_value(default), + minimum=validation.ensure_optional_float_value(minimum), + maximum=validation.ensure_optional_float_value(maximum), + choices=validation.ensure_float_choices(choices), description=description, unit=unit, ) @@ -115,6 +117,7 @@ def add_bool( default: The default value the boolean parameter will be set to. This will be used in initial analysis. description: A description of the parameter as it will show up on the frontend. """ + validation.validate_variable_name_unique(variable_name, set(self._parameters)) parameter = parameter_definition.create_bool_parameter( display_name=display_name, variable_name=variable_name, @@ -145,6 +148,7 @@ def add_str( Mutually exclusive with minimum and maximum. description: A description of the parameter as it will show up on the frontend. """ + validation.validate_variable_name_unique(variable_name, set(self._parameters)) parameter = parameter_definition.create_str_parameter( display_name=display_name, variable_name=variable_name, diff --git a/api/src/opentrons/protocol_api/core/engine/instrument.py b/api/src/opentrons/protocol_api/core/engine/instrument.py index 6bf569bcd67..485f45d0e94 100644 --- a/api/src/opentrons/protocol_api/core/engine/instrument.py +++ b/api/src/opentrons/protocol_api/core/engine/instrument.py @@ -408,13 +408,18 @@ def pick_up_tip( well_name=well_name, well_location=well_location, ) - self._engine_client.pick_up_tip( + + self._engine_client.pick_up_tip_wait_for_recovery( pipette_id=self._pipette_id, labware_id=labware_id, well_name=well_name, well_location=well_location, ) + # Set the "last location" unconditionally, even if the command failed + # and was recovered from and we don't know if the pipette is physically here. + # This isn't used for path planning, but rather for implicit destination + # selection like in `pipette.aspirate(location=None)`. self._protocol_core.set_last_location(location=location, mount=self.get_mount()) def drop_tip( @@ -777,3 +782,8 @@ def configure_nozzle_layout( self._engine_client.configure_nozzle_layout( pipette_id=self._pipette_id, configuration_params=configuration_model ) + + def retract(self) -> None: + """Retract this instrument to the top of the gantry.""" + z_axis = self._engine_client.state.pipettes.get_z_axis(self._pipette_id) + self._engine_client.home([z_axis]) diff --git a/api/src/opentrons/protocol_api/core/engine/protocol.py b/api/src/opentrons/protocol_api/core/engine/protocol.py index e3146a98a08..4089dff4b4d 100644 --- a/api/src/opentrons/protocol_api/core/engine/protocol.py +++ b/api/src/opentrons/protocol_api/core/engine/protocol.py @@ -3,7 +3,7 @@ from typing import Dict, Optional, Type, Union, List, Tuple, TYPE_CHECKING from opentrons.protocol_engine.commands import LoadModuleResult -from opentrons_shared_data.deck.dev_types import DeckDefinitionV4, SlotDefV3 +from opentrons_shared_data.deck.dev_types import DeckDefinitionV5, SlotDefV3 from opentrons_shared_data.labware.labware_definition import LabwareDefinition from opentrons_shared_data.labware.dev_types import LabwareDefinition as LabwareDefDict from opentrons_shared_data.pipette.dev_types import PipetteNameType @@ -409,7 +409,6 @@ def load_module( robot_type = self._engine_client.state.config.robot_type normalized_deck_slot = deck_slot.to_equivalent_for_robot_type(robot_type) - self._ensure_module_location(normalized_deck_slot, module_type) result = self._engine_client.load_module( model=EngineModuleModel(model), @@ -602,7 +601,7 @@ def set_last_location( self._last_location = location self._last_mount = mount - def get_deck_definition(self) -> DeckDefinitionV4: + def get_deck_definition(self) -> DeckDefinitionV5: """Get the geometry definition of the robot's deck.""" return self._engine_client.state.labware.get_deck_definition() @@ -622,14 +621,6 @@ def get_staging_slot_definitions(self) -> Dict[str, SlotDefV3]: self._engine_client.state.addressable_areas.get_staging_slot_definitions() ) - def _ensure_module_location( - self, slot: DeckSlotName, module_type: ModuleType - ) -> None: - slot_def = self.get_slot_definition(slot) - compatible_modules = slot_def["compatibleModuleTypes"] - if module_type.value not in compatible_modules: - raise ValueError(f"A {module_type.value} cannot be loaded into slot {slot}") - def get_slot_item( self, slot_name: Union[DeckSlotName, StagingSlotName] ) -> Union[LabwareCore, ModuleCore, NonConnectedModuleCore, None]: diff --git a/api/src/opentrons/protocol_api/core/instrument.py b/api/src/opentrons/protocol_api/core/instrument.py index 061e7d13960..fec252a009e 100644 --- a/api/src/opentrons/protocol_api/core/instrument.py +++ b/api/src/opentrons/protocol_api/core/instrument.py @@ -289,6 +289,9 @@ def configure_nozzle_layout( @abstractmethod def is_tip_tracking_available(self) -> bool: """Return whether auto tip tracking is available for the pipette's current nozzle configuration.""" + + def retract(self) -> None: + """Retract this instrument to the top of the gantry.""" ... diff --git a/api/src/opentrons/protocol_api/core/legacy/deck.py b/api/src/opentrons/protocol_api/core/legacy/deck.py index 9a9092af5ae..685f0f5d553 100644 --- a/api/src/opentrons/protocol_api/core/legacy/deck.py +++ b/api/src/opentrons/protocol_api/core/legacy/deck.py @@ -280,6 +280,11 @@ def resolve_module_location( compatible_modules = slot_def["compatibleModuleTypes"] if module_type.value in compatible_modules: return location + elif ( + self._definition["robot"]["model"] == "OT-3 Standard" + and ModuleType.to_module_fixture_id(module_type) == slot_def["id"] + ): + return location else: raise ValueError( f"A {dn_from_type[module_type]} cannot be loaded" diff --git a/api/src/opentrons/protocol_api/core/legacy/legacy_instrument_core.py b/api/src/opentrons/protocol_api/core/legacy/legacy_instrument_core.py index 57f129c32b3..3755b093e78 100644 --- a/api/src/opentrons/protocol_api/core/legacy/legacy_instrument_core.py +++ b/api/src/opentrons/protocol_api/core/legacy/legacy_instrument_core.py @@ -558,3 +558,7 @@ def get_nozzle_map(self) -> NozzleMap: def is_tip_tracking_available(self) -> bool: # Tip tracking is always available in legacy context return True + + def retract(self) -> None: + """Retract this instrument to the top of the gantry.""" + self._protocol_interface.get_hardware.retract(self._mount) # type: ignore [attr-defined] diff --git a/api/src/opentrons/protocol_api/core/legacy/legacy_protocol_core.py b/api/src/opentrons/protocol_api/core/legacy/legacy_protocol_core.py index d99c3032a71..02fc2003733 100644 --- a/api/src/opentrons/protocol_api/core/legacy/legacy_protocol_core.py +++ b/api/src/opentrons/protocol_api/core/legacy/legacy_protocol_core.py @@ -1,7 +1,7 @@ import logging from typing import Dict, List, Optional, Set, Union, cast, Tuple -from opentrons_shared_data.deck.dev_types import DeckDefinitionV4, SlotDefV3 +from opentrons_shared_data.deck.dev_types import DeckDefinitionV5, SlotDefV3 from opentrons_shared_data.labware.dev_types import LabwareDefinition from opentrons_shared_data.pipette.dev_types import PipetteNameType from opentrons_shared_data.robot.dev_types import RobotType @@ -491,7 +491,7 @@ def get_labware_on_labware( ) -> Optional[LegacyLabwareCore]: assert False, "get_labware_on_labware only supported on engine core" - def get_deck_definition(self) -> DeckDefinitionV4: + def get_deck_definition(self) -> DeckDefinitionV5: """Get the geometry definition of the robot's deck.""" assert False, "get_deck_definition only supported on engine core" diff --git a/api/src/opentrons/protocol_api/core/legacy_simulator/legacy_instrument_core.py b/api/src/opentrons/protocol_api/core/legacy_simulator/legacy_instrument_core.py index 2ee61adf24e..ffcdda5019c 100644 --- a/api/src/opentrons/protocol_api/core/legacy_simulator/legacy_instrument_core.py +++ b/api/src/opentrons/protocol_api/core/legacy_simulator/legacy_instrument_core.py @@ -476,3 +476,7 @@ def get_nozzle_map(self) -> NozzleMap: def is_tip_tracking_available(self) -> bool: # Tip tracking is always available in legacy context return True + + def retract(self) -> None: + """Retract this instrument to the top of the gantry.""" + self._protocol_interface.get_hardware.retract(self._mount) # type: ignore [attr-defined] diff --git a/api/src/opentrons/protocol_api/core/protocol.py b/api/src/opentrons/protocol_api/core/protocol.py index 8ed83388c07..a554c14e306 100644 --- a/api/src/opentrons/protocol_api/core/protocol.py +++ b/api/src/opentrons/protocol_api/core/protocol.py @@ -5,7 +5,7 @@ from abc import abstractmethod, ABC from typing import Generic, List, Optional, Union, Tuple, Dict, TYPE_CHECKING -from opentrons_shared_data.deck.dev_types import DeckDefinitionV4, SlotDefV3 +from opentrons_shared_data.deck.dev_types import DeckDefinitionV5, SlotDefV3 from opentrons_shared_data.pipette.dev_types import PipetteNameType from opentrons_shared_data.labware.dev_types import LabwareDefinition from opentrons_shared_data.robot.dev_types import RobotType @@ -188,7 +188,7 @@ def set_last_location( ... @abstractmethod - def get_deck_definition(self) -> DeckDefinitionV4: + def get_deck_definition(self) -> DeckDefinitionV5: """Get the geometry definition of the robot's deck.""" @abstractmethod diff --git a/api/src/opentrons/protocol_api/instrument_context.py b/api/src/opentrons/protocol_api/instrument_context.py index 1b58bcfc524..e070b896a6e 100644 --- a/api/src/opentrons/protocol_api/instrument_context.py +++ b/api/src/opentrons/protocol_api/instrument_context.py @@ -11,9 +11,9 @@ from opentrons.legacy_broker import LegacyBroker from opentrons.hardware_control.dev_types import PipetteDict from opentrons import types -from opentrons.commands import commands as cmds +from opentrons.legacy_commands import commands as cmds -from opentrons.commands import publisher +from opentrons.legacy_commands import publisher from opentrons.protocols.advanced_control.mix import mix_from_kwargs from opentrons.protocols.advanced_control import transfers @@ -1532,6 +1532,12 @@ def move_to( return self + @requires_version(2, 18) + def _retract( + self, + ) -> None: + self._core.retract() + @property @requires_version(2, 0) def mount(self) -> str: diff --git a/api/src/opentrons/protocol_api/module_contexts.py b/api/src/opentrons/protocol_api/module_contexts.py index f525fe6b320..654a6ec46c1 100644 --- a/api/src/opentrons/protocol_api/module_contexts.py +++ b/api/src/opentrons/protocol_api/module_contexts.py @@ -8,8 +8,8 @@ from opentrons.legacy_broker import LegacyBroker from opentrons.hardware_control.modules import ThermocyclerStep -from opentrons.commands import module_commands as cmds -from opentrons.commands.publisher import CommandPublisher, publish +from opentrons.legacy_commands import module_commands as cmds +from opentrons.legacy_commands.publisher import CommandPublisher, publish from opentrons.protocols.api_support.types import APIVersion from opentrons.protocols.api_support.util import APIVersionError, requires_version @@ -151,7 +151,7 @@ def load_labware( load_location = loaded_adapter._core else: load_location = self._core - + name = validation.ensure_lowercase_name(name) labware_core = self._protocol_core.load_labware( load_name=name, label=label, @@ -467,9 +467,9 @@ def engage( if height is not None: if self._api_version >= _MAGNETIC_MODULE_HEIGHT_PARAM_REMOVED_IN: raise APIVersionError( - "The height parameter of MagneticModuleContext.engage() was removed" - " in {_MAGNETIC_MODULE_HEIGHT_PARAM_REMOVED_IN}." - " Use offset or height_from_base instead." + f"The height parameter of MagneticModuleContext.engage() was removed" + f" in {_MAGNETIC_MODULE_HEIGHT_PARAM_REMOVED_IN}." + f" Use offset or height_from_base instead." ) self._core.engage(height_from_home=height) diff --git a/api/src/opentrons/protocol_api/protocol_context.py b/api/src/opentrons/protocol_api/protocol_context.py index 2dd7815c09f..feb8f56d91c 100644 --- a/api/src/opentrons/protocol_api/protocol_context.py +++ b/api/src/opentrons/protocol_api/protocol_context.py @@ -20,9 +20,13 @@ from opentrons.legacy_broker import LegacyBroker from opentrons.hardware_control import SyncHardwareAPI from opentrons.hardware_control.modules.types import MagneticBlockModel -from opentrons.commands import protocol_commands as cmds, types as cmd_types -from opentrons.commands.helpers import stringify_labware_movement_command -from opentrons.commands.publisher import CommandPublisher, publish, publish_context +from opentrons.legacy_commands import protocol_commands as cmds, types as cmd_types +from opentrons.legacy_commands.helpers import stringify_labware_movement_command +from opentrons.legacy_commands.publisher import ( + CommandPublisher, + publish, + publish_context, +) from opentrons.protocols.api_support import instrument as instrument_support from opentrons.protocols.api_support.deck_type import ( NoTrashDefinedError, diff --git a/api/src/opentrons/protocol_api/validation.py b/api/src/opentrons/protocol_api/validation.py index f714f35cecd..eb72c6b6dfd 100644 --- a/api/src/opentrons/protocol_api/validation.py +++ b/api/src/opentrons/protocol_api/validation.py @@ -87,6 +87,10 @@ class InvalidTrashBinLocationError(ValueError): """An error raised when attempting to load trash bins in invalid slots.""" +class InvalidFixtureLocationError(ValueError): + """An error raised when attempting to load a fixture in an invalid cutout.""" + + def ensure_mount_for_pipette( mount: Union[str, Mount, None], pipette: PipetteNameType ) -> Mount: diff --git a/api/src/opentrons/protocol_engine/actions/__init__.py b/api/src/opentrons/protocol_engine/actions/__init__.py index b1181e6a50e..ac3fc653976 100644 --- a/api/src/opentrons/protocol_engine/actions/__init__.py +++ b/api/src/opentrons/protocol_engine/actions/__init__.py @@ -11,6 +11,7 @@ PauseAction, PauseSource, StopAction, + ResumeFromRecoveryAction, FinishAction, HardwareStoppedAction, QueueCommandAction, @@ -38,6 +39,7 @@ "PlayAction", "PauseAction", "StopAction", + "ResumeFromRecoveryAction", "FinishAction", "HardwareStoppedAction", "QueueCommandAction", diff --git a/api/src/opentrons/protocol_engine/actions/actions.py b/api/src/opentrons/protocol_engine/actions/actions.py index d5c6bb49abc..adcf4f9e40b 100644 --- a/api/src/opentrons/protocol_engine/actions/actions.py +++ b/api/src/opentrons/protocol_engine/actions/actions.py @@ -55,10 +55,7 @@ class PauseAction: @dataclass(frozen=True) class StopAction: - """Stop the current engine execution. - - After a StopAction, the engine status will be marked as stopped. - """ + """Request engine execution to stop soon.""" from_estop: bool = False @@ -119,6 +116,7 @@ class QueueCommandAction: created_at: datetime request: CommandCreate request_hash: Optional[str] + failed_command_id: Optional[str] = None @dataclass(frozen=True) @@ -154,11 +152,32 @@ class FailCommandAction: """ command_id: str + """The command to fail.""" + error_id: str + """An ID to assign to the command's error. + + Must be unique to this occurrence of the error. + """ + failed_at: datetime + """When the command failed.""" + error: EnumeratedError + """The underlying exception that caused this command to fail.""" + notes: List[CommandNote] + """Overwrite the command's `.notes` with these.""" + type: ErrorRecoveryType + """How this error should be handled in the context of the overall run.""" + + # This is a quick hack so FailCommandAction handlers can get the params of the + # command that failed. We probably want this to be a new "failure details" + # object instead, similar to how succeeded commands can send a "private result" + # to Protocol Engine internals. + running_command: Command + """The command to fail, in its prior `running` state.""" @dataclass(frozen=True) diff --git a/api/src/opentrons/protocol_engine/clients/sync_client.py b/api/src/opentrons/protocol_engine/clients/sync_client.py index 53703c16dee..f95611c1b4c 100644 --- a/api/src/opentrons/protocol_engine/clients/sync_client.py +++ b/api/src/opentrons/protocol_engine/clients/sync_client.py @@ -6,7 +6,9 @@ from opentrons_shared_data.labware.dev_types import LabwareUri from opentrons_shared_data.labware.labware_definition import LabwareDefinition -from opentrons.commands.protocol_commands import comment as make_legacy_comment_command +from opentrons.legacy_commands.protocol_commands import ( + comment as make_legacy_comment_command, +) from opentrons.types import MountType from opentrons.hardware_control.modules.types import ThermocyclerStep @@ -294,6 +296,29 @@ def pick_up_tip( return cast(commands.PickUpTipResult, result) + def pick_up_tip_wait_for_recovery( + self, + pipette_id: str, + labware_id: str, + well_name: str, + well_location: WellLocation, + ) -> commands.PickUpTip: + """Execute a PickUpTip, wait for any error recovery, and return it. + + Note that the returned command will not necessarily have a `result`. + """ + request = commands.PickUpTipCreate( + params=commands.PickUpTipParams( + pipetteId=pipette_id, + labwareId=labware_id, + wellName=well_name, + wellLocation=well_location, + ) + ) + command = self._transport.execute_command_wait_for_recovery(request=request) + + return cast(commands.PickUpTip, command) + def drop_tip( self, pipette_id: str, diff --git a/api/src/opentrons/protocol_engine/clients/transports.py b/api/src/opentrons/protocol_engine/clients/transports.py index 270599ff469..6de08db97ed 100644 --- a/api/src/opentrons/protocol_engine/clients/transports.py +++ b/api/src/opentrons/protocol_engine/clients/transports.py @@ -1,15 +1,28 @@ """A helper for controlling a `ProtocolEngine` without async/await.""" from asyncio import AbstractEventLoop, run_coroutine_threadsafe -from typing import Any, overload +from typing import Any, Final, overload from typing_extensions import Literal from opentrons_shared_data.labware.dev_types import LabwareUri from opentrons_shared_data.labware.labware_definition import LabwareDefinition + from ..protocol_engine import ProtocolEngine from ..errors import ProtocolCommandFailedError +from ..error_recovery_policy import ErrorRecoveryType from ..state import StateView -from ..commands import CommandCreate, CommandResult +from ..commands import Command, CommandCreate, CommandResult, CommandStatus + + +class RunStoppedBeforeCommandError(RuntimeError): + """Raised if the ProtocolEngine was stopped before a command could start.""" + + def __init__(self, command: Command) -> None: + self._command = command + super().__init__( + f"The run was stopped" + f" before {command.commandType} command {command.id} could execute." + ) class ChildThreadTransport: @@ -30,8 +43,10 @@ def __init__(self, engine: ProtocolEngine, loop: AbstractEventLoop) -> None: want to synchronously access it. loop: The event loop that `engine` is running in (in the other thread). """ - self._engine = engine - self._loop = loop + # We might access these from different threads, + # so let's make them Final for (shallow) immutability. + self._engine: Final = engine + self._loop: Final = loop @property def state(self) -> StateView: @@ -39,7 +54,11 @@ def state(self) -> StateView: return self._engine.state_view def execute_command(self, request: CommandCreate) -> CommandResult: - """Execute a ProtocolEngine command, blocking until the command completes. + """Execute a ProtocolEngine command. + + This blocks until the command completes. If the command fails, this will always + raise the failure as an exception--even if ProtocolEngine deemed the failure + recoverable. Args: request: The ProtocolEngine command request @@ -48,8 +67,11 @@ def execute_command(self, request: CommandCreate) -> CommandResult: The command's result data. Raises: - ProtocolEngineError: if the command execution is not successful, - the specific error that cause the command to fail is raised. + ProtocolEngineError: If the command execution was not successful, + the specific error that caused the command to fail is raised. + + If the run was stopped before the command could complete, that's + also signaled as this exception. """ command = run_coroutine_threadsafe( self._engine.add_and_execute_command(request=request), @@ -64,21 +86,76 @@ def execute_command(self, request: CommandCreate) -> CommandResult: message=f"{error.errorType}: {error.detail}", ) - # FIXME(mm, 2023-04-10): This assert can easily trigger from this sequence: - # - # 1. The engine is paused. - # 2. The user's Python script calls this method to start a new command, - # which remains `queued` because of the pause. - # 3. The engine is stopped. - # - # The returned command will be `queued`, so it won't have a result. - # - # We need to figure out a proper way to report this condition to callers - # so they correctly interpret it as an intentional stop, not an internal error. - assert command.result is not None, f"Expected Command {command} to have result" + if command.result is None: + # This can happen with a certain pause timing: + # + # 1. The engine is paused. + # 2. The user's Python script calls this method to start a new command, + # which remains `queued` because of the pause. + # 3. The engine is stopped. The returned command will be `queued` + # and won't have a result. + raise RunStoppedBeforeCommandError(command) return command.result + def execute_command_wait_for_recovery(self, request: CommandCreate) -> Command: + """Execute a ProtocolEngine command, including error recovery. + + This blocks until the command completes. Additionally, if the command fails, + this will continue to block until its error recovery has been completed. + + Args: + request: The ProtocolEngine command request. + + Returns: + The command. If error recovery happened for it, the command will be + reported here as failed. + + Raises: + ProtocolEngineError: If the command failed, *and* the failure was not + recovered from. + + If the run was stopped before the command could complete, that's + also signalled as this exception. + """ + + async def run_in_pe_thread() -> Command: + command = await self._engine.add_and_execute_command_wait_for_recovery( + request=request + ) + + if command.error is not None: + error_was_recovered_from = ( + self._engine.state_view.commands.get_error_recovery_type(command.id) + == ErrorRecoveryType.WAIT_FOR_RECOVERY + ) + if not error_was_recovered_from: + error = command.error + # TODO: this needs to have an actual code + raise ProtocolCommandFailedError( + original_error=error, + message=f"{error.errorType}: {error.detail}", + ) + + elif command.status == CommandStatus.QUEUED: + # This can happen with a certain pause timing: + # + # 1. The engine is paused. + # 2. The user's Python script calls this method to start a new command, + # which remains `queued` because of the pause. + # 3. The engine is stopped. The returned command will be `queued`, + # and won't have a result. + raise RunStoppedBeforeCommandError(command) + + return command + + command = run_coroutine_threadsafe( + run_in_pe_thread(), + loop=self._loop, + ).result() + + return command + @overload def call_method( self, diff --git a/api/src/opentrons/protocol_engine/commands/__init__.py b/api/src/opentrons/protocol_engine/commands/__init__.py index 3dfe6eaf51f..7ce6e07eb68 100644 --- a/api/src/opentrons/protocol_engine/commands/__init__.py +++ b/api/src/opentrons/protocol_engine/commands/__init__.py @@ -19,7 +19,7 @@ from . import thermocycler from . import calibration -from .hash_command_params import hash_command_params +from .hash_command_params import hash_protocol_command_params from .generate_command_schema import generate_command_schema from .command import ( @@ -333,7 +333,7 @@ "CommandStatus", "CommandIntent", # command parameter hashing - "hash_command_params", + "hash_protocol_command_params", # command schema generation "generate_command_schema", # aspirate command models diff --git a/api/src/opentrons/protocol_engine/commands/command.py b/api/src/opentrons/protocol_engine/commands/command.py index 5c2ab46b06f..ad43128236d 100644 --- a/api/src/opentrons/protocol_engine/commands/command.py +++ b/api/src/opentrons/protocol_engine/commands/command.py @@ -55,6 +55,7 @@ class CommandIntent(str, Enum): PROTOCOL = "protocol" SETUP = "setup" + FIXIT = "fixit" class BaseCommandCreate(GenericModel, Generic[CommandParamsT]): @@ -159,6 +160,12 @@ class BaseCommand(GenericModel, Generic[CommandParamsT, CommandResultT]): " the command's execution or the command's generation." ), ) + failedCommandId: Optional[str] = Field( + None, + description=( + "FIXIT command use only. Reference of the failed command id we are trying to fix." + ), + ) class AbstractCommandImpl( diff --git a/api/src/opentrons/protocol_engine/commands/hash_command_params.py b/api/src/opentrons/protocol_engine/commands/hash_command_params.py index 39a042e55dd..9b927aab014 100644 --- a/api/src/opentrons/protocol_engine/commands/hash_command_params.py +++ b/api/src/opentrons/protocol_engine/commands/hash_command_params.py @@ -9,7 +9,7 @@ # TODO(mm, 2023-04-28): # This implementation will not notice that commands are different if they have different params # but share the same commandType. We should also hash command params. (Jira RCORE-326.) -def hash_command_params( +def hash_protocol_command_params( create: CommandCreate, last_hash: Optional[str] ) -> Optional[str]: """Given a command create object, return a hash. @@ -28,12 +28,11 @@ def hash_command_params( The command hash, if the command is a protocol command. `None` if the command is a setup command. """ - if create.intent == CommandIntent.SETUP: + if create.intent != CommandIntent.PROTOCOL: return None - else: - # We avoid Python's built-in hash() function because it's not stable across - # runs of the Python interpreter. (Jira RSS-215.) - last_contribution = b"" if last_hash is None else last_hash.encode("ascii") - this_contribution = md5(create.commandType.encode("ascii")).digest() - to_hash = last_contribution + this_contribution - return md5(to_hash).hexdigest() + # We avoid Python's built-in hash() function because it's not stable across + # runs of the Python interpreter. (Jira RSS-215.) + last_contribution = b"" if last_hash is None else last_hash.encode("ascii") + this_contribution = md5(create.commandType.encode("ascii")).digest() + to_hash = last_contribution + this_contribution + return md5(to_hash).hexdigest() diff --git a/api/src/opentrons/protocol_engine/commands/load_module.py b/api/src/opentrons/protocol_engine/commands/load_module.py index 1d877d08941..5c1d474be4d 100644 --- a/api/src/opentrons/protocol_engine/commands/load_module.py +++ b/api/src/opentrons/protocol_engine/commands/load_module.py @@ -5,7 +5,15 @@ from pydantic import BaseModel, Field from .command import AbstractCommandImpl, BaseCommand, BaseCommandCreate -from ..types import DeckSlotLocation, ModuleModel, ModuleDefinition +from ..types import ( + DeckSlotLocation, + ModuleType, + ModuleModel, + ModuleDefinition, +) +from opentrons.types import DeckSlotName + +from opentrons.protocol_engine.resources import deck_configuration_provider if TYPE_CHECKING: from ..state import StateView @@ -104,9 +112,22 @@ def __init__( async def execute(self, params: LoadModuleParams) -> LoadModuleResult: """Check that the requested module is attached and assign its identifier.""" - self._state_view.addressable_areas.raise_if_area_not_in_deck_configuration( - params.location.slotName.id - ) + module_type = params.model.as_type() + self._ensure_module_location(params.location.slotName, module_type) + + if self._state_view.config.robot_type == "OT-2 Standard": + self._state_view.addressable_areas.raise_if_area_not_in_deck_configuration( + params.location.slotName.id + ) + else: + addressable_area = self._state_view.geometry._modules.ensure_and_convert_module_fixture_location( + deck_slot=params.location.slotName, + deck_type=self._state_view.config.deck_type, + model=params.model, + ) + self._state_view.addressable_areas.raise_if_area_not_in_deck_configuration( + addressable_area + ) verified_location = self._state_view.geometry.ensure_location_not_occupied( params.location @@ -132,6 +153,30 @@ async def execute(self, params: LoadModuleParams) -> LoadModuleResult: definition=loaded_module.definition, ) + def _ensure_module_location( + self, slot: DeckSlotName, module_type: ModuleType + ) -> None: + if self._state_view.config.robot_type == "OT-2 Standard": + slot_def = self._state_view.addressable_areas.get_slot_definition(slot.id) + compatible_modules = slot_def["compatibleModuleTypes"] + if module_type.value not in compatible_modules: + raise ValueError( + f"A {module_type.value} cannot be loaded into slot {slot}" + ) + else: + cutout_fixture_id = ModuleType.to_module_fixture_id(module_type) + module_fixture = deck_configuration_provider.get_cutout_fixture( + cutout_fixture_id, + self._state_view.addressable_areas.state.deck_definition, + ) + cutout_id = ( + self._state_view.addressable_areas.get_cutout_id_by_deck_slot_name(slot) + ) + if cutout_id not in module_fixture["mayMountTo"]: + raise ValueError( + f"A {module_type.value} cannot be loaded into slot {slot}" + ) + class LoadModule(BaseCommand[LoadModuleParams, LoadModuleResult]): """The model for a load module command.""" diff --git a/api/src/opentrons/protocol_engine/create_protocol_engine.py b/api/src/opentrons/protocol_engine/create_protocol_engine.py index 39268f28bc7..ab91b5fabaa 100644 --- a/api/src/opentrons/protocol_engine/create_protocol_engine.py +++ b/api/src/opentrons/protocol_engine/create_protocol_engine.py @@ -20,6 +20,7 @@ async def create_protocol_engine( config: Config, load_fixed_trash: bool = False, deck_configuration: typing.Optional[DeckConfigurationType] = None, + notify_publishers: typing.Optional[typing.Callable[[], None]] = None, ) -> ProtocolEngine: """Create a ProtocolEngine instance. @@ -28,6 +29,7 @@ async def create_protocol_engine( config: ProtocolEngine configuration. load_fixed_trash: Automatically load fixed trash labware in engine. deck_configuration: The initial deck configuration the engine will be instantiated with. + notify_publishers: Notifies robot server publishers of internal state change. """ deck_data = DeckDataProvider(config.deck_type) deck_definition = await deck_data.get_deck_definition() @@ -45,6 +47,7 @@ async def create_protocol_engine( is_door_open=hardware_api.door_state is DoorState.OPEN, module_calibration_offsets=module_calibration_offsets, deck_configuration=deck_configuration, + notify_publishers=notify_publishers, ) return ProtocolEngine(state_store=state_store, hardware_api=hardware_api) diff --git a/api/src/opentrons/protocol_engine/errors/__init__.py b/api/src/opentrons/protocol_engine/errors/__init__.py index d3c3bb6d79e..994e4cc9ed3 100644 --- a/api/src/opentrons/protocol_engine/errors/__init__.py +++ b/api/src/opentrons/protocol_engine/errors/__init__.py @@ -39,6 +39,7 @@ MustHomeError, RunStoppedError, SetupCommandNotAllowedError, + FixitCommandNotAllowedError, ModuleNotAttachedError, ModuleAlreadyPresentError, WrongModuleTypeError, @@ -55,6 +56,7 @@ InvalidHoldTimeError, CannotPerformModuleAction, PauseNotAllowedError, + ResumeFromRecoveryNotAllowedError, GripperNotAttachedError, CannotPerformGripperAction, HardwareNotSupportedError, @@ -65,6 +67,7 @@ LocationIsStagingSlotError, InvalidAxisForRobotType, NotSupportedOnRobotType, + CommandNotAllowedError, ) from .error_occurrence import ErrorOccurrence, ProtocolCommandFailedError @@ -109,6 +112,7 @@ "MustHomeError", "RunStoppedError", "SetupCommandNotAllowedError", + "FixitCommandNotAllowedError", "ModuleNotAttachedError", "ModuleAlreadyPresentError", "WrongModuleTypeError", @@ -124,6 +128,7 @@ "InvalidBlockVolumeError", "InvalidHoldTimeError", "CannotPerformModuleAction", + "ResumeFromRecoveryNotAllowedError", "PauseNotAllowedError", "ProtocolCommandFailedError", "GripperNotAttachedError", @@ -138,5 +143,5 @@ "NotSupportedOnRobotType", # error occurrence models "ErrorOccurrence", - "FailedGripperPickupError", + "CommandNotAllowedError", ] diff --git a/api/src/opentrons/protocol_engine/errors/exceptions.py b/api/src/opentrons/protocol_engine/errors/exceptions.py index 9d9ff99b33e..7f022652d71 100644 --- a/api/src/opentrons/protocol_engine/errors/exceptions.py +++ b/api/src/opentrons/protocol_engine/errors/exceptions.py @@ -505,6 +505,32 @@ def __init__( super().__init__(ErrorCodes.POSITION_UNKNOWN, message, details, wrapping) +class CommandNotAllowedError(ProtocolEngineError): + """Raised when adding a command with bad data.""" + + def __init__( + self, + message: Optional[str] = None, + details: Optional[Dict[str, Any]] = None, + wrapping: Optional[Sequence[EnumeratedError]] = None, + ) -> None: + """Build a CommandNotAllowedError.""" + super().__init__(ErrorCodes.GENERAL_ERROR, message, details, wrapping) + + +class FixitCommandNotAllowedError(ProtocolEngineError): + """Raised when adding a fixit command to a non-recoverable engine.""" + + def __init__( + self, + message: Optional[str] = None, + details: Optional[Dict[str, Any]] = None, + wrapping: Optional[Sequence[EnumeratedError]] = None, + ) -> None: + """Build a SetupCommandNotAllowedError.""" + super().__init__(ErrorCodes.GENERAL_ERROR, message, details, wrapping) + + class SetupCommandNotAllowedError(ProtocolEngineError): """Raised when adding a setup command to a non-idle/non-paused engine.""" @@ -518,6 +544,19 @@ def __init__( super().__init__(ErrorCodes.GENERAL_ERROR, message, details, wrapping) +class ResumeFromRecoveryNotAllowedError(ProtocolEngineError): + """Raised when attempting to resume a run from recovery that has a fixit command in the queue.""" + + def __init__( + self, + message: Optional[str] = None, + details: Optional[Dict[str, Any]] = None, + wrapping: Optional[Sequence[EnumeratedError]] = None, + ) -> None: + """Build a ResumeFromRecoveryNotAllowedError.""" + super().__init__(ErrorCodes.GENERAL_ERROR, message, details, wrapping) + + class PauseNotAllowedError(ProtocolEngineError): """Raised when attempting to pause a run that is not running.""" @@ -951,16 +990,18 @@ def __init__( class EStopActivatedError(ProtocolEngineError): - """Raised when an operation's required pipette tip is not attached.""" + """Represents an E-stop event.""" def __init__( self, - message: Optional[str] = None, - details: Optional[Dict[str, Any]] = None, wrapping: Optional[Sequence[EnumeratedError]] = None, ) -> None: """Build an EStopActivatedError.""" - super().__init__(ErrorCodes.E_STOP_ACTIVATED, message, details, wrapping) + super().__init__( + code=ErrorCodes.E_STOP_ACTIVATED, + message="E-stop activated.", + wrapping=wrapping, + ) class NotSupportedOnRobotType(ProtocolEngineError): diff --git a/api/src/opentrons/protocol_engine/execution/command_executor.py b/api/src/opentrons/protocol_engine/execution/command_executor.py index d44d37f5641..d00b5c0a96d 100644 --- a/api/src/opentrons/protocol_engine/execution/command_executor.py +++ b/api/src/opentrons/protocol_engine/execution/command_executor.py @@ -159,7 +159,7 @@ async def execute(self, command_id: str) -> None: if isinstance(error, asyncio.CancelledError): error = RunStoppedError("Run was cancelled") elif isinstance(error, EStopActivatedError): - error = PE_EStopActivatedError(message=str(error), wrapping=[error]) + error = PE_EStopActivatedError(wrapping=[error]) elif not isinstance(error, EnumeratedError): error = PythonException(error) @@ -167,6 +167,7 @@ async def execute(self, command_id: str) -> None: FailCommandAction( error=error, command_id=running_command.id, + running_command=running_command, error_id=self._model_utils.generate_id(), failed_at=self._model_utils.get_timestamp(), notes=note_tracker.get_notes(), diff --git a/api/src/opentrons/protocol_engine/execution/create_queue_worker.py b/api/src/opentrons/protocol_engine/execution/create_queue_worker.py index 3323aab0aa3..8b59eda5ef2 100644 --- a/api/src/opentrons/protocol_engine/execution/create_queue_worker.py +++ b/api/src/opentrons/protocol_engine/execution/create_queue_worker.py @@ -39,7 +39,6 @@ def create_queue_worker( equipment_handler = EquipmentHandler( hardware_api=hardware_api, state_store=state_store, - action_dispatcher=action_dispatcher, ) movement_handler = MovementHandler( diff --git a/api/src/opentrons/protocol_engine/execution/equipment.py b/api/src/opentrons/protocol_engine/execution/equipment.py index 2487ad50aaa..ee04653bda2 100644 --- a/api/src/opentrons/protocol_engine/execution/equipment.py +++ b/api/src/opentrons/protocol_engine/execution/equipment.py @@ -1,6 +1,6 @@ """Equipment command side-effect logic.""" from dataclasses import dataclass -from typing import Optional, overload +from typing import Optional, overload, Union from opentrons_shared_data.pipette.dev_types import PipetteNameType @@ -22,7 +22,6 @@ TemperatureModuleId, ThermocyclerModuleId, ) -from ..actions import ActionDispatcher from ..errors import ( FailedToLoadPipetteError, LabwareDefinitionDoesNotExistError, @@ -44,6 +43,7 @@ LabwareOffsetLocation, ModuleModel, ModuleDefinition, + AddressableAreaLocation, ) @@ -98,7 +98,6 @@ def __init__( self, hardware_api: HardwareControlAPI, state_store: StateStore, - action_dispatcher: ActionDispatcher, labware_data_provider: Optional[LabwareDataProvider] = None, module_data_provider: Optional[ModuleDataProvider] = None, model_utils: Optional[ModelUtils] = None, @@ -109,7 +108,6 @@ def __init__( """Initialize an EquipmentHandler instance.""" self._hardware_api = hardware_api self._state_store = state_store - self._action_dispatcher = action_dispatcher self._labware_data_provider = labware_data_provider or LabwareDataProvider() self._module_data_provider = module_data_provider or ModuleDataProvider() self._model_utils = model_utils or ModelUtils() @@ -252,7 +250,7 @@ async def load_pipette( async def load_magnetic_block( self, model: ModuleModel, - location: DeckSlotLocation, + location: Union[DeckSlotLocation, AddressableAreaLocation], module_id: Optional[str], ) -> LoadedModuleData: """Ensure the required magnetic block is attached. @@ -317,10 +315,14 @@ async def load_module( for hw_mod in self._hardware_api.attached_modules ] + serial_number_at_locaiton = self._state_store.geometry._addressable_areas.get_fixture_serial_from_deck_configuration_by_deck_slot( + location.slotName + ) attached_module = self._state_store.modules.select_hardware_module_to_load( model=model, location=location, attached_modules=attached_modules, + expected_serial_number=serial_number_at_locaiton, ) else: diff --git a/api/src/opentrons/protocol_engine/execution/queue_worker.py b/api/src/opentrons/protocol_engine/execution/queue_worker.py index c1ba60eb143..179880c03e9 100644 --- a/api/src/opentrons/protocol_engine/execution/queue_worker.py +++ b/api/src/opentrons/protocol_engine/execution/queue_worker.py @@ -72,6 +72,9 @@ async def _run_commands(self) -> None: command_id = await self._state_store.wait_for( condition=self._state_store.commands.get_next_to_execute ) + # Assert for type hinting. This is valid because the wait_for() above + # only returns when the value is truthy. + assert command_id is not None except RunStoppedError: # There are no more commands that we should execute, either because the run has # completed on its own, or because a client requested it to stop. diff --git a/api/src/opentrons/protocol_engine/protocol_engine.py b/api/src/opentrons/protocol_engine/protocol_engine.py index 8e23c08013f..0c4f2c4b670 100644 --- a/api/src/opentrons/protocol_engine/protocol_engine.py +++ b/api/src/opentrons/protocol_engine/protocol_engine.py @@ -5,7 +5,6 @@ from opentrons.protocol_engine.actions.actions import ResumeFromRecoveryAction from opentrons.protocol_engine.error_recovery_policy import ( ErrorRecoveryPolicy, - ErrorRecoveryType, error_recovery_by_ff, ) @@ -18,7 +17,7 @@ EnumeratedError, ) -from .errors import ProtocolCommandFailedError, ErrorOccurrence +from .errors import ProtocolCommandFailedError, ErrorOccurrence, CommandNotAllowedError from .errors.exceptions import EStopActivatedError from . import commands, slot_standardization from .resources import ModelUtils, ModuleDataProvider @@ -58,7 +57,6 @@ HardwareStoppedAction, ResetTipsAction, SetPipetteMovementSpeedAction, - FailCommandAction, ) @@ -159,8 +157,12 @@ def play(self, deck_configuration: Optional[DeckConfigurationType] = None) -> No else: self._hardware_api.resume(HardwarePauseType.PAUSE) - def pause(self) -> None: - """Pause executing commands in the queue.""" + def request_pause(self) -> None: + """Make command execution pause soon. + + This will try to pause in the middle of the ongoing command, if there is one. + Otherwise, whenever the next command begins, the pause will happen then. + """ action = self._state_store.commands.validate_action_allowed( PauseAction(source=PauseSource.CLIENT) ) @@ -174,7 +176,9 @@ def resume_from_recovery(self) -> None: ) self._action_dispatcher.dispatch(action) - def add_command(self, request: commands.CommandCreate) -> commands.Command: + def add_command( + self, request: commands.CommandCreate, failed_command_id: Optional[str] = None + ) -> commands.Command: """Add a command to the `ProtocolEngine`'s queue. Arguments: @@ -189,16 +193,29 @@ def add_command(self, request: commands.CommandCreate) -> commands.Command: but the engine was not idle or paused. RunStoppedError: the run has been stopped, so no new commands may be added. + CommandNotAllowedError: the request specified a failed command id + with a non fixit command. """ request = slot_standardization.standardize_command( request, self.state_view.config.robot_type ) + if failed_command_id and request.intent != commands.CommandIntent.FIXIT: + raise CommandNotAllowedError( + "failed command id should be supplied with a FIXIT command." + ) + command_id = self._model_utils.generate_id() - request_hash = commands.hash_command_params( - create=request, - last_hash=self._state_store.commands.get_latest_command_hash(), - ) + if request.intent in ( + commands.CommandIntent.SETUP, + commands.CommandIntent.FIXIT, + ): + request_hash = None + else: + request_hash = commands.hash_protocol_command_params( + create=request, + last_hash=self._state_store.commands.get_latest_protocol_command_hash(), + ) action = self.state_view.commands.validate_action_allowed( QueueCommandAction( @@ -206,6 +223,7 @@ def add_command(self, request: commands.CommandCreate) -> commands.Command: request_hash=request_hash, command_id=command_id, created_at=self._model_utils.get_timestamp(), + failed_command_id=failed_command_id, ) ) self._action_dispatcher.dispatch(action) @@ -234,7 +252,10 @@ async def add_and_execute_command( the command in state. Returns: - The command. If the command completed, it will be succeeded or failed. + The command. + + If the command completed, it will be succeeded or failed. + If the engine was stopped before it reached the command, the command will be queued. """ @@ -242,80 +263,85 @@ async def add_and_execute_command( await self.wait_for_command(command.id) return self._state_store.commands.get(command.id) - def estop( - self, - # TODO(mm, 2024-03-26): Maintenance runs are a robot-server concept that - # ProtocolEngine should not have to know about. Can this be simplified or - # defined in other terms? - maintenance_run: bool, - ) -> None: - """Signal to the engine that an estop event occurred. - - If there are any queued commands for the engine, they will be marked - as failed due to the estop event. If there aren't any queued commands - *and* this is a maintenance run (which has commands queued one-by-one), - a series of actions will mark the engine as Stopped. In either case the - queue worker will be deactivated; the primary difference is that the former - case will expect the protocol runner to `finish()` the engine, whereas the - maintenance run will be put into a state wherein the engine can be discarded. - """ - if self._state_store.commands.get_is_stopped(): - return + async def add_and_execute_command_wait_for_recovery( + self, request: commands.CommandCreate + ) -> commands.Command: + """Like `add_and_execute_command()`, except wait for error recovery. + + Unlike `add_and_execute_command()`, if the command fails, this will not + immediately return the failed command. Instead, if the error is recoverable, + it will wait until error recovery has completed (e.g. when some other task + calls `self.resume_from_recovery()`). + + Returns: + The command. - current_id = ( - self._state_store.commands.get_running_command_id() - or self._state_store.commands.get_queue_ids().head(None) + If the command completed, it will be succeeded or failed. If it failed + and then its failure was recovered from, it will still be failed. + + If the engine was stopped before it reached the command, + the command will be queued. + """ + queued_command = self.add_command(request) + await self.wait_for_command(command_id=queued_command.id) + completed_command = self._state_store.commands.get(queued_command.id) + await self._state_store.wait_for_not( + self.state_view.commands.get_recovery_in_progress_for_command, + queued_command.id, ) + return completed_command - if current_id is not None: - fail_action = FailCommandAction( - command_id=current_id, - error_id=self._model_utils.generate_id(), - failed_at=self._model_utils.get_timestamp(), - error=EStopActivatedError(message="Estop Activated"), - notes=[], - type=ErrorRecoveryType.FAIL_RUN, - ) - self._action_dispatcher.dispatch(fail_action) - - # In the case where the running command was a setup command - check if there - # are any pending *run* commands and, if so, clear them all - current_id = self._state_store.commands.get_queue_ids().head(None) - if current_id is not None: - fail_action = FailCommandAction( - command_id=current_id, - error_id=self._model_utils.generate_id(), - failed_at=self._model_utils.get_timestamp(), - error=EStopActivatedError(message="Estop Activated"), - notes=[], - type=ErrorRecoveryType.FAIL_RUN, - ) - self._action_dispatcher.dispatch(fail_action) - self._queue_worker.cancel() - elif maintenance_run: - stop_action = self._state_store.commands.validate_action_allowed( + def estop(self) -> None: + """Signal to the engine that an E-stop event occurred. + + If an estop happens while the robot is moving, lower layers physically stop + motion and raise the event as an exception, which fails the Protocol Engine + command. No action from the `ProtocolEngine` caller is needed to handle that. + + However, if an estop happens in between commands, or in the middle of + a command like `comment` or `waitForDuration` that doesn't access the hardware, + `ProtocolEngine` needs to be told about it so it can interrupt the command + and stop executing any more. This method is how to do that. + + This acts roughly like `request_stop()`. After calling this, you should call + `finish()` with an EStopActivatedError. + """ + try: + action = self._state_store.commands.validate_action_allowed( StopAction(from_estop=True) ) - self._action_dispatcher.dispatch(stop_action) - hardware_stop_action = HardwareStoppedAction( - completed_at=self._model_utils.get_timestamp(), - finish_error_details=FinishErrorDetails( - error=EStopActivatedError(message="Estop Activated"), - error_id=self._model_utils.generate_id(), - created_at=self._model_utils.get_timestamp(), - ), + except Exception: # todo(mm, 2024-04-16): Catch a more specific type. + # This is likely called from some hardware API callback that doesn't care + # about ProtocolEngine lifecycle or what methods are valid to call at what + # times. So it makes more sense for us to no-op here than to propagate this + # as an error. + _log.info( + "ProtocolEngine cannot handle E-stop event right now. Ignoring it.", + exc_info=True, ) - self._action_dispatcher.dispatch(hardware_stop_action) - self._queue_worker.cancel() - else: - _log.info("estop pressed before protocol was started, taking no action.") + return + self._action_dispatcher.dispatch(action) + # self._queue_worker.cancel() will try to interrupt any ongoing command. + # Unfortunately, if it's a hardware command, this interruption will race + # against the E-stop exception propagating up from lower layers. But we need to + # do this because we want to make sure non-hardware commands, like + # `waitForDuration`, are also interrupted. + self._queue_worker.cancel() + # Unlike self.request_stop(), we don't need to do + # self._hardware_api.cancel_execution_and_running_tasks(). Since this was an + # E-stop event, the hardware API already knows. + + async def request_stop(self) -> None: + """Make command execution stop soon. - async def stop(self) -> None: - """Stop execution immediately, halting all motion and cancelling future commands. + This will try to interrupt the ongoing command, if there is one. Future commands + are canceled. However, by the time this method returns, things may not have + settled by the time this method returns; the last command may still be + running. - After an engine has been `stop`'ed, it cannot be restarted. + After a stop has been requested, the engine cannot be restarted. - After a `stop`, you must still call `finish` to give the engine a chance + After a stop request, you must still call `finish` to give the engine a chance to clean up resources and propagate errors. """ action = self._state_store.commands.validate_action_allowed(StopAction()) @@ -348,14 +374,20 @@ async def finish( set_run_status: bool = True, post_run_hardware_state: PostRunHardwareState = PostRunHardwareState.HOME_AND_STAY_ENGAGED, ) -> None: - """Gracefully finish using the ProtocolEngine, waiting for it to become idle. + """Finish using the `ProtocolEngine`. + + This does a few things: + + 1. It may do post-run actions like homing and dropping tips. This depends on the + arguments passed as well as heuristics based on the history of the engine. + 2. It waits for the engine to be done controlling the robot's hardware. + 3. It releases internal resources, like background tasks. - The engine will finish executing its current command (if any), - and then shut down. After an engine has been `finished`'ed, it cannot - be restarted. + It's safe to call `finish()` multiple times. After you call `finish()`, + the engine can't be restarted. This method should not raise. If any exceptions happened during execution that were not - properly caught by the CommandExecutor, or if any exceptions happen during this + properly caught by `ProtocolEngine` internals, or if any exceptions happen during this `finish()` call, they should be saved as `.state_view.get_summary().errors`. Arguments: @@ -369,12 +401,11 @@ async def finish( if self._state_store.commands.state.stopped_by_estop: # This handles the case where the E-stop was pressed while we were *not* in the middle # of some hardware interaction that would raise it as an exception. For example, imagine - # we were paused between two commands, or imagine we were executing a very long run of - # comment commands. + # we were paused between two commands, or imagine we were executing a waitForDuration. drop_tips_after_run = False post_run_hardware_state = PostRunHardwareState.DISENGAGE_IN_PLACE if error is None: - error = EStopActivatedError(message="Estop was activated during a run") + error = EStopActivatedError() if error: # If the run had an error, check if that error indicates an E-stop. diff --git a/api/src/opentrons/protocol_engine/resources/deck_configuration_provider.py b/api/src/opentrons/protocol_engine/resources/deck_configuration_provider.py index 112be3663cd..648bd4f4484 100644 --- a/api/src/opentrons/protocol_engine/resources/deck_configuration_provider.py +++ b/api/src/opentrons/protocol_engine/resources/deck_configuration_provider.py @@ -1,7 +1,7 @@ """Deck configuration resource provider.""" from typing import List, Set, Tuple -from opentrons_shared_data.deck.dev_types import DeckDefinitionV4, CutoutFixture +from opentrons_shared_data.deck.dev_types import DeckDefinitionV5, CutoutFixture from opentrons.types import DeckSlotName @@ -17,11 +17,10 @@ CutoutDoesNotExistError, FixtureDoesNotExistError, AddressableAreaDoesNotExistError, - FixtureDoesNotProvideAreasError, ) -def get_cutout_position(cutout_id: str, deck_definition: DeckDefinitionV4) -> DeckPoint: +def get_cutout_position(cutout_id: str, deck_definition: DeckDefinitionV5) -> DeckPoint: """Get the base position of a cutout on the deck.""" for cutout in deck_definition["locations"]["cutouts"]: if cutout_id == cutout["id"]: @@ -32,7 +31,7 @@ def get_cutout_position(cutout_id: str, deck_definition: DeckDefinitionV4) -> De def get_cutout_fixture( - cutout_fixture_id: str, deck_definition: DeckDefinitionV4 + cutout_fixture_id: str, deck_definition: DeckDefinitionV5 ) -> CutoutFixture: """Gets cutout fixture from deck that matches the cutout fixture ID provided.""" for cutout_fixture in deck_definition["cutoutFixtures"]: @@ -44,20 +43,18 @@ def get_cutout_fixture( def get_provided_addressable_area_names( - cutout_fixture_id: str, cutout_id: str, deck_definition: DeckDefinitionV4 + cutout_fixture_id: str, cutout_id: str, deck_definition: DeckDefinitionV5 ) -> List[str]: """Gets a list of the addressable areas provided by the cutout fixture on the cutout.""" cutout_fixture = get_cutout_fixture(cutout_fixture_id, deck_definition) try: return cutout_fixture["providesAddressableAreas"][cutout_id] - except KeyError as exception: - raise FixtureDoesNotProvideAreasError( - f"Cutout fixture {cutout_fixture['id']} does not provide addressable areas for {cutout_id}" - ) from exception + except KeyError: + return [] def get_addressable_area_display_name( - addressable_area_name: str, deck_definition: DeckDefinitionV4 + addressable_area_name: str, deck_definition: DeckDefinitionV5 ) -> str: """Get the display name for an addressable area name.""" for addressable_area in deck_definition["locations"]["addressableAreas"]: @@ -69,7 +66,7 @@ def get_addressable_area_display_name( def get_potential_cutout_fixtures( - addressable_area_name: str, deck_definition: DeckDefinitionV4 + addressable_area_name: str, deck_definition: DeckDefinitionV5 ) -> Tuple[str, Set[PotentialCutoutFixture]]: """Given an addressable area name, gets the cutout ID associated with it and a set of potential fixtures.""" potential_fixtures = [] @@ -102,7 +99,7 @@ def get_addressable_area_from_name( addressable_area_name: str, cutout_position: DeckPoint, base_slot: DeckSlotName, - deck_definition: DeckDefinitionV4, + deck_definition: DeckDefinitionV5, ) -> AddressableArea: """Given a name and a cutout position, get an addressable area on the deck.""" for addressable_area in deck_definition["locations"]["addressableAreas"]: diff --git a/api/src/opentrons/protocol_engine/resources/deck_data_provider.py b/api/src/opentrons/protocol_engine/resources/deck_data_provider.py index 6098c2f4301..017fc58f552 100644 --- a/api/src/opentrons/protocol_engine/resources/deck_data_provider.py +++ b/api/src/opentrons/protocol_engine/resources/deck_data_provider.py @@ -9,7 +9,7 @@ load as load_deck, DEFAULT_DECK_DEFINITION_VERSION, ) -from opentrons_shared_data.deck.dev_types import DeckDefinitionV4 +from opentrons_shared_data.deck.dev_types import DeckDefinitionV5 from opentrons.protocols.models import LabwareDefinition from opentrons.types import DeckSlotName @@ -39,10 +39,10 @@ def __init__( self._deck_type = deck_type self._labware_data = labware_data or LabwareDataProvider() - async def get_deck_definition(self) -> DeckDefinitionV4: + async def get_deck_definition(self) -> DeckDefinitionV5: """Get a labware definition given the labware's identification.""" - def sync() -> DeckDefinitionV4: + def sync() -> DeckDefinitionV5: return load_deck( name=self._deck_type.value, version=DEFAULT_DECK_DEFINITION_VERSION ) @@ -51,7 +51,7 @@ def sync() -> DeckDefinitionV4: async def get_deck_fixed_labware( self, - deck_definition: DeckDefinitionV4, + deck_definition: DeckDefinitionV5, ) -> List[DeckFixedLabware]: """Get a list of all labware fixtures from a given deck definition.""" labware: List[DeckFixedLabware] = [] diff --git a/api/src/opentrons/protocol_engine/state/addressable_areas.py b/api/src/opentrons/protocol_engine/state/addressable_areas.py index 04894fe3338..909beffbe86 100644 --- a/api/src/opentrons/protocol_engine/state/addressable_areas.py +++ b/api/src/opentrons/protocol_engine/state/addressable_areas.py @@ -4,7 +4,7 @@ from opentrons_shared_data.robot.dev_types import RobotType from opentrons_shared_data.deck.dev_types import ( - DeckDefinitionV4, + DeckDefinitionV5, SlotDefV3, CutoutFixture, ) @@ -56,7 +56,7 @@ class AddressableAreaState: potential_cutout_fixtures_by_cutout_id: Dict[str, Set[PotentialCutoutFixture]] - deck_definition: DeckDefinitionV4 + deck_definition: DeckDefinitionV5 deck_configuration: Optional[DeckConfigurationType] """The host robot's full deck configuration. @@ -94,7 +94,7 @@ class AddressableAreaState: def _get_conflicting_addressable_areas_error_string( potential_cutout_fixtures: Set[PotentialCutoutFixture], loaded_addressable_areas: Dict[str, AddressableArea], - deck_definition: DeckDefinitionV4, + deck_definition: DeckDefinitionV5, ) -> str: loaded_areas_on_cutout = set() for fixture in potential_cutout_fixtures: @@ -158,7 +158,7 @@ def __init__( self, deck_configuration: DeckConfigurationType, config: Config, - deck_definition: DeckDefinitionV4, + deck_definition: DeckDefinitionV5, ) -> None: """Initialize an addressable area store and its state.""" if config.use_simulated_deck_config: @@ -224,11 +224,11 @@ def _handle_command(self, command: Command) -> None: @staticmethod def _get_addressable_areas_from_deck_configuration( - deck_config: DeckConfigurationType, deck_definition: DeckDefinitionV4 + deck_config: DeckConfigurationType, deck_definition: DeckDefinitionV5 ) -> Dict[str, AddressableArea]: """Return all addressable areas provided by the given deck configuration.""" addressable_areas = [] - for cutout_id, cutout_fixture_id in deck_config: + for cutout_id, cutout_fixture_id, opentrons_module_serial_number in deck_config: provided_addressable_areas = ( deck_configuration_provider.get_provided_addressable_area_names( cutout_fixture_id, cutout_id, deck_definition @@ -351,7 +351,7 @@ def get_all_cutout_fixtures(self) -> Optional[List[str]]: assert self._state.deck_configuration is not None return [ cutout_fixture_id - for _, cutout_fixture_id in self._state.deck_configuration + for _, cutout_fixture_id, _serial in self._state.deck_configuration ] def _get_loaded_addressable_area( @@ -453,11 +453,31 @@ def get_addressable_area_position( """ addressable_area = self._get_addressable_area_from_deck_data( addressable_area_name=addressable_area_name, - do_compatibility_check=do_compatibility_check, + do_compatibility_check=False, # This should probably not default to false ) position = addressable_area.position return Point(x=position.x, y=position.y, z=position.z) + def get_addressable_area_offsets_from_cutout( + self, + addressable_area_name: str, + ) -> Point: + """Get the offset form cutout fixture of an addressable area.""" + for addressable_area in self.state.deck_definition["locations"][ + "addressableAreas" + ]: + if addressable_area["id"] == addressable_area_name: + area_offset = addressable_area["offsetFromCutoutFixture"] + position = Point( + x=area_offset[0], + y=area_offset[1], + z=area_offset[2], + ) + return Point(x=position.x, y=position.y, z=position.z) + raise ValueError( + f"No matching addressable area named {addressable_area_name} identified." + ) + def get_addressable_area_bounding_box( self, addressable_area_name: str, @@ -499,6 +519,10 @@ def get_addressable_area_center(self, addressable_area_name: str) -> Point: z=position.z, ) + def get_cutout_id_by_deck_slot_name(self, slot_name: DeckSlotName) -> str: + """Get the Cutout ID of a given Deck Slot by Deck Slot Name.""" + return DECK_SLOT_TO_CUTOUT_MAP[slot_name] + def get_fixture_by_deck_slot_name( self, slot_name: DeckSlotName ) -> Optional[CutoutFixture]: @@ -508,7 +532,11 @@ def get_fixture_by_deck_slot_name( slot_cutout_id = DECK_SLOT_TO_CUTOUT_MAP[slot_name] slot_cutout_fixture = None # This will only ever be one under current assumptions - for cutout_id, cutout_fixture_id in deck_config: + for ( + cutout_id, + cutout_fixture_id, + opentrons_module_serial_number, + ) in deck_config: if cutout_id == slot_cutout_id: slot_cutout_fixture = ( deck_configuration_provider.get_cutout_fixture( @@ -532,6 +560,23 @@ def get_fixture_height(self, cutout_fixture_name: str) -> float: ) return cutout_fixture["height"] + def get_fixture_serial_from_deck_configuration_by_deck_slot( + self, slot_name: DeckSlotName + ) -> Optional[str]: + """Get the serial number provided by the deck configuration for a Fixture at a given location.""" + deck_config = self.state.deck_configuration + if deck_config: + slot_cutout_id = DECK_SLOT_TO_CUTOUT_MAP[slot_name] + # This will only ever be one under current assumptions + for ( + cutout_id, + cutout_fixture_id, + opentrons_module_serial_number, + ) in deck_config: + if cutout_id == slot_cutout_id: + return opentrons_module_serial_number + return None + def get_slot_definition(self, slot_id: str) -> SlotDefV3: """Get the definition of a slot in the deck. diff --git a/api/src/opentrons/protocol_engine/state/command_history.py b/api/src/opentrons/protocol_engine/state/command_history.py index 6a66a2b8209..b21fca030ae 100644 --- a/api/src/opentrons/protocol_engine/state/command_history.py +++ b/api/src/opentrons/protocol_engine/state/command_history.py @@ -33,6 +33,9 @@ class CommandHistory: _queued_setup_command_ids: OrderedSet[str] """The IDs of queued setup commands, in FIFO order""" + _queued_fixit_command_ids: OrderedSet[str] + """The IDs of queued fixit commands, in FIFO order""" + _running_command_id: Optional[str] """The ID of the currently running command, if any""" @@ -43,6 +46,7 @@ def __init__(self) -> None: self._all_command_ids = [] self._queued_command_ids = OrderedSet() self._queued_setup_command_ids = OrderedSet() + self._queued_fixit_command_ids = OrderedSet() self._commands_by_id = OrderedDict() self._running_command_id = None self._terminal_command_id = None @@ -135,6 +139,10 @@ def get_setup_queue_ids(self) -> OrderedSet[str]: """Get the IDs of all queued setup commands, in FIFO order.""" return self._queued_setup_command_ids + def get_fixit_queue_ids(self) -> OrderedSet[str]: + """Get the IDs of all queued fixit commands, in FIFO order.""" + return self._queued_fixit_command_ids + def clear_queue(self) -> None: """Clears all commands within the queued command ids structure.""" self._queued_command_ids.clear() @@ -143,6 +151,10 @@ def clear_setup_queue(self) -> None: """Clears all commands within the queued setup command ids structure.""" self._queued_setup_command_ids.clear() + def clear_fixit_queue(self) -> None: + """Clears all commands within the queued setup command ids structure.""" + self._queued_fixit_command_ids.clear() + def set_command_queued(self, command: Command) -> None: """Validate and mark a command as queued in the command history.""" assert command.status == CommandStatus.QUEUED @@ -157,6 +169,8 @@ def set_command_queued(self, command: Command) -> None: if command.intent == CommandIntent.SETUP: self._add_to_setup_queue(command.id) + elif command.intent == CommandIntent.FIXIT: + self._add_to_fixit_queue(command.id) else: self._add_to_queue(command.id) @@ -177,6 +191,7 @@ def set_command_running(self, command: Command) -> None: self._remove_queue_id(command.id) self._remove_setup_queue_id(command.id) + self._remove_fixit_queue_id(command.id) def set_command_succeeded(self, command: Command) -> None: """Validate and mark a command as succeeded in the command history.""" @@ -239,6 +254,10 @@ def _add_to_setup_queue(self, command_id: str) -> None: """Add a new ID to the queued setup.""" self._queued_setup_command_ids.add(command_id) + def _add_to_fixit_queue(self, command_id: str) -> None: + """Add a new ID to the queued fixit.""" + self._queued_fixit_command_ids.add(command_id) + def _remove_queue_id(self, command_id: str) -> None: """Remove a specific command from the queued command ids structure.""" self._queued_command_ids.discard(command_id) @@ -247,6 +266,10 @@ def _remove_setup_queue_id(self, command_id: str) -> None: """Remove a specific command from the queued setup command ids structure.""" self._queued_setup_command_ids.discard(command_id) + def _remove_fixit_queue_id(self, command_id: str) -> None: + """Remove a specific command from the queued fixit command ids structure.""" + self._queued_fixit_command_ids.discard(command_id) + def _set_terminal_command_id(self, command_id: str) -> None: """Set the ID of the most recently dequeued command.""" self._terminal_command_id = command_id diff --git a/api/src/opentrons/protocol_engine/state/commands.py b/api/src/opentrons/protocol_engine/state/commands.py index ab4d3b8f5cb..f9d7643b728 100644 --- a/api/src/opentrons/protocol_engine/state/commands.py +++ b/api/src/opentrons/protocol_engine/state/commands.py @@ -4,7 +4,7 @@ import enum from dataclasses import dataclass from datetime import datetime -from typing import List, Optional, Union +from typing import Dict, List, Optional, Union from typing_extensions import assert_never from opentrons_shared_data.errors import EnumeratedError, ErrorCodes, PythonException @@ -38,6 +38,8 @@ ErrorOccurrence, RobotDoorOpenError, SetupCommandNotAllowedError, + FixitCommandNotAllowedError, + ResumeFromRecoveryNotAllowedError, PauseNotAllowedError, UnexpectedProtocolError, ProtocolCommandFailedError, @@ -164,12 +166,28 @@ class CommandState: # that we're doing error recovery. See if we can implement robot-server pagination # atop simpler concepts, like "the last command that ran" or "the next command that # would run." + # + # TODO(mm, 2024-04-03): Can this be replaced by + # CommandHistory.get_terminal_command() now? + + command_error_recovery_types: Dict[str, ErrorRecoveryType] + """For each command that failed (indexed by ID), what its recovery type was. + + This only includes commands that actually failed, not the ones that we mark as + failed but that are effectively "cancelled" because a command before them failed. + + This separate attribute is a stopgap until error recovery concepts are a bit more + stable. Eventually, we might want this info to be stored directly on each command. + """ + + recovery_target_command_id: Optional[str] + """If we're currently recovering from a command failure, which command it was.""" finish_error: Optional[ErrorOccurrence] """The error that happened during the post-run finish steps (homing & dropping tips), if any.""" - latest_command_hash: Optional[str] - """The latest hash value received in a QueueCommandAction. + latest_protocol_command_hash: Optional[str] + """The latest PROTOCOL command hash value received in a QueueCommandAction. This value can be used to generate future hashes. """ @@ -199,9 +217,11 @@ def __init__( run_error=None, finish_error=None, failed_command=None, + command_error_recovery_types={}, + recovery_target_command_id=None, run_completed_at=None, run_started_at=None, - latest_command_hash=None, + latest_protocol_command_hash=None, stopped_by_estop=False, ) @@ -223,12 +243,13 @@ def handle_action(self, action: Action) -> None: # noqa: C901 params=action.request.params, # type: ignore[arg-type] intent=action.request.intent, status=CommandStatus.QUEUED, + failedCommandId=action.failed_command_id, ) self._state.command_history.set_command_queued(queued_command) if action.request_hash is not None: - self._state.latest_command_hash = action.request_hash + self._state.latest_protocol_command_hash = action.request_hash elif isinstance(action, RunCommandAction): prev_entry = self._state.command_history.get(action.command_id) @@ -253,11 +274,11 @@ def handle_action(self, action: Action) -> None: # noqa: C901 error=action.error, ) - # TODO(mc, 2022-06-06): add new "cancelled" status or similar self._update_to_failed( command_id=action.command_id, failed_at=action.failed_at, error_occurrence=error_occurrence, + error_recovery_type=action.type, notes=action.notes, ) @@ -271,10 +292,12 @@ def handle_action(self, action: Action) -> None: # noqa: C901 self._state.command_history.get_setup_queue_ids() ) for command_id in other_command_ids_to_fail: + # TODO(mc, 2022-06-06): add new "cancelled" status or similar self._update_to_failed( command_id=command_id, failed_at=action.failed_at, error_occurrence=None, + error_recovery_type=None, notes=None, ) self._state.command_history.clear_setup_queue() @@ -284,20 +307,37 @@ def handle_action(self, action: Action) -> None: # noqa: C901 ): if action.type == ErrorRecoveryType.WAIT_FOR_RECOVERY: self._state.queue_status = QueueStatus.AWAITING_RECOVERY + self._state.recovery_target_command_id = action.command_id elif action.type == ErrorRecoveryType.FAIL_RUN: other_command_ids_to_fail = ( self._state.command_history.get_queue_ids() ) for command_id in other_command_ids_to_fail: + # TODO(mc, 2022-06-06): add new "cancelled" status or similar self._update_to_failed( command_id=command_id, failed_at=action.failed_at, error_occurrence=None, + error_recovery_type=None, notes=None, ) self._state.command_history.clear_queue() else: assert_never(action.type) + elif prev_entry.command.intent == CommandIntent.FIXIT: + other_command_ids_to_fail = ( + self._state.command_history.get_fixit_queue_ids() + ) + for command_id in other_command_ids_to_fail: + # TODO(mc, 2022-06-06): add new "cancelled" status or similar + self._update_to_failed( + command_id=command_id, + failed_at=action.failed_at, + error_occurrence=None, + error_recovery_type=None, + notes=None, + ) + self._state.command_history.clear_fixit_queue() else: assert_never(prev_entry.command.intent) @@ -316,14 +356,18 @@ def handle_action(self, action: Action) -> None: # noqa: C901 self._state.queue_status = QueueStatus.PAUSED elif isinstance(action, ResumeFromRecoveryAction): + self._state.command_history.clear_fixit_queue() self._state.queue_status = QueueStatus.RUNNING + self._state.recovery_target_command_id = None elif isinstance(action, StopAction): if not self._state.run_result: self._state.queue_status = QueueStatus.PAUSED - self._state.run_result = RunResult.STOPPED if action.from_estop: self._state.stopped_by_estop = True + self._state.run_result = RunResult.FAILED + else: + self._state.run_result = RunResult.STOPPED elif isinstance(action, FinishAction): if not self._state.run_result: @@ -337,12 +381,12 @@ def handle_action(self, action: Action) -> None: # noqa: C901 else: self._state.run_result = RunResult.STOPPED - if action.error_details: - self._state.run_error = self._map_run_exception_to_error_occurrence( - action.error_details.error_id, - action.error_details.created_at, - action.error_details.error, - ) + if not self._state.run_error and action.error_details: + self._state.run_error = self._map_run_exception_to_error_occurrence( + action.error_details.error_id, + action.error_details.created_at, + action.error_details.error, + ) elif isinstance(action, HardwareStoppedAction): self._state.queue_status = QueueStatus.PAUSED @@ -376,6 +420,7 @@ def _update_to_failed( command_id: str, failed_at: datetime, error_occurrence: Optional[ErrorOccurrence], + error_recovery_type: Optional[ErrorRecoveryType], notes: Optional[List[CommandNote]], ) -> None: prev_entry = self._state.command_history.get(command_id) @@ -391,6 +436,8 @@ def _update_to_failed( } ) self._state.command_history.set_command_failed(failed_command) + if error_recovery_type is not None: + self._state.command_error_recovery_types[command_id] = error_recovery_type @staticmethod def _map_run_exception_to_error_occurrence( @@ -577,9 +624,18 @@ def get_next_to_execute(self) -> Optional[str]: if self._state.run_result: raise RunStoppedError("Engine was stopped") + # if queue is in recovery mode, return the next fixit command + next_fixit_cmd = self._state.command_history.get_fixit_queue_ids().head(None) + if next_fixit_cmd and self._state.queue_status == QueueStatus.AWAITING_RECOVERY: + return next_fixit_cmd + # if there is a setup command queued, prioritize it next_setup_cmd = self._state.command_history.get_setup_queue_ids().head(None) - if self._state.queue_status != QueueStatus.PAUSED and next_setup_cmd: + if ( + self._state.queue_status + not in [QueueStatus.PAUSED, QueueStatus.AWAITING_RECOVERY] + and next_setup_cmd + ): return next_setup_cmd # if the queue is running, return the next protocol command @@ -687,6 +743,10 @@ def get_all_commands_final(self) -> bool: return no_command_running and no_command_to_execute + def get_recovery_in_progress_for_command(self, command_id: str) -> bool: + """Return whether the given command failed and its error recovery is in progress.""" + return self._state.recovery_target_command_id == command_id + def raise_fatal_command_error(self) -> None: """Raise the run's fatal command error, if there was one, as an exception. @@ -709,6 +769,13 @@ def raise_fatal_command_error(self) -> None: message=failed_command.command.error.detail, ) + def get_error_recovery_type(self, command_id: str) -> ErrorRecoveryType: + """Return the error recovery type with which the given command failed. + + The command ID is assumed to point to a failed command. + """ + return self.state.command_error_recovery_types[command_id] + def get_is_stopped(self) -> bool: """Get whether an engine stop has completed.""" return self._state.run_completed_at is not None @@ -776,12 +843,28 @@ def validate_action_allowed( # noqa: C901 raise SetupCommandNotAllowedError( "Setup commands are not allowed after run has started." ) + elif action.request.intent == CommandIntent.FIXIT: + if self._state.queue_status != QueueStatus.AWAITING_RECOVERY: + raise FixitCommandNotAllowedError( + "Fixit commands are not allowed when the run is not in a recoverable state." + ) + else: + return action else: return action elif isinstance(action, ResumeFromRecoveryAction): if self.get_status() != EngineStatus.AWAITING_RECOVERY: - raise NotImplementedError() + raise ResumeFromRecoveryNotAllowedError( + "Cannot resume from recovery if the run is not in recovery mode." + ) + elif ( + self.get_status() == EngineStatus.AWAITING_RECOVERY + and len(self._state.command_history.get_fixit_queue_ids()) > 0 + ): + raise ResumeFromRecoveryNotAllowedError( + "Cannot resume from recovery while there are fixit commands in the queue." + ) else: return action @@ -833,6 +916,6 @@ def get_status(self) -> EngineStatus: # SETUP and we're currently a setup command? return EngineStatus.IDLE - def get_latest_command_hash(self) -> Optional[str]: + def get_latest_protocol_command_hash(self) -> Optional[str]: """Get the command hash of the last queued command, if any.""" - return self._state.latest_command_hash + return self._state.latest_protocol_command_hash diff --git a/api/src/opentrons/protocol_engine/state/geometry.py b/api/src/opentrons/protocol_engine/state/geometry.py index 1822881eea2..4a37bf798c1 100644 --- a/api/src/opentrons/protocol_engine/state/geometry.py +++ b/api/src/opentrons/protocol_engine/state/geometry.py @@ -166,6 +166,7 @@ def get_highest_z_in_slot( except LabwareNotLoadedOnModuleError: return self._modules.get_module_highest_z( module_id=module_id, + addressable_areas=self._addressable_areas, ) else: return self.get_highest_z_of_labware_stack(labware_id) @@ -246,7 +247,9 @@ def _get_labware_position_offset( return LabwareOffsetVector(x=0, y=0, z=0) elif isinstance(labware_location, ModuleLocation): module_id = labware_location.moduleId - module_offset = self._modules.get_nominal_module_offset(module_id=module_id) + module_offset = self._modules.get_nominal_module_offset( + module_id=module_id, addressable_areas=self._addressable_areas + ) module_model = self._modules.get_connected_model(module_id) stacking_overlap = self._labware.get_module_overlap_offsets( labware_id, module_model diff --git a/api/src/opentrons/protocol_engine/state/labware.py b/api/src/opentrons/protocol_engine/state/labware.py index 7709410fd0f..a11f1a58e4a 100644 --- a/api/src/opentrons/protocol_engine/state/labware.py +++ b/api/src/opentrons/protocol_engine/state/labware.py @@ -15,7 +15,7 @@ Union, ) -from opentrons_shared_data.deck.dev_types import DeckDefinitionV4 +from opentrons_shared_data.deck.dev_types import DeckDefinitionV5 from opentrons_shared_data.gripper.constants import LABWARE_GRIP_FORCE from opentrons_shared_data.labware.labware_definition import LabwareRole from opentrons_shared_data.pipette.dev_types import LabwareUri @@ -106,7 +106,7 @@ class LabwareState: labware_offsets_by_id: Dict[str, LabwareOffset] definitions_by_uri: Dict[str, LabwareDefinition] - deck_definition: DeckDefinitionV4 + deck_definition: DeckDefinitionV5 class LabwareStore(HasState[LabwareState], HandlesActions): @@ -116,7 +116,7 @@ class LabwareStore(HasState[LabwareState], HandlesActions): def __init__( self, - deck_definition: DeckDefinitionV4, + deck_definition: DeckDefinitionV5, deck_fixed_labware: Sequence[DeckFixedLabware], ) -> None: """Initialize a labware store and its state.""" @@ -324,7 +324,7 @@ def get_display_name(self, labware_id: str) -> str: or self.get_definition(labware_id).metadata.displayName ) - def get_deck_definition(self) -> DeckDefinitionV4: + def get_deck_definition(self) -> DeckDefinitionV5: """Get the current deck definition.""" return self._state.deck_definition diff --git a/api/src/opentrons/protocol_engine/state/modules.py b/api/src/opentrons/protocol_engine/state/modules.py index 84093de0d4a..0e79dd53cf2 100644 --- a/api/src/opentrons/protocol_engine/state/modules.py +++ b/api/src/opentrons/protocol_engine/state/modules.py @@ -46,6 +46,7 @@ DeckType, LabwareMovementOffsetData, ) +from .addressable_areas import AddressableAreaView from .. import errors from ..commands import ( Command, @@ -210,11 +211,12 @@ def handle_action(self, action: Action) -> None: def _handle_command(self, command: Command) -> None: if isinstance(command.result, LoadModuleResult): + slot_name = command.params.location.slotName self._add_module_substate( module_id=command.result.moduleId, serial_number=command.result.serialNumber, definition=command.result.definition, - slot_name=command.params.location.slotName, + slot_name=slot_name, requested_model=command.params.model, module_live_data=None, ) @@ -707,35 +709,70 @@ def get_dimensions(self, module_id: str) -> ModuleDimensions: def get_nominal_module_offset( self, module_id: str, + addressable_areas: AddressableAreaView, ) -> LabwareOffsetVector: """Get the module's nominal offset vector computed with slot transform.""" - definition = self.get_definition(module_id) - slot = self.get_location(module_id).slotName.id - - pre_transform: NDArray[npdouble] = array( - ( - definition.labwareOffset.x, - definition.labwareOffset.y, - definition.labwareOffset.z, - 1, + if ( + self.state.deck_type == DeckType.OT2_STANDARD + or self.state.deck_type == DeckType.OT2_SHORT_TRASH + ): + definition = self.get_definition(module_id) + slot = self.get_location(module_id).slotName.id + + pre_transform: NDArray[npdouble] = array( + ( + definition.labwareOffset.x, + definition.labwareOffset.y, + definition.labwareOffset.z, + 1, + ) + ) + xforms_ser = definition.slotTransforms.get( + str(self._state.deck_type.value), {} + ).get( + slot, + { + "labwareOffset": [ + [1, 0, 0, 0], + [0, 1, 0, 0], + [0, 0, 1, 0], + [0, 0, 0, 1], + ] + }, + ) + xforms_ser_offset = xforms_ser["labwareOffset"] + + # Apply the slot transform, if any + xform: NDArray[npdouble] = array(xforms_ser_offset) + xformed = dot(xform, pre_transform) + return LabwareOffsetVector( + x=xformed[0], + y=xformed[1], + z=xformed[2], + ) + else: + module = self.get(module_id) + if isinstance(module.location, DeckSlotLocation): + location = module.location.slotName + elif module.model == ModuleModel.THERMOCYCLER_MODULE_V2: + location = DeckSlotName.SLOT_B1 + else: + raise ValueError( + "Module location invalid for nominal module offset calculation." + ) + module_addressable_area = self.ensure_and_convert_module_fixture_location( + location, self.state.deck_type, module.model + ) + module_addressable_area_position = ( + addressable_areas.get_addressable_area_offsets_from_cutout( + module_addressable_area + ) + ) + return LabwareOffsetVector( + x=module_addressable_area_position.x, + y=module_addressable_area_position.y, + z=module_addressable_area_position.z, ) - ) - xforms_ser = definition.slotTransforms.get( - str(self._state.deck_type.value), {} - ).get( - slot, - {"labwareOffset": [[1, 0, 0, 0], [0, 1, 0, 0], [0, 0, 1, 0], [0, 0, 0, 1]]}, - ) - xforms_ser_offset = xforms_ser["labwareOffset"] - - # Apply the slot transform, if any - xform: NDArray[npdouble] = array(xforms_ser_offset) - xformed = dot(xform, pre_transform) - return LabwareOffsetVector( - x=xformed[0], - y=xformed[1], - z=xformed[2], - ) def get_module_calibration_offset( self, module_id: str @@ -755,7 +792,9 @@ def get_height_over_labware(self, module_id: str) -> float: """Get the height of module parts above module labware base.""" return self.get_dimensions(module_id).overLabwareHeight - def get_module_highest_z(self, module_id: str) -> float: + def get_module_highest_z( + self, module_id: str, addressable_areas: AddressableAreaView + ) -> float: """Get the highest z point of the module, as placed on the robot. The highest Z of a module, unlike the bare overall height, depends on @@ -781,7 +820,7 @@ def get_module_highest_z(self, module_id: str) -> float: z_difference = module_height - default_lw_offset_point nominal_transformed_lw_offset_z = self.get_nominal_module_offset( - module_id=module_id + module_id=module_id, addressable_areas=addressable_areas ).z calibration_offset = self.get_module_calibration_offset(module_id) return ( @@ -943,11 +982,12 @@ def is_edge_move_unsafe(self, mount: MountType, target_slot: DeckSlotName) -> bo return neighbor_slot in self._state.slot_by_module_id.values() - def select_hardware_module_to_load( + def select_hardware_module_to_load( # noqa: C901 self, model: ModuleModel, location: DeckSlotLocation, attached_modules: Sequence[HardwareModule], + expected_serial_number: Optional[str] = None, ) -> HardwareModule: """Get the next matching hardware module for the given model and location. @@ -963,6 +1003,8 @@ def select_hardware_module_to_load( location: The location the module will be assigned to. attached_modules: All attached modules as reported by the HardwareAPI, in the order in which they should be used. + expected_serial_number: An optional variable containing the serial number + expected of the module identified. Raises: ModuleNotAttachedError: A not-yet-assigned module matching the requested @@ -976,7 +1018,6 @@ def select_hardware_module_to_load( if slot == location.slotName: existing_mod_in_slot = self._state.hardware_by_module_id.get(mod_id) break - if existing_mod_in_slot: existing_def = existing_mod_in_slot.definition @@ -992,7 +1033,11 @@ def select_hardware_module_to_load( for m in attached_modules: if m not in self._state.hardware_by_module_id.values(): if model == m.definition.model or model in m.definition.compatibleWith: - return m + if expected_serial_number is not None: + if m.serial_number == expected_serial_number: + return m + else: + return m raise errors.ModuleNotAttachedError(f"No available {model.value} found.") @@ -1063,3 +1108,92 @@ def is_flex_deck_with_thermocycler(self) -> bool: return True else: return False + + def ensure_and_convert_module_fixture_location( + self, + deck_slot: DeckSlotName, + deck_type: DeckType, + model: ModuleModel, + ) -> str: + """Ensure module fixture load location is valid. + + Also, convert the deck slot to a valid module fixture addressable area. + """ + if deck_type == DeckType.OT2_STANDARD or deck_type == DeckType.OT2_SHORT_TRASH: + raise ValueError( + f"Invalid Deck Type: {deck_type.name} - Does not support modules as fixtures." + ) + + if model == ModuleModel.MAGNETIC_BLOCK_V1: + valid_slots = [ + slot + for slot in [ + "A1", + "B1", + "C1", + "D1", + "A2", + "B2", + "C2", + "D2", + "A3", + "B3", + "C3", + "D3", + ] + ] + addressable_areas = [ + "magneticBlockV1A1", + "magneticBlockV1B1", + "magneticBlockV1C1", + "magneticBlockV1D1", + "magneticBlockV1A2", + "magneticBlockV1B2", + "magneticBlockV1C2", + "magneticBlockV1D2", + "magneticBlockV1A3", + "magneticBlockV1B3", + "magneticBlockV1C3", + "magneticBlockV1D3", + ] + + elif model == ModuleModel.HEATER_SHAKER_MODULE_V1: + valid_slots = [ + slot for slot in ["A1", "B1", "C1", "D1", "A3", "B3", "C3", "D3"] + ] + addressable_areas = [ + "heaterShakerV1A1", + "heaterShakerV1B1", + "heaterShakerV1C1", + "heaterShakerV1D1", + "heaterShakerV1A3", + "heaterShakerV1B3", + "heaterShakerV1C3", + "heaterShakerV1D3", + ] + elif model == ModuleModel.TEMPERATURE_MODULE_V2: + valid_slots = [ + slot for slot in ["A1", "B1", "C1", "D1", "A3", "B3", "C3", "D3"] + ] + addressable_areas = [ + "temperatureModuleV2A1", + "temperatureModuleV2B1", + "temperatureModuleV2C1", + "temperatureModuleV2D1", + "temperatureModuleV2A3", + "temperatureModuleV2B3", + "temperatureModuleV2C3", + "temperatureModuleV2D3", + ] + elif model == ModuleModel.THERMOCYCLER_MODULE_V2: + return "thermocyclerModuleV2" + else: + raise ValueError( + f"Unknown module {model.name} has no addressable areas to provide." + ) + + map_addressable_area = { + slot: addressable_area + for slot, addressable_area in zip(valid_slots, addressable_areas) + } + return map_addressable_area[deck_slot.value] diff --git a/api/src/opentrons/protocol_engine/state/state.py b/api/src/opentrons/protocol_engine/state/state.py index a34f016deab..dcde17a7894 100644 --- a/api/src/opentrons/protocol_engine/state/state.py +++ b/api/src/opentrons/protocol_engine/state/state.py @@ -2,10 +2,10 @@ from __future__ import annotations from dataclasses import dataclass -from functools import partial -from typing import Any, Callable, Dict, List, Optional, Sequence, TypeVar +from typing import Callable, Dict, List, Optional, Sequence, TypeVar +from typing_extensions import ParamSpec -from opentrons_shared_data.deck.dev_types import DeckDefinitionV4 +from opentrons_shared_data.deck.dev_types import DeckDefinitionV5 from opentrons.protocol_engine.types import ModuleOffsetData @@ -30,7 +30,9 @@ from .state_summary import StateSummary from ..types import DeckConfigurationType -ReturnT = TypeVar("ReturnT") + +_ParamsT = ParamSpec("_ParamsT") +_ReturnT = TypeVar("_ReturnT") @dataclass(frozen=True) @@ -140,12 +142,13 @@ def __init__( self, *, config: Config, - deck_definition: DeckDefinitionV4, + deck_definition: DeckDefinitionV5, deck_fixed_labware: Sequence[DeckFixedLabware], is_door_open: bool, change_notifier: Optional[ChangeNotifier] = None, module_calibration_offsets: Optional[Dict[str, ModuleOffsetData]] = None, deck_configuration: Optional[DeckConfigurationType] = None, + notify_publishers: Optional[Callable[[], None]] = None, ) -> None: """Initialize a StateStore and its substores. @@ -159,6 +162,7 @@ def __init__( change_notifier: Internal state change notifier. module_calibration_offsets: Module offsets to preload. deck_configuration: The initial deck configuration the addressable area store will be instantiated with. + notify_publishers: Notifies robot server publishers of internal state change. """ self._command_store = CommandStore(config=config, is_door_open=is_door_open) self._pipette_store = PipetteStore() @@ -191,6 +195,7 @@ def __init__( ] self._config = config self._change_notifier = change_notifier or ChangeNotifier() + self._notify_robot_server = notify_publishers self._initialize_state() def handle_action(self, action: Action) -> None: @@ -207,10 +212,10 @@ def handle_action(self, action: Action) -> None: async def wait_for( self, - condition: Callable[..., Optional[ReturnT]], - *args: Any, - **kwargs: Any, - ) -> ReturnT: + condition: Callable[_ParamsT, _ReturnT], + *args: _ParamsT.args, + **kwargs: _ParamsT.kwargs, + ) -> _ReturnT: """Wait for a condition to become true, checking whenever state changes. If the condition is already true, return immediately. @@ -255,14 +260,43 @@ async def wait_for( Raises: The exception raised by the `condition` function, if any. """ - predicate = partial(condition, *args, **kwargs) - is_done = predicate() - while not is_done: + def predicate() -> _ReturnT: + return condition(*args, **kwargs) + + return await self._wait_for(condition=predicate, truthiness_to_wait_for=True) + + async def wait_for_not( + self, + condition: Callable[_ParamsT, _ReturnT], + *args: _ParamsT.args, + **kwargs: _ParamsT.kwargs, + ) -> _ReturnT: + """Like `wait_for()`, except wait for the condition to become false. + + See the documentation in `wait_for()`, especially the warning about condition + design. + + The advantage of having this separate method over just passing a wrapper lambda + as the condition to `wait_for()` yourself is that wrapper lambdas are hard to + test in the mock-heavy Decoy + Protocol Engine style. + """ + + def predicate() -> _ReturnT: + return condition(*args, **kwargs) + + return await self._wait_for(condition=predicate, truthiness_to_wait_for=False) + + async def _wait_for( + self, condition: Callable[[], _ReturnT], truthiness_to_wait_for: bool + ) -> _ReturnT: + current_value = condition() + + while bool(current_value) != truthiness_to_wait_for: await self._change_notifier.wait() - is_done = predicate() + current_value = condition() - return is_done + return current_value def _get_next_state(self) -> State: """Get a new instance of the state value object.""" @@ -319,3 +353,5 @@ def _update_state_views(self) -> None: self._liquid._state = next_state.liquids self._tips._state = next_state.tips self._change_notifier.notify() + if self._notify_robot_server is not None: + self._notify_robot_server() diff --git a/api/src/opentrons/protocol_engine/state/tips.py b/api/src/opentrons/protocol_engine/state/tips.py index a2539ff45e7..f5d68d61ee5 100644 --- a/api/src/opentrons/protocol_engine/state/tips.py +++ b/api/src/opentrons/protocol_engine/state/tips.py @@ -7,11 +7,13 @@ from ..actions import ( Action, SucceedCommandAction, + FailCommandAction, ResetTipsAction, ) from ..commands import ( Command, LoadLabwareResult, + PickUpTip, PickUpTipResult, DropTipResult, DropTipInPlaceResult, @@ -20,6 +22,7 @@ PipetteConfigUpdateResultMixin, PipetteNozzleLayoutResultMixin, ) +from ..error_recovery_policy import ErrorRecoveryType from opentrons.hardware_control.nozzle_manager import NozzleMap @@ -71,7 +74,7 @@ def handle_action(self, action: Action) -> None: self._state.channels_by_pipette_id[pipette_id] = config.channels self._state.active_channels_by_pipette_id[pipette_id] = config.channels self._state.nozzle_map_by_pipette_id[pipette_id] = config.nozzle_map - self._handle_command(action.command) + self._handle_succeeded_command(action.command) if isinstance(action.private_result, PipetteNozzleLayoutResultMixin): pipette_id = action.private_result.pipette_id @@ -86,6 +89,9 @@ def handle_action(self, action: Action) -> None: pipette_id ] = self._state.channels_by_pipette_id[pipette_id] + elif isinstance(action, FailCommandAction): + self._handle_failed_command(action) + elif isinstance(action, ResetTipsAction): labware_id = action.labware_id @@ -94,7 +100,7 @@ def handle_action(self, action: Action) -> None: well_name ] = TipRackWellState.CLEAN - def _handle_command(self, command: Command) -> None: + def _handle_succeeded_command(self, command: Command) -> None: if ( isinstance(command.result, LoadLabwareResult) and command.result.definition.parameters.isTiprack @@ -124,6 +130,28 @@ def _handle_command(self, command: Command) -> None: pipette_id = command.params.pipetteId self._state.length_by_pipette_id.pop(pipette_id, None) + def _handle_failed_command( + self, + action: FailCommandAction, + ) -> None: + # If a pickUpTip command fails recoverably, mark the tips as used. This way, + # when the protocol is resumed and the Python Protocol API calls + # `get_next_tip()`, we'll move on to other tips as expected. + # + # We don't attempt this for nonrecoverable errors because maybe the failure + # was due to a bad labware ID or well name. + if ( + isinstance(action.running_command, PickUpTip) + and action.type != ErrorRecoveryType.FAIL_RUN + ): + self._set_used_tips( + pipette_id=action.running_command.params.pipetteId, + labware_id=action.running_command.params.labwareId, + well_name=action.running_command.params.wellName, + ) + # Note: We're logically removing the tip from the tip rack, + # but we're not logically updating the pipette to have that tip on it. + def _set_used_tips( # noqa: C901 self, pipette_id: str, well_name: str, labware_id: str ) -> None: diff --git a/api/src/opentrons/protocol_engine/types.py b/api/src/opentrons/protocol_engine/types.py index 266dc6aa81f..d7b0e981b2a 100644 --- a/api/src/opentrons/protocol_engine/types.py +++ b/api/src/opentrons/protocol_engine/types.py @@ -714,6 +714,10 @@ class AreaType(Enum): MOVABLE_TRASH = "movableTrash" FIXED_TRASH = "fixedTrash" WASTE_CHUTE = "wasteChute" + THERMOCYCLER = "thermocycler" + HEATER_SHAKER = "heaterShaker" + TEMPERATURE = "temperatureModule" + MAGNETICBLOCK = "magneticBlock" @dataclass(frozen=True) @@ -820,7 +824,10 @@ class QuadrantNozzleLayoutConfiguration(BaseModel): ] # TODO make the below some sort of better type -DeckConfigurationType = List[Tuple[str, str]] # cutout_id, cutout_fixture_id +# TODO This should instead contain a proper cutout fixture type +DeckConfigurationType = List[ + Tuple[str, str, Optional[str]] +] # cutout_id, cutout_fixture_id, opentrons_module_serial_number class TipPresenceStatus(str, Enum): @@ -847,6 +854,7 @@ def from_hw_state(cls, state: HwTipStateType) -> "TipPresenceStatus": }[state] +# TODO (spp, 2024-04-02): move all RTP types to runner class RTPBase(BaseModel): """Parameters defined in a protocol.""" diff --git a/api/src/opentrons/protocol_runner/legacy_command_mapper.py b/api/src/opentrons/protocol_runner/legacy_command_mapper.py index 53846baf653..9243f50f70d 100644 --- a/api/src/opentrons/protocol_runner/legacy_command_mapper.py +++ b/api/src/opentrons/protocol_runner/legacy_command_mapper.py @@ -6,7 +6,7 @@ from opentrons_shared_data.pipette.dev_types import PipetteNameType from opentrons.types import MountType, DeckSlotName, Location -from opentrons.commands import types as legacy_command_types +from opentrons.legacy_commands import types as legacy_command_types from opentrons.protocol_engine import ( ProtocolEngineError, actions as pe_actions, @@ -79,6 +79,7 @@ def __init__(self, wrapping_exc: BaseException) -> None: legacy_command_types.DISTRIBUTE, legacy_command_types.TRANSFER, legacy_command_types.RETURN_TIP, + legacy_command_types.AIR_GAP, } @@ -265,6 +266,7 @@ def map_command( # noqa: C901 results.append( pe_actions.FailCommandAction( command_id=running_command.id, + running_command=running_command, error_id=ModelUtils.generate_id(), failed_at=now, error=LegacyContextCommandError(command_error), diff --git a/api/src/opentrons/protocol_runner/legacy_context_plugin.py b/api/src/opentrons/protocol_runner/legacy_context_plugin.py index 3e32877f232..7dd882f0fb7 100644 --- a/api/src/opentrons/protocol_runner/legacy_context_plugin.py +++ b/api/src/opentrons/protocol_runner/legacy_context_plugin.py @@ -5,7 +5,7 @@ from contextlib import ExitStack from typing import List, Optional -from opentrons.commands.types import CommandMessage as LegacyCommand +from opentrons.legacy_commands.types import CommandMessage as LegacyCommand from opentrons.legacy_broker import LegacyBroker from opentrons.protocol_engine import AbstractPlugin, actions as pe_actions from opentrons.util.broker import ReadOnlyBroker diff --git a/api/src/opentrons/protocol_runner/protocol_runner.py b/api/src/opentrons/protocol_runner/protocol_runner.py index 67ea3d15db4..9c097bbba2d 100644 --- a/api/src/opentrons/protocol_runner/protocol_runner.py +++ b/api/src/opentrons/protocol_runner/protocol_runner.py @@ -36,6 +36,7 @@ LegacyExecutor, LegacyLoadInfo, ) +from ..protocol_engine.errors import ProtocolCommandFailedError from ..protocol_engine.types import ( PostRunHardwareState, DeckConfigurationType, @@ -100,12 +101,12 @@ def play(self, deck_configuration: Optional[DeckConfigurationType] = None) -> No def pause(self) -> None: """Pause the run.""" - self._protocol_engine.pause() + self._protocol_engine.request_pause() async def stop(self) -> None: """Stop (cancel) the run.""" if self.was_started(): - await self._protocol_engine.stop() + await self._protocol_engine.request_stop() else: await self._protocol_engine.finish( drop_tips_after_run=False, @@ -283,6 +284,7 @@ def __init__( ) self._hardware_api.should_taskify_movement_execution(taskify=False) + self._queued_commands: List[pe_commands.CommandCreate] = [] async def load(self, protocol_source: ProtocolSource) -> None: """Load a JSONv6+ ProtocolSource into managed ProtocolEngine.""" @@ -324,17 +326,16 @@ async def load(self, protocol_source: ProtocolSource) -> None: color=liquid.displayColor, ) await _yield() + initial_home_command = pe_commands.HomeCreate( params=pe_commands.HomeParams(axes=None) ) # this command homes all axes, including pipette plugner and gripper jaw self._protocol_engine.add_command(request=initial_home_command) - for command in commands: - self._protocol_engine.add_command(request=command) - await _yield() + self._queued_commands = commands - self._task_queue.set_run_func(func=self._protocol_engine.wait_until_complete) + self._task_queue.set_run_func(func=self._add_command_and_execute) async def run( # noqa: D102 self, @@ -355,6 +356,15 @@ async def run( # noqa: D102 commands = self._protocol_engine.state_view.commands.get_all() return RunResult(commands=commands, state_summary=run_data, parameters=[]) + async def _add_command_and_execute(self) -> None: + for command in self._queued_commands: + result = await self._protocol_engine.add_and_execute_command(command) + if result and result.error: + raise ProtocolCommandFailedError( + original_error=result.error, + message=f"{result.error.errorType}: {result.error.detail}", + ) + class LiveRunner(AbstractRunner): """Protocol runner implementation for live http protocols.""" diff --git a/api/src/opentrons/protocols/duration/estimator.py b/api/src/opentrons/protocols/duration/estimator.py index 6f481c29772..5e3b6ef2663 100644 --- a/api/src/opentrons/protocols/duration/estimator.py +++ b/api/src/opentrons/protocols/duration/estimator.py @@ -7,7 +7,7 @@ from dataclasses import dataclass -from opentrons.commands import types +from opentrons.legacy_commands import types from opentrons.protocols.api_support.deck_type import ( guess_from_global_config as guess_deck_type_from_global_config, ) diff --git a/api/src/opentrons/protocols/parameters/validation.py b/api/src/opentrons/protocols/parameters/validation.py index cbb2464ebd0..8e7a0bed8ad 100644 --- a/api/src/opentrons/protocols/parameters/validation.py +++ b/api/src/opentrons/protocols/parameters/validation.py @@ -1,5 +1,5 @@ import keyword -from typing import List, Optional, Union, Literal +from typing import List, Set, Optional, Union, Literal from .types import ( AllowedTypes, @@ -16,17 +16,34 @@ DESCRIPTION_MAX_LEN = 100 +def validate_variable_name_unique( + variable_name: str, other_variable_names: Set[str] +) -> None: + """Validate that the given variable name is unique.""" + if isinstance(variable_name, str) and variable_name in other_variable_names: + raise ParameterNameError( + f'"{variable_name}" is already defined as a variable name for another parameter.' + f" All variable names must be unique." + ) + + def ensure_display_name(display_name: str) -> str: """Validate display name is within the character limit.""" + if not isinstance(display_name, str): + raise ParameterNameError( + f"Display name must be a string and at most {DISPLAY_NAME_MAX_LEN} characters." + ) if len(display_name) > DISPLAY_NAME_MAX_LEN: raise ParameterNameError( - f"Display name {display_name} greater than {DISPLAY_NAME_MAX_LEN} characters." + f'Display name "{display_name}" greater than {DISPLAY_NAME_MAX_LEN} characters.' ) return display_name def ensure_variable_name(variable_name: str) -> str: """Validate variable name is a valid python variable name.""" + if not isinstance(variable_name, str): + raise ParameterNameError("Variable name must be a string.") if not variable_name.isidentifier(): raise ParameterNameError( "Variable name must only contain alphanumeric characters, underscores, and cannot start with a digit." @@ -38,19 +55,29 @@ def ensure_variable_name(variable_name: str) -> str: def ensure_description(description: Optional[str]) -> Optional[str]: """Validate description is within the character limit.""" - if description is not None and len(description) > DESCRIPTION_MAX_LEN: - raise ParameterNameError( - f"Description {description} greater than {DESCRIPTION_MAX_LEN} characters." - ) + if description is not None: + if not isinstance(description, str): + raise ParameterNameError( + f"Description must be a string and at most {DESCRIPTION_MAX_LEN} characters." + ) + if len(description) > DESCRIPTION_MAX_LEN: + raise ParameterNameError( + f'Description "{description}" greater than {DESCRIPTION_MAX_LEN} characters.' + ) return description def ensure_unit_string_length(unit: Optional[str]) -> Optional[str]: """Validate unit is within the character limit.""" - if unit is not None and len(unit) > UNIT_MAX_LEN: - raise ParameterNameError( - f"Description {unit} greater than {UNIT_MAX_LEN} characters." - ) + if unit is not None: + if not isinstance(unit, str): + raise ParameterNameError( + f"Unit must be a string and at most {UNIT_MAX_LEN} characters." + ) + if len(unit) > UNIT_MAX_LEN: + raise ParameterNameError( + f'Unit "{unit}" greater than {UNIT_MAX_LEN} characters.' + ) return unit @@ -61,17 +88,57 @@ def ensure_value_type( This does not guarantee that the value will be the correct type for the given parameter, only that any data coming in is in the format that we expect. For now, the only transformation it is doing is converting integers represented - as floating points to integers. If something is labelled as an int but is not actually an integer, that will be - caught when it is attempted to be set as the parameter value and will raise the appropriate error there. + as floating points to integers, and bools represented as 1.0/0.0 to True/False, and floating points represented as + ints to floats. + + If something is labelled as a type but does not get converted here, that will be caught when it is attempted to be + set as the parameter value and will raise the appropriate error there. """ - validated_value: AllowedTypes - if isinstance(value, float) and parameter_type is int and value.is_integer(): - validated_value = int(value) - else: - validated_value = value + validated_value: AllowedTypes = value + if isinstance(value, float): + if parameter_type is bool and (value == 0 or value == 1): + validated_value = bool(value) + elif parameter_type is int and value.is_integer(): + validated_value = int(value) + elif ( + isinstance(value, int) + and not isinstance(value, bool) + and parameter_type is float + ): + validated_value = float(value) return validated_value +def ensure_float_value(value: Union[float, int]) -> float: + """Ensures that if we are expecting a float and receive an int, that will be converted to a float.""" + if not isinstance(value, bool) and isinstance(value, int): + return float(value) + return value + + +def ensure_optional_float_value(value: Optional[Union[float, int]]) -> Optional[float]: + """Ensures that if we are expecting an optional float and receive an int, that will be converted to a float.""" + if not isinstance(value, bool) and isinstance(value, int): + return float(value) + return value + + +def ensure_float_choices( + choices: Optional[List[ParameterChoice]], +) -> Optional[List[ParameterChoice]]: + """Ensures that if we are expecting float parameter choices and any are int types, those will be converted.""" + if choices is not None: + return [ + ParameterChoice( + display_name=choice["display_name"], + # Type ignore because if for some reason this is a str or bool, that will raise in `validate_options` + value=ensure_float_value(choice["value"]), # type: ignore[arg-type] + ) + for choice in choices + ] + return choices + + def convert_type_string_for_enum( parameter_type: type, ) -> Literal["int", "float", "str"]: @@ -84,7 +151,7 @@ def convert_type_string_for_enum( return "str" else: raise ParameterValueError( - f"Cannot resolve parameter type {parameter_type} for an enumerated parameter." + f"Cannot resolve parameter type '{parameter_type.__name__}' for an enumerated parameter." ) @@ -96,7 +163,7 @@ def convert_type_string_for_num_param(parameter_type: type) -> Literal["int", "f return "float" else: raise ParameterValueError( - f"Cannot resolve parameter type {parameter_type} for a number parameter." + f"Cannot resolve parameter type '{parameter_type.__name__}' for a number parameter." ) @@ -122,7 +189,7 @@ def _validate_choices( ensure_display_name(display_name) if not isinstance(value, parameter_type): raise ParameterDefinitionError( - f"All choices provided must match type {type(parameter_type)}" + f"All choices provided must be of type '{parameter_type.__name__}'" ) @@ -141,21 +208,27 @@ def _validate_min_and_max( "If a maximum value is provided a minimum must also be provided." ) elif maximum is not None and minimum is not None: - if isinstance(maximum, (int, float)) and isinstance(minimum, (int, float)): - if maximum <= minimum: + if parameter_type is int or parameter_type is float: + if not isinstance(minimum, parameter_type): raise ParameterDefinitionError( - "Maximum must be greater than the minimum" + f"Minimum is type '{type(minimum).__name__}'," + f" but must be of parameter type '{parameter_type.__name__}'" ) - - if not isinstance(minimum, parameter_type) or not isinstance( - maximum, parameter_type - ): + if not isinstance(maximum, parameter_type): + raise ParameterDefinitionError( + f"Maximum is type '{type(maximum).__name__}'," + f" but must be of parameter type '{parameter_type.__name__}'" + ) + # These asserts are for the type checker and should never actually be asserted false + assert isinstance(minimum, (int, float)) + assert isinstance(maximum, (int, float)) + if maximum < minimum: raise ParameterDefinitionError( - f"Minimum and maximum must match type {parameter_type}" + "Maximum must be greater than the minimum" ) else: raise ParameterDefinitionError( - "Only parameters of type float or int can have a minimum and maximum" + "Only parameters of type float or int can have a minimum and maximum." ) @@ -163,7 +236,8 @@ def validate_type(value: ParamType, parameter_type: type) -> None: """Validate parameter value is the correct type.""" if not isinstance(value, parameter_type): raise ParameterValueError( - f"Parameter value has type {type(value)} must match type {parameter_type}." + f"Parameter value {value} has type '{type(value).__name__}'," + f" but must be of type '{parameter_type.__name__}'." ) @@ -175,7 +249,11 @@ def validate_options( parameter_type: type, ) -> None: """Validate default values and all possible constraints for a valid parameter definition.""" - validate_type(default, parameter_type) + if not isinstance(default, parameter_type): + raise ParameterValueError( + f"Parameter default {default} has type '{type(default).__name__}'," + f" but must be of type '{parameter_type.__name__}'." + ) if choices is None and minimum is None and maximum is None: raise ParameterDefinitionError( diff --git a/api/src/opentrons/simulate.py b/api/src/opentrons/simulate.py index c5f48c9d1bd..9626fa86b96 100644 --- a/api/src/opentrons/simulate.py +++ b/api/src/opentrons/simulate.py @@ -54,7 +54,7 @@ from opentrons.legacy_broker import LegacyBroker from opentrons.config import IS_ROBOT from opentrons import protocol_api -from opentrons.commands import types as command_types +from opentrons.legacy_commands import types as command_types from opentrons.protocols import parse, bundle from opentrons.protocols.types import ( @@ -114,7 +114,7 @@ # TODO(mm, 2023-10-05): Type _SimulateResultRunLog more precisely by using TypedDicts from -# opentrons.commands. +# opentrons.legacy_commands. _SimulateResultRunLog = List[Mapping[str, Any]] _SimulateResult = Tuple[_SimulateResultRunLog, Optional[BundleContents]] @@ -223,6 +223,7 @@ def get_protocol_api( # type checking, like Jupyter Notebook. *, robot_type: Optional[_UserSpecifiedRobotType] = None, + use_virtual_hardware: bool = True, ) -> protocol_api.ProtocolContext: """ Build and return a ``protocol_api.ProtocolContext`` @@ -260,6 +261,7 @@ def get_protocol_api( :param robot_type: The type of robot to simulate: either ``"Flex"`` or ``"OT-2"``. If you're running this function on a robot, the default is the type of that robot. Otherwise, the default is ``"OT-2"``, for backwards compatibility. + :param use_virtual_hardware: If true, use the protocol engines virtual hardware, if false use the lower level hardware simulator. :return: The protocol context. """ if isinstance(version, str): @@ -317,6 +319,7 @@ def get_protocol_api( hardware_api=checked_hardware, bundled_data=bundled_data, extra_labware=extra_labware, + use_virtual_hardware=use_virtual_hardware, ) # Intentional difference from execute.get_protocol_api(): @@ -453,7 +456,7 @@ def simulate( - ``payload``: The command. The human-readable run log text is available at ``payload["text"]``. The other keys of ``payload`` are command-dependent; - see ``opentrons.commands``. + see ``opentrons.legacy_commands``. .. note:: In older software versions, ``payload["text"]`` was a @@ -790,6 +793,7 @@ def _create_live_context_pe( deck_type: str, extra_labware: Dict[str, "LabwareDefinitionDict"], bundled_data: Optional[Dict[str, bytes]], + use_virtual_hardware: bool = True, ) -> ProtocolContext: """Return a live ProtocolContext that controls the robot through ProtocolEngine.""" assert api_version >= ENGINE_CORE_API_VERSION @@ -798,7 +802,9 @@ def _create_live_context_pe( pe, loop = _LIVE_PROTOCOL_ENGINE_CONTEXTS.enter_context( create_protocol_engine_in_thread( hardware_api=hardware_api.wrapped(), - config=_get_protocol_engine_config(robot_type), + config=_get_protocol_engine_config( + robot_type, virtual=use_virtual_hardware + ), drop_tips_after_run=False, post_run_hardware_state=PostRunHardwareState.STAY_ENGAGED_IN_PLACE, load_fixed_trash=should_load_fixed_trash_labware_for_python_protocol( @@ -899,7 +905,7 @@ def _run_file_pe( async def run(protocol_source: ProtocolSource) -> _SimulateResult: protocol_engine = await create_protocol_engine( hardware_api=hardware_api.wrapped(), - config=_get_protocol_engine_config(robot_type), + config=_get_protocol_engine_config(robot_type, virtual=True), load_fixed_trash=should_load_fixed_trash(protocol_source.config), ) @@ -934,15 +940,15 @@ async def run(protocol_source: ProtocolSource) -> _SimulateResult: return asyncio.run(run(protocol_source)) -def _get_protocol_engine_config(robot_type: RobotType) -> Config: +def _get_protocol_engine_config(robot_type: RobotType, virtual: bool) -> Config: """Return a Protocol Engine config to execute protocols on this device.""" return Config( robot_type=robot_type, deck_type=DeckType(deck_type_for_simulation(robot_type)), ignore_pause=True, - use_virtual_pipettes=True, - use_virtual_modules=True, - use_virtual_gripper=True, + use_virtual_pipettes=virtual, + use_virtual_modules=virtual, + use_virtual_gripper=virtual, use_simulated_deck_config=True, ) diff --git a/api/src/opentrons/system/camera.py b/api/src/opentrons/system/camera.py index 1c2d09d8747..761a9ba66a1 100644 --- a/api/src/opentrons/system/camera.py +++ b/api/src/opentrons/system/camera.py @@ -1,6 +1,7 @@ import asyncio import os from pathlib import Path + from opentrons.config import ARCHITECTURE, SystemArchitecture from opentrons_shared_data.errors.exceptions import CommunicationError from opentrons_shared_data.errors.codes import ErrorCodes @@ -29,7 +30,7 @@ async def take_picture(filename: Path) -> None: pass if ARCHITECTURE == SystemArchitecture.YOCTO: - cmd = f"v4l2-ctl --device /dev/video0 --set-fmt-video=width=1280,height=720,pixelformat=MJPG --stream-mmap --stream-to={str(filename)} --stream-count=1" + cmd = f"v4l2-ctl --device /dev/video2 --set-fmt-video=width=1280,height=720,pixelformat=MJPG --stream-mmap --stream-to={str(filename)} --stream-count=1" elif ARCHITECTURE == SystemArchitecture.BUILDROOT: cmd = f"ffmpeg -f video4linux2 -s 640x480 -i /dev/video0 -ss 0:0:1 -frames 1 {str(filename)}" else: # HOST diff --git a/api/src/opentrons/util/performance_helpers.py b/api/src/opentrons/util/performance_helpers.py new file mode 100644 index 00000000000..ddd547e2ce7 --- /dev/null +++ b/api/src/opentrons/util/performance_helpers.py @@ -0,0 +1,76 @@ +"""Performance helpers for tracking robot context.""" + +from pathlib import Path +from opentrons_shared_data.performance.dev_types import ( + SupportsTracking, + F, + RobotContextState, +) +from opentrons_shared_data.robot.dev_types import RobotTypeEnum +from typing import Callable, Type +from opentrons.config import ( + feature_flags as ff, + get_performance_metrics_data_dir, + robot_configs, +) + + +_should_track = ff.enable_performance_metrics( + RobotTypeEnum.robot_literal_to_enum(robot_configs.load().model) +) + + +def _handle_package_import() -> Type[SupportsTracking]: + """Handle the import of the performance_metrics package. + + If the package is not available, return a stubbed tracker. + """ + try: + from performance_metrics import RobotContextTracker + + return RobotContextTracker + except ImportError: + return StubbedTracker + + +package_to_use = _handle_package_import() +_robot_context_tracker: SupportsTracking | None = None + + +class StubbedTracker(SupportsTracking): + """A stubbed tracker that does nothing.""" + + def __init__(self, storage_location: Path, should_track: bool) -> None: + """Initialize the stubbed tracker.""" + pass + + def track(self, state: RobotContextState) -> Callable[[F], F]: + """Return the function unchanged.""" + + def inner_decorator(func: F) -> F: + """Return the function unchanged.""" + return func + + return inner_decorator + + def store(self) -> None: + """Do nothing.""" + pass + + +def _get_robot_context_tracker() -> SupportsTracking: + """Singleton for the robot context tracker.""" + global _robot_context_tracker + if _robot_context_tracker is None: + # TODO: replace with path lookup and should_store lookup + _robot_context_tracker = package_to_use( + get_performance_metrics_data_dir(), _should_track + ) + return _robot_context_tracker + + +def track_analysis(func: F) -> F: + """Track the analysis of a protocol.""" + return _get_robot_context_tracker().track(RobotContextState.ANALYZING_PROTOCOL)( + func + ) diff --git a/api/tests/opentrons/calibration_storage/test_deck_configuration.py b/api/tests/opentrons/calibration_storage/test_deck_configuration.py index 3cb8d59535f..afdd4449eb4 100644 --- a/api/tests/opentrons/calibration_storage/test_deck_configuration.py +++ b/api/tests/opentrons/calibration_storage/test_deck_configuration.py @@ -10,8 +10,12 @@ def test_deck_configuration_serdes() -> None: """Test that deck configuration serialization/deserialization survives a round trip.""" dummy_cutout_fixture_placements = [ - CutoutFixturePlacement(cutout_fixture_id="a", cutout_id="b"), - CutoutFixturePlacement(cutout_fixture_id="c", cutout_id="d"), + CutoutFixturePlacement( + cutout_fixture_id="a", cutout_id="b", opentrons_module_serial_number="1" + ), + CutoutFixturePlacement( + cutout_fixture_id="c", cutout_id="d", opentrons_module_serial_number="2" + ), ] dummy_datetime = datetime(year=1961, month=5, day=6, tzinfo=timezone.utc) diff --git a/api/tests/opentrons/cli/test_cli.py b/api/tests/opentrons/cli/test_cli.py index eae5aa31ccc..007a7dd6a03 100644 --- a/api/tests/opentrons/cli/test_cli.py +++ b/api/tests/opentrons/cli/test_cli.py @@ -1,4 +1,6 @@ """Test cli execution.""" + + import json import tempfile import textwrap @@ -9,8 +11,17 @@ import pytest from click.testing import CliRunner +from opentrons.util.performance_helpers import _get_robot_context_tracker + -from opentrons.cli.analyze import analyze +# Enable tracking for the RobotContextTracker +# This must come before the import of the analyze CLI +context_tracker = _get_robot_context_tracker() + +# Ignore the type error for the next line, as we're setting a private attribute for testing purposes +context_tracker._should_track = True # type: ignore[attr-defined] + +from opentrons.cli.analyze import analyze # noqa: E402 def _list_fixtures(version: int) -> Iterator[Path]: @@ -242,3 +253,24 @@ def test_python_error_line_numbers( assert result.json_output is not None [error] = result.json_output["errors"] assert error["detail"] == expected_detail + + +def test_track_analysis(tmp_path: Path) -> None: + """Test that the RobotContextTracker tracks analysis.""" + protocol_source = textwrap.dedent( + """ + requirements = {"apiLevel": "2.15"} + + def run(protocol): + pass + """ + ) + + protocol_source_file = tmp_path / "protocol.py" + protocol_source_file.write_text(protocol_source, encoding="utf-8") + + before_analysis = len(context_tracker._storage) # type: ignore[attr-defined] + + _get_analysis_result([protocol_source_file]) + + assert len(context_tracker._storage) == before_analysis + 1 # type: ignore[attr-defined] diff --git a/api/tests/opentrons/config/ot3_settings.py b/api/tests/opentrons/config/ot3_settings.py index e9f840486af..3cfa9b7c34c 100644 --- a/api/tests/opentrons/config/ot3_settings.py +++ b/api/tests/opentrons/config/ot3_settings.py @@ -129,7 +129,7 @@ "aspirate_while_sensing": False, "auto_zero_sensor": True, "num_baseline_reads": 10, - "data_file": "/var/pressure_sensor_data.csv", + "data_files": {"PRIMARY": "/data/pressure_sensor_data.csv"}, }, "calibration": { "z_offset": { diff --git a/api/tests/opentrons/config/test_advanced_settings.py b/api/tests/opentrons/config/test_advanced_settings.py index b81b9149c67..17122fca0dd 100644 --- a/api/tests/opentrons/config/test_advanced_settings.py +++ b/api/tests/opentrons/config/test_advanced_settings.py @@ -34,6 +34,15 @@ def mock_settings_values_flex() -> Dict[str, Optional[bool]]: } +@pytest.fixture +def mock_settings_values_flex_all() -> Dict[str, Optional[bool]]: + return { + s.id: False + for s in advanced_settings.settings + if RobotTypeEnum.FLEX in s.robot_type + } + + @pytest.fixture def mock_settings_values_empty() -> Dict[str, Optional[bool]]: return {s.id: None for s in advanced_settings.settings} @@ -57,12 +66,12 @@ def mock_settings( @pytest.fixture def mock_read_settings_file_ot2( - mock_settings_values_ot2: Dict[str, Optional[bool]], + mock_settings_values_ot2_all: Dict[str, Optional[bool]], mock_settings_version: int, ) -> Generator[MagicMock, None, None]: with patch("opentrons.config.advanced_settings._read_settings_file") as p: p.return_value = advanced_settings.SettingsData( - settings_map=mock_settings_values_ot2, + settings_map=mock_settings_values_ot2_all, version=mock_settings_version, ) yield p @@ -70,12 +79,12 @@ def mock_read_settings_file_ot2( @pytest.fixture def mock_read_settings_file_flex( - mock_settings_values_flex: Dict[str, Optional[bool]], + mock_settings_values_flex_all: Dict[str, Optional[bool]], mock_settings_version: int, ) -> Generator[MagicMock, None, None]: with patch("opentrons.config.advanced_settings._read_settings_file") as p: p.return_value = advanced_settings.SettingsData( - settings_map=mock_settings_values_flex, + settings_map=mock_settings_values_flex_all, version=mock_settings_version, ) yield p @@ -168,19 +177,19 @@ def test_get_all_adv_settings_empty( async def test_set_adv_setting( mock_read_settings_file_ot2: MagicMock, - mock_settings_values_ot2: MagicMock, + mock_settings_values_ot2_all: MagicMock, mock_write_settings_file: MagicMock, mock_settings_version: int, restore_restart_required: None, ) -> None: - for k, v in mock_settings_values_ot2.items(): + for k, v in mock_settings_values_ot2_all.items(): # Toggle the advanced setting await advanced_settings.set_adv_setting(k, not v) mock_write_settings_file.assert_called_with( # Only the current key is toggled { nk: nv if nk != k else not v - for nk, nv in mock_settings_values_ot2.items() + for nk, nv in mock_settings_values_ot2_all.items() }, mock_settings_version, CONFIG["feature_flags_file"], diff --git a/api/tests/opentrons/config/test_advanced_settings_migration.py b/api/tests/opentrons/config/test_advanced_settings_migration.py index e1c3f51b651..e3269433db5 100644 --- a/api/tests/opentrons/config/test_advanced_settings_migration.py +++ b/api/tests/opentrons/config/test_advanced_settings_migration.py @@ -8,7 +8,7 @@ @pytest.fixture def migrated_file_version() -> int: - return 32 + return 33 # make sure to set a boolean value in default_file_settings only if @@ -31,6 +31,7 @@ def default_file_settings() -> Dict[str, Any]: "estopNotRequired": None, "enableErrorRecoveryExperiments": None, "enableOEMMode": None, + "enablePerformanceMetrics": None, } @@ -392,6 +393,18 @@ def v32_config(v31_config: Dict[str, Any]) -> Dict[str, Any]: return r +@pytest.fixture +def v33_config(v32_config: Dict[str, Any]) -> Dict[str, Any]: + r = v32_config.copy() + r.update( + { + "_version": 33, + "enablePerformanceMetrics": None, + } + ) + return r + + @pytest.fixture( scope="session", params=[ @@ -429,6 +442,7 @@ def v32_config(v31_config: Dict[str, Any]) -> Dict[str, Any]: lazy_fixture("v30_config"), lazy_fixture("v31_config"), lazy_fixture("v32_config"), + lazy_fixture("v33_config"), ], ) def old_settings(request: SubRequest) -> Dict[str, Any]: @@ -522,4 +536,5 @@ def test_ensures_config() -> None: "disableOverpressureDetection": None, "enableErrorRecoveryExperiments": None, "enableOEMMode": None, + "enablePerformanceMetrics": None, } diff --git a/api/tests/opentrons/conftest.py b/api/tests/opentrons/conftest.py index dcf6b6c4e37..de731268bce 100755 --- a/api/tests/opentrons/conftest.py +++ b/api/tests/opentrons/conftest.py @@ -40,7 +40,7 @@ from opentrons_shared_data.deck.dev_types import ( RobotModel, DeckDefinitionV3, - DeckDefinitionV4, + DeckDefinitionV5, ) from opentrons_shared_data.deck import ( load as load_deck, @@ -256,7 +256,7 @@ def deck_definition_name(robot_model: RobotModel) -> str: @pytest.fixture -def deck_definition(deck_definition_name: str) -> DeckDefinitionV4: +def deck_definition(deck_definition_name: str) -> DeckDefinitionV5: return load_deck(deck_definition_name, DEFAULT_DECK_DEFINITION_VERSION) diff --git a/api/tests/opentrons/hardware_control/backends/test_ot3_controller.py b/api/tests/opentrons/hardware_control/backends/test_ot3_controller.py index 12743993d33..ed639444b3d 100644 --- a/api/tests/opentrons/hardware_control/backends/test_ot3_controller.py +++ b/api/tests/opentrons/hardware_control/backends/test_ot3_controller.py @@ -61,6 +61,7 @@ UpdateState, EstopState, CurrentConfig, + InstrumentProbeType, ) from opentrons.hardware_control.errors import ( InvalidPipetteName, @@ -185,7 +186,7 @@ def fake_liquid_settings() -> LiquidProbeSettings: aspirate_while_sensing=False, auto_zero_sensor=False, num_baseline_reads=8, - data_file="fake_data_file", + data_files={InstrumentProbeType.PRIMARY: "fake_file_name"}, ) diff --git a/api/tests/opentrons/hardware_control/backends/test_ot3_tip_presence_manager.py b/api/tests/opentrons/hardware_control/backends/test_ot3_tip_presence_manager.py index 543f7b3b400..6ea39738fc2 100644 --- a/api/tests/opentrons/hardware_control/backends/test_ot3_tip_presence_manager.py +++ b/api/tests/opentrons/hardware_control/backends/test_ot3_tip_presence_manager.py @@ -2,7 +2,7 @@ from typing import AsyncIterator, Dict from decoy import Decoy -from opentrons.hardware_control.types import OT3Mount, TipStateType +from opentrons.hardware_control.types import OT3Mount, TipStateType, InstrumentProbeType from opentrons.hardware_control.backends.tip_presence_manager import TipPresenceManager from opentrons_hardware.hardware_control.tip_presence import ( TipDetector, @@ -110,6 +110,51 @@ async def test_get_tip_status_for_high_throughput( result == expected_type +@pytest.mark.parametrize( + "tip_presence,expected_type,sensor_to_look_at", + [ + ( + {SensorId.S0: False, SensorId.S1: False}, + TipStateType.ABSENT, + InstrumentProbeType.PRIMARY, + ), + ( + {SensorId.S0: True, SensorId.S1: True}, + TipStateType.PRESENT, + InstrumentProbeType.SECONDARY, + ), + ( + {SensorId.S0: False, SensorId.S1: True}, + TipStateType.ABSENT, + InstrumentProbeType.PRIMARY, + ), + ( + {SensorId.S0: False, SensorId.S1: True}, + TipStateType.PRESENT, + InstrumentProbeType.SECONDARY, + ), + ], +) +async def test_allow_different_tip_states_ht( + subject: TipPresenceManager, + tip_detector_controller: TipDetectorController, + tip_presence: Dict[SensorId, bool], + expected_type: TipStateType, + sensor_to_look_at: InstrumentProbeType, +) -> None: + mount = OT3Mount.LEFT + await tip_detector_controller.retrieve_tip_status_highthroughput(tip_presence) + + result = await subject.get_tip_status(mount, sensor_to_look_at) + result == expected_type + + # if sensor_to_look_at is not used, different tip states + # should result in an UnmatchedTipStates error + if len(set(tip_presence[t] for t in tip_presence)) > 1: + with pytest.raises(UnmatchedTipPresenceStates): + result = await subject.get_tip_status(mount) + + @pytest.mark.parametrize( "tip_presence", [ diff --git a/api/tests/opentrons/hardware_control/instruments/test_instrument_calibration.py b/api/tests/opentrons/hardware_control/instruments/test_instrument_calibration.py index 6aa3ca2a009..d1f705d596f 100644 --- a/api/tests/opentrons/hardware_control/instruments/test_instrument_calibration.py +++ b/api/tests/opentrons/hardware_control/instruments/test_instrument_calibration.py @@ -134,9 +134,9 @@ def test_load_tip_length( (top_types.Point(0, 1.0, 1.5), top_types.Point(-1, 0, 0.2), True), # If both points are non-zero but at least one element is more than # the range different the test should fail - (top_types.Point(0.1, -1, 1.5), top_types.Point(1.7, 0, 0.2), False), - (top_types.Point(0.1, -1, 1.5), top_types.Point(0.6, 0.6, 1.3), False), - (top_types.Point(0.1, -1, 1.5), top_types.Point(-0.2, -0.1, 5), False), + (top_types.Point(0.1, -1, 4.3), top_types.Point(1.7, 0, 0.2), False), + (top_types.Point(0.1, -3.2, 1.5), top_types.Point(0.6, 0.9, 1.3), False), + (top_types.Point(0.1, -1, 1.5), top_types.Point(-0.2, -0.1, 6), False), ], ) def test_instrument_consistency_check_ot3( @@ -151,4 +151,4 @@ def test_instrument_consistency_check_ot3( top_types.Mount.LEFT: left, top_types.Mount.RIGHT: right, } - assert result[0].limit == 1.5 + assert result[0].limit == 4.0 diff --git a/api/tests/opentrons/hardware_control/test_modules.py b/api/tests/opentrons/hardware_control/test_modules.py index ce92ad2c1a8..eb3d0e48c6c 100644 --- a/api/tests/opentrons/hardware_control/test_modules.py +++ b/api/tests/opentrons/hardware_control/test_modules.py @@ -3,6 +3,7 @@ from pathlib import Path from unittest import mock +from packaging.version import Version from opentrons.hardware_control import ExecutionManager from opentrons.hardware_control.modules import ModuleAtPort @@ -22,6 +23,7 @@ HeaterShaker, AbstractModule, ) +from opentrons.hardware_control.modules.mod_abc import parse_fw_version from opentrons.drivers.rpi_drivers.types import USBPort @@ -422,3 +424,20 @@ def test_magnetic_module_revision_parsing(revision, model): ) def test_temperature_module_revision_parsing(revision, model): assert TempDeck._model_from_revision(revision) == model + + +@pytest.mark.parametrize( + argnames=["device_version", "expected_result"], + argvalues=[ + ["v1.0.4", Version("v1.0.4")], + ["v0.5.6", Version("v0.5.6")], + ["v1.0.4-dhfs", Version("v0.0.0")], + ["v3.0.dshjfd", Version("v0.0.0")], + ], +) +async def test_catch_invalid_fw_version( + device_version: str, + expected_result: bool, +) -> None: + """Assert that invalid firmware versions prompt a valid Version object of v0.0.0.""" + assert parse_fw_version(device_version) == expected_result diff --git a/api/tests/opentrons/hardware_control/test_ot3_api.py b/api/tests/opentrons/hardware_control/test_ot3_api.py index b10628cf99e..7ab0a2f1c00 100644 --- a/api/tests/opentrons/hardware_control/test_ot3_api.py +++ b/api/tests/opentrons/hardware_control/test_ot3_api.py @@ -124,7 +124,7 @@ def fake_liquid_settings() -> LiquidProbeSettings: aspirate_while_sensing=False, auto_zero_sensor=False, num_baseline_reads=10, - data_file="fake_file_name", + data_files={InstrumentProbeType.PRIMARY: "fake_file_name"}, ) @@ -809,7 +809,7 @@ async def test_liquid_probe( aspirate_while_sensing=True, auto_zero_sensor=False, num_baseline_reads=10, - data_file="fake_file_name", + data_files={InstrumentProbeType.PRIMARY: "fake_file_name"}, ) await ot3_hardware.liquid_probe(mount, fake_settings_aspirate) mock_move_to_plunger_bottom.assert_called_once() @@ -820,7 +820,7 @@ async def test_liquid_probe( (fake_settings_aspirate.plunger_speed * -1), fake_settings_aspirate.sensor_threshold_pascals, fake_settings_aspirate.output_option, - fake_settings_aspirate.data_file, + fake_settings_aspirate.data_files, fake_settings_aspirate.auto_zero_sensor, fake_settings_aspirate.num_baseline_reads, probe=InstrumentProbeType.PRIMARY, diff --git a/api/src/opentrons/commands/__init__.py b/api/tests/opentrons/legacy_commands/__init__.py similarity index 100% rename from api/src/opentrons/commands/__init__.py rename to api/tests/opentrons/legacy_commands/__init__.py diff --git a/api/tests/opentrons/commands/test_protocol_commands.py b/api/tests/opentrons/legacy_commands/test_protocol_commands.py similarity index 96% rename from api/tests/opentrons/commands/test_protocol_commands.py rename to api/tests/opentrons/legacy_commands/test_protocol_commands.py index e7fb31aed1c..1ff5475f95b 100644 --- a/api/tests/opentrons/commands/test_protocol_commands.py +++ b/api/tests/opentrons/legacy_commands/test_protocol_commands.py @@ -1,5 +1,5 @@ import pytest -from opentrons.commands import protocol_commands +from opentrons.legacy_commands import protocol_commands @pytest.mark.parametrize( diff --git a/api/tests/opentrons/commands/test_publisher.py b/api/tests/opentrons/legacy_commands/test_publisher.py similarity index 97% rename from api/tests/opentrons/commands/test_publisher.py rename to api/tests/opentrons/legacy_commands/test_publisher.py index a88e6c04523..359b6b3c5fd 100644 --- a/api/tests/opentrons/commands/test_publisher.py +++ b/api/tests/opentrons/legacy_commands/test_publisher.py @@ -1,12 +1,16 @@ -"""Tests for opentrons.commands.publisher.""" +"""Tests for opentrons.legacy_commands.publisher.""" from __future__ import annotations import pytest from decoy import Decoy, matchers from typing import Any, Dict, cast from opentrons.legacy_broker import LegacyBroker -from opentrons.commands.types import Command as CommandDict, CommandMessage -from opentrons.commands.publisher import CommandPublisher, publish, publish_context +from opentrons.legacy_commands.types import Command as CommandDict, CommandMessage +from opentrons.legacy_commands.publisher import ( + CommandPublisher, + publish, + publish_context, +) @pytest.fixture diff --git a/api/tests/opentrons/protocol_api/core/engine/test_instrument_core.py b/api/tests/opentrons/protocol_api/core/engine/test_instrument_core.py index 3b296067a0d..6ac0e9aaaf0 100644 --- a/api/tests/opentrons/protocol_api/core/engine/test_instrument_core.py +++ b/api/tests/opentrons/protocol_api/core/engine/test_instrument_core.py @@ -276,7 +276,7 @@ def test_pick_up_tip( origin=WellOrigin.TOP, offset=WellOffset(x=3, y=2, z=1) ), ), - mock_engine_client.pick_up_tip( + mock_engine_client.pick_up_tip_wait_for_recovery( pipette_id="abc123", labware_id="labware-id", well_name="well-name", diff --git a/api/tests/opentrons/protocol_api/core/engine/test_protocol_core.py b/api/tests/opentrons/protocol_api/core/engine/test_protocol_core.py index fdf12f1e51b..8f6589b1104 100644 --- a/api/tests/opentrons/protocol_api/core/engine/test_protocol_core.py +++ b/api/tests/opentrons/protocol_api/core/engine/test_protocol_core.py @@ -7,7 +7,10 @@ from decoy import Decoy from opentrons_shared_data.deck import load as load_deck -from opentrons_shared_data.deck.dev_types import DeckDefinitionV4, SlotDefV3 +from opentrons_shared_data.deck.dev_types import ( + DeckDefinitionV5, + SlotDefV3, +) from opentrons_shared_data.pipette.dev_types import PipetteNameType from opentrons_shared_data.labware.dev_types import ( LabwareDefinition as LabwareDefDict, @@ -85,15 +88,15 @@ @pytest.fixture(scope="session") -def ot2_standard_deck_def() -> DeckDefinitionV4: +def ot2_standard_deck_def() -> DeckDefinitionV5: """Get the OT-2 standard deck definition.""" - return load_deck(STANDARD_OT2_DECK, 4) + return load_deck(STANDARD_OT2_DECK, 5) @pytest.fixture(scope="session") -def ot3_standard_deck_def() -> DeckDefinitionV4: +def ot3_standard_deck_def() -> DeckDefinitionV5: """Get the OT-2 standard deck definition.""" - return load_deck(STANDARD_OT3_DECK, 4) + return load_deck(STANDARD_OT3_DECK, 5) @pytest.fixture(autouse=True) @@ -180,7 +183,7 @@ def test_api_version( def test_get_slot_definition( - ot2_standard_deck_def: DeckDefinitionV4, subject: ProtocolCore, decoy: Decoy + ot2_standard_deck_def: DeckDefinitionV5, subject: ProtocolCore, decoy: Decoy ) -> None: """It should return a deck slot's definition.""" expected_slot_def = cast( @@ -1154,7 +1157,7 @@ def test_add_labware_definition( EngineModuleModel.THERMOCYCLER_MODULE_V2, ThermocyclerModuleCore, lazy_fixture("ot3_standard_deck_def"), - DeckSlotName.SLOT_A1, + DeckSlotName.SLOT_B1, "OT-3 Standard", ), ( @@ -1177,7 +1180,7 @@ def test_load_module( engine_model: EngineModuleModel, expected_core_cls: Type[ModuleCore], subject: ProtocolCore, - deck_def: DeckDefinitionV4, + deck_def: DeckDefinitionV5, slot_name: DeckSlotName, robot_type: RobotType, ) -> None: @@ -1193,12 +1196,22 @@ def test_load_module( [mock_hw_mod_1, mock_hw_mod_2] ) - decoy.when(subject.get_slot_definition(slot_name)).then_return( - cast( - SlotDefV3, - {"compatibleModuleTypes": [ModuleType.from_model(requested_model)]}, + if robot_type == "OT-2 Standard": + decoy.when(subject.get_slot_definition(slot_name)).then_return( + cast( + SlotDefV3, + {"compatibleModuleTypes": [ModuleType.from_model(requested_model)]}, + ) ) - ) + else: + decoy.when( + mock_engine_client.state.addressable_areas.state.deck_definition + ).then_return(deck_def) + decoy.when( + mock_engine_client.state.addressable_areas.get_cutout_id_by_deck_slot_name( + slot_name + ) + ).then_return("cutout" + slot_name.value) decoy.when(mock_engine_client.state.config.robot_type).then_return(robot_type) @@ -1251,97 +1264,6 @@ def test_load_module( assert subject.get_labware_on_module(result) is None -@pytest.mark.parametrize( - ( - "requested_model", - "engine_model", - "expected_core_cls", - "deck_def", - "slot_name", - "robot_type", - ), - [ - ( - TemperatureModuleModel.TEMPERATURE_V2, - EngineModuleModel.TEMPERATURE_MODULE_V2, - TemperatureModuleCore, - lazy_fixture("ot3_standard_deck_def"), - DeckSlotName.SLOT_D2, - "OT-3 Standard", - ), - ( - MagneticModuleModel.MAGNETIC_V2, - EngineModuleModel.MAGNETIC_MODULE_V2, - MagneticModuleCore, - lazy_fixture("ot3_standard_deck_def"), - DeckSlotName.SLOT_A2, - "OT-3 Standard", - ), - ( - ThermocyclerModuleModel.THERMOCYCLER_V1, - EngineModuleModel.THERMOCYCLER_MODULE_V1, - ThermocyclerModuleCore, - lazy_fixture("ot2_standard_deck_def"), - DeckSlotName.SLOT_1, - "OT-2 Standard", - ), - ( - ThermocyclerModuleModel.THERMOCYCLER_V2, - EngineModuleModel.THERMOCYCLER_MODULE_V2, - ThermocyclerModuleCore, - lazy_fixture("ot3_standard_deck_def"), - DeckSlotName.SLOT_A2, - "OT-3 Standard", - ), - ( - HeaterShakerModuleModel.HEATER_SHAKER_V1, - EngineModuleModel.HEATER_SHAKER_MODULE_V1, - HeaterShakerModuleCore, - lazy_fixture("ot3_standard_deck_def"), - DeckSlotName.SLOT_A2, - "OT-3 Standard", - ), - ], -) -def test_load_module_raises_wrong_location( - decoy: Decoy, - mock_engine_client: EngineClient, - mock_sync_hardware_api: SyncHardwareAPI, - requested_model: ModuleModel, - engine_model: EngineModuleModel, - expected_core_cls: Type[ModuleCore], - subject: ProtocolCore, - deck_def: DeckDefinitionV4, - slot_name: DeckSlotName, - robot_type: RobotType, -) -> None: - """It should issue a load module engine command.""" - mock_hw_mod_1 = decoy.mock(cls=AbstractModule) - mock_hw_mod_2 = decoy.mock(cls=AbstractModule) - - decoy.when(mock_hw_mod_1.device_info).then_return({"serial": "abc123"}) - decoy.when(mock_hw_mod_2.device_info).then_return({"serial": "xyz789"}) - decoy.when(mock_sync_hardware_api.attached_modules).then_return( - [mock_hw_mod_1, mock_hw_mod_2] - ) - - decoy.when(mock_engine_client.state.config.robot_type).then_return(robot_type) - - decoy.when(subject.get_slot_definition(slot_name)).then_return( - cast(SlotDefV3, {"compatibleModuleTypes": []}) - ) - - with pytest.raises( - ValueError, - match=f"A {ModuleType.from_model(requested_model).value} cannot be loaded into slot {slot_name}", - ): - subject.load_module( - model=requested_model, - deck_slot=slot_name, - configuration=None, - ) - - # APIv2.15 because we're expecting a fixed trash. @pytest.mark.parametrize("api_version", [APIVersion(2, 15)]) def test_load_mag_block( @@ -1349,7 +1271,7 @@ def test_load_mag_block( mock_engine_client: EngineClient, mock_sync_hardware_api: SyncHardwareAPI, subject: ProtocolCore, - ot3_standard_deck_def: DeckDefinitionV4, + ot3_standard_deck_def: DeckDefinitionV5, ) -> None: """It should issue a load module engine command.""" definition = ModuleDefinition.construct() # type: ignore[call-arg] @@ -1366,6 +1288,14 @@ def test_load_mag_block( }, ) ) + decoy.when( + mock_engine_client.state.addressable_areas.state.deck_definition + ).then_return(ot3_standard_deck_def) + decoy.when( + mock_engine_client.state.addressable_areas.get_cutout_id_by_deck_slot_name( + DeckSlotName.SLOT_A2 + ) + ).then_return("cutout" + DeckSlotName.SLOT_A2.value) decoy.when( mock_engine_client.load_module( @@ -1440,7 +1370,7 @@ def test_load_module_thermocycler_with_no_location( requested_model: ModuleModel, engine_model: EngineModuleModel, subject: ProtocolCore, - deck_def: DeckDefinitionV4, + deck_def: DeckDefinitionV5, expected_slot: DeckSlotName, ) -> None: """It should issue a load module engine command with location at 7.""" @@ -1450,12 +1380,14 @@ def test_load_module_thermocycler_with_no_location( decoy.when(mock_hw_mod.device_info).then_return({"serial": "xyz789"}) decoy.when(mock_sync_hardware_api.attached_modules).then_return([mock_hw_mod]) decoy.when(mock_engine_client.state.config.robot_type).then_return("OT-3 Standard") - decoy.when(subject.get_slot_definition(expected_slot)).then_return( - cast( - SlotDefV3, - {"compatibleModuleTypes": [ModuleType.from_model(requested_model)]}, + decoy.when( + mock_engine_client.state.addressable_areas.state.deck_definition + ).then_return(deck_def) + decoy.when( + mock_engine_client.state.addressable_areas.get_cutout_id_by_deck_slot_name( + expected_slot ) - ) + ).then_return("cutout" + expected_slot.value) decoy.when( mock_engine_client.load_module( @@ -1590,7 +1522,7 @@ def test_get_deck_definition( decoy: Decoy, mock_engine_client: EngineClient, subject: ProtocolCore ) -> None: """It should return the loaded deck definition from engine state.""" - deck_definition = cast(DeckDefinitionV4, {"schemaVersion": "4"}) + deck_definition = cast(DeckDefinitionV5, {"schemaVersion": "5"}) decoy.when(mock_engine_client.state.labware.get_deck_definition()).then_return( deck_definition diff --git a/api/tests/opentrons/protocol_api/test_deck.py b/api/tests/opentrons/protocol_api/test_deck.py index b3dc4716449..f471cb936e1 100644 --- a/api/tests/opentrons/protocol_api/test_deck.py +++ b/api/tests/opentrons/protocol_api/test_deck.py @@ -5,7 +5,7 @@ import pytest from decoy import Decoy -from opentrons_shared_data.deck.dev_types import DeckDefinitionV4, SlotDefV3 +from opentrons_shared_data.deck.dev_types import DeckDefinitionV5, SlotDefV3 from opentrons.motion_planning import adjacent_slots_getters as mock_adjacent_slots from opentrons.protocols.api_support.types import APIVersion @@ -23,10 +23,10 @@ @pytest.fixture -def deck_definition() -> DeckDefinitionV4: +def deck_definition() -> DeckDefinitionV5: """Get a deck definition value object.""" return cast( - DeckDefinitionV4, + DeckDefinitionV5, { "locations": {"addressableAreas": [], "calibrationPoints": []}, "cutoutFixtures": {}, @@ -81,7 +81,7 @@ def staging_slot_definitions_by_name() -> Dict[str, SlotDefV3]: @pytest.fixture def subject( decoy: Decoy, - deck_definition: DeckDefinitionV4, + deck_definition: DeckDefinitionV5, mock_protocol_core: ProtocolCore, mock_core_map: LoadedCoreMap, api_version: APIVersion, diff --git a/api/tests/opentrons/protocol_api/test_module_context.py b/api/tests/opentrons/protocol_api/test_module_context.py index 6ce8928abc4..c57f1ff52dc 100644 --- a/api/tests/opentrons/protocol_api/test_module_context.py +++ b/api/tests/opentrons/protocol_api/test_module_context.py @@ -108,7 +108,7 @@ def test_load_labware( decoy.when(mock_labware_core.get_well_columns()).then_return([]) result = subject.load_labware( - name="infinite tip rack", + name="Infinite Tip Rack", label="it doesn't run out", namespace="ideal", version=101, diff --git a/api/tests/opentrons/protocol_api/test_parameter_context.py b/api/tests/opentrons/protocol_api/test_parameter_context.py index 8b98ae204ca..7dcc246f216 100644 --- a/api/tests/opentrons/protocol_api/test_parameter_context.py +++ b/api/tests/opentrons/protocol_api/test_parameter_context.py @@ -46,6 +46,9 @@ def subject(api_version: APIVersion) -> ParameterContext: def test_add_int(decoy: Decoy, subject: ParameterContext) -> None: """It should create and add an int parameter definition.""" + subject._parameters["other_param"] = decoy.mock( + cls=mock_parameter_definition.ParameterDefinition + ) param_def = decoy.mock(cls=mock_parameter_definition.ParameterDefinition) decoy.when(param_def.variable_name).then_return("my cool variable") decoy.when( @@ -60,6 +63,7 @@ def test_add_int(decoy: Decoy, subject: ParameterContext) -> None: unit="foot candles", ) ).then_return(param_def) + subject.add_int( display_name="abc", variable_name="xyz", @@ -70,25 +74,37 @@ def test_add_int(decoy: Decoy, subject: ParameterContext) -> None: description="blah blah blah", unit="foot candles", ) + assert param_def is subject._parameters["my cool variable"] + decoy.verify(mock_validation.validate_variable_name_unique("xyz", {"other_param"})) def test_add_float(decoy: Decoy, subject: ParameterContext) -> None: """It should create and add a float parameter definition.""" + subject._parameters["other_param"] = decoy.mock( + cls=mock_parameter_definition.ParameterDefinition + ) param_def = decoy.mock(cls=mock_parameter_definition.ParameterDefinition) decoy.when(param_def.variable_name).then_return("my cooler variable") + decoy.when(mock_validation.ensure_float_value(12.3)).then_return(3.21) + decoy.when(mock_validation.ensure_optional_float_value(4.5)).then_return(5.4) + decoy.when(mock_validation.ensure_optional_float_value(67.8)).then_return(87.6) + decoy.when( + mock_validation.ensure_float_choices([{"display_name": "foo", "value": 4.2}]) + ).then_return([{"display_name": "bar", "value": 2.4}]) decoy.when( mock_parameter_definition.create_float_parameter( display_name="abc", variable_name="xyz", - default=12.3, - minimum=4.5, - maximum=67.8, - choices=[{"display_name": "foo", "value": 4.2}], + default=3.21, + minimum=5.4, + maximum=87.6, + choices=[{"display_name": "bar", "value": 2.4}], description="blah blah blah", unit="lux", ) ).then_return(param_def) + subject.add_float( display_name="abc", variable_name="xyz", @@ -99,11 +115,16 @@ def test_add_float(decoy: Decoy, subject: ParameterContext) -> None: description="blah blah blah", unit="lux", ) + assert param_def is subject._parameters["my cooler variable"] + decoy.verify(mock_validation.validate_variable_name_unique("xyz", {"other_param"})) def test_add_bool(decoy: Decoy, subject: ParameterContext) -> None: """It should create and add a boolean parameter definition.""" + subject._parameters["other_param"] = decoy.mock( + cls=mock_parameter_definition.ParameterDefinition + ) param_def = decoy.mock(cls=mock_parameter_definition.ParameterDefinition) decoy.when(param_def.variable_name).then_return("my coolest variable") decoy.when( @@ -118,17 +139,23 @@ def test_add_bool(decoy: Decoy, subject: ParameterContext) -> None: description="lorem ipsum", ) ).then_return(param_def) + subject.add_bool( display_name="cba", variable_name="zxy", default=False, description="lorem ipsum", ) + assert param_def is subject._parameters["my coolest variable"] + decoy.verify(mock_validation.validate_variable_name_unique("zxy", {"other_param"})) def test_add_string(decoy: Decoy, subject: ParameterContext) -> None: """It should create and add a string parameter definition.""" + subject._parameters["other_param"] = decoy.mock( + cls=mock_parameter_definition.ParameterDefinition + ) param_def = decoy.mock(cls=mock_parameter_definition.ParameterDefinition) decoy.when(param_def.variable_name).then_return("my slightly less cool variable") decoy.when( @@ -140,6 +167,7 @@ def test_add_string(decoy: Decoy, subject: ParameterContext) -> None: description="fee foo fum", ) ).then_return(param_def) + subject.add_str( display_name="jkl", variable_name="qwerty", @@ -147,7 +175,11 @@ def test_add_string(decoy: Decoy, subject: ParameterContext) -> None: choices=[{"display_name": "bar", "value": "aaa"}], description="fee foo fum", ) + assert param_def is subject._parameters["my slightly less cool variable"] + decoy.verify( + mock_validation.validate_variable_name_unique("qwerty", {"other_param"}) + ) def test_set_parameters(decoy: Decoy, subject: ParameterContext) -> None: @@ -193,5 +225,5 @@ def test_export_parameters_for_protocol( subject._parameters = {"foo": param_def_1, "bar": param_def_2} result = subject.export_parameters_for_protocol() - assert result.x == "a" # type: ignore [attr-defined] - assert result.y == 1.23 # type: ignore [attr-defined] + assert result.x == "a" # type: ignore[attr-defined] + assert result.y == 1.23 # type: ignore[attr-defined] diff --git a/api/tests/opentrons/protocol_engine/commands/test_hash_command_params.py b/api/tests/opentrons/protocol_engine/commands/test_hash_command_params.py index 098ce53c321..9988854a9d4 100644 --- a/api/tests/opentrons/protocol_engine/commands/test_hash_command_params.py +++ b/api/tests/opentrons/protocol_engine/commands/test_hash_command_params.py @@ -2,7 +2,9 @@ from opentrons.protocol_engine import CommandIntent from opentrons.protocol_engine import commands -from opentrons.protocol_engine.commands.hash_command_params import hash_command_params +from opentrons.protocol_engine.commands.hash_command_params import ( + hash_protocol_command_params, +) def test_equivalent_commands() -> None: @@ -20,10 +22,14 @@ def test_equivalent_commands() -> None: params=commands.WaitForDurationParams(seconds=123) ) - assert hash_command_params(b, None) == hash_command_params(c, None) + assert hash_protocol_command_params(b, None) == hash_protocol_command_params( + c, None + ) - a_hash = hash_command_params(a, None) - assert hash_command_params(b, a_hash) == hash_command_params(c, a_hash) + a_hash = hash_protocol_command_params(a, None) + assert hash_protocol_command_params(b, a_hash) == hash_protocol_command_params( + c, a_hash + ) def test_nonequivalent_commands() -> None: @@ -32,26 +38,31 @@ def test_nonequivalent_commands() -> None: params=commands.BlowOutInPlaceParams( pipetteId="abc123", flowRate=123, - ) + ), + intent=CommandIntent.PROTOCOL, ) b = commands.WaitForDurationCreate( params=commands.WaitForDurationParams(seconds=123) ) - assert hash_command_params(a, None) != hash_command_params(b, None) + assert hash_protocol_command_params(a, None) != hash_protocol_command_params( + b, None + ) def test_repeated_commands() -> None: """Repeated commands should hash differently, even though they're equivalent in isolation.""" a = commands.WaitForDurationCreate( - params=commands.WaitForDurationParams(seconds=123) + params=commands.WaitForDurationParams(seconds=123), + intent=CommandIntent.PROTOCOL, ) b = commands.WaitForDurationCreate( - params=commands.WaitForDurationParams(seconds=123) + params=commands.WaitForDurationParams(seconds=123), + intent=CommandIntent.PROTOCOL, ) - a_hash = hash_command_params(a, None) - b_hash = hash_command_params(b, a_hash) + a_hash = hash_protocol_command_params(a, None) + b_hash = hash_protocol_command_params(b, a_hash) assert a_hash != b_hash @@ -61,4 +72,4 @@ def test_setup_command() -> None: params=commands.WaitForDurationParams(seconds=123), intent=CommandIntent.SETUP, ) - assert hash_command_params(setup_command, None) is None + assert hash_protocol_command_params(setup_command, None) is None diff --git a/api/tests/opentrons/protocol_engine/commands/test_load_module.py b/api/tests/opentrons/protocol_engine/commands/test_load_module.py index 84be22d4661..65306f34adc 100644 --- a/api/tests/opentrons/protocol_engine/commands/test_load_module.py +++ b/api/tests/opentrons/protocol_engine/commands/test_load_module.py @@ -1,9 +1,11 @@ """Test load module command.""" import pytest +from typing import cast from decoy import Decoy from opentrons.protocol_engine.errors import LocationIsOccupiedError from opentrons.protocol_engine.state import StateView +from opentrons_shared_data.robot.dev_types import RobotType from opentrons.types import DeckSlotName from opentrons.protocol_engine.types import ( DeckSlotLocation, @@ -11,12 +13,30 @@ ModuleDefinition, ) from opentrons.protocol_engine.execution import EquipmentHandler, LoadedModuleData +from opentrons.protocol_engine import ModuleModel as EngineModuleModel +from opentrons.hardware_control.modules import ModuleType from opentrons.protocol_engine.commands.load_module import ( LoadModuleParams, LoadModuleResult, LoadModuleImplementation, ) +from opentrons.hardware_control.modules.types import ( + ModuleModel as HardwareModuleModel, + TemperatureModuleModel, + MagneticModuleModel, + ThermocyclerModuleModel, + HeaterShakerModuleModel, +) +from opentrons_shared_data.deck.dev_types import ( + DeckDefinitionV5, + SlotDefV3, +) +from opentrons_shared_data.deck import load as load_deck +from opentrons.protocols.api_support.deck_type import ( + STANDARD_OT2_DECK, + STANDARD_OT3_DECK, +) async def test_load_module_implementation( @@ -29,19 +49,29 @@ async def test_load_module_implementation( subject = LoadModuleImplementation(equipment=equipment, state_view=state_view) data = LoadModuleParams( - model=ModuleModel.TEMPERATURE_MODULE_V1, - location=DeckSlotLocation(slotName=DeckSlotName.SLOT_1), + model=ModuleModel.TEMPERATURE_MODULE_V2, + location=DeckSlotLocation(slotName=DeckSlotName.SLOT_D1), moduleId="some-id", ) + + deck_def = load_deck(STANDARD_OT3_DECK, 5) + + decoy.when(state_view.addressable_areas.state.deck_definition).then_return(deck_def) + decoy.when( + state_view.addressable_areas.get_cutout_id_by_deck_slot_name( + DeckSlotName.SLOT_D1 + ) + ).then_return("cutout" + DeckSlotName.SLOT_D1.value) + decoy.when( state_view.geometry.ensure_location_not_occupied( - DeckSlotLocation(slotName=DeckSlotName.SLOT_1) + DeckSlotLocation(slotName=DeckSlotName.SLOT_D1) ) ).then_return(DeckSlotLocation(slotName=DeckSlotName.SLOT_2)) decoy.when( await equipment.load_module( - model=ModuleModel.TEMPERATURE_MODULE_V1, + model=ModuleModel.TEMPERATURE_MODULE_V2, location=DeckSlotLocation(slotName=DeckSlotName.SLOT_2), module_id="some-id", ) @@ -73,12 +103,22 @@ async def test_load_module_implementation_mag_block( data = LoadModuleParams( model=ModuleModel.MAGNETIC_BLOCK_V1, - location=DeckSlotLocation(slotName=DeckSlotName.SLOT_1), + location=DeckSlotLocation(slotName=DeckSlotName.SLOT_D1), moduleId="some-id", ) + + deck_def = load_deck(STANDARD_OT3_DECK, 5) + + decoy.when(state_view.addressable_areas.state.deck_definition).then_return(deck_def) + decoy.when( + state_view.addressable_areas.get_cutout_id_by_deck_slot_name( + DeckSlotName.SLOT_D1 + ) + ).then_return("cutout" + DeckSlotName.SLOT_D1.value) + decoy.when( state_view.geometry.ensure_location_not_occupied( - DeckSlotLocation(slotName=DeckSlotName.SLOT_1) + DeckSlotLocation(slotName=DeckSlotName.SLOT_D1) ) ).then_return(DeckSlotLocation(slotName=DeckSlotName.SLOT_2)) @@ -114,16 +154,162 @@ async def test_load_module_raises_if_location_occupied( subject = LoadModuleImplementation(equipment=equipment, state_view=state_view) data = LoadModuleParams( - model=ModuleModel.TEMPERATURE_MODULE_V1, - location=DeckSlotLocation(slotName=DeckSlotName.SLOT_1), + model=ModuleModel.TEMPERATURE_MODULE_V2, + location=DeckSlotLocation(slotName=DeckSlotName.SLOT_D1), moduleId="some-id", ) + deck_def = load_deck(STANDARD_OT3_DECK, 5) + + decoy.when(state_view.addressable_areas.state.deck_definition).then_return(deck_def) + decoy.when( + state_view.addressable_areas.get_cutout_id_by_deck_slot_name( + DeckSlotName.SLOT_D1 + ) + ).then_return("cutout" + DeckSlotName.SLOT_D1.value) + decoy.when( state_view.geometry.ensure_location_not_occupied( - DeckSlotLocation(slotName=DeckSlotName.SLOT_1) + DeckSlotLocation(slotName=DeckSlotName.SLOT_D1) ) ).then_raise(LocationIsOccupiedError("Get your own spot!")) with pytest.raises(LocationIsOccupiedError): await subject.execute(data) + + +@pytest.mark.parametrize( + ( + "requested_model", + "engine_model", + "deck_def", + "slot_name", + "robot_type", + ), + [ + ( + TemperatureModuleModel.TEMPERATURE_V2, + EngineModuleModel.TEMPERATURE_MODULE_V2, + load_deck(STANDARD_OT3_DECK, 5), + DeckSlotName.SLOT_D2, + "OT-3 Standard", + ), + ( + ThermocyclerModuleModel.THERMOCYCLER_V1, + EngineModuleModel.THERMOCYCLER_MODULE_V1, + load_deck(STANDARD_OT2_DECK, 5), + DeckSlotName.SLOT_1, + "OT-2 Standard", + ), + ( + ThermocyclerModuleModel.THERMOCYCLER_V2, + EngineModuleModel.THERMOCYCLER_MODULE_V2, + load_deck(STANDARD_OT3_DECK, 5), + DeckSlotName.SLOT_A2, + "OT-3 Standard", + ), + ( + HeaterShakerModuleModel.HEATER_SHAKER_V1, + EngineModuleModel.HEATER_SHAKER_MODULE_V1, + load_deck(STANDARD_OT3_DECK, 5), + DeckSlotName.SLOT_A2, + "OT-3 Standard", + ), + ], +) +async def test_load_module_raises_wrong_location( + decoy: Decoy, + equipment: EquipmentHandler, + state_view: StateView, + requested_model: HardwareModuleModel, + engine_model: EngineModuleModel, + deck_def: DeckDefinitionV5, + slot_name: DeckSlotName, + robot_type: RobotType, +) -> None: + """It should issue a load module engine command.""" + subject = LoadModuleImplementation(equipment=equipment, state_view=state_view) + + data = LoadModuleParams( + model=engine_model, + location=DeckSlotLocation(slotName=slot_name), + moduleId="some-id", + ) + + decoy.when(state_view.config.robot_type).then_return(robot_type) + + if robot_type == "OT-2 Standard": + decoy.when( + state_view.addressable_areas.get_slot_definition(slot_name.id) + ).then_return(cast(SlotDefV3, {"compatibleModuleTypes": []})) + else: + decoy.when(state_view.addressable_areas.state.deck_definition).then_return( + deck_def + ) + decoy.when( + state_view.addressable_areas.get_cutout_id_by_deck_slot_name(slot_name) + ).then_return("cutout" + slot_name.value) + + with pytest.raises( + ValueError, + match=f"A {ModuleType.from_model(model=requested_model).value} cannot be loaded into slot {slot_name}", + ): + await subject.execute(data) + + +@pytest.mark.parametrize( + ( + "requested_model", + "engine_model", + "deck_def", + "slot_name", + "robot_type", + ), + [ + ( + MagneticModuleModel.MAGNETIC_V2, + EngineModuleModel.MAGNETIC_MODULE_V2, + load_deck(STANDARD_OT3_DECK, 5), + DeckSlotName.SLOT_A2, + "OT-3 Standard", + ), + ], +) +async def test_load_module_raises_module_fixture_id_does_not_exist( + decoy: Decoy, + equipment: EquipmentHandler, + state_view: StateView, + requested_model: HardwareModuleModel, + engine_model: EngineModuleModel, + deck_def: DeckDefinitionV5, + slot_name: DeckSlotName, + robot_type: RobotType, +) -> None: + """It should issue a load module engine command and raise an error for unmatched fixtures.""" + subject = LoadModuleImplementation(equipment=equipment, state_view=state_view) + + data = LoadModuleParams( + model=engine_model, + location=DeckSlotLocation(slotName=slot_name), + moduleId="some-id", + ) + + decoy.when(state_view.config.robot_type).then_return(robot_type) + + if robot_type == "OT-2 Standard": + decoy.when( + state_view.addressable_areas.get_slot_definition(slot_name.id) + ).then_return(cast(SlotDefV3, {"compatibleModuleTypes": []})) + else: + decoy.when(state_view.addressable_areas.state.deck_definition).then_return( + deck_def + ) + decoy.when( + state_view.addressable_areas.get_cutout_id_by_deck_slot_name(slot_name) + ).then_return("cutout" + slot_name.value) + + with pytest.raises( + ValueError, + match=f"Module Type {ModuleType.from_model(requested_model).value} does not have a related fixture ID.", + ): + await subject.execute(data) diff --git a/api/tests/opentrons/protocol_engine/conftest.py b/api/tests/opentrons/protocol_engine/conftest.py index dfd59089c2d..ab23f7e9e08 100644 --- a/api/tests/opentrons/protocol_engine/conftest.py +++ b/api/tests/opentrons/protocol_engine/conftest.py @@ -7,7 +7,7 @@ from opentrons_shared_data import load_shared_data from opentrons_shared_data.deck import load as load_deck -from opentrons_shared_data.deck.dev_types import DeckDefinitionV4 +from opentrons_shared_data.deck.dev_types import DeckDefinitionV5 from opentrons_shared_data.labware import load_definition from opentrons_shared_data.pipette import pipette_definition from opentrons.protocols.models import LabwareDefinition @@ -57,21 +57,21 @@ def ot3_hardware_api(decoy: Decoy) -> OT3API: @pytest.fixture(scope="session") -def ot2_standard_deck_def() -> DeckDefinitionV4: +def ot2_standard_deck_def() -> DeckDefinitionV5: """Get the OT-2 standard deck definition.""" - return load_deck(STANDARD_OT2_DECK, 4) + return load_deck(STANDARD_OT2_DECK, 5) @pytest.fixture(scope="session") -def ot2_short_trash_deck_def() -> DeckDefinitionV4: +def ot2_short_trash_deck_def() -> DeckDefinitionV5: """Get the OT-2 short-trash deck definition.""" - return load_deck(SHORT_TRASH_DECK, 4) + return load_deck(SHORT_TRASH_DECK, 5) @pytest.fixture(scope="session") -def ot3_standard_deck_def() -> DeckDefinitionV4: +def ot3_standard_deck_def() -> DeckDefinitionV5: """Get the OT-2 standard deck definition.""" - return load_deck(STANDARD_OT3_DECK, 4) + return load_deck(STANDARD_OT3_DECK, 5) @pytest.fixture(scope="session") 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 94b7ad25509..1cdb051164c 100644 --- a/api/tests/opentrons/protocol_engine/execution/test_command_executor.py +++ b/api/tests/opentrons/protocol_engine/execution/test_command_executor.py @@ -360,8 +360,8 @@ def _ImplementationCls(self) -> Type[_TestCommandImpl]: False, ), ( - EStopActivatedError("oh no"), - matchers.ErrorMatching(PE_EStopActivatedError, match="oh no"), + EStopActivatedError(), + matchers.ErrorMatching(PE_EStopActivatedError), True, ), ( @@ -500,6 +500,7 @@ def _ImplementationCls(self) -> Type[_TestCommandImpl]: action_dispatcher.dispatch( FailCommandAction( command_id="command-id", + running_command=running_command, error_id="error-id", failed_at=datetime(year=2023, month=3, day=3), error=expected_error, diff --git a/api/tests/opentrons/protocol_engine/execution/test_equipment_handler.py b/api/tests/opentrons/protocol_engine/execution/test_equipment_handler.py index 69a249ebfc2..1177894e977 100644 --- a/api/tests/opentrons/protocol_engine/execution/test_equipment_handler.py +++ b/api/tests/opentrons/protocol_engine/execution/test_equipment_handler.py @@ -22,7 +22,6 @@ from opentrons.protocols.models import LabwareDefinition from opentrons.protocol_engine import errors -from opentrons.protocol_engine.actions import ActionDispatcher from opentrons.protocol_engine.types import ( DeckSlotLocation, DeckType, @@ -84,12 +83,6 @@ def state_store(decoy: Decoy) -> StateStore: return decoy.mock(cls=StateStore) -@pytest.fixture -def action_dispatcher(decoy: Decoy) -> ActionDispatcher: - """Get a mocked out ActionDispatcher instance.""" - return decoy.mock(cls=ActionDispatcher) - - @pytest.fixture def model_utils(decoy: Decoy) -> ModelUtils: """Get a mocked out ModelUtils instance.""" @@ -166,7 +159,6 @@ def virtual_pipette_data_provider( def subject( hardware_api: HardwareControlAPI, state_store: StateStore, - action_dispatcher: ActionDispatcher, labware_data_provider: LabwareDataProvider, module_data_provider: ModuleDataProvider, model_utils: ModelUtils, @@ -176,7 +168,6 @@ def subject( return EquipmentHandler( hardware_api=hardware_api, state_store=state_store, - action_dispatcher=action_dispatcher, labware_data_provider=labware_data_provider, module_data_provider=module_data_provider, model_utils=model_utils, @@ -614,7 +605,6 @@ async def test_load_pipette( model_utils: ModelUtils, hardware_api: HardwareControlAPI, state_store: StateStore, - action_dispatcher: ActionDispatcher, loaded_static_pipette_data: LoadedStaticPipetteData, subject: EquipmentHandler, ) -> None: @@ -665,7 +655,6 @@ async def test_load_pipette_96_channels( model_utils: ModelUtils, hardware_api: HardwareControlAPI, state_store: StateStore, - action_dispatcher: ActionDispatcher, loaded_static_pipette_data: LoadedStaticPipetteData, subject: EquipmentHandler, ) -> None: @@ -702,7 +691,6 @@ async def test_load_pipette_uses_provided_id( decoy: Decoy, hardware_api: HardwareControlAPI, state_store: StateStore, - action_dispatcher: ActionDispatcher, loaded_static_pipette_data: LoadedStaticPipetteData, subject: EquipmentHandler, ) -> None: @@ -734,7 +722,6 @@ async def test_load_pipette_use_virtual( decoy: Decoy, model_utils: ModelUtils, state_store: StateStore, - action_dispatcher: ActionDispatcher, loaded_static_pipette_data: LoadedStaticPipetteData, subject: EquipmentHandler, virtual_pipette_data_provider: pipette_data_provider.VirtualPipetteDataProvider, @@ -837,6 +824,7 @@ async def test_load_module( HardwareModule(serial_number="serial-1", definition=tempdeck_v1_def), HardwareModule(serial_number="serial-2", definition=tempdeck_v2_def), ], + expected_serial_number=None, ) ).then_return(HardwareModule(serial_number="serial-1", definition=tempdeck_v1_def)) diff --git a/api/tests/opentrons/protocol_engine/resources/test_deck_configuration_provider.py b/api/tests/opentrons/protocol_engine/resources/test_deck_configuration_provider.py index 8071cc98a66..12b324955be 100644 --- a/api/tests/opentrons/protocol_engine/resources/test_deck_configuration_provider.py +++ b/api/tests/opentrons/protocol_engine/resources/test_deck_configuration_provider.py @@ -5,7 +5,7 @@ from pytest_lazyfixture import lazy_fixture # type: ignore[import-untyped] from opentrons_shared_data.deck import load as load_deck -from opentrons_shared_data.deck.dev_types import DeckDefinitionV4 +from opentrons_shared_data.deck.dev_types import DeckDefinitionV5 from opentrons.types import DeckSlotName @@ -13,7 +13,6 @@ FixtureDoesNotExistError, CutoutDoesNotExistError, AddressableAreaDoesNotExistError, - FixtureDoesNotProvideAreasError, ) from opentrons.protocol_engine.types import ( AddressableArea, @@ -33,21 +32,21 @@ @pytest.fixture(scope="session") -def ot2_standard_deck_def() -> DeckDefinitionV4: +def ot2_standard_deck_def() -> DeckDefinitionV5: """Get the OT-2 standard deck definition.""" - return load_deck(STANDARD_OT2_DECK, 4) + return load_deck(STANDARD_OT2_DECK, 5) @pytest.fixture(scope="session") -def ot2_short_trash_deck_def() -> DeckDefinitionV4: +def ot2_short_trash_deck_def() -> DeckDefinitionV5: """Get the OT-2 standard deck definition.""" - return load_deck(SHORT_TRASH_DECK, 4) + return load_deck(SHORT_TRASH_DECK, 5) @pytest.fixture(scope="session") -def ot3_standard_deck_def() -> DeckDefinitionV4: +def ot3_standard_deck_def() -> DeckDefinitionV5: """Get the OT-2 standard deck definition.""" - return load_deck(STANDARD_OT3_DECK, 4) + return load_deck(STANDARD_OT3_DECK, 5) @pytest.mark.parametrize( @@ -73,7 +72,7 @@ def ot3_standard_deck_def() -> DeckDefinitionV4: def test_get_cutout_position( cutout_id: str, expected_deck_point: DeckPoint, - deck_def: DeckDefinitionV4, + deck_def: DeckDefinitionV5, ) -> None: """It should get the deck position for the requested cutout id.""" cutout_position = subject.get_cutout_position(cutout_id, deck_def) @@ -81,7 +80,7 @@ def test_get_cutout_position( def test_get_cutout_position_raises( - ot3_standard_deck_def: DeckDefinitionV4, + ot3_standard_deck_def: DeckDefinitionV5, ) -> None: """It should raise if there is no cutout with that ID in the deck definition.""" with pytest.raises(CutoutDoesNotExistError): @@ -107,7 +106,7 @@ def test_get_cutout_position_raises( def test_get_cutout_fixture( cutout_fixture_id: str, expected_display_name: str, - deck_def: DeckDefinitionV4, + deck_def: DeckDefinitionV5, ) -> None: """It should get the cutout fixture given the cutout fixture id.""" cutout_fixture = subject.get_cutout_fixture(cutout_fixture_id, deck_def) @@ -115,7 +114,7 @@ def test_get_cutout_fixture( def test_get_cutout_fixture_raises( - ot3_standard_deck_def: DeckDefinitionV4, + ot3_standard_deck_def: DeckDefinitionV5, ) -> None: """It should raise if the given cutout fixture id does not exist.""" with pytest.raises(FixtureDoesNotExistError): @@ -149,7 +148,7 @@ def test_get_provided_addressable_area_names( cutout_fixture_id: str, cutout_id: str, expected_areas: List[str], - deck_def: DeckDefinitionV4, + deck_def: DeckDefinitionV5, ) -> None: """It should get the provided addressable area for the cutout fixture and cutout.""" provided_addressable_areas = subject.get_provided_addressable_area_names( @@ -158,16 +157,6 @@ def test_get_provided_addressable_area_names( assert provided_addressable_areas == expected_areas -def test_get_provided_addressable_area_raises( - ot3_standard_deck_def: DeckDefinitionV4, -) -> None: - """It should raise if the cutout fixture does not provide areas for the given cutout id.""" - with pytest.raises(FixtureDoesNotProvideAreasError): - subject.get_provided_addressable_area_names( - "singleRightSlot", "theFunCutout", ot3_standard_deck_def - ) - - @pytest.mark.parametrize( ( "addressable_area_name", @@ -223,7 +212,7 @@ def test_get_potential_cutout_fixtures( addressable_area_name: str, expected_cutout_id: str, expected_potential_fixtures: Set[PotentialCutoutFixture], - deck_def: DeckDefinitionV4, + deck_def: DeckDefinitionV5, ) -> None: """It should get a cutout id and a set of potential cutout fixtures for an addressable area name.""" cutout_id, potential_fixtures = subject.get_potential_cutout_fixtures( @@ -234,7 +223,7 @@ def test_get_potential_cutout_fixtures( def test_get_potential_cutout_fixtures_raises( - ot3_standard_deck_def: DeckDefinitionV4, + ot3_standard_deck_def: DeckDefinitionV5, ) -> None: """It should raise if there is no fixtures that provide the requested area.""" with pytest.raises(AddressableAreaDoesNotExistError): @@ -288,11 +277,7 @@ def test_get_potential_cutout_fixtures_raises( display_name="Slot D1", bounding_box=Dimensions(x=128.0, y=86.0, z=0), position=AddressableOffsetVector(x=1, y=2, z=3), - compatible_module_types=[ - "temperatureModuleType", - "heaterShakerModuleType", - "magneticBlockType", - ], + compatible_module_types=[], ), lazy_fixture("ot3_standard_deck_def"), ), @@ -327,7 +312,7 @@ def test_get_potential_cutout_fixtures_raises( def test_get_addressable_area_from_name( addressable_area_name: str, expected_addressable_area: AddressableArea, - deck_def: DeckDefinitionV4, + deck_def: DeckDefinitionV5, ) -> None: """It should get the deck position for the requested cutout id.""" addressable_area = subject.get_addressable_area_from_name( @@ -337,7 +322,7 @@ def test_get_addressable_area_from_name( def test_get_addressable_area_from_name_raises( - ot3_standard_deck_def: DeckDefinitionV4, + ot3_standard_deck_def: DeckDefinitionV5, ) -> None: """It should raise if there is no addressable area by that name in the deck.""" with pytest.raises(AddressableAreaDoesNotExistError): diff --git a/api/tests/opentrons/protocol_engine/resources/test_deck_data_provider.py b/api/tests/opentrons/protocol_engine/resources/test_deck_data_provider.py index f587d7ce5dd..bd720777ed6 100644 --- a/api/tests/opentrons/protocol_engine/resources/test_deck_data_provider.py +++ b/api/tests/opentrons/protocol_engine/resources/test_deck_data_provider.py @@ -3,7 +3,7 @@ from pytest_lazyfixture import lazy_fixture # type: ignore[import-untyped] from decoy import Decoy -from opentrons_shared_data.deck.dev_types import DeckDefinitionV4 +from opentrons_shared_data.deck.dev_types import DeckDefinitionV5 from opentrons.protocols.models import LabwareDefinition from opentrons.types import DeckSlotName @@ -31,7 +31,7 @@ def mock_labware_data_provider(decoy: Decoy) -> LabwareDataProvider: ) async def test_get_deck_definition( deck_type: DeckType, - expected_definition: DeckDefinitionV4, + expected_definition: DeckDefinitionV5, mock_labware_data_provider: LabwareDataProvider, ) -> None: """It should be able to load the correct deck definition.""" @@ -44,7 +44,7 @@ async def test_get_deck_definition( async def test_get_deck_labware_fixtures_ot2_standard( decoy: Decoy, - ot2_standard_deck_def: DeckDefinitionV4, + ot2_standard_deck_def: DeckDefinitionV5, ot2_fixed_trash_def: LabwareDefinition, mock_labware_data_provider: LabwareDataProvider, ) -> None: @@ -74,7 +74,7 @@ async def test_get_deck_labware_fixtures_ot2_standard( async def test_get_deck_labware_fixtures_ot2_short_trash( decoy: Decoy, - ot2_short_trash_deck_def: DeckDefinitionV4, + ot2_short_trash_deck_def: DeckDefinitionV5, ot2_short_fixed_trash_def: LabwareDefinition, mock_labware_data_provider: LabwareDataProvider, ) -> None: @@ -104,7 +104,7 @@ async def test_get_deck_labware_fixtures_ot2_short_trash( async def test_get_deck_labware_fixtures_ot3_standard( decoy: Decoy, - ot3_standard_deck_def: DeckDefinitionV4, + ot3_standard_deck_def: DeckDefinitionV5, ot3_fixed_trash_def: LabwareDefinition, mock_labware_data_provider: LabwareDataProvider, ) -> None: diff --git a/api/tests/opentrons/protocol_engine/state/command_fixtures.py b/api/tests/opentrons/protocol_engine/state/command_fixtures.py index 191dd49bd48..b8b47648b3a 100644 --- a/api/tests/opentrons/protocol_engine/state/command_fixtures.py +++ b/api/tests/opentrons/protocol_engine/state/command_fixtures.py @@ -24,6 +24,7 @@ def create_queued_command( command_id: str = "command-id", command_key: str = "command-key", command_type: str = "command-type", + intent: cmd.CommandIntent = cmd.CommandIntent.PROTOCOL, params: Optional[BaseModel] = None, ) -> cmd.Command: """Given command data, build a pending command model.""" @@ -36,6 +37,7 @@ def create_queued_command( createdAt=datetime(year=2021, month=1, day=1), status=cmd.CommandStatus.QUEUED, params=params or BaseModel(), + intent=intent, ), ) diff --git a/api/tests/opentrons/protocol_engine/state/test_addressable_area_store.py b/api/tests/opentrons/protocol_engine/state/test_addressable_area_store.py index 63e9cea2925..8a79d31ce92 100644 --- a/api/tests/opentrons/protocol_engine/state/test_addressable_area_store.py +++ b/api/tests/opentrons/protocol_engine/state/test_addressable_area_store.py @@ -1,7 +1,7 @@ """Addressable area state store tests.""" import pytest -from opentrons_shared_data.deck.dev_types import DeckDefinitionV4 +from opentrons_shared_data.deck.dev_types import DeckDefinitionV5 from opentrons_shared_data.labware.labware_definition import Parameters from opentrons.protocols.models import LabwareDefinition from opentrons.types import DeckSlotName @@ -35,24 +35,24 @@ def _make_deck_config() -> DeckConfigurationType: return [ - ("cutoutA1", "singleLeftSlot"), - ("cutoutB1", "singleLeftSlot"), - ("cutoutC1", "singleLeftSlot"), - ("cutoutD1", "singleLeftSlot"), - ("cutoutA2", "singleCenterSlot"), - ("cutoutB2", "singleCenterSlot"), - ("cutoutC2", "singleCenterSlot"), - ("cutoutD2", "singleCenterSlot"), - ("cutoutA3", "trashBinAdapter"), - ("cutoutB3", "singleRightSlot"), - ("cutoutC3", "stagingAreaRightSlot"), - ("cutoutD3", "wasteChuteRightAdapterNoCover"), + ("cutoutA1", "singleLeftSlot", None), + ("cutoutB1", "singleLeftSlot", None), + ("cutoutC1", "singleLeftSlot", None), + ("cutoutD1", "singleLeftSlot", None), + ("cutoutA2", "singleCenterSlot", None), + ("cutoutB2", "singleCenterSlot", None), + ("cutoutC2", "singleCenterSlot", None), + ("cutoutD2", "singleCenterSlot", None), + ("cutoutA3", "trashBinAdapter", None), + ("cutoutB3", "singleRightSlot", None), + ("cutoutC3", "stagingAreaRightSlot", None), + ("cutoutD3", "wasteChuteRightAdapterNoCover", None), ] @pytest.fixture def simulated_subject( - ot3_standard_deck_def: DeckDefinitionV4, + ot3_standard_deck_def: DeckDefinitionV5, ) -> AddressableAreaStore: """Get an AddressableAreaStore test subject, under simulated deck conditions.""" return AddressableAreaStore( @@ -68,7 +68,7 @@ def simulated_subject( @pytest.fixture def subject( - ot3_standard_deck_def: DeckDefinitionV4, + ot3_standard_deck_def: DeckDefinitionV5, ) -> AddressableAreaStore: """Get an AddressableAreaStore test subject.""" return AddressableAreaStore( @@ -83,7 +83,7 @@ def subject( def test_initial_state_simulated( - ot3_standard_deck_def: DeckDefinitionV4, + ot3_standard_deck_def: DeckDefinitionV5, simulated_subject: AddressableAreaStore, ) -> None: """It should create the Addressable Area store with no loaded addressable areas.""" @@ -98,7 +98,7 @@ def test_initial_state_simulated( def test_initial_state( - ot3_standard_deck_def: DeckDefinitionV4, + ot3_standard_deck_def: DeckDefinitionV5, subject: AddressableAreaStore, ) -> None: """It should create the Addressable Area store with loaded addressable areas.""" diff --git a/api/tests/opentrons/protocol_engine/state/test_addressable_area_view.py b/api/tests/opentrons/protocol_engine/state/test_addressable_area_view.py index 34ddcaa37fa..e903c59a45d 100644 --- a/api/tests/opentrons/protocol_engine/state/test_addressable_area_view.py +++ b/api/tests/opentrons/protocol_engine/state/test_addressable_area_view.py @@ -6,7 +6,7 @@ from typing import Dict, Set, Optional, cast from opentrons_shared_data.robot.dev_types import RobotType -from opentrons_shared_data.deck.dev_types import DeckDefinitionV4 +from opentrons_shared_data.deck.dev_types import DeckDefinitionV5 from opentrons.types import Point, DeckSlotName from opentrons.protocol_engine.errors import ( @@ -47,7 +47,7 @@ def get_addressable_area_view( potential_cutout_fixtures_by_cutout_id: Optional[ Dict[str, Set[PotentialCutoutFixture]] ] = None, - deck_definition: Optional[DeckDefinitionV4] = None, + deck_definition: Optional[DeckDefinitionV5] = None, deck_configuration: Optional[DeckConfigurationType] = None, robot_type: RobotType = "OT-3 Standard", use_simulated_deck_config: bool = False, @@ -57,7 +57,7 @@ def get_addressable_area_view( loaded_addressable_areas_by_name=loaded_addressable_areas_by_name or {}, potential_cutout_fixtures_by_cutout_id=potential_cutout_fixtures_by_cutout_id or {}, - deck_definition=deck_definition or cast(DeckDefinitionV4, {"otId": "fake"}), + deck_definition=deck_definition or cast(DeckDefinitionV5, {"otId": "fake"}), deck_configuration=deck_configuration or [], robot_type=robot_type, use_simulated_deck_config=use_simulated_deck_config, @@ -79,8 +79,8 @@ def test_get_all_cutout_fixtures_non_simulated_deck_config() -> None: """It should return the cutout fixtures from the deck config, if it's not simulated.""" subject = get_addressable_area_view( deck_configuration=[ - ("cutout-id-1", "cutout-fixture-id-1"), - ("cutout-id-2", "cutout-fixture-id-2"), + ("cutout-id-1", "cutout-fixture-id-1", None), + ("cutout-id-2", "cutout-fixture-id-2", None), ], use_simulated_deck_config=False, ) @@ -309,6 +309,8 @@ def test_get_fixture_height(decoy: Decoy) -> None: "height": 10, # These values don't matter: "id": "id", + "expectOpentronsModuleSerialNumber": False, + "fixtureGroup": {}, "mayMountTo": [], "displayName": "", "providesAddressableAreas": {}, @@ -324,6 +326,8 @@ def test_get_fixture_height(decoy: Decoy) -> None: "height": 9000.1, # These values don't matter: "id": "id", + "expectOpentronsModuleSerialNumber": False, + "fixtureGroup": {}, "mayMountTo": [], "displayName": "", "providesAddressableAreas": {}, diff --git a/api/tests/opentrons/protocol_engine/state/test_command_history.py b/api/tests/opentrons/protocol_engine/state/test_command_history.py index c6344141281..3c84b86e07f 100644 --- a/api/tests/opentrons/protocol_engine/state/test_command_history.py +++ b/api/tests/opentrons/protocol_engine/state/test_command_history.py @@ -5,6 +5,7 @@ from opentrons.protocol_engine.errors.exceptions import CommandDoesNotExistError from opentrons.protocol_engine.state.command_history import CommandHistory, CommandEntry +from opentrons.protocol_engine.commands import CommandIntent, CommandStatus from .command_fixtures import ( create_queued_command, @@ -18,6 +19,15 @@ def create_queued_command_entry( return CommandEntry(create_queued_command(command_id=command_id), index) +def create_fixit_command_entry( + command_id: str = "command-id", index: int = 0 +) -> CommandEntry: + """Create a command entry for a fixit command.""" + return CommandEntry( + create_queued_command(command_id=command_id, intent=CommandIntent.FIXIT), index + ) + + @pytest.fixture def command_history() -> CommandHistory: """Instantiates a CommandHistory instance.""" @@ -161,6 +171,14 @@ def test_get_setup_queue_ids(command_history: CommandHistory) -> None: assert command_history.get_setup_queue_ids() == OrderedSet(["0", "1"]) +def test_get_fixit_queue_ids(command_history: CommandHistory) -> None: + """It should return the IDs of all commands in the setup queue.""" + assert command_history.get_fixit_queue_ids() == OrderedSet() + command_history._add_to_fixit_queue("0") + command_history._add_to_fixit_queue("1") + assert command_history.get_fixit_queue_ids() == OrderedSet(["0", "1"]) + + def test_set_command_entry(command_history: CommandHistory) -> None: """It should set the command entry for the given ID.""" command_entry = create_queued_command_entry() @@ -184,6 +202,41 @@ def test_set_running_command_id(command_history: CommandHistory) -> None: assert command_history.get_running_command() == command_entry +def test_set_fixit_running_command_id(command_history: CommandHistory) -> None: + """It should set the ID of the currently running fixit command.""" + command_entry = create_queued_command() + command_history.set_command_queued(command_entry) + running_command = command_entry.copy( + update={ + "status": CommandStatus.RUNNING, + } + ) + command_history.set_command_running(running_command) + finished_command = command_entry.copy( + update={ + "status": CommandStatus.SUCCEEDED, + } + ) + command_history.set_command_succeeded(finished_command) + fixit_command_entry = create_queued_command( + command_id="fixit-id", intent=CommandIntent.FIXIT + ) + command_history.set_command_queued(fixit_command_entry) + fixit_running_command = fixit_command_entry.copy( + update={ + "status": CommandStatus.RUNNING, + } + ) + command_history.set_command_running(fixit_running_command) + current_running_command = command_history.get_running_command() + assert current_running_command is not None + assert current_running_command.command == fixit_running_command + assert command_history.get_all_commands() == [ + finished_command, + fixit_running_command, + ] + + def test_add_to_queue(command_history: CommandHistory) -> None: """It should add the given ID to the queue.""" command_history._add_to_queue("0") @@ -196,6 +249,13 @@ def test_add_to_setup_queue(command_history: CommandHistory) -> None: assert command_history.get_setup_queue_ids() == OrderedSet(["0"]) +def test_add_to_fixit_queue(command_history: CommandHistory) -> None: + """It should add the given ID to the setup queue.""" + fixit_command = create_queued_command(intent=CommandIntent.FIXIT) + command_history.set_command_queued(fixit_command) + assert command_history.get_fixit_queue_ids() == OrderedSet(["command-id"]) + + def test_clear_queue(command_history: CommandHistory) -> None: """It should clear all commands in the queue.""" command_history._add_to_queue("0") @@ -212,6 +272,19 @@ def test_clear_setup_queue(command_history: CommandHistory) -> None: assert command_history.get_setup_queue_ids() == OrderedSet() +def test_clear_fixit_queue(command_history: CommandHistory) -> None: + """It should clear all commands in the setup queue.""" + command_history.set_command_queued( + create_queued_command(command_id="0", intent=CommandIntent.FIXIT) + ) + command_history.set_command_queued( + create_queued_command(command_id="1", intent=CommandIntent.FIXIT) + ) + assert command_history.get_fixit_queue_ids() == OrderedSet(["0", "1"]) + command_history.clear_fixit_queue() + assert command_history.get_fixit_queue_ids() == OrderedSet() + + def test_remove_id_from_queue(command_history: CommandHistory) -> None: """It should remove the given ID from the queue.""" command_history._add_to_queue("0") diff --git a/api/tests/opentrons/protocol_engine/state/test_command_state.py b/api/tests/opentrons/protocol_engine/state/test_command_state.py new file mode 100644 index 00000000000..742abf3e6e9 --- /dev/null +++ b/api/tests/opentrons/protocol_engine/state/test_command_state.py @@ -0,0 +1,471 @@ +"""Tests for the CommandStore+CommandState+CommandView trifecta. + +The trifecta is tested here as a single unit, treating CommandState as a private +implementation detail. +""" + +from datetime import datetime +from unittest.mock import sentinel + +import pytest + +from opentrons_shared_data.errors import ErrorCodes, PythonException + +from opentrons.ordered_set import OrderedSet +from opentrons.protocol_engine import actions, commands, errors +from opentrons.protocol_engine.error_recovery_policy import ErrorRecoveryType +from opentrons.protocol_engine.errors.error_occurrence import ErrorOccurrence +from opentrons.protocol_engine.errors.exceptions import EStopActivatedError +from opentrons.protocol_engine.notes.notes import CommandNote +from opentrons.protocol_engine.state.commands import ( + CommandStore, + CommandView, +) +from opentrons.protocol_engine.state.config import Config +from opentrons.protocol_engine.types import DeckType, EngineStatus + + +def _make_config() -> Config: + return Config( + # Choice of robot and deck type is arbitrary. + robot_type="OT-2 Standard", + deck_type=DeckType.OT2_STANDARD, + ) + + +@pytest.mark.parametrize("error_recovery_type", ErrorRecoveryType) +def test_command_failure(error_recovery_type: ErrorRecoveryType) -> None: + """It should store an error and mark the command if it fails.""" + subject = CommandStore(is_door_open=False, config=_make_config()) + subject_view = CommandView(subject.state) + + command_id = "command-id" + command_key = "command-key" + created_at = datetime(year=2021, month=1, day=1) + started_at = datetime(year=2022, month=2, day=2) + failed_at = datetime(year=2023, month=3, day=3) + error_id = "error-id" + notes = [ + CommandNote( + noteKind="noteKind", + shortMessage="shortMessage", + longMessage="longMessage", + source="source", + ) + ] + + params = commands.CommentParams(message="No comment.") + + subject.handle_action( + actions.QueueCommandAction( + command_id=command_id, + created_at=created_at, + request=commands.CommentCreate(params=params, key=command_key), + request_hash=None, + ) + ) + subject.handle_action( + actions.RunCommandAction(command_id=command_id, started_at=started_at) + ) + subject.handle_action( + actions.FailCommandAction( + command_id=command_id, + running_command=subject_view.get(command_id), + error_id=error_id, + failed_at=failed_at, + error=errors.ProtocolEngineError(message="oh no"), + notes=notes, + type=error_recovery_type, + ) + ) + + expected_error_occurrence = errors.ErrorOccurrence( + id=error_id, + errorType="ProtocolEngineError", + createdAt=failed_at, + detail="oh no", + errorCode=ErrorCodes.GENERAL_ERROR.value.code, + ) + expected_failed_command = commands.Comment( + id=command_id, + key=command_key, + commandType="comment", + createdAt=created_at, + startedAt=started_at, + completedAt=failed_at, + status=commands.CommandStatus.FAILED, + params=params, + result=None, + error=expected_error_occurrence, + notes=notes, + ) + + assert subject_view.get("command-id") == expected_failed_command + + +def test_command_failure_clears_queues() -> None: + """It should clear the command queue on command failure.""" + subject = CommandStore(config=_make_config(), is_door_open=False) + subject_view = CommandView(subject.state) + + queue_1 = actions.QueueCommandAction( + request=commands.WaitForResumeCreate( + params=commands.WaitForResumeParams(), key="command-key-1" + ), + request_hash=None, + created_at=datetime(year=2021, month=1, day=1), + command_id="command-id-1", + ) + subject.handle_action(queue_1) + queue_2 = actions.QueueCommandAction( + request=commands.WaitForResumeCreate( + params=commands.WaitForResumeParams(), key="command-key-2" + ), + request_hash=None, + created_at=datetime(year=2021, month=1, day=1), + command_id="command-id-2", + ) + subject.handle_action(queue_2) + + run_1 = actions.RunCommandAction( + command_id="command-id-1", + started_at=datetime(year=2022, month=2, day=2), + ) + subject.handle_action(run_1) + fail_1 = actions.FailCommandAction( + command_id="command-id-1", + running_command=subject_view.get("command-id-1"), + error_id="error-id", + failed_at=datetime(year=2023, month=3, day=3), + error=errors.ProtocolEngineError(message="oh no"), + notes=[ + CommandNote( + noteKind="noteKind", + shortMessage="shortMessage", + longMessage="longMessage", + source="source", + ) + ], + type=ErrorRecoveryType.FAIL_RUN, + ) + subject.handle_action(fail_1) + + assert [(c.id, c.status) for c in subject_view.get_all()] == [ + ("command-id-1", commands.CommandStatus.FAILED), + ("command-id-2", commands.CommandStatus.FAILED), + ] + assert subject_view.get_running_command_id() is None + assert subject_view.get_queue_ids() == OrderedSet() + assert subject_view.get_next_to_execute() is None + + +def test_setup_command_failure_only_clears_setup_command_queue() -> None: + """It should clear only the setup command queue for a failed setup command. + + This test queues up a non-setup command followed by two setup commands, + then runs and fails the first setup command. + """ + subject = CommandStore(is_door_open=False, config=_make_config()) + subject_view = CommandView(subject.state) + + queue_1 = actions.QueueCommandAction( + request=commands.WaitForResumeCreate( + params=commands.WaitForResumeParams(), key="command-key-1" + ), + request_hash=None, + created_at=datetime(year=2021, month=1, day=1), + command_id="command-id-1", + ) + subject.handle_action(queue_1) + queue_2_setup = actions.QueueCommandAction( + request=commands.WaitForResumeCreate( + params=commands.WaitForResumeParams(), + intent=commands.CommandIntent.SETUP, + key="command-key-2", + ), + request_hash=None, + created_at=datetime(year=2021, month=1, day=1), + command_id="command-id-2", + ) + subject.handle_action(queue_2_setup) + queue_3_setup = actions.QueueCommandAction( + request=commands.WaitForResumeCreate( + params=commands.WaitForResumeParams(), + intent=commands.CommandIntent.SETUP, + key="command-key-3", + ), + request_hash=None, + created_at=datetime(year=2021, month=1, day=1), + command_id="command-id-3", + ) + subject.handle_action(queue_3_setup) + + run_2_setup = actions.RunCommandAction( + command_id="command-id-2", + started_at=datetime(year=2022, month=2, day=2), + ) + subject.handle_action(run_2_setup) + fail_2_setup = actions.FailCommandAction( + command_id="command-id-2", + running_command=subject_view.get("command-id-2"), + error_id="error-id", + failed_at=datetime(year=2023, month=3, day=3), + error=errors.ProtocolEngineError(message="oh no"), + notes=[ + CommandNote( + noteKind="noteKind", + shortMessage="shortMessage", + longMessage="longMessage", + source="source", + ) + ], + type=ErrorRecoveryType.FAIL_RUN, + ) + subject.handle_action(fail_2_setup) + + assert [(c.id, c.status) for c in subject_view.get_all()] == [ + ("command-id-1", commands.CommandStatus.QUEUED), + ("command-id-2", commands.CommandStatus.FAILED), + ("command-id-3", commands.CommandStatus.FAILED), + ] + assert subject_view.get_running_command_id() is None + + subject.handle_action( + actions.PlayAction(requested_at=datetime.now(), deck_configuration=None) + ) + assert subject_view.get_next_to_execute() == "command-id-1" + + +def test_nonfatal_command_failure() -> None: + """Test the command queue if a command fails recoverably. + + Commands that were after the failed command in the queue should be left in + the queue. + + The queue status should be "awaiting-recovery." + """ + subject = CommandStore(is_door_open=False, config=_make_config()) + subject_view = CommandView(subject.state) + + queue_1 = actions.QueueCommandAction( + request=commands.WaitForResumeCreate( + params=commands.WaitForResumeParams(), key="command-key-1" + ), + request_hash=None, + created_at=datetime(year=2021, month=1, day=1), + command_id="command-id-1", + ) + subject.handle_action(queue_1) + queue_2 = actions.QueueCommandAction( + request=commands.WaitForResumeCreate( + params=commands.WaitForResumeParams(), key="command-key-2" + ), + request_hash=None, + created_at=datetime(year=2021, month=1, day=1), + command_id="command-id-2", + ) + subject.handle_action(queue_2) + + run_1 = actions.RunCommandAction( + command_id="command-id-1", + started_at=datetime(year=2022, month=2, day=2), + ) + subject.handle_action(run_1) + fail_1 = actions.FailCommandAction( + command_id="command-id-1", + running_command=subject_view.get("command-id-1"), + error_id="error-id", + failed_at=datetime(year=2023, month=3, day=3), + error=errors.ProtocolEngineError(message="oh no"), + notes=[ + CommandNote( + noteKind="noteKind", + shortMessage="shortMessage", + longMessage="longMessage", + source="source", + ) + ], + type=ErrorRecoveryType.WAIT_FOR_RECOVERY, + ) + subject.handle_action(fail_1) + + assert [(c.id, c.status) for c in subject_view.get_all()] == [ + ("command-id-1", commands.CommandStatus.FAILED), + ("command-id-2", commands.CommandStatus.QUEUED), + ] + assert subject_view.get_running_command_id() is None + + +def test_error_recovery_type_tracking() -> None: + """It should keep track of each failed command's error recovery type.""" + subject = CommandStore(config=_make_config(), is_door_open=False) + + subject.handle_action( + actions.QueueCommandAction( + command_id="c1", + created_at=datetime.now(), + request=commands.CommentCreate( + params=commands.CommentParams(message="yeehaw"), + ), + request_hash=None, + ) + ) + subject.handle_action( + actions.QueueCommandAction( + command_id="c2", + created_at=datetime.now(), + request=commands.CommentCreate( + params=commands.CommentParams(message="yeehaw"), + ), + request_hash=None, + ) + ) + subject.handle_action( + actions.RunCommandAction(command_id="c1", started_at=datetime.now()) + ) + running_command_1 = CommandView(subject.state).get("c1") + subject.handle_action( + actions.FailCommandAction( + command_id="c1", + running_command=running_command_1, + error_id="c1-error", + failed_at=datetime.now(), + error=PythonException(RuntimeError("new sheriff in town")), + notes=[], + type=ErrorRecoveryType.WAIT_FOR_RECOVERY, + ) + ) + subject.handle_action( + actions.RunCommandAction(command_id="c2", started_at=datetime.now()) + ) + running_command_2 = CommandView(subject.state).get("c2") + subject.handle_action( + actions.FailCommandAction( + command_id="c2", + running_command=running_command_2, + error_id="c2-error", + failed_at=datetime.now(), + error=PythonException(RuntimeError("new sheriff in town")), + notes=[], + type=ErrorRecoveryType.FAIL_RUN, + ) + ) + + view = CommandView(subject.state) + assert view.get_error_recovery_type("c1") == ErrorRecoveryType.WAIT_FOR_RECOVERY + assert view.get_error_recovery_type("c2") == ErrorRecoveryType.FAIL_RUN + + +def test_get_recovery_in_progress_for_command() -> None: + """It should return whether error recovery is in progress for the given command.""" + subject = CommandStore(config=_make_config(), is_door_open=False) + subject_view = CommandView(subject.state) + + queue_1 = actions.QueueCommandAction( + "c1", + created_at=datetime.now(), + request=commands.CommentCreate(params=commands.CommentParams(message="")), + request_hash=None, + ) + subject.handle_action(queue_1) + run_1 = actions.RunCommandAction(command_id="c1", started_at=datetime.now()) + subject.handle_action(run_1) + fail_1 = actions.FailCommandAction( + command_id="c1", + error_id="c1-error", + failed_at=datetime.now(), + error=PythonException(RuntimeError()), + notes=[], + type=ErrorRecoveryType.WAIT_FOR_RECOVERY, + running_command=subject_view.get("c1"), + ) + subject.handle_action(fail_1) + + # c1 failed recoverably and we're currently recovering from it. + assert subject_view.get_recovery_in_progress_for_command("c1") + + resume_from_1_recovery = actions.ResumeFromRecoveryAction() + subject.handle_action(resume_from_1_recovery) + + # c1 failed recoverably, but we've already completed its recovery. + assert not subject_view.get_recovery_in_progress_for_command("c1") + + queue_2 = actions.QueueCommandAction( + "c2", + created_at=datetime.now(), + request=commands.CommentCreate(params=commands.CommentParams(message="")), + request_hash=None, + ) + subject.handle_action(queue_2) + run_2 = actions.RunCommandAction(command_id="c2", started_at=datetime.now()) + subject.handle_action(run_2) + fail_2 = actions.FailCommandAction( + command_id="c2", + error_id="c2-error", + failed_at=datetime.now(), + error=PythonException(RuntimeError()), + notes=[], + type=ErrorRecoveryType.WAIT_FOR_RECOVERY, + running_command=subject_view.get("c2"), + ) + subject.handle_action(fail_2) + + # c2 failed recoverably and we're currently recovering from it. + assert subject_view.get_recovery_in_progress_for_command("c2") + # ...and that means we're *not* currently recovering from c1, + # even though it failed recoverably before. + assert not subject_view.get_recovery_in_progress_for_command("c1") + + resume_from_2_recovery = actions.ResumeFromRecoveryAction() + subject.handle_action(resume_from_2_recovery) + queue_3 = actions.QueueCommandAction( + "c3", + created_at=datetime.now(), + request=commands.CommentCreate(params=commands.CommentParams(message="")), + request_hash=None, + ) + subject.handle_action(queue_3) + run_3 = actions.RunCommandAction(command_id="c3", started_at=datetime.now()) + subject.handle_action(run_3) + fail_3 = actions.FailCommandAction( + command_id="c3", + error_id="c3-error", + failed_at=datetime.now(), + error=PythonException(RuntimeError()), + notes=[], + type=ErrorRecoveryType.FAIL_RUN, + running_command=subject_view.get("c3"), + ) + subject.handle_action(fail_3) + + # c3 failed, but not recoverably. + assert not subject_view.get_recovery_in_progress_for_command("c2") + + +def test_final_state_after_estop() -> None: + """Test the final state of the run after it's E-stopped.""" + subject = CommandStore(config=_make_config(), is_door_open=False) + subject_view = CommandView(subject.state) + + error_details = actions.FinishErrorDetails( + error=EStopActivatedError(), error_id="error-id", created_at=datetime.now() + ) + expected_error_occurrence = ErrorOccurrence( + id=error_details.error_id, + createdAt=error_details.created_at, + errorCode=ErrorCodes.E_STOP_ACTIVATED.value.code, + errorType="EStopActivatedError", + detail="E-stop activated.", + ) + + subject.handle_action(actions.StopAction(from_estop=True)) + subject.handle_action(actions.FinishAction(error_details=error_details)) + subject.handle_action( + actions.HardwareStoppedAction( + completed_at=sentinel.hardware_stopped_action_completed_at, + finish_error_details=None, + ) + ) + + assert subject_view.get_status() == EngineStatus.FAILED + assert subject_view.get_error() == expected_error_occurrence diff --git a/api/tests/opentrons/protocol_engine/state/test_command_store.py b/api/tests/opentrons/protocol_engine/state/test_command_store_old.py similarity index 69% rename from api/tests/opentrons/protocol_engine/state/test_command_store.py rename to api/tests/opentrons/protocol_engine/state/test_command_store_old.py index d5bfc1e963a..60cdf27838f 100644 --- a/api/tests/opentrons/protocol_engine/state/test_command_store.py +++ b/api/tests/opentrons/protocol_engine/state/test_command_store_old.py @@ -1,4 +1,10 @@ -"""Tests for the command lifecycle state.""" +"""Tests for CommandStore. + +DEPRECATED: Testing CommandStore independently of CommandView is no longer helpful. +Add new tests to test_command_state.py, where they can be tested together. +""" + + import pytest from datetime import datetime from typing import NamedTuple, Type @@ -8,12 +14,10 @@ from opentrons.ordered_set import OrderedSet from opentrons.protocol_engine.actions.actions import RunCommandAction -from opentrons.protocol_engine.notes.notes import CommandNote from opentrons.types import MountType, DeckSlotName from opentrons.hardware_control.types import DoorState from opentrons.protocol_engine import commands, errors -from opentrons.protocol_engine.error_recovery_policy import ErrorRecoveryType from opentrons.protocol_engine.types import DeckSlotLocation, DeckType, WellLocation from opentrons.protocol_engine.state import Config from opentrons.protocol_engine.state.commands import ( @@ -27,7 +31,6 @@ from opentrons.protocol_engine.actions import ( QueueCommandAction, SucceedCommandAction, - FailCommandAction, PlayAction, PauseAction, PauseSource, @@ -79,7 +82,9 @@ def test_initial_state( run_error=None, finish_error=None, failed_command=None, - latest_command_hash=None, + command_error_recovery_types={}, + recovery_target_command_id=None, + latest_protocol_command_hash=None, stopped_by_estop=False, ) @@ -249,7 +254,7 @@ def test_command_queue_with_hash() -> None: ) assert subject.state.command_history.get("command-id-1").command.key == "abc123" - assert subject.state.latest_command_hash == "abc123" + assert subject.state.latest_protocol_command_hash == "abc123" subject.handle_action( QueueCommandAction( @@ -260,7 +265,7 @@ def test_command_queue_with_hash() -> None: ) ) - assert subject.state.latest_command_hash == "def456" + assert subject.state.latest_protocol_command_hash == "def456" def test_command_queue_and_unqueue() -> None: @@ -422,321 +427,6 @@ def test_running_command_id() -> None: assert subject.state.command_history.get_running_command() is None -def test_command_failure_clears_queues() -> None: - """It should clear the command queue on command failure.""" - queue_1 = QueueCommandAction( - request=commands.WaitForResumeCreate( - params=commands.WaitForResumeParams(), key="command-key-1" - ), - request_hash=None, - created_at=datetime(year=2021, month=1, day=1), - command_id="command-id-1", - ) - queue_2 = QueueCommandAction( - request=commands.WaitForResumeCreate( - params=commands.WaitForResumeParams(), key="command-key-2" - ), - request_hash=None, - created_at=datetime(year=2021, month=1, day=1), - command_id="command-id-2", - ) - run_1 = RunCommandAction( - command_id="command-id-1", - started_at=datetime(year=2022, month=2, day=2), - ) - fail_1 = FailCommandAction( - command_id="command-id-1", - error_id="error-id", - failed_at=datetime(year=2023, month=3, day=3), - error=errors.ProtocolEngineError(message="oh no"), - notes=[ - CommandNote( - noteKind="noteKind", - shortMessage="shortMessage", - longMessage="longMessage", - source="source", - ) - ], - type=ErrorRecoveryType.FAIL_RUN, - ) - - expected_failed_1 = commands.WaitForResume( - id="command-id-1", - key="command-key-1", - error=errors.ErrorOccurrence( - id="error-id", - createdAt=datetime(year=2023, month=3, day=3), - errorCode=ErrorCodes.GENERAL_ERROR.value.code, - errorType="ProtocolEngineError", - detail="oh no", - ), - notes=[ - CommandNote( - noteKind="noteKind", - shortMessage="shortMessage", - longMessage="longMessage", - source="source", - ) - ], - createdAt=datetime(year=2021, month=1, day=1), - startedAt=datetime(year=2022, month=2, day=2), - completedAt=datetime(year=2023, month=3, day=3), - params=commands.WaitForResumeParams(), - status=commands.CommandStatus.FAILED, - ) - expected_failed_2 = commands.WaitForResume( - id="command-id-2", - key="command-key-2", - error=None, - createdAt=datetime(year=2021, month=1, day=1), - completedAt=datetime(year=2023, month=3, day=3), - params=commands.WaitForResumeParams(), - status=commands.CommandStatus.FAILED, - ) - - subject = CommandStore(is_door_open=False, config=_make_config()) - - subject.handle_action(queue_1) - subject.handle_action(queue_2) - subject.handle_action(run_1) - subject.handle_action(fail_1) - - assert subject.state.command_history.get_running_command() is None - assert subject.state.command_history.get_queue_ids() == OrderedSet() - assert subject.state.command_history.get_all_ids() == [ - "command-id-1", - "command-id-2", - ] - assert subject.state.command_history.get("command-id-1") == CommandEntry( - index=0, command=expected_failed_1 - ) - assert subject.state.command_history.get("command-id-2") == CommandEntry( - index=1, command=expected_failed_2 - ) - - -def test_setup_command_failure_only_clears_setup_command_queue() -> None: - """It should clear only the setup command queue for a failed setup command. - - This test queues up a non-setup command followed by two setup commands, - then attempts to run and fail the first setup command and - """ - cmd_1_non_setup = commands.WaitForResume( - id="command-id-1", - key="command-key-1", - createdAt=datetime(year=2021, month=1, day=1), - params=commands.WaitForResumeParams(), - status=commands.CommandStatus.QUEUED, - ) - queue_action_1_non_setup = QueueCommandAction( - request=commands.WaitForResumeCreate( - params=cmd_1_non_setup.params, key="command-key-1" - ), - request_hash=None, - created_at=datetime(year=2021, month=1, day=1), - command_id="command-id-1", - ) - queue_action_2_setup = QueueCommandAction( - request=commands.WaitForResumeCreate( - params=commands.WaitForResumeParams(), - intent=commands.CommandIntent.SETUP, - key="command-key-2", - ), - request_hash=None, - created_at=datetime(year=2021, month=1, day=1), - command_id="command-id-2", - ) - queue_action_3_setup = QueueCommandAction( - request=commands.WaitForResumeCreate( - params=commands.WaitForResumeParams(), - intent=commands.CommandIntent.SETUP, - key="command-key-3", - ), - request_hash=None, - created_at=datetime(year=2021, month=1, day=1), - command_id="command-id-3", - ) - - run_action_cmd_2 = RunCommandAction( - command_id="command-id-2", - started_at=datetime(year=2022, month=2, day=2), - ) - failed_action_cmd_2 = FailCommandAction( - command_id="command-id-2", - error_id="error-id", - failed_at=datetime(year=2023, month=3, day=3), - error=errors.ProtocolEngineError(message="oh no"), - notes=[ - CommandNote( - noteKind="noteKind", - shortMessage="shortMessage", - longMessage="longMessage", - source="source", - ) - ], - type=ErrorRecoveryType.FAIL_RUN, - ) - expected_failed_cmd_2 = commands.WaitForResume( - id="command-id-2", - key="command-key-2", - error=errors.ErrorOccurrence( - id="error-id", - createdAt=datetime(year=2023, month=3, day=3), - errorType="ProtocolEngineError", - detail="oh no", - errorCode=ErrorCodes.GENERAL_ERROR.value.code, - ), - notes=[ - CommandNote( - noteKind="noteKind", - shortMessage="shortMessage", - longMessage="longMessage", - source="source", - ) - ], - createdAt=datetime(year=2021, month=1, day=1), - startedAt=datetime(year=2022, month=2, day=2), - completedAt=datetime(year=2023, month=3, day=3), - params=commands.WaitForResumeParams(), - status=commands.CommandStatus.FAILED, - intent=commands.CommandIntent.SETUP, - ) - expected_failed_cmd_3 = commands.WaitForResume( - id="command-id-3", - key="command-key-3", - error=None, - createdAt=datetime(year=2021, month=1, day=1), - completedAt=datetime(year=2023, month=3, day=3), - params=commands.WaitForResumeParams(), - status=commands.CommandStatus.FAILED, - intent=commands.CommandIntent.SETUP, - ) - - subject = CommandStore(is_door_open=False, config=_make_config()) - - subject.handle_action(queue_action_1_non_setup) - subject.handle_action(queue_action_2_setup) - subject.handle_action(queue_action_3_setup) - subject.handle_action(run_action_cmd_2) - subject.handle_action(failed_action_cmd_2) - - assert subject.state.command_history.get_running_command() is None - assert subject.state.command_history.get_setup_queue_ids() == OrderedSet() - assert subject.state.command_history.get_queue_ids() == OrderedSet(["command-id-1"]) - assert subject.state.command_history.get_all_ids() == [ - "command-id-1", - "command-id-2", - "command-id-3", - ] - assert subject.state.command_history.get("command-id-1") == CommandEntry( - index=0, command=cmd_1_non_setup - ) - assert subject.state.command_history.get("command-id-2") == CommandEntry( - index=1, command=expected_failed_cmd_2 - ) - assert subject.state.command_history.get("command-id-3") == CommandEntry( - index=2, command=expected_failed_cmd_3 - ) - - -def test_nonfatal_command_failure() -> None: - """Test the command queue if a command fails recoverably. - - Commands that were after the failed command in the queue should be left in - the queue. - """ - queue_1 = QueueCommandAction( - request=commands.WaitForResumeCreate( - params=commands.WaitForResumeParams(), key="command-key-1" - ), - request_hash=None, - created_at=datetime(year=2021, month=1, day=1), - command_id="command-id-1", - ) - queue_2 = QueueCommandAction( - request=commands.WaitForResumeCreate( - params=commands.WaitForResumeParams(), key="command-key-2" - ), - request_hash=None, - created_at=datetime(year=2021, month=1, day=1), - command_id="command-id-2", - ) - run_1 = RunCommandAction( - command_id="command-id-1", - started_at=datetime(year=2022, month=2, day=2), - ) - fail_1 = FailCommandAction( - command_id="command-id-1", - error_id="error-id", - failed_at=datetime(year=2023, month=3, day=3), - error=errors.ProtocolEngineError(message="oh no"), - notes=[ - CommandNote( - noteKind="noteKind", - shortMessage="shortMessage", - longMessage="longMessage", - source="source", - ) - ], - type=ErrorRecoveryType.WAIT_FOR_RECOVERY, - ) - - expected_failed_1 = commands.WaitForResume( - id="command-id-1", - key="command-key-1", - error=errors.ErrorOccurrence( - id="error-id", - createdAt=datetime(year=2023, month=3, day=3), - errorCode=ErrorCodes.GENERAL_ERROR.value.code, - errorType="ProtocolEngineError", - detail="oh no", - ), - notes=[ - CommandNote( - noteKind="noteKind", - shortMessage="shortMessage", - longMessage="longMessage", - source="source", - ) - ], - createdAt=datetime(year=2021, month=1, day=1), - startedAt=datetime(year=2022, month=2, day=2), - completedAt=datetime(year=2023, month=3, day=3), - params=commands.WaitForResumeParams(), - status=commands.CommandStatus.FAILED, - ) - expected_queued_2 = commands.WaitForResume( - id="command-id-2", - key="command-key-2", - error=None, - createdAt=datetime(year=2021, month=1, day=1), - startedAt=None, - completedAt=None, - params=commands.WaitForResumeParams(), - status=commands.CommandStatus.QUEUED, - ) - - subject = CommandStore(is_door_open=False, config=_make_config()) - - subject.handle_action(queue_1) - subject.handle_action(queue_2) - subject.handle_action(run_1) - subject.handle_action(fail_1) - - assert subject.state.command_history.get_running_command() is None - assert subject.state.command_history.get_queue_ids() == OrderedSet(["command-id-2"]) - assert subject.state.command_history.get_all_ids() == [ - "command-id-1", - "command-id-2", - ] - assert subject.state.command_history.get("command-id-1") == CommandEntry( - index=0, command=expected_failed_1 - ) - assert subject.state.command_history.get("command-id-2") == CommandEntry( - index=1, command=expected_queued_2 - ) - - def test_command_store_keeps_commands_in_queue_order() -> None: """It should keep commands in the order they were originally enqueued.""" command_create_1_non_setup = commands.CommentCreate( @@ -826,7 +516,9 @@ def test_command_store_handles_pause_action(pause_source: PauseSource) -> None: run_error=None, finish_error=None, failed_command=None, - latest_command_hash=None, + command_error_recovery_types={}, + recovery_target_command_id=None, + latest_protocol_command_hash=None, stopped_by_estop=False, ) @@ -850,8 +542,10 @@ def test_command_store_handles_play_action(pause_source: PauseSource) -> None: run_error=None, finish_error=None, failed_command=None, + command_error_recovery_types={}, + recovery_target_command_id=None, run_started_at=datetime(year=2021, month=1, day=1), - latest_command_hash=None, + latest_protocol_command_hash=None, stopped_by_estop=False, ) assert subject.state.command_history.get_running_command() is None @@ -880,8 +574,10 @@ def test_command_store_handles_finish_action() -> None: run_error=None, finish_error=None, failed_command=None, + command_error_recovery_types={}, + recovery_target_command_id=None, run_started_at=datetime(year=2021, month=1, day=1), - latest_command_hash=None, + latest_protocol_command_hash=None, stopped_by_estop=False, ) assert subject.state.command_history.get_running_command() is None @@ -904,8 +600,13 @@ def test_command_store_handles_finish_action_with_stopped() -> None: assert subject.state.run_result == RunResult.STOPPED -@pytest.mark.parametrize("from_estop", [True, False]) -def test_command_store_handles_stop_action(from_estop: bool) -> None: +@pytest.mark.parametrize( + ["from_estop", "expected_run_result"], + [(True, RunResult.FAILED), (False, RunResult.STOPPED)], +) +def test_command_store_handles_stop_action( + from_estop: bool, expected_run_result: RunResult +) -> None: """It should mark the engine as non-gracefully stopped on StopAction.""" subject = CommandStore(is_door_open=False, config=_make_config()) @@ -919,14 +620,16 @@ def test_command_store_handles_stop_action(from_estop: bool) -> None: assert subject.state == CommandState( command_history=CommandHistory(), queue_status=QueueStatus.PAUSED, - run_result=RunResult.STOPPED, + run_result=expected_run_result, run_completed_at=None, is_door_blocking=False, run_error=None, finish_error=None, failed_command=None, + command_error_recovery_types={}, + recovery_target_command_id=None, run_started_at=datetime(year=2021, month=1, day=1), - latest_command_hash=None, + latest_protocol_command_hash=None, stopped_by_estop=from_estop, ) assert subject.state.command_history.get_running_command() is None @@ -954,8 +657,10 @@ def test_command_store_cannot_restart_after_should_stop() -> None: run_error=None, finish_error=None, failed_command=None, + command_error_recovery_types={}, + recovery_target_command_id=None, run_started_at=None, - latest_command_hash=None, + latest_protocol_command_hash=None, stopped_by_estop=False, ) assert subject.state.command_history.get_running_command() is None @@ -1085,7 +790,9 @@ def test_command_store_wraps_unknown_errors() -> None: ), run_started_at=None, failed_command=None, - latest_command_hash=None, + command_error_recovery_types={}, + recovery_target_command_id=None, + latest_protocol_command_hash=None, stopped_by_estop=False, ) assert subject.state.command_history.get_running_command() is None @@ -1145,8 +852,10 @@ def __init__(self, message: str) -> None: errorCode=ErrorCodes.PIPETTE_NOT_PRESENT.value.code, ), failed_command=None, + command_error_recovery_types={}, + recovery_target_command_id=None, run_started_at=None, - latest_command_hash=None, + latest_protocol_command_hash=None, stopped_by_estop=False, ) assert subject.state.command_history.get_running_command() is None @@ -1176,8 +885,10 @@ def test_command_store_ignores_stop_after_graceful_finish() -> None: run_error=None, finish_error=None, failed_command=None, + command_error_recovery_types={}, + recovery_target_command_id=None, run_started_at=datetime(year=2021, month=1, day=1), - latest_command_hash=None, + latest_protocol_command_hash=None, stopped_by_estop=False, ) assert subject.state.command_history.get_running_command() is None @@ -1207,8 +918,10 @@ def test_command_store_ignores_finish_after_non_graceful_stop() -> None: run_error=None, finish_error=None, failed_command=None, + command_error_recovery_types={}, + recovery_target_command_id=None, run_started_at=datetime(year=2021, month=1, day=1), - latest_command_hash=None, + latest_protocol_command_hash=None, stopped_by_estop=False, ) assert subject.state.command_history.get_running_command() is None @@ -1217,99 +930,6 @@ def test_command_store_ignores_finish_after_non_graceful_stop() -> None: assert subject.state.command_history.get_setup_queue_ids() == OrderedSet() -def test_command_store_handles_command_failed() -> None: - """It should store an error and mark the command if it fails.""" - expected_error_occurrence = errors.ErrorOccurrence( - id="error-id", - errorType="ProtocolEngineError", - createdAt=datetime(year=2023, month=3, day=3), - detail="oh no", - errorCode=ErrorCodes.GENERAL_ERROR.value.code, - ) - - expected_failed_command = commands.Comment( - id="command-id", - commandType="comment", - key="command-key", - createdAt=datetime(year=2021, month=1, day=1), - startedAt=datetime(year=2022, month=2, day=2), - completedAt=expected_error_occurrence.createdAt, - status=commands.CommandStatus.FAILED, - params=commands.CommentParams(message="hello, world"), - result=None, - error=expected_error_occurrence, - notes=[ - CommandNote( - noteKind="noteKind", - shortMessage="shortMessage", - longMessage="longMessage", - source="source", - ) - ], - ) - - subject = CommandStore(is_door_open=False, config=_make_config()) - - subject.handle_action( - QueueCommandAction( - command_id=expected_failed_command.id, - created_at=expected_failed_command.createdAt, - request=commands.CommentCreate( - params=expected_failed_command.params, key=expected_failed_command.key - ), - request_hash=None, - ) - ) - subject.handle_action( - RunCommandAction( - command_id=expected_failed_command.id, - # Ignore arg-type errors because we know this isn't None. - started_at=expected_failed_command.startedAt, # type: ignore[arg-type] - ) - ) - subject.handle_action( - FailCommandAction( - command_id=expected_failed_command.id, - error_id=expected_error_occurrence.id, - failed_at=expected_error_occurrence.createdAt, - error=errors.ProtocolEngineError(message="oh no"), - notes=[ - CommandNote( - noteKind="noteKind", - shortMessage="shortMessage", - longMessage="longMessage", - source="source", - ) - ], - type=ErrorRecoveryType.FAIL_RUN, - ) - ) - - failed_command_entry = CommandEntry(index=0, command=expected_failed_command) - command_history = CommandHistory() - command_history._add("command-id", failed_command_entry) - command_history._set_terminal_command_id("command-id") - - assert subject.state == CommandState( - command_history=command_history, - queue_status=QueueStatus.SETUP, - run_result=None, - run_completed_at=None, - is_door_blocking=False, - run_error=None, - finish_error=None, - failed_command=failed_command_entry, - run_started_at=None, - latest_command_hash=None, - stopped_by_estop=False, - ) - assert subject.state.command_history.get_running_command() is None - assert subject.state.command_history.get_all_ids() == ["command-id"] - assert subject.state.command_history.get_queue_ids() == OrderedSet() - assert subject.state.command_history.get_setup_queue_ids() == OrderedSet() - assert subject.state.command_history.get("command-id") == failed_command_entry - - def test_handles_hardware_stopped() -> None: """It should mark the hardware as stopped on HardwareStoppedAction.""" subject = CommandStore(is_door_open=False, config=_make_config()) @@ -1327,8 +947,10 @@ def test_handles_hardware_stopped() -> None: run_error=None, finish_error=None, failed_command=None, + command_error_recovery_types={}, + recovery_target_command_id=None, run_started_at=None, - latest_command_hash=None, + latest_protocol_command_hash=None, stopped_by_estop=False, ) assert subject.state.command_history.get_running_command() is None diff --git a/api/tests/opentrons/protocol_engine/state/test_command_view.py b/api/tests/opentrons/protocol_engine/state/test_command_view_old.py similarity index 87% rename from api/tests/opentrons/protocol_engine/state/test_command_view.py rename to api/tests/opentrons/protocol_engine/state/test_command_view_old.py index 047230d4f6d..19a2515a3e6 100644 --- a/api/tests/opentrons/protocol_engine/state/test_command_view.py +++ b/api/tests/opentrons/protocol_engine/state/test_command_view_old.py @@ -1,8 +1,14 @@ -"""Labware state store tests.""" +"""Tests for CommandView. + +DEPRECATED: Testing CommandView independently of CommandStore is no longer helpful. +Add new tests to test_command_state.py, where they can be tested together. +""" + + import pytest from contextlib import nullcontext as does_not_raise from datetime import datetime -from typing import List, NamedTuple, Optional, Sequence, Type, Union +from typing import Dict, List, NamedTuple, Optional, Sequence, Type, Union from opentrons.protocol_engine import EngineStatus, commands as cmd, errors from opentrons.protocol_engine.actions import ( @@ -14,6 +20,7 @@ ) from opentrons.protocol_engine.actions.actions import ResumeFromRecoveryAction +from opentrons.protocol_engine.error_recovery_policy import ErrorRecoveryType from opentrons.protocol_engine.state.commands import ( CommandState, CommandView, @@ -39,7 +46,7 @@ ) -def get_command_view( +def get_command_view( # noqa: C901 queue_status: QueueStatus = QueueStatus.SETUP, run_completed_at: Optional[datetime] = None, run_started_at: Optional[datetime] = None, @@ -48,8 +55,11 @@ def get_command_view( running_command_id: Optional[str] = None, queued_command_ids: Sequence[str] = (), queued_setup_command_ids: Sequence[str] = (), + queued_fixit_command_ids: Sequence[str] = (), run_error: Optional[errors.ErrorOccurrence] = None, failed_command: Optional[CommandEntry] = None, + command_error_recovery_types: Optional[Dict[str, ErrorRecoveryType]] = None, + recovery_target_command_id: Optional[str] = None, finish_error: Optional[errors.ErrorOccurrence] = None, commands: Sequence[cmd.Command] = (), latest_command_hash: Optional[str] = None, @@ -65,6 +75,9 @@ def get_command_view( if queued_setup_command_ids: for command_id in queued_setup_command_ids: command_history._add_to_setup_queue(command_id) + if queued_fixit_command_ids: + for command_id in queued_fixit_command_ids: + command_history._add_to_fixit_queue(command_id) if commands: for index, command in enumerate(commands): command_history._add( @@ -81,8 +94,10 @@ def get_command_view( run_error=run_error, finish_error=finish_error, failed_command=failed_command, + command_error_recovery_types=command_error_recovery_types or {}, + recovery_target_command_id=recovery_target_command_id, run_started_at=run_started_at, - latest_command_hash=latest_command_hash, + latest_protocol_command_hash=latest_command_hash, stopped_by_estop=False, ) @@ -122,6 +137,7 @@ def test_get_next_to_execute_returns_first_queued() -> None: subject = get_command_view( queue_status=QueueStatus.RUNNING, queued_command_ids=["command-id-1", "command-id-2"], + queued_fixit_command_ids=["fixit-id-1", "fixit-id-2"], ) assert subject.get_next_to_execute() == "command-id-1" @@ -144,6 +160,24 @@ def test_get_next_to_execute_prioritizes_setup_command_queue( assert subject.get_next_to_execute() == "setup-command-id" +@pytest.mark.parametrize( + "queue_status", + [QueueStatus.AWAITING_RECOVERY], +) +def test_get_next_to_execute_prioritizes_fixit_command_queue( + queue_status: QueueStatus, +) -> None: + """It should prioritize fixit command queue over protocol command queue.""" + subject = get_command_view( + queue_status=queue_status, + queued_command_ids=["command-id-1", "command-id-2"], + queued_setup_command_ids=["setup-command-id"], + queued_fixit_command_ids=["fixit-1", "fixit-2"], + ) + + assert subject.get_next_to_execute() == "fixit-1" + + def test_get_next_to_execute_returns_none_when_no_queued() -> None: """It should return None if there are no queued commands.""" subject = get_command_view( @@ -175,6 +209,20 @@ def test_get_next_to_execute_returns_no_commands_if_paused() -> None: queue_status=QueueStatus.PAUSED, queued_setup_command_ids=["setup-id-1", "setup-id-2"], queued_command_ids=["command-id-1", "command-id-2"], + queued_fixit_command_ids=["fixit-id-1", "fixit-id-2"], + ) + result = subject.get_next_to_execute() + + assert result is None + + +def test_get_next_to_execute_returns_no_commands_if_awaiting_recovery_no_fixit() -> None: + """It should not return any type of command if the engine is awaiting-recovery.""" + subject = get_command_view( + queue_status=QueueStatus.AWAITING_RECOVERY, + queued_setup_command_ids=["setup-id-1", "setup-id-2"], + queued_command_ids=["command-id-1", "command-id-2"], + queued_fixit_command_ids=[], ) result = subject.get_next_to_execute() @@ -475,12 +523,69 @@ class ActionAllowedSpec(NamedTuple): ), expected_error=errors.SetupCommandNotAllowedError, ), - # Resuming from error recovery is not implemented yet. - # https://opentrons.atlassian.net/browse/EXEC-301 + # fixit command is disallowed if not in recovery mode ActionAllowedSpec( - subject=get_command_view(), + subject=get_command_view(queue_status=QueueStatus.RUNNING), + action=QueueCommandAction( + request=cmd.HomeCreate( + params=cmd.HomeParams(), + intent=cmd.CommandIntent.FIXIT, + ), + request_hash=None, + command_id="command-id", + created_at=datetime(year=2021, month=1, day=1), + ), + expected_error=errors.FixitCommandNotAllowedError, + ), + ActionAllowedSpec( + subject=get_command_view( + queue_status=QueueStatus.AWAITING_RECOVERY, + failed_command=CommandEntry( + index=2, + command=create_failed_command( + command_id="command-id-3", + error=ErrorOccurrence( + id="error-id", + errorType="ProtocolEngineError", + createdAt=datetime(year=2022, month=2, day=2), + detail="oh no", + errorCode=ErrorCodes.GENERAL_ERROR.value.code, + ), + ), + ), + ), + action=QueueCommandAction( + request=cmd.HomeCreate( + params=cmd.HomeParams(), + intent=cmd.CommandIntent.FIXIT, + ), + request_hash=None, + command_id="command-id", + created_at=datetime(year=2021, month=1, day=1), + ), + expected_error=None, + ), + # resume from recovery not allowed if fixit commands in queue + ActionAllowedSpec( + subject=get_command_view( + queue_status=QueueStatus.AWAITING_RECOVERY, + queued_fixit_command_ids=["fixit-id-1", "fixit-id-2"], + failed_command=CommandEntry( + index=2, + command=create_failed_command( + command_id="command-id-3", + error=ErrorOccurrence( + id="error-id", + errorType="ProtocolEngineError", + createdAt=datetime(year=2022, month=2, day=2), + detail="oh no", + errorCode=ErrorCodes.GENERAL_ERROR.value.code, + ), + ), + ), + ), action=ResumeFromRecoveryAction(), - expected_error=NotImplementedError, + expected_error=errors.ResumeFromRecoveryNotAllowedError, ), ] @@ -920,4 +1025,4 @@ def test_get_slice_default_cursor_queued() -> None: def test_get_latest_command_hash() -> None: """It should get the latest command hash from state, if set.""" subject = get_command_view(latest_command_hash="abc123") - assert subject.get_latest_command_hash() == "abc123" + assert subject.get_latest_protocol_command_hash() == "abc123" diff --git a/api/tests/opentrons/protocol_engine/state/test_geometry_view.py b/api/tests/opentrons/protocol_engine/state/test_geometry_view.py index 731bcfb9a0e..a390036bdcf 100644 --- a/api/tests/opentrons/protocol_engine/state/test_geometry_view.py +++ b/api/tests/opentrons/protocol_engine/state/test_geometry_view.py @@ -3,9 +3,10 @@ import pytest from decoy import Decoy -from typing import cast, List, Tuple, Optional, NamedTuple +from typing import cast, List, Tuple, Optional, NamedTuple, Dict, Set -from opentrons_shared_data.deck.dev_types import DeckDefinitionV4 +from opentrons_shared_data.deck.dev_types import DeckDefinitionV5 +from opentrons_shared_data.robot.dev_types import RobotType from opentrons_shared_data.labware.dev_types import LabwareUri from opentrons_shared_data.pipette import pipette_definition from opentrons.calibration_storage.helpers import uri_from_details @@ -26,6 +27,7 @@ ModuleLocation, OnLabwareLocation, AddressableAreaLocation, + AddressableArea, ModuleOffsetVector, ModuleOffsetData, LoadedLabware, @@ -45,6 +47,8 @@ LabwareMovementOffsetData, LoadedPipette, TipGeometry, + PotentialCutoutFixture, + DeckConfigurationType, ) from opentrons.protocol_engine.state import move_types from opentrons.protocol_engine.state.config import Config @@ -56,7 +60,10 @@ BoundingNozzlesOffsets, PipetteBoundingBoxOffsets, ) -from opentrons.protocol_engine.state.addressable_areas import AddressableAreaView +from opentrons.protocol_engine.state.addressable_areas import ( + AddressableAreaView, + AddressableAreaState, +) from opentrons.protocol_engine.state.geometry import GeometryView, _GripperMoveType from ..pipette_fixtures import get_default_nozzle_map @@ -112,6 +119,30 @@ def subject( ) +def get_addressable_area_view( + loaded_addressable_areas_by_name: Optional[Dict[str, AddressableArea]] = None, + potential_cutout_fixtures_by_cutout_id: Optional[ + Dict[str, Set[PotentialCutoutFixture]] + ] = None, + deck_definition: Optional[DeckDefinitionV5] = None, + deck_configuration: Optional[DeckConfigurationType] = None, + robot_type: RobotType = "OT-3 Standard", + use_simulated_deck_config: bool = False, +) -> AddressableAreaView: + """Get a labware view test subject.""" + state = AddressableAreaState( + loaded_addressable_areas_by_name=loaded_addressable_areas_by_name or {}, + potential_cutout_fixtures_by_cutout_id=potential_cutout_fixtures_by_cutout_id + or {}, + deck_definition=deck_definition or cast(DeckDefinitionV5, {"otId": "fake"}), + deck_configuration=deck_configuration or [], + robot_type=robot_type, + use_simulated_deck_config=use_simulated_deck_config, + ) + + return AddressableAreaView(state=state) + + def test_get_labware_parent_position( decoy: Decoy, labware_view: LabwareView, @@ -159,7 +190,7 @@ def test_get_labware_parent_position_on_module( labware_view: LabwareView, module_view: ModuleView, addressable_area_view: AddressableAreaView, - ot2_standard_deck_def: DeckDefinitionV4, + ot2_standard_deck_def: DeckDefinitionV5, subject: GeometryView, ) -> None: """It should return a module position for labware on a module.""" @@ -178,10 +209,16 @@ def test_get_labware_parent_position_on_module( decoy.when( addressable_area_view.get_addressable_area_position(DeckSlotName.SLOT_3.id) ).then_return(Point(1, 2, 3)) + decoy.when(labware_view.get_deck_definition()).then_return(ot2_standard_deck_def) + decoy.when( - module_view.get_nominal_module_offset(module_id="module-id") + module_view.get_nominal_module_offset( + module_id="module-id", + addressable_areas=addressable_area_view, + ) ).then_return(LabwareOffsetVector(x=4, y=5, z=6)) + decoy.when(module_view.get_connected_model("module-id")).then_return( ModuleModel.THERMOCYCLER_MODULE_V2 ) @@ -207,7 +244,7 @@ def test_get_labware_parent_position_on_labware( labware_view: LabwareView, module_view: ModuleView, addressable_area_view: AddressableAreaView, - ot2_standard_deck_def: DeckDefinitionV4, + ot2_standard_deck_def: DeckDefinitionV5, subject: GeometryView, ) -> None: """It should return a labware position for labware on a labware on a module.""" @@ -242,7 +279,10 @@ def test_get_labware_parent_position_on_labware( decoy.when(labware_view.get_deck_definition()).then_return(ot2_standard_deck_def) decoy.when( - module_view.get_nominal_module_offset(module_id="module-id") + module_view.get_nominal_module_offset( + module_id="module-id", + addressable_areas=addressable_area_view, + ) ).then_return(LabwareOffsetVector(x=1, y=2, z=3)) decoy.when(module_view.get_connected_model("module-id")).then_return( @@ -270,7 +310,7 @@ def test_module_calibration_offset_rotation( decoy: Decoy, labware_view: LabwareView, module_view: ModuleView, - ot2_standard_deck_def: DeckDefinitionV4, + ot2_standard_deck_def: DeckDefinitionV5, subject: GeometryView, ) -> None: """Return the rotated module calibration offset if the module was moved from one side of the deck to the other.""" @@ -395,7 +435,7 @@ def test_get_module_labware_highest_z( labware_view: LabwareView, module_view: ModuleView, addressable_area_view: AddressableAreaView, - ot2_standard_deck_def: DeckDefinitionV4, + ot2_standard_deck_def: DeckDefinitionV5, subject: GeometryView, ) -> None: """It should get the absolute location of a labware's highest Z point.""" @@ -422,7 +462,10 @@ def test_get_module_labware_highest_z( ) decoy.when(labware_view.get_deck_definition()).then_return(ot2_standard_deck_def) decoy.when( - module_view.get_nominal_module_offset(module_id="module-id") + module_view.get_nominal_module_offset( + module_id="module-id", + addressable_areas=addressable_area_view, + ) ).then_return(LabwareOffsetVector(x=4, y=5, z=6)) decoy.when(module_view.get_height_over_labware("module-id")).then_return(0.5) decoy.when(module_view.get_module_calibration_offset("module-id")).then_return( @@ -692,7 +735,7 @@ def test_get_highest_z_in_slot_with_single_module( module_view: ModuleView, addressable_area_view: AddressableAreaView, subject: GeometryView, - ot2_standard_deck_def: DeckDefinitionV4, + ot2_standard_deck_def: DeckDefinitionV5, ) -> None: """It should get the highest Z in slot with just a single module.""" # Case: Slot has a module that doesn't have any labware on it. Highest z is equal to module height. @@ -707,9 +750,12 @@ def test_get_highest_z_in_slot_with_single_module( errors.LabwareNotLoadedOnModuleError("only module") ) decoy.when(labware_view.get_deck_definition()).then_return(ot2_standard_deck_def) - decoy.when(module_view.get_module_highest_z(module_id="only-module")).then_return( - 12345 - ) + decoy.when( + module_view.get_module_highest_z( + module_id="only-module", + addressable_areas=addressable_area_view, + ) + ).then_return(12345) assert ( subject.get_highest_z_in_slot(DeckSlotLocation(slotName=DeckSlotName.SLOT_3)) @@ -820,7 +866,7 @@ def test_get_highest_z_in_slot_with_labware_stack_on_module( addressable_area_view: AddressableAreaView, subject: GeometryView, well_plate_def: LabwareDefinition, - ot2_standard_deck_def: DeckDefinitionV4, + ot2_standard_deck_def: DeckDefinitionV5, ) -> None: """It should get the highest z in slot of labware on module. @@ -883,7 +929,10 @@ def test_get_highest_z_in_slot_with_labware_stack_on_module( DeckSlotLocation(slotName=DeckSlotName.SLOT_3) ) decoy.when( - module_view.get_nominal_module_offset(module_id="module-id") + module_view.get_nominal_module_offset( + module_id="module-id", + addressable_areas=addressable_area_view, + ) ).then_return(LabwareOffsetVector(x=40, y=50, z=60)) decoy.when(module_view.get_connected_model("module-id")).then_return( ModuleModel.TEMPERATURE_MODULE_V2 @@ -1056,7 +1105,7 @@ def test_get_module_labware_well_position( labware_view: LabwareView, module_view: ModuleView, addressable_area_view: AddressableAreaView, - ot2_standard_deck_def: DeckDefinitionV4, + ot2_standard_deck_def: DeckDefinitionV5, subject: GeometryView, ) -> None: """It should be able to get the position of a well top in a labware on module.""" @@ -1087,7 +1136,10 @@ def test_get_module_labware_well_position( ) decoy.when(labware_view.get_deck_definition()).then_return(ot2_standard_deck_def) decoy.when( - module_view.get_nominal_module_offset(module_id="module-id") + module_view.get_nominal_module_offset( + module_id="module-id", + addressable_areas=addressable_area_view, + ) ).then_return(LabwareOffsetVector(x=4, y=5, z=6)) decoy.when(module_view.get_module_calibration_offset("module-id")).then_return( ModuleOffsetData( @@ -1544,7 +1596,7 @@ def test_get_labware_grip_point( labware_view: LabwareView, module_view: ModuleView, addressable_area_view: AddressableAreaView, - ot2_standard_deck_def: DeckDefinitionV4, + ot2_standard_deck_def: DeckDefinitionV5, subject: GeometryView, ) -> None: """It should get the grip point of the labware at the specified location.""" @@ -1567,7 +1619,7 @@ def test_get_labware_grip_point_on_labware( labware_view: LabwareView, module_view: ModuleView, addressable_area_view: AddressableAreaView, - ot2_standard_deck_def: DeckDefinitionV4, + ot2_standard_deck_def: DeckDefinitionV5, subject: GeometryView, ) -> None: """It should get the grip point of a labware on another labware.""" @@ -1614,7 +1666,7 @@ def test_get_labware_grip_point_for_labware_on_module( labware_view: LabwareView, module_view: ModuleView, addressable_area_view: AddressableAreaView, - ot2_standard_deck_def: DeckDefinitionV4, + ot2_standard_deck_def: DeckDefinitionV5, subject: GeometryView, ) -> None: """It should return the grip point for labware directly on a module.""" @@ -1626,7 +1678,10 @@ def test_get_labware_grip_point_for_labware_on_module( ) decoy.when(labware_view.get_deck_definition()).then_return(ot2_standard_deck_def) decoy.when( - module_view.get_nominal_module_offset(module_id="module-id") + module_view.get_nominal_module_offset( + module_id="module-id", + addressable_areas=addressable_area_view, + ) ).then_return(LabwareOffsetVector(x=1, y=2, z=3)) decoy.when(module_view.get_connected_model("module-id")).then_return( ModuleModel.MAGNETIC_MODULE_V2 diff --git a/api/tests/opentrons/protocol_engine/state/test_labware_store.py b/api/tests/opentrons/protocol_engine/state/test_labware_store.py index 2c0c8cdefd9..9d926583fb0 100644 --- a/api/tests/opentrons/protocol_engine/state/test_labware_store.py +++ b/api/tests/opentrons/protocol_engine/state/test_labware_store.py @@ -4,7 +4,7 @@ from datetime import datetime from opentrons.calibration_storage.helpers import uri_from_details -from opentrons_shared_data.deck.dev_types import DeckDefinitionV4 +from opentrons_shared_data.deck.dev_types import DeckDefinitionV5 from opentrons.protocols.models import LabwareDefinition from opentrons.types import DeckSlotName @@ -33,7 +33,7 @@ @pytest.fixture def subject( - ot2_standard_deck_def: DeckDefinitionV4, + ot2_standard_deck_def: DeckDefinitionV5, ) -> LabwareStore: """Get a LabwareStore test subject.""" return LabwareStore( @@ -43,7 +43,7 @@ def subject( def test_initial_state( - ot2_standard_deck_def: DeckDefinitionV4, + ot2_standard_deck_def: DeckDefinitionV5, subject: LabwareStore, ) -> None: """It should create the labware store with preloaded fixed labware.""" diff --git a/api/tests/opentrons/protocol_engine/state/test_labware_view.py b/api/tests/opentrons/protocol_engine/state/test_labware_view.py index 5e7e96412fa..0f8086de606 100644 --- a/api/tests/opentrons/protocol_engine/state/test_labware_view.py +++ b/api/tests/opentrons/protocol_engine/state/test_labware_view.py @@ -5,7 +5,7 @@ from contextlib import nullcontext as does_not_raise from opentrons_shared_data.deck import load as load_deck -from opentrons_shared_data.deck.dev_types import DeckDefinitionV4 +from opentrons_shared_data.deck.dev_types import DeckDefinitionV5 from opentrons_shared_data.pipette.dev_types import LabwareUri from opentrons_shared_data.labware import load_definition from opentrons_shared_data.labware.labware_definition import ( @@ -110,14 +110,14 @@ def get_labware_view( labware_by_id: Optional[Dict[str, LoadedLabware]] = None, labware_offsets_by_id: Optional[Dict[str, LabwareOffset]] = None, definitions_by_uri: Optional[Dict[str, LabwareDefinition]] = None, - deck_definition: Optional[DeckDefinitionV4] = None, + deck_definition: Optional[DeckDefinitionV5] = None, ) -> LabwareView: """Get a labware view test subject.""" state = LabwareState( labware_by_id=labware_by_id or {}, labware_offsets_by_id=labware_offsets_by_id or {}, definitions_by_uri=definitions_by_uri or {}, - deck_definition=deck_definition or cast(DeckDefinitionV4, {"fake": True}), + deck_definition=deck_definition or cast(DeckDefinitionV5, {"fake": True}), ) return LabwareView(state=state) @@ -696,7 +696,7 @@ def test_get_labware_overlap_offsets() -> None: class ModuleOverlapSpec(NamedTuple): """Spec data to test LabwareView.get_module_overlap_offsets.""" - spec_deck_definition: DeckDefinitionV4 + spec_deck_definition: DeckDefinitionV5 module_model: ModuleModel stacking_offset_with_module: Dict[str, SharedDataOverlapOffset] expected_offset: OverlapOffset @@ -705,7 +705,7 @@ class ModuleOverlapSpec(NamedTuple): module_overlap_specs: List[ModuleOverlapSpec] = [ ModuleOverlapSpec( # Labware on temp module on OT2, with stacking overlap for temp module - spec_deck_definition=load_deck(STANDARD_OT2_DECK, 4), + spec_deck_definition=load_deck(STANDARD_OT2_DECK, 5), module_model=ModuleModel.TEMPERATURE_MODULE_V2, stacking_offset_with_module={ str(ModuleModel.TEMPERATURE_MODULE_V2.value): SharedDataOverlapOffset( @@ -716,7 +716,7 @@ class ModuleOverlapSpec(NamedTuple): ), ModuleOverlapSpec( # Labware on TC Gen1 on OT2, with stacking overlap for TC Gen1 - spec_deck_definition=load_deck(STANDARD_OT2_DECK, 4), + spec_deck_definition=load_deck(STANDARD_OT2_DECK, 5), module_model=ModuleModel.THERMOCYCLER_MODULE_V1, stacking_offset_with_module={ str(ModuleModel.THERMOCYCLER_MODULE_V1.value): SharedDataOverlapOffset( @@ -727,21 +727,21 @@ class ModuleOverlapSpec(NamedTuple): ), ModuleOverlapSpec( # Labware on TC Gen2 on OT2, with no stacking overlap - spec_deck_definition=load_deck(STANDARD_OT2_DECK, 4), + spec_deck_definition=load_deck(STANDARD_OT2_DECK, 5), module_model=ModuleModel.THERMOCYCLER_MODULE_V2, stacking_offset_with_module={}, expected_offset=OverlapOffset(x=0, y=0, z=10.7), ), ModuleOverlapSpec( # Labware on TC Gen2 on Flex, with no stacking overlap - spec_deck_definition=load_deck(STANDARD_OT3_DECK, 4), + spec_deck_definition=load_deck(STANDARD_OT3_DECK, 5), module_model=ModuleModel.THERMOCYCLER_MODULE_V2, stacking_offset_with_module={}, expected_offset=OverlapOffset(x=0, y=0, z=0), ), ModuleOverlapSpec( # Labware on TC Gen2 on Flex, with stacking overlap for TC Gen2 - spec_deck_definition=load_deck(STANDARD_OT3_DECK, 4), + spec_deck_definition=load_deck(STANDARD_OT3_DECK, 5), module_model=ModuleModel.THERMOCYCLER_MODULE_V2, stacking_offset_with_module={ str(ModuleModel.THERMOCYCLER_MODULE_V2.value): SharedDataOverlapOffset( @@ -758,7 +758,7 @@ class ModuleOverlapSpec(NamedTuple): argvalues=module_overlap_specs, ) def test_get_module_overlap_offsets( - spec_deck_definition: DeckDefinitionV4, + spec_deck_definition: DeckDefinitionV5, module_model: ModuleModel, stacking_offset_with_module: Dict[str, SharedDataOverlapOffset], expected_offset: OverlapOffset, @@ -800,7 +800,7 @@ def test_get_default_magnet_height( assert subject.get_default_magnet_height(module_id="module-id", offset=2) == 12.0 -def test_get_deck_definition(ot2_standard_deck_def: DeckDefinitionV4) -> None: +def test_get_deck_definition(ot2_standard_deck_def: DeckDefinitionV5) -> None: """It should get the deck definition from the state.""" subject = get_labware_view(deck_definition=ot2_standard_deck_def) @@ -1404,7 +1404,7 @@ def test_raise_if_labware_cannot_be_stacked_on_labware_on_adapter() -> None: ) -def test_get_deck_gripper_offsets(ot3_standard_deck_def: DeckDefinitionV4) -> None: +def test_get_deck_gripper_offsets(ot3_standard_deck_def: DeckDefinitionV5) -> None: """It should get the deck's gripper offsets.""" subject = get_labware_view(deck_definition=ot3_standard_deck_def) diff --git a/api/tests/opentrons/protocol_engine/state/test_module_store.py b/api/tests/opentrons/protocol_engine/state/test_module_store.py index 1d0d7003496..e6de0a96ac0 100644 --- a/api/tests/opentrons/protocol_engine/state/test_module_store.py +++ b/api/tests/opentrons/protocol_engine/state/test_module_store.py @@ -1,8 +1,9 @@ """Module state store tests.""" -from typing import List +from typing import List, Set, cast, Dict, Optional import pytest from opentrons_shared_data.robot.dev_types import RobotType +from opentrons_shared_data.deck.dev_types import DeckDefinitionV5 from pytest_lazyfixture import lazy_fixture # type: ignore[import-untyped] from opentrons.types import DeckSlotName @@ -18,6 +19,9 @@ ModuleModel, HeaterShakerLatchStatus, DeckType, + AddressableArea, + DeckConfigurationType, + PotentialCutoutFixture, ) from opentrons.protocol_engine.state.modules import ( @@ -37,6 +41,11 @@ ThermocyclerModuleSubState, ModuleSubStateType, ) + +from opentrons.protocol_engine.state.addressable_areas import ( + AddressableAreaView, + AddressableAreaState, +) from opentrons.protocol_engine.state.config import Config from opentrons.hardware_control.modules.types import LiveData @@ -48,9 +57,35 @@ ) +def get_addressable_area_view( + loaded_addressable_areas_by_name: Optional[Dict[str, AddressableArea]] = None, + potential_cutout_fixtures_by_cutout_id: Optional[ + Dict[str, Set[PotentialCutoutFixture]] + ] = None, + deck_definition: Optional[DeckDefinitionV5] = None, + deck_configuration: Optional[DeckConfigurationType] = None, + robot_type: RobotType = "OT-3 Standard", + use_simulated_deck_config: bool = False, +) -> AddressableAreaView: + """Get a labware view test subject.""" + state = AddressableAreaState( + loaded_addressable_areas_by_name=loaded_addressable_areas_by_name or {}, + potential_cutout_fixtures_by_cutout_id=potential_cutout_fixtures_by_cutout_id + or {}, + deck_definition=deck_definition or cast(DeckDefinitionV5, {"otId": "fake"}), + deck_configuration=deck_configuration or [], + robot_type=robot_type, + use_simulated_deck_config=use_simulated_deck_config, + ) + + return AddressableAreaView(state=state) + + def test_initial_state() -> None: """It should initialize the module state.""" - subject = ModuleStore(config=_OT2_STANDARD_CONFIG) + subject = ModuleStore( + config=_OT2_STANDARD_CONFIG, + ) assert subject.state == ModuleState( deck_type=DeckType.OT2_STANDARD, @@ -158,7 +193,9 @@ def test_load_module( ), ) - subject = ModuleStore(config=_OT2_STANDARD_CONFIG) + subject = ModuleStore( + config=_OT2_STANDARD_CONFIG, + ) subject.handle_action(action) assert subject.state == ModuleState( @@ -223,7 +260,7 @@ def test_load_thermocycler_in_thermocycler_slot( use_simulated_deck_config=False, robot_type=robot_type, deck_type=deck_type, - ) + ), ) subject.handle_action(action) @@ -302,7 +339,9 @@ def test_add_module_action( module_live_data=live_data, ) - subject = ModuleStore(_OT2_STANDARD_CONFIG) + subject = ModuleStore( + config=_OT2_STANDARD_CONFIG, + ) subject.handle_action(action) assert subject.state == ModuleState( @@ -343,7 +382,9 @@ def test_handle_hs_temperature_commands(heater_shaker_v1_def: ModuleDefinition) params=hs_commands.DeactivateHeaterParams(moduleId="module-id"), result=hs_commands.DeactivateHeaterResult(), ) - subject = ModuleStore(_OT2_STANDARD_CONFIG) + subject = ModuleStore( + config=_OT2_STANDARD_CONFIG, + ) subject.handle_action( actions.SucceedCommandAction(private_result=None, command=load_module_cmd) @@ -394,7 +435,9 @@ def test_handle_hs_shake_commands(heater_shaker_v1_def: ModuleDefinition) -> Non params=hs_commands.DeactivateShakerParams(moduleId="module-id"), result=hs_commands.DeactivateShakerResult(), ) - subject = ModuleStore(_OT2_STANDARD_CONFIG) + subject = ModuleStore( + config=_OT2_STANDARD_CONFIG, + ) subject.handle_action( actions.SucceedCommandAction(private_result=None, command=load_module_cmd) @@ -447,7 +490,9 @@ def test_handle_hs_labware_latch_commands( params=hs_commands.OpenLabwareLatchParams(moduleId="module-id"), result=hs_commands.OpenLabwareLatchResult(pipetteRetracted=False), ) - subject = ModuleStore(_OT2_STANDARD_CONFIG) + subject = ModuleStore( + config=_OT2_STANDARD_CONFIG, + ) subject.handle_action( actions.SucceedCommandAction(private_result=None, command=load_module_cmd) @@ -511,7 +556,9 @@ def test_handle_tempdeck_temperature_commands( params=temp_commands.DeactivateTemperatureParams(moduleId="module-id"), result=temp_commands.DeactivateTemperatureResult(), ) - subject = ModuleStore(_OT2_STANDARD_CONFIG) + subject = ModuleStore( + config=_OT2_STANDARD_CONFIG, + ) subject.handle_action( actions.SucceedCommandAction(private_result=None, command=load_module_cmd) @@ -570,7 +617,9 @@ def test_handle_thermocycler_temperature_commands( params=tc_commands.DeactivateLidParams(moduleId="module-id"), result=tc_commands.DeactivateLidResult(), ) - subject = ModuleStore(_OT2_STANDARD_CONFIG) + subject = ModuleStore( + config=_OT2_STANDARD_CONFIG, + ) subject.handle_action( actions.SucceedCommandAction(private_result=None, command=load_module_cmd) @@ -652,7 +701,7 @@ def test_handle_thermocycler_lid_commands( use_simulated_deck_config=False, robot_type="OT-3 Standard", deck_type=DeckType.OT3_STANDARD, - ) + ), ) subject.handle_action( diff --git a/api/tests/opentrons/protocol_engine/state/test_module_view.py b/api/tests/opentrons/protocol_engine/state/test_module_view.py index 77ab24bb336..b840673f2e8 100644 --- a/api/tests/opentrons/protocol_engine/state/test_module_view.py +++ b/api/tests/opentrons/protocol_engine/state/test_module_view.py @@ -4,7 +4,21 @@ from pytest_lazyfixture import lazy_fixture # type: ignore[import-untyped] from contextlib import nullcontext as does_not_raise -from typing import ContextManager, Dict, NamedTuple, Optional, Type, Union, Any, List +from typing import ( + ContextManager, + Dict, + NamedTuple, + Optional, + Type, + Union, + Any, + List, + Set, + cast, +) + +from opentrons_shared_data.robot.dev_types import RobotType +from opentrons_shared_data.deck.dev_types import DeckDefinitionV5 from opentrons_shared_data import load_shared_data from opentrons.types import DeckSlotName, MountType @@ -19,12 +33,19 @@ ModuleOffsetData, HeaterShakerLatchStatus, LabwareMovementOffsetData, + AddressableArea, + DeckConfigurationType, + PotentialCutoutFixture, ) from opentrons.protocol_engine.state.modules import ( ModuleView, ModuleState, HardwareModule, ) +from opentrons.protocol_engine.state.addressable_areas import ( + AddressableAreaView, + AddressableAreaState, +) from opentrons.protocol_engine.state.module_substates import ( HeaterShakerModuleSubState, @@ -37,6 +58,40 @@ ThermocyclerModuleId, ModuleSubStateType, ) +from opentrons_shared_data.deck import load as load_deck +from opentrons.protocols.api_support.deck_type import ( + STANDARD_OT3_DECK, +) + + +@pytest.fixture(scope="session") +def ot3_standard_deck_def() -> DeckDefinitionV5: + """Get the OT-2 standard deck definition.""" + return load_deck(STANDARD_OT3_DECK, 5) + + +def get_addressable_area_view( + loaded_addressable_areas_by_name: Optional[Dict[str, AddressableArea]] = None, + potential_cutout_fixtures_by_cutout_id: Optional[ + Dict[str, Set[PotentialCutoutFixture]] + ] = None, + deck_definition: Optional[DeckDefinitionV5] = None, + deck_configuration: Optional[DeckConfigurationType] = None, + robot_type: RobotType = "OT-3 Standard", + use_simulated_deck_config: bool = False, +) -> AddressableAreaView: + """Get a labware view test subject.""" + state = AddressableAreaState( + loaded_addressable_areas_by_name=loaded_addressable_areas_by_name or {}, + potential_cutout_fixtures_by_cutout_id=potential_cutout_fixtures_by_cutout_id + or {}, + deck_definition=deck_definition or cast(DeckDefinitionV5, {"otId": "fake"}), + deck_configuration=deck_configuration or [], + robot_type=robot_type, + use_simulated_deck_config=use_simulated_deck_config, + ) + + return AddressableAreaView(state=state) def make_module_view( @@ -332,41 +387,50 @@ def test_get_module_offset_for_ot2_standard( ) }, ) - assert subject.get_nominal_module_offset("module-id") == expected_offset + assert ( + subject.get_nominal_module_offset("module-id", get_addressable_area_view()) + == expected_offset + ) @pytest.mark.parametrize( - argnames=["module_def", "slot", "expected_offset"], + argnames=["module_def", "slot", "expected_offset", "deck_definition"], argvalues=[ ( lazy_fixture("tempdeck_v2_def"), DeckSlotName.SLOT_1.to_ot3_equivalent(), LabwareOffsetVector(x=0, y=0, z=9), + lazy_fixture("ot3_standard_deck_def"), ), ( lazy_fixture("tempdeck_v2_def"), DeckSlotName.SLOT_3.to_ot3_equivalent(), LabwareOffsetVector(x=0, y=0, z=9), + lazy_fixture("ot3_standard_deck_def"), ), ( lazy_fixture("thermocycler_v2_def"), DeckSlotName.SLOT_7.to_ot3_equivalent(), LabwareOffsetVector(x=-20.005, y=67.96, z=10.96), + lazy_fixture("ot3_standard_deck_def"), ), ( lazy_fixture("heater_shaker_v1_def"), DeckSlotName.SLOT_1.to_ot3_equivalent(), LabwareOffsetVector(x=0, y=0, z=18.95), + lazy_fixture("ot3_standard_deck_def"), ), ( lazy_fixture("heater_shaker_v1_def"), DeckSlotName.SLOT_3.to_ot3_equivalent(), LabwareOffsetVector(x=0, y=0, z=18.95), + lazy_fixture("ot3_standard_deck_def"), ), ( lazy_fixture("mag_block_v1_def"), - DeckSlotName.SLOT_2, + DeckSlotName.SLOT_2.to_ot3_equivalent(), LabwareOffsetVector(x=0, y=0, z=38.0), + lazy_fixture("ot3_standard_deck_def"), ), ], ) @@ -374,6 +438,7 @@ def test_get_module_offset_for_ot3_standard( module_def: ModuleDefinition, slot: DeckSlotName, expected_offset: LabwareOffsetVector, + deck_definition: DeckDefinitionV5, ) -> None: """It should return the correct labware offset for module in specified slot.""" subject = make_module_view( @@ -386,7 +451,16 @@ def test_get_module_offset_for_ot3_standard( ) }, ) - result_offset = subject.get_nominal_module_offset("module-id") + + result_offset = subject.get_nominal_module_offset( + "module-id", + get_addressable_area_view( + deck_configuration=None, + deck_definition=deck_definition, + use_simulated_deck_config=True, + ), + ) + assert (result_offset.x, result_offset.y, result_offset.z) == pytest.approx( (expected_offset.x, expected_offset.y, expected_offset.z) ) @@ -1767,10 +1841,20 @@ def test_get_default_gripper_offsets( @pytest.mark.parametrize( - argnames=["deck_type", "slot_name", "expected_highest_z"], + argnames=["deck_type", "slot_name", "expected_highest_z", "deck_definition"], argvalues=[ - (DeckType.OT2_STANDARD, DeckSlotName.SLOT_1, 84), - (DeckType.OT3_STANDARD, DeckSlotName.SLOT_D1, 12.91), + ( + DeckType.OT2_STANDARD, + DeckSlotName.SLOT_1, + 84, + lazy_fixture("ot3_standard_deck_def"), + ), + ( + DeckType.OT3_STANDARD, + DeckSlotName.SLOT_D1, + 12.91, + lazy_fixture("ot3_standard_deck_def"), + ), ], ) def test_get_module_highest_z( @@ -1778,6 +1862,7 @@ def test_get_module_highest_z( deck_type: DeckType, slot_name: DeckSlotName, expected_highest_z: float, + deck_definition: DeckDefinitionV5, ) -> None: """It should get the highest z point of the module.""" subject = make_module_view( @@ -1794,7 +1879,14 @@ def test_get_module_highest_z( }, ) assert isclose( - subject.get_module_highest_z(module_id="module-id"), + subject.get_module_highest_z( + module_id="module-id", + addressable_areas=get_addressable_area_view( + deck_configuration=None, + deck_definition=deck_definition, + use_simulated_deck_config=True, + ), + ), expected_highest_z, ) diff --git a/api/tests/opentrons/protocol_engine/state/test_state_store.py b/api/tests/opentrons/protocol_engine/state/test_state_store.py index dd32bbec591..515cbbd81e1 100644 --- a/api/tests/opentrons/protocol_engine/state/test_state_store.py +++ b/api/tests/opentrons/protocol_engine/state/test_state_store.py @@ -1,11 +1,11 @@ """Tests for the top-level StateStore/StateView.""" -from typing import Callable, Optional +from typing import Callable, Union from datetime import datetime import pytest from decoy import Decoy -from opentrons_shared_data.deck.dev_types import DeckDefinitionV4 +from opentrons_shared_data.deck.dev_types import DeckDefinitionV5 from opentrons.protocol_engine.actions import PlayAction from opentrons.protocol_engine.state import State, StateStore, Config @@ -32,7 +32,7 @@ def engine_config() -> Config: @pytest.fixture def subject( change_notifier: ChangeNotifier, - ot2_standard_deck_def: DeckDefinitionV4, + ot2_standard_deck_def: DeckDefinitionV5, engine_config: Config, ) -> StateStore: """Get a StateStore test subject.""" @@ -80,47 +80,52 @@ def test_notify_on_state_change( decoy.verify(change_notifier.notify(), times=1) -async def test_wait_for_state( +async def test_wait_for( decoy: Decoy, change_notifier: ChangeNotifier, subject: StateStore, ) -> None: """It should return an awaitable that signals state changes.""" - check_condition: Callable[..., Optional[str]] = decoy.mock(name="check_condition") + check_condition: Callable[..., Union[str, int]] = decoy.mock(name="check_condition") decoy.when(check_condition("foo", bar="baz")).then_return( - None, - None, + 0, + 0, "hello world", ) - result = await subject.wait_for(check_condition, "foo", bar="baz") assert result == "hello world" + decoy.verify(await change_notifier.wait(), times=2) + decoy.reset() + + decoy.when(check_condition("foo", bar="baz")).then_return( + "hello world", + "hello world again", + 0, + ) + result = await subject.wait_for_not(check_condition, "foo", bar="baz") + assert result == 0 decoy.verify(await change_notifier.wait(), times=2) -async def test_wait_for_state_short_circuit( +async def test_wait_for_already_satisfied( decoy: Decoy, subject: StateStore, change_notifier: ChangeNotifier, ) -> None: - """It should short-circuit the change notifier if condition is satisfied.""" - check_condition: Callable[..., Optional[str]] = decoy.mock(name="check_condition") + """It should return immediately and skip the change notifier.""" + check_condition: Callable[..., Union[str, int]] = decoy.mock(name="check_condition") decoy.when(check_condition("foo", bar="baz")).then_return("hello world") - result = await subject.wait_for(check_condition, "foo", bar="baz") assert result == "hello world" - decoy.verify(await change_notifier.wait(), times=0) - -async def test_wait_for_already_true(decoy: Decoy, subject: StateStore) -> None: - """It should signal immediately if condition is already met.""" - check_condition = decoy.mock(name="check_condition") - decoy.when(check_condition()).then_return(True) - await subject.wait_for(check_condition) + decoy.when(check_condition("foo", bar="baz")).then_return(0) + result = await subject.wait_for_not(check_condition, "foo", bar="baz") + assert result == 0 + decoy.verify(await change_notifier.wait(), times=0) async def test_wait_for_raises(decoy: Decoy, subject: StateStore) -> None: @@ -131,3 +136,6 @@ async def test_wait_for_raises(decoy: Decoy, subject: StateStore) -> None: with pytest.raises(ValueError, match="oh no"): await subject.wait_for(check_condition) + + with pytest.raises(ValueError, match="oh no"): + await subject.wait_for_not(check_condition) diff --git a/api/tests/opentrons/protocol_engine/test_create_protocol_engine.py b/api/tests/opentrons/protocol_engine/test_create_protocol_engine.py index b509946de75..2f7a0cae441 100644 --- a/api/tests/opentrons/protocol_engine/test_create_protocol_engine.py +++ b/api/tests/opentrons/protocol_engine/test_create_protocol_engine.py @@ -2,8 +2,9 @@ import pytest from pytest_lazyfixture import lazy_fixture # type: ignore[import-untyped] -from opentrons_shared_data.deck.dev_types import DeckDefinitionV4 +from opentrons_shared_data.deck.dev_types import DeckDefinitionV5 from opentrons_shared_data.robot.dev_types import RobotType +from opentrons_shared_data.deck import load as load_deck from opentrons.calibration_storage.helpers import uri_from_details from opentrons.hardware_control import API as HardwareAPI @@ -18,6 +19,30 @@ from opentrons.protocol_engine.types import DeckSlotLocation, LoadedLabware from opentrons.types import DeckSlotName +from opentrons.protocols.api_support.deck_type import ( + STANDARD_OT2_DECK, + SHORT_TRASH_DECK, + STANDARD_OT3_DECK, +) + + +@pytest.fixture(scope="session") +def ot2_standard_deck_def() -> DeckDefinitionV5: + """Get the OT-2 standard deck definition.""" + return load_deck(STANDARD_OT2_DECK, 5) + + +@pytest.fixture(scope="session") +def ot2_short_trash_deck_def() -> DeckDefinitionV5: + """Get the OT-2 with short trash standard deck definition.""" + return load_deck(SHORT_TRASH_DECK, 5) + + +@pytest.fixture(scope="session") +def ot3_standard_deck_def() -> DeckDefinitionV5: + """Get the OT-2 standard deck definition.""" + return load_deck(STANDARD_OT3_DECK, 5) + @pytest.mark.parametrize( ( @@ -47,7 +72,7 @@ async def test_create_engine_initializes_state_with_no_fixed_trash( hardware_api: HardwareAPI, robot_type: RobotType, deck_type: DeckType, - expected_deck_def: DeckDefinitionV4, + expected_deck_def: DeckDefinitionV5, ) -> None: """It should load deck geometry data into the store on create.""" engine = await create_protocol_engine( @@ -102,7 +127,7 @@ async def test_create_engine_initializes_state_with_fixed_trash( hardware_api: HardwareAPI, robot_type: RobotType, deck_type: DeckType, - expected_deck_def: DeckDefinitionV4, + expected_deck_def: DeckDefinitionV5, expected_fixed_trash_def: LabwareDefinition, expected_fixed_trash_slot: DeckSlotName, ) -> None: diff --git a/api/tests/opentrons/protocol_engine/test_protocol_engine.py b/api/tests/opentrons/protocol_engine/test_protocol_engine.py index 2191b1c4954..4816708fa57 100644 --- a/api/tests/opentrons/protocol_engine/test_protocol_engine.py +++ b/api/tests/opentrons/protocol_engine/test_protocol_engine.py @@ -2,14 +2,13 @@ import inspect from datetime import datetime from typing import Any +from unittest.mock import sentinel import pytest from decoy import Decoy from opentrons_shared_data.robot.dev_types import RobotType -from opentrons.ordered_set import OrderedSet from opentrons.protocol_engine.actions.actions import ResumeFromRecoveryAction -from opentrons.protocol_engine.error_recovery_policy import ErrorRecoveryType from opentrons.types import DeckSlotName from opentrons.hardware_control import HardwareControlAPI, OT2HardwareControlAPI @@ -18,7 +17,9 @@ from opentrons.protocols.models import LabwareDefinition from opentrons.protocol_engine import ProtocolEngine, commands, slot_standardization -from opentrons.protocol_engine.errors.exceptions import EStopActivatedError +from opentrons.protocol_engine.errors.exceptions import ( + CommandNotAllowedError, +) from opentrons.protocol_engine.types import ( DeckType, LabwareOffset, @@ -58,7 +59,6 @@ QueueCommandAction, HardwareStoppedAction, ResetTipsAction, - FailCommandAction, ) @@ -129,9 +129,9 @@ def _mock_slot_standardization_module( def _mock_hash_command_params_module( decoy: Decoy, monkeypatch: pytest.MonkeyPatch ) -> None: - hash_command_params = commands.hash_command_params + hash_command_params = commands.hash_protocol_command_params monkeypatch.setattr( - commands, "hash_command_params", decoy.mock(func=hash_command_params) + commands, "hash_protocol_command_params", decoy.mock(func=hash_command_params) ) @@ -183,7 +183,9 @@ def test_add_command( original_request = commands.WaitForResumeCreate( params=commands.WaitForResumeParams() ) - standardized_request = commands.HomeCreate(params=commands.HomeParams()) + standardized_request = commands.HomeCreate( + params=commands.HomeParams(), intent=commands.CommandIntent.PROTOCOL + ) queued = commands.Home( id="command-id", key="command-key", @@ -203,9 +205,13 @@ def test_add_command( decoy.when(model_utils.generate_id()).then_return("command-id") decoy.when(model_utils.get_timestamp()).then_return(created_at) - decoy.when(state_store.commands.get_latest_command_hash()).then_return("abc") + decoy.when(state_store.commands.get_latest_protocol_command_hash()).then_return( + "abc" + ) decoy.when( - commands.hash_command_params(create=standardized_request, last_hash="abc") + commands.hash_protocol_command_params( + create=standardized_request, last_hash="abc" + ) ).then_return("123") def _stub_queued(*_a: object, **_k: object) -> None: @@ -245,6 +251,105 @@ def _stub_queued(*_a: object, **_k: object) -> None: assert result == queued +def test_add_fixit_command( + decoy: Decoy, + state_store: StateStore, + action_dispatcher: ActionDispatcher, + model_utils: ModelUtils, + subject: ProtocolEngine, +) -> None: + """It should add a fixit command to the state from a request.""" + created_at = datetime(year=2021, month=1, day=1) + original_request = commands.WaitForResumeCreate( + params=commands.WaitForResumeParams() + ) + standardized_request = commands.HomeCreate( + params=commands.HomeParams(), intent=commands.CommandIntent.FIXIT + ) + queued = commands.Home( + id="command-id", + key="command-key", + status=commands.CommandStatus.QUEUED, + createdAt=created_at, + params=commands.HomeParams(), + ) + + robot_type: RobotType = "OT-3 Standard" + decoy.when(state_store.config).then_return( + Config(robot_type=robot_type, deck_type=DeckType.OT3_STANDARD) + ) + + decoy.when( + slot_standardization.standardize_command(original_request, robot_type) + ).then_return(standardized_request) + + decoy.when(model_utils.generate_id()).then_return("command-id") + decoy.when(model_utils.get_timestamp()).then_return(created_at) + + def _stub_queued(*_a: object, **_k: object) -> None: + decoy.when(state_store.commands.get("command-id")).then_return(queued) + + decoy.when( + state_store.commands.validate_action_allowed( + QueueCommandAction( + command_id="command-id", + created_at=created_at, + request=standardized_request, + request_hash=None, + ) + ) + ).then_return( + QueueCommandAction( + command_id="command-id-validated", + created_at=created_at, + request=standardized_request, + request_hash=None, + ) + ) + + decoy.when( + action_dispatcher.dispatch( + QueueCommandAction( + command_id="command-id-validated", + created_at=created_at, + request=standardized_request, + request_hash=None, + ) + ), + ).then_do(_stub_queued) + + result = subject.add_command(original_request) + assert result == queued + + +def test_add_fixit_command_raises( + decoy: Decoy, + state_store: StateStore, + action_dispatcher: ActionDispatcher, + model_utils: ModelUtils, + subject: ProtocolEngine, +) -> None: + """It should raise if a failedCommandId is supplied without a fixit command.""" + original_request = commands.WaitForResumeCreate( + params=commands.WaitForResumeParams() + ) + standardized_request = commands.HomeCreate( + params=commands.HomeParams(), intent=commands.CommandIntent.PROTOCOL + ) + + robot_type: RobotType = "OT-3 Standard" + decoy.when(state_store.config).then_return( + Config(robot_type=robot_type, deck_type=DeckType.OT3_STANDARD) + ) + + decoy.when( + slot_standardization.standardize_command(original_request, robot_type) + ).then_return(standardized_request) + + with pytest.raises(CommandNotAllowedError): + subject.add_command(original_request, "id-123") + + async def test_add_and_execute_command( decoy: Decoy, state_store: StateStore, @@ -333,6 +438,99 @@ def _stub_completed(*_a: object, **_k: object) -> bool: assert result == completed +async def test_add_and_execute_command_wait_for_recovery( + decoy: Decoy, + state_store: StateStore, + action_dispatcher: ActionDispatcher, + model_utils: ModelUtils, + subject: ProtocolEngine, +) -> None: + """It should add and execute a command from a request.""" + created_at = datetime(year=2021, month=1, day=1) + original_request = commands.WaitForResumeCreate( + params=commands.WaitForResumeParams() + ) + standardized_request = commands.HomeCreate(params=commands.HomeParams()) + queued = commands.Home( + id="command-id", + key="command-key", + status=commands.CommandStatus.QUEUED, + createdAt=created_at, + params=commands.HomeParams(), + ) + completed = commands.Home( + id="command-id", + key="command-key", + status=commands.CommandStatus.SUCCEEDED, + createdAt=created_at, + params=commands.HomeParams(), + ) + + robot_type: RobotType = "OT-3 Standard" + decoy.when(state_store.config).then_return( + Config(robot_type=robot_type, deck_type=DeckType.OT3_STANDARD) + ) + + decoy.when( + slot_standardization.standardize_command(original_request, robot_type) + ).then_return(standardized_request) + + decoy.when(model_utils.generate_id()).then_return("command-id") + decoy.when(model_utils.get_timestamp()).then_return(created_at) + + def _stub_queued(*_a: object, **_k: object) -> None: + decoy.when(state_store.commands.get("command-id")).then_return(queued) + + def _stub_completed(*_a: object, **_k: object) -> bool: + decoy.when(state_store.commands.get("command-id")).then_return(completed) + return True + + decoy.when( + state_store.commands.validate_action_allowed( + QueueCommandAction( + command_id="command-id", + created_at=created_at, + request=standardized_request, + request_hash=None, + ) + ) + ).then_return( + QueueCommandAction( + command_id="command-id-validated", + created_at=created_at, + request=standardized_request, + request_hash=None, + ) + ) + + decoy.when( + action_dispatcher.dispatch( + QueueCommandAction( + command_id="command-id-validated", + created_at=created_at, + request=standardized_request, + request_hash=None, + ) + ) + ).then_do(_stub_queued) + + decoy.when( + await state_store.wait_for( + condition=state_store.commands.get_command_is_final, + command_id="command-id", + ), + ).then_do(_stub_completed) + + result = await subject.add_and_execute_command_wait_for_recovery(original_request) + assert result == completed + decoy.verify( + await state_store.wait_for_not( + state_store.commands.get_recovery_in_progress_for_command, + "command-id", + ) + ) + + def test_play( decoy: Decoy, state_store: StateStore, @@ -421,7 +619,7 @@ def test_pause( state_store.commands.validate_action_allowed(expected_action), ).then_return(expected_action) - subject.pause() + subject.request_pause() decoy.verify( action_dispatcher.dispatch(expected_action), @@ -476,8 +674,8 @@ async def test_finish( """It should be able to gracefully tell the engine it's done.""" completed_at = datetime(2021, 1, 1, 0, 0) - decoy.when(model_utils.get_timestamp()).then_return(completed_at) decoy.when(state_store.commands.state.stopped_by_estop).then_return(False) + decoy.when(model_utils.get_timestamp()).then_return(completed_at) await subject.finish( drop_tips_after_run=drop_tips_after_run, @@ -716,7 +914,7 @@ async def test_stop( state_store.commands.validate_action_allowed(expected_action), ).then_return(expected_action) - await subject.stop() + await subject.request_stop() decoy.verify( action_dispatcher.dispatch(expected_action), @@ -742,7 +940,7 @@ async def test_stop_for_legacy_core_protocols( decoy.when(hardware_api.is_movement_execution_taskified()).then_return(True) - await subject.stop() + await subject.request_stop() decoy.verify( action_dispatcher.dispatch(expected_action), @@ -751,98 +949,53 @@ async def test_stop_for_legacy_core_protocols( ) -@pytest.mark.parametrize("maintenance_run", [True, False]) -async def test_estop_during_command( +async def test_estop( decoy: Decoy, action_dispatcher: ActionDispatcher, queue_worker: QueueWorker, state_store: StateStore, subject: ProtocolEngine, - model_utils: ModelUtils, - maintenance_run: bool, ) -> None: """It should be able to stop the engine.""" - timestamp = datetime(2021, 1, 1, 0, 0) - command_id = "command_fake_id" - error_id = "fake_error_id" - fake_command_set = OrderedSet(["fake-id-1", "fake-id-1"]) - - decoy.when(model_utils.get_timestamp()).then_return(timestamp) - decoy.when(model_utils.generate_id()).then_return(error_id) - decoy.when(state_store.commands.get_is_stopped()).then_return(False) - decoy.when(state_store.commands.get_running_command_id()).then_return(command_id) - decoy.when(state_store.commands.get_queue_ids()).then_return(fake_command_set) - - expected_action = FailCommandAction( - command_id=command_id, - error_id=error_id, - failed_at=timestamp, - error=EStopActivatedError(message="Estop Activated"), - notes=[], - type=ErrorRecoveryType.FAIL_RUN, - ) - expected_action_2 = FailCommandAction( - command_id=fake_command_set.head(), - error_id=error_id, - failed_at=timestamp, - error=EStopActivatedError(message="Estop Activated"), - notes=[], - type=ErrorRecoveryType.FAIL_RUN, - ) + expected_action = StopAction(from_estop=True) + validated_action = sentinel.validated_action + decoy.when( + state_store.commands.validate_action_allowed(expected_action), + ).then_return(validated_action) - subject.estop(maintenance_run=maintenance_run) + subject.estop() decoy.verify( - action_dispatcher.dispatch(action=expected_action), - action_dispatcher.dispatch(action=expected_action_2), + action_dispatcher.dispatch(action=validated_action), queue_worker.cancel(), ) -@pytest.mark.parametrize("maintenance_run", [True, False]) -async def test_estop_without_command( +async def test_estop_noops_if_invalid( decoy: Decoy, action_dispatcher: ActionDispatcher, queue_worker: QueueWorker, state_store: StateStore, subject: ProtocolEngine, - model_utils: ModelUtils, - maintenance_run: bool, ) -> None: - """It should be able to stop the engine.""" - timestamp = datetime(2021, 1, 1, 0, 0) - error_id = "fake_error_id" - - decoy.when(model_utils.get_timestamp()).then_return(timestamp) - decoy.when(model_utils.generate_id()).then_return(error_id) - decoy.when(state_store.commands.get_is_stopped()).then_return(False) - decoy.when(state_store.commands.get_running_command_id()).then_return(None) - decoy.when(state_store.commands.get_queue_ids()).then_return(OrderedSet()) - - expected_stop = StopAction(from_estop=True) - expected_hardware_stop = HardwareStoppedAction( - completed_at=timestamp, - finish_error_details=FinishErrorDetails( - error=EStopActivatedError(message="Estop Activated"), - error_id=error_id, - created_at=timestamp, - ), - ) - + """It should no-op if a stop is invalid right now..""" + expected_action = StopAction(from_estop=True) decoy.when( - state_store.commands.validate_action_allowed(expected_stop), - ).then_return(expected_stop) + state_store.commands.validate_action_allowed(expected_action), + ).then_raise(RuntimeError("unable to stop; this machine craves flesh")) - subject.estop(maintenance_run=maintenance_run) + subject.estop() # Should not raise. decoy.verify( - action_dispatcher.dispatch(expected_stop), times=1 if maintenance_run else 0 + action_dispatcher.dispatch(), # type: ignore + ignore_extra_args=True, + times=0, ) decoy.verify( - action_dispatcher.dispatch(expected_hardware_stop), - times=1 if maintenance_run else 0, + queue_worker.cancel(), + ignore_extra_args=True, + times=0, ) - decoy.verify(queue_worker.cancel(), times=1 if maintenance_run else 0) def test_add_plugin( diff --git a/api/tests/opentrons/protocol_runner/smoke_tests/test_legacy_command_mapper.py b/api/tests/opentrons/protocol_runner/smoke_tests/test_legacy_command_mapper.py index 5d6595227b9..c8950cbe090 100644 --- a/api/tests/opentrons/protocol_runner/smoke_tests/test_legacy_command_mapper.py +++ b/api/tests/opentrons/protocol_runner/smoke_tests/test_legacy_command_mapper.py @@ -5,6 +5,7 @@ """ from datetime import datetime from pathlib import Path +from textwrap import dedent from typing import List import pytest @@ -753,3 +754,46 @@ async def test_zero_volume_dispense_commands( labwareId=load_well_plate.result.labwareId, wellName="D7", ) + + +async def test_air_gap(tmp_path: Path) -> None: + """An `air_gap()` should be mapped to an `aspirate`. + + This covers RQA-2621. + """ + path = tmp_path / "protocol.py" + path.write_text( + dedent( + """\ + metadata = {"apiLevel": "2.13"} + def run(protocol): + # Prep: + tip_rack = protocol.load_labware("opentrons_96_tiprack_300ul", 1) + well_plate = protocol.load_labware("biorad_96_wellplate_200ul_pcr", 2) + pipette = protocol.load_instrument("p300_single_gen2", mount="left", tip_racks=[tip_rack]) + pipette.pick_up_tip() + + # Test: + pipette.move_to(well_plate["A1"].top()) + pipette.air_gap(100) + """ + ) + ) + result_commands = await simulate_and_get_commands(path) + [ + initial_home, + load_tip_rack, + load_well_plate, + load_pipette, + pick_up_tip, + move_to_well, + air_gap_aspirate, + ] = result_commands + assert isinstance(initial_home, commands.Home) + assert isinstance(load_tip_rack, commands.LoadLabware) + assert isinstance(load_well_plate, commands.LoadLabware) + assert isinstance(load_pipette, commands.LoadPipette) + assert isinstance(pick_up_tip, commands.PickUpTip) + # TODO(mm, 2024-04-23): This commands.Custom looks wrong. This should be a commands.MoveToWell. + assert isinstance(move_to_well, commands.Custom) + assert isinstance(air_gap_aspirate, commands.Aspirate) 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 8a8ec50b779..a0581001a82 100644 --- a/api/tests/opentrons/protocol_runner/test_legacy_command_mapper.py +++ b/api/tests/opentrons/protocol_runner/test_legacy_command_mapper.py @@ -7,7 +7,7 @@ from decoy import matchers, Decoy from opentrons.hardware_control.dev_types import PipetteDict -from opentrons.commands.types import CommentMessage, PauseMessage, CommandMessage +from opentrons.legacy_commands.types import CommentMessage, PauseMessage, CommandMessage from opentrons.protocol_engine import ( DeckSlotLocation, ModuleLocation, @@ -156,6 +156,7 @@ def test_map_after_with_error_command() -> None: assert result == [ pe_actions.FailCommandAction( command_id="command.COMMENT-0", + running_command=matchers.Anything(), error_id=matchers.IsA(str), failed_at=matchers.IsA(datetime), error=matchers.ErrorMatching( @@ -257,6 +258,7 @@ def test_command_stack() -> None: ), pe_actions.FailCommandAction( command_id="command.COMMENT-1", + running_command=matchers.Anything(), error_id=matchers.IsA(str), failed_at=matchers.IsA(datetime), error=matchers.ErrorMatching(LegacyContextCommandError, "oh no"), @@ -577,6 +579,7 @@ def test_map_pause() -> None: "command.DISTRIBUTE", "command.TRANSFER", "command.RETURN_TIP", + "command.AIR_GAP", ], ) def test_filter_higher_order_commands(command_type: str) -> None: diff --git a/api/tests/opentrons/protocol_runner/test_legacy_context_plugin.py b/api/tests/opentrons/protocol_runner/test_legacy_context_plugin.py index 1f7de8388ca..f11676bcd37 100644 --- a/api/tests/opentrons/protocol_runner/test_legacy_context_plugin.py +++ b/api/tests/opentrons/protocol_runner/test_legacy_context_plugin.py @@ -5,7 +5,10 @@ from datetime import datetime from typing import Callable -from opentrons.commands.types import CommandMessage as LegacyCommand, PauseMessage +from opentrons.legacy_commands.types import ( + CommandMessage as LegacyCommand, + PauseMessage, +) from opentrons.protocol_engine import ( StateView, actions as pe_actions, diff --git a/api/tests/opentrons/protocol_runner/test_protocol_runner.py b/api/tests/opentrons/protocol_runner/test_protocol_runner.py index 4f3ca342359..68e215bf3dd 100644 --- a/api/tests/opentrons/protocol_runner/test_protocol_runner.py +++ b/api/tests/opentrons/protocol_runner/test_protocol_runner.py @@ -1,4 +1,6 @@ """Tests for the PythonAndLegacyRunner, JsonRunner & LiveRunner classes.""" +from datetime import datetime + import pytest from pytest_lazyfixture import lazy_fixture # type: ignore[import-untyped] from decoy import Decoy, matchers @@ -18,7 +20,12 @@ from opentrons.util.broker import Broker from opentrons import protocol_reader -from opentrons.protocol_engine import ProtocolEngine, Liquid, commands as pe_commands +from opentrons.protocol_engine import ( + ProtocolEngine, + Liquid, + commands as pe_commands, + errors as pe_errors, +) from opentrons.protocol_reader import ( ProtocolSource, JsonProtocolConfig, @@ -231,7 +238,7 @@ def test_pause( """It should pause a protocol run with pause.""" subject.pause() - decoy.verify(protocol_engine.pause(), times=1) + decoy.verify(protocol_engine.request_pause(), times=1) @pytest.mark.parametrize( @@ -254,7 +261,7 @@ async def test_stop( subject.play() await subject.stop() - decoy.verify(await protocol_engine.stop(), times=1) + decoy.verify(await protocol_engine.request_stop(), times=1) @pytest.mark.parametrize( @@ -328,6 +335,96 @@ async def test_run_json_runner( ) +async def test_run_json_runner_stop_requested_stops_enquqing( + decoy: Decoy, + hardware_api: HardwareAPI, + protocol_engine: ProtocolEngine, + task_queue: TaskQueue, + json_runner_subject: JsonRunner, + json_file_reader: JsonFileReader, + json_translator: JsonTranslator, +) -> None: + """It should run a protocol to completion.""" + labware_definition = LabwareDefinition.construct() # type: ignore[call-arg] + json_protocol_source = ProtocolSource( + directory=Path("/dev/null"), + main_file=Path("/dev/null/abc.json"), + files=[], + metadata={}, + robot_type="OT-2 Standard", + config=JsonProtocolConfig(schema_version=6), + content_hash="abc123", + ) + + commands: List[pe_commands.CommandCreate] = [ + pe_commands.HomeCreate(params=pe_commands.HomeParams()), + pe_commands.WaitForDurationCreate( + params=pe_commands.WaitForDurationParams(seconds=10) + ), + pe_commands.LoadLiquidCreate( + params=pe_commands.LoadLiquidParams( + liquidId="water-id", labwareId="labware-id", volumeByWell={"A1": 30} + ) + ), + ] + + liquids: List[Liquid] = [ + Liquid(id="water-id", displayName="water", description="water desc") + ] + + json_protocol = ProtocolSchemaV6.construct() # type: ignore[call-arg] + + decoy.when( + await protocol_reader.extract_labware_definitions(json_protocol_source) + ).then_return([labware_definition]) + decoy.when(json_file_reader.read(json_protocol_source)).then_return(json_protocol) + decoy.when(json_translator.translate_commands(json_protocol)).then_return(commands) + decoy.when(json_translator.translate_liquids(json_protocol)).then_return(liquids) + decoy.when( + await protocol_engine.add_and_execute_command( + pe_commands.HomeCreate(params=pe_commands.HomeParams()), + ) + ).then_return( + pe_commands.Home.construct(status=pe_commands.CommandStatus.SUCCEEDED) # type: ignore[call-arg] + ) + decoy.when( + await protocol_engine.add_and_execute_command( + pe_commands.WaitForDurationCreate( + params=pe_commands.WaitForDurationParams(seconds=10) + ), + ) + ).then_return( + pe_commands.WaitForDuration.construct( # type: ignore[call-arg] + error=pe_errors.ErrorOccurrence.from_failed( + id="some-id", + createdAt=datetime(year=2021, month=1, day=1), + error=pe_errors.ProtocolEngineError(), + ) + ) + ) + + await json_runner_subject.load(json_protocol_source) + + run_func_captor = matchers.Captor() + + decoy.verify( + protocol_engine.add_labware_definition(labware_definition), + protocol_engine.add_liquid( + id="water-id", name="water", description="water desc", color=None + ), + protocol_engine.add_command( + request=pe_commands.HomeCreate(params=pe_commands.HomeParams(axes=None)) + ), + task_queue.set_run_func(func=run_func_captor), + ) + + # Verify that the run func calls the right things: + run_func = run_func_captor.value + + with pytest.raises(pe_errors.ProtocolEngineError): + await run_func() + + @pytest.mark.parametrize( "schema_version, json_protocol", [ @@ -385,6 +482,8 @@ async def test_load_json_runner( await json_runner_subject.load(json_protocol_source) + run_func_captor = matchers.Captor() + decoy.verify( protocol_engine.add_labware_definition(labware_definition), protocol_engine.add_liquid( @@ -393,24 +492,30 @@ async def test_load_json_runner( protocol_engine.add_command( request=pe_commands.HomeCreate(params=pe_commands.HomeParams(axes=None)) ), - protocol_engine.add_command( + task_queue.set_run_func(func=run_func_captor), + ) + + # Verify that the run func calls the right things: + run_func = run_func_captor.value + await run_func() + decoy.verify( + await protocol_engine.add_and_execute_command( request=pe_commands.WaitForResumeCreate( params=pe_commands.WaitForResumeParams(message="hello") - ) + ), ), - protocol_engine.add_command( + await protocol_engine.add_and_execute_command( request=pe_commands.WaitForResumeCreate( params=pe_commands.WaitForResumeParams(message="goodbye") - ) + ), ), - protocol_engine.add_command( + await protocol_engine.add_and_execute_command( request=pe_commands.LoadLiquidCreate( params=pe_commands.LoadLiquidParams( liquidId="water-id", labwareId="labware-id", volumeByWell={"A1": 30} ) ), ), - task_queue.set_run_func(func=protocol_engine.wait_until_complete), ) diff --git a/api/tests/opentrons/protocols/duration/test_estimator.py b/api/tests/opentrons/protocols/duration/test_estimator.py index 92614869641..594f1cfad57 100644 --- a/api/tests/opentrons/protocols/duration/test_estimator.py +++ b/api/tests/opentrons/protocols/duration/test_estimator.py @@ -3,7 +3,7 @@ import math import pytest -from opentrons.commands import types +from opentrons.legacy_commands import types from opentrons.protocol_api import InstrumentContext from opentrons.protocols.duration.estimator import ( DurationEstimator, diff --git a/api/tests/opentrons/protocols/parameters/test_validation.py b/api/tests/opentrons/protocols/parameters/test_validation.py index 988e203a822..0ff337eb91d 100644 --- a/api/tests/opentrons/protocols/parameters/test_validation.py +++ b/api/tests/opentrons/protocols/parameters/test_validation.py @@ -12,6 +12,14 @@ from opentrons.protocols.parameters import validation as subject +def test_validate_variable_name_unique() -> None: + """It should no-op if the name is unique or if it's not a string, and raise if it is not.""" + subject.validate_variable_name_unique("one of a kind", {"fee", "foo", "fum"}) + subject.validate_variable_name_unique({}, {"fee", "foo", "fum"}) # type: ignore[arg-type] + with pytest.raises(ParameterNameError): + subject.validate_variable_name_unique("copy", {"paste", "copy", "cut"}) + + def test_ensure_display_name() -> None: """It should ensure the display name is within the character limit.""" result = subject.ensure_display_name("abc") @@ -96,10 +104,12 @@ def test_ensure_variable_name_raises_keyword(variable_name: str) -> None: def test_validate_options() -> None: """It should not raise when given valid constraints""" subject.validate_options(123, 1, 100, None, int) + subject.validate_options(123, 100, 100, None, int) subject.validate_options( 123, None, None, [{"display_name": "abc", "value": 456}], int ) subject.validate_options(12.3, 1.1, 100.9, None, float) + subject.validate_options(12.3, 1.1, 1.1, None, float) subject.validate_options( 12.3, None, None, [{"display_name": "abc", "value": 45.6}], float ) @@ -134,19 +144,84 @@ def test_validate_options_raises_name_error() -> None: [ (1.0, int, 1), (1.1, int, 1.1), + (2, float, 2.0), (2.0, float, 2.0), (2.2, float, 2.2), ("3.0", str, "3.0"), + (0.0, bool, False), + (1, bool, True), + (3.0, bool, 3.0), (True, bool, True), ], ) def test_ensure_value_type( value: Union[float, bool, str], param_type: type, result: AllowedTypes ) -> None: - """It should ensure the correct type is there, converting floats to ints.""" + """It should ensure that if applicable, the value is coerced into the expected type""" assert result == subject.ensure_value_type(value, param_type) +@pytest.mark.parametrize( + ["value", "result"], + [ + (1, 1.0), + (2.0, 2.0), + (3.3, 3.3), + ], +) +def test_ensure_float_value(value: Union[float, int], result: float) -> None: + """It should ensure that if applicable, the value is coerced into a float.""" + assert result == subject.ensure_float_value(value) + + +@pytest.mark.parametrize( + ["value", "result"], + [ + (1, 1.0), + (2.0, 2.0), + (3.3, 3.3), + (None, None), + ], +) +def test_ensure_optional_float_value(value: Union[float, int], result: float) -> None: + """It should ensure that if applicable, the value is coerced into a float.""" + assert result == subject.ensure_optional_float_value(value) + + +@pytest.mark.parametrize( + ["choices", "result"], + [ + ([], []), + (None, None), + ( + [{"display_name": "foo", "value": 1}], + [{"display_name": "foo", "value": 1.0}], + ), + ( + [{"display_name": "foo", "value": 2.0}], + [{"display_name": "foo", "value": 2.0}], + ), + ( + [{"display_name": "foo", "value": 3.3}], + [{"display_name": "foo", "value": 3.3}], + ), + ( + [{"display_name": "foo", "value": "4"}], + [{"display_name": "foo", "value": "4"}], + ), + ( + [{"display_name": "foo", "value": True}], + [{"display_name": "foo", "value": True}], + ), + ], +) +def test_ensure_float_choices( + choices: Optional[List[ParameterChoice]], result: Optional[List[ParameterChoice]] +) -> None: + """It should ensure that if applicable, the value in a choice is coerced into a float.""" + assert result == subject.ensure_float_choices(choices) + + @pytest.mark.parametrize( ["param_type", "result"], [(int, "int"), (float, "float"), (str, "str")], @@ -206,14 +281,15 @@ def test_convert_type_string_for_num_param_raises(param_type: type) -> None: None, [{"display_name": "abc", "value": "123"}], int, - "must match type", + "must be of type", ), (123, 1, None, None, int, "maximum must also"), (123, None, 100, None, int, "minimum must also"), (123, 100, 1, None, int, "Maximum must be greater"), - (123, 1.1, 100, None, int, "Minimum and maximum must match type"), - (123, 1, 100.5, None, int, "Minimum and maximum must match type"), - (123, "1", "100", None, int, "Only parameters of type float or int"), + (123, 1.1, 100, None, int, "Minimum is type"), + (123, 1, 100.5, None, int, "Maximum is type"), + (123.0, "1.0", 100.0, None, float, "Minimum is type"), + ("blah", 1, 100, None, str, "Only parameters of type float or int"), ], ) def test_validate_options_raise_definition_error( diff --git a/api/tests/opentrons/test_legacy_broker.py b/api/tests/opentrons/test_legacy_broker.py index 2351f73e348..719fe43052d 100644 --- a/api/tests/opentrons/test_legacy_broker.py +++ b/api/tests/opentrons/test_legacy_broker.py @@ -2,8 +2,8 @@ from typing import List, NamedTuple, cast -from opentrons.commands.types import CommandMessage -from opentrons.commands.publisher import CommandPublisher, publish +from opentrons.legacy_commands.types import CommandMessage +from opentrons.legacy_commands.publisher import CommandPublisher, publish def _my_command(arg1: int, arg2: str = "", arg3: str = "") -> CommandMessage: diff --git a/api/tests/opentrons/util/test_performance_helpers.py b/api/tests/opentrons/util/test_performance_helpers.py new file mode 100644 index 00000000000..57a42ef6a71 --- /dev/null +++ b/api/tests/opentrons/util/test_performance_helpers.py @@ -0,0 +1,28 @@ +"""Tests for performance_helpers.""" + +from pathlib import Path +from opentrons_shared_data.performance.dev_types import RobotContextState +from opentrons.util.performance_helpers import ( + StubbedTracker, + _get_robot_context_tracker, +) + + +def test_return_function_unchanged() -> None: + """Test that the function is returned unchanged when using StubbedTracker.""" + tracker = StubbedTracker(Path("/path/to/storage"), True) + + def func_to_track() -> None: + pass + + assert ( + tracker.track(RobotContextState.ANALYZING_PROTOCOL)(func_to_track) + is func_to_track + ) + + +def test_singleton_tracker() -> None: + """Test that the tracker is a singleton.""" + tracker = _get_robot_context_tracker() + tracker2 = _get_robot_context_tracker() + assert tracker is tracker2 diff --git a/app-shell-odd/src/config/__fixtures__/index.ts b/app-shell-odd/src/config/__fixtures__/index.ts index 5e26ddc99ef..b3ff0cbfbd7 100644 --- a/app-shell-odd/src/config/__fixtures__/index.ts +++ b/app-shell-odd/src/config/__fixtures__/index.ts @@ -11,11 +11,13 @@ import type { ConfigV21, } from '@opentrons/app/src/redux/config/types' +const PKG_VERSION: string = _PKG_VERSION_ + export const MOCK_CONFIG_V12: ConfigV12 = { version: 12, devtools: false, reinstallDevtools: false, - update: { channel: _PKG_VERSION_.includes('beta') ? 'beta' : 'latest' }, + update: { channel: PKG_VERSION.includes('beta') ? 'beta' : 'latest' }, log: { level: { file: 'debug', console: 'info' } }, ui: { width: 1024, diff --git a/app-shell-odd/src/config/migrate.ts b/app-shell-odd/src/config/migrate.ts index 6d9a1c9b82b..9a05df79594 100644 --- a/app-shell-odd/src/config/migrate.ts +++ b/app-shell-odd/src/config/migrate.ts @@ -22,11 +22,12 @@ import type { const CONFIG_VERSION_LATEST = 21 // update this after each config version bump +const PKG_VERSION: string = _PKG_VERSION_ export const DEFAULTS_V12: ConfigV12 = { version: 12, devtools: false, reinstallDevtools: false, - update: { channel: _PKG_VERSION_.includes('beta') ? 'beta' : 'latest' }, + update: { channel: PKG_VERSION.includes('beta') ? 'beta' : 'latest' }, log: { level: { file: 'debug', console: 'info' } }, ui: { width: 1024, diff --git a/app-shell-odd/src/notifications/deserialize.ts b/app-shell-odd/src/notifications/deserialize.ts index 4539bc97faa..01fd4bc933b 100644 --- a/app-shell-odd/src/notifications/deserialize.ts +++ b/app-shell-odd/src/notifications/deserialize.ts @@ -12,7 +12,7 @@ import type { import { FAILURE_STATUSES } from '../constants' const VALID_NOTIFY_RESPONSES: [NotifyRefetchData, NotifyUnsubscribeData] = [ - { refetchUsingHTTP: true }, + { refetch: true }, { unsubscribe: true }, ] diff --git a/app-shell-odd/src/update.ts b/app-shell-odd/src/update.ts index f27ce2eced4..d1ea2f154b3 100644 --- a/app-shell-odd/src/update.ts +++ b/app-shell-odd/src/update.ts @@ -14,15 +14,15 @@ import type { ReleaseSetUrls } from './system-update/types' const log = createLogger('update') +const OPENTRONS_PROJECT: string = _OPENTRONS_PROJECT_ + export const FLEX_MANIFEST_URL = - // @ts-expect-error can't get TS to recognize global.d.ts - global._OPENTRONS_PROJECT_ && - // @ts-expect-error can't get TS to recognize global.d.ts - global._OPENTRONS_PROJECT_.includes('robot-stack') + OPENTRONS_PROJECT && OPENTRONS_PROJECT.includes('robot-stack') ? 'https://builds.opentrons.com/ot3-oe/releases.json' : 'https://ot3-development.builds.opentrons.com/ot3-oe/releases.json' -let LATEST_OT_SYSTEM_VERSION = _PKG_VERSION_ +const PKG_VERSION = _PKG_VERSION_ +let LATEST_OT_SYSTEM_VERSION = PKG_VERSION const channelFinder = (version: string, channel: string): boolean => { // return the latest alpha/beta if a user subscribes to alpha/beta updates @@ -60,7 +60,7 @@ export const updateLatestVersion = (): Promise => { }) .find(verson => channelFinder(verson, channel)) const changed = LATEST_OT_SYSTEM_VERSION !== latestAvailableVersion - LATEST_OT_SYSTEM_VERSION = latestAvailableVersion ?? _PKG_VERSION_ + LATEST_OT_SYSTEM_VERSION = latestAvailableVersion ?? PKG_VERSION if (changed) { log.info( `Update: latest version available from ${FLEX_MANIFEST_URL} is ${latestAvailableVersion}` @@ -80,7 +80,7 @@ export const getLatestVersion = (): string => { return LATEST_OT_SYSTEM_VERSION } -export const getCurrentVersion = (): string => _PKG_VERSION_ +export const getCurrentVersion = (): string => PKG_VERSION export const isUpdateAvailable = (): boolean => getLatestVersion() !== getCurrentVersion() diff --git a/app-shell-odd/typings/global.d.ts b/app-shell-odd/typings/global.d.ts index 8513596d045..3b470870c2b 100644 --- a/app-shell-odd/typings/global.d.ts +++ b/app-shell-odd/typings/global.d.ts @@ -1,11 +1,4 @@ -import type { IpcRenderer } from 'electron' - declare global { - const _PKG_VERSION_: string - const _PKG_PRODUCT_NAME_: string - const _PKG_BUGS_URL_: string - const _OPENTRONS_PROJECT_: string - namespace NodeJS { export interface Global { APP_SHELL_REMOTE: { @@ -14,3 +7,8 @@ declare global { } } } + +declare const _PKG_VERSION_: string +declare const _PKG_PRODUCT_NAME_: string +declare const _PKG_BUGS_URL_: string +declare const _OPENTRONS_PROJECT_: string diff --git a/app-shell-odd/vite.config.ts b/app-shell-odd/vite.config.ts index b9575159675..7848c92bd8d 100644 --- a/app-shell-odd/vite.config.ts +++ b/app-shell-odd/vite.config.ts @@ -1,13 +1,14 @@ -import { versionForProject } from '../scripts/git-version' +import { versionForProject } from '../scripts/git-version.mjs' import pkg from './package.json' import path from 'path' -import { UserConfig, defineConfig } from 'vite' +import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' import postCssImport from 'postcss-import' import postCssApply from 'postcss-apply' import postColorModFunction from 'postcss-color-mod-function' import postCssPresetEnv from 'postcss-preset-env' import lostCss from 'lost' +import type { UserConfig } from 'vite' export default defineConfig( async (): Promise => { @@ -79,7 +80,7 @@ export default defineConfig( '../discovery-client/src/index.ts' ), '@opentrons/usb-bridge/node-client': path.resolve( - '../usb-bridge/node-client/src/inxex.ts' + '../usb-bridge/node-client/src/index.ts' ), }, }, diff --git a/app-shell/build/release-notes-internal.md b/app-shell/build/release-notes-internal.md index a15d877c0ab..591aa411a3c 100644 --- a/app-shell/build/release-notes-internal.md +++ b/app-shell/build/release-notes-internal.md @@ -1,6 +1,34 @@ For more details about this release, please see the full [technical changelog][]. [technical change log]: https://github.com/Opentrons/opentrons/releases +## Internal Release 1.4.0-alpha.1 + +This internal release is from the `edge` branch to contain rapid dev on new features for 7.3.0. This release is for internal testing purposes and if used may require a factory reset of the robot to return to a stable version. + +### Notable bug fixes + +App and robot update prompts should now function properly. However, updating from 1.4.0-alpha.0 to 1.4.0-alpha.1 will still present issues, as the fix is not in 1.4.0-alpha.0. After installing 1.4.0-alpha.1, switch your update channel to "latest" to receive the latest stable internal release prompt, which validates the fix. + +### All changes + + + +--- + +## Internal Release 1.4.0-alpha.0 + +This internal release is from the `edge` branch to contain rapid dev on new features for 7.3.0. This release is for internal testing purposes and if used may require a factory reset of the robot to return to a stable version. + + + +--- + +## Internal Release 1.3.0-alpha.0 + +This internal release is from the `edge` branch to contain rapid dev on new features for 7.3.0. This release is for internal testing purposes and if used may require a factory reset of the robot to return to a stable version. + + + --- # Internal Release 1.1.0 diff --git a/app-shell/build/release-notes.md b/app-shell/build/release-notes.md index 97fa5f01b81..43db1bdfaf8 100644 --- a/app-shell/build/release-notes.md +++ b/app-shell/build/release-notes.md @@ -6,6 +6,14 @@ log][]. For a list of currently known issues, please see the [Opentrons issue tr --- +## Opentrons App Changes in 7.2.2 + +Welcome to the v7.2.2 release of the Opentrons App! + +There are no changes to the Opentrons App in v7.2.2, but it is required for updating the robot software to improve some features. + +--- + ## Opentrons App Changes in 7.2.1 Welcome to the v7.2.1 release of the Opentrons App! diff --git a/app-shell/electron-builder.config.js b/app-shell/electron-builder.config.js index 727b2d5e900..aa61720338b 100644 --- a/app-shell/electron-builder.config.js +++ b/app-shell/electron-builder.config.js @@ -1,6 +1,5 @@ 'use strict' const path = require('path') -const { versionForProject } = require('../scripts/git-version') const { OT_APP_DEPLOY_BUCKET, @@ -45,7 +44,9 @@ module.exports = async () => ({ }, ], extraMetadata: { - version: await versionForProject(project), + version: await ( + await import('../scripts/git-version.mjs') + ).versionForProject(project), productName: project === 'robot-stack' ? 'Opentrons' : 'Opentrons-OT3', }, extraResources: USE_PYTHON ? ['python'] : [], diff --git a/app-shell/src/menu.ts b/app-shell/src/menu.ts index 71b1318df38..52f04978934 100644 --- a/app-shell/src/menu.ts +++ b/app-shell/src/menu.ts @@ -5,6 +5,9 @@ import type { MenuItemConstructorOptions } from 'electron' import { LOG_DIR } from './log' +const PRODUCT_NAME: string = _PKG_PRODUCT_NAME_ +const BUGS_URL: string = _PKG_BUGS_URL_ + // file or application menu const firstMenu: MenuItemConstructorOptions = { role: process.platform === 'darwin' ? 'appMenu' : 'fileMenu', @@ -27,8 +30,7 @@ const helpMenu: MenuItemConstructorOptions = { }, }, { - // @ts-expect-error can't get TS to recognize global.d.ts - label: `View ${global._PKG_PRODUCT_NAME_} App Logs`, + label: `View ${PRODUCT_NAME} App Logs`, click: () => { shell.openPath(LOG_DIR) }, @@ -37,8 +39,7 @@ const helpMenu: MenuItemConstructorOptions = { label: 'Report an Issue', click: () => { // eslint-disable-next-line @typescript-eslint/no-floating-promises - // @ts-expect-error can't get TS to recognize global.d.ts - shell.openExternal(global._PKG_BUGS_URL_) + shell.openExternal(BUGS_URL) }, }, ], diff --git a/app-shell/src/notifications/__tests__/deserialize.test.ts b/app-shell/src/notifications/__tests__/deserialize.test.ts index 9c6642d3931..ca9bab984fb 100644 --- a/app-shell/src/notifications/__tests__/deserialize.test.ts +++ b/app-shell/src/notifications/__tests__/deserialize.test.ts @@ -4,7 +4,7 @@ import { deserializeExpectedMessages } from '../deserialize' import type { NotifyResponseData } from '@opentrons/app/src/redux/shell/types' -const MOCK_VALID_RESPONSE: NotifyResponseData = { refetchUsingHTTP: true } +const MOCK_VALID_RESPONSE: NotifyResponseData = { refetch: true } const MOCK_VALID_STRING_RESPONSE = JSON.stringify(MOCK_VALID_RESPONSE) const MOCK_INVALID_OBJECT = JSON.stringify({ test: 'MOCK_RESPONSE' }) const MOCK_INVALID_STRING = 'MOCK_STRING' diff --git a/app-shell/src/notifications/__tests__/store.test.ts b/app-shell/src/notifications/__tests__/store.test.ts new file mode 100644 index 00000000000..7192c8c2fa0 --- /dev/null +++ b/app-shell/src/notifications/__tests__/store.test.ts @@ -0,0 +1,348 @@ +import { describe, it, expect, beforeEach } from 'vitest' + +import { connectionStore } from '../store' + +const MOCK_IP = 'MOCK_IP' +const MOCK_ROBOT = 'MOCK_ROBOT' +const MOCK_WINDOW = {} as any +const MOCK_CLIENT = { connected: true } as any +const MOCK_TOPIC = 'MOCK_TOPIC' as any + +describe('ConnectionStore', () => { + beforeEach(() => { + connectionStore.clearStore() + }) + + describe('getBrowserWindow', () => { + it('should return the browser window', () => { + connectionStore.setBrowserWindow(MOCK_WINDOW) + expect(connectionStore.getBrowserWindow()).toBe(MOCK_WINDOW) + }) + }) + + describe('getAllBrokersInStore', () => { + it('should return an empty array if there are no brokers in the store', () => { + expect(connectionStore.getAllBrokersInStore()).toEqual([]) + }) + + it('should return an array of broker names in the store', async () => { + await connectionStore.setPendingConnection(MOCK_ROBOT) + await connectionStore.setPendingConnection('robot2') + expect(connectionStore.getAllBrokersInStore()).toEqual([ + MOCK_ROBOT, + 'robot2', + ]) + }) + }) + + describe('getClient', () => { + it('should return null if the given IP is not associated with a connection', () => { + expect(connectionStore.getClient(MOCK_IP)).toBeNull() + }) + + it('should return the client if the given IP is associated with a connection', async () => { + await connectionStore.setPendingConnection(MOCK_ROBOT) + connectionStore.associateIPWithRobotName(MOCK_IP, MOCK_ROBOT) + await connectionStore.setConnected(MOCK_ROBOT, MOCK_CLIENT) + expect(connectionStore.getClient(MOCK_IP)).toBe(MOCK_CLIENT) + }) + }) + + describe('setErrorStatus and getFailedConnectionStatus', () => { + it('should return null if the given IP is not associated with a connection', () => { + expect(connectionStore.getFailedConnectionStatus(MOCK_IP)).toBeNull() + }) + + it('should return the unreachable status for the given IP', async () => { + connectionStore.associateIPWithRobotName(MOCK_IP, MOCK_ROBOT) + await connectionStore.setPendingConnection(MOCK_ROBOT) + await connectionStore.setErrorStatus(MOCK_IP, 'ECONNFAILED') + expect(connectionStore.getFailedConnectionStatus(MOCK_IP)).toBe( + 'ECONNFAILED' + ) + }) + + it('should return "ECONNFAILED" if the unreachable status for the given IP is "ECONNREFUSED" after the first error status check', async () => { + connectionStore.associateIPWithRobotName(MOCK_IP, MOCK_ROBOT) + await connectionStore.setPendingConnection(MOCK_ROBOT) + await connectionStore.setErrorStatus(MOCK_IP, 'ECONNREFUSED') + expect(connectionStore.getFailedConnectionStatus(MOCK_IP)).toBe( + 'ECONNREFUSED' + ) + expect(connectionStore.getFailedConnectionStatus(MOCK_IP)).toBe( + 'ECONNFAILED' + ) + }) + + it('should throw an error if the given IP is not associated with a connection', async () => { + await expect( + connectionStore.setErrorStatus(MOCK_IP, 'Connection refused') + ).rejects.toThrowError('MOCK_IP is not associated with a connection') + }) + }) + + describe('getRobotNameByIP', () => { + it('should return null if the given IP is not associated with a connection', () => { + expect(connectionStore.getRobotNameByIP(MOCK_IP)).toBeNull() + }) + + it('should return the robot name associated with the given IP', () => { + connectionStore.associateIPWithRobotName(MOCK_IP, MOCK_ROBOT) + expect(connectionStore.getRobotNameByIP(MOCK_IP)).toBe(MOCK_ROBOT) + }) + }) + + describe('setBrowserWindow', () => { + it('should set the browser window', () => { + connectionStore.setBrowserWindow(MOCK_WINDOW) + expect(connectionStore.getBrowserWindow()).toBe(MOCK_WINDOW) + }) + }) + + describe('setPendingConnection', () => { + it('should create a new connection if there is no connection currently connecting', async () => { + await connectionStore.setPendingConnection(MOCK_ROBOT) + expect(connectionStore.getAllBrokersInStore()).toEqual([MOCK_ROBOT]) + }) + + it('should reject with an error if there is already a connection currently connecting', async () => { + await expect( + connectionStore.setPendingConnection(MOCK_ROBOT) + ).resolves.toBeUndefined() + await expect( + connectionStore.setPendingConnection(MOCK_ROBOT) + ).rejects.toThrowError( + 'Cannot create a new connection while currently connecting.' + ) + }) + }) + + describe('setConnected', () => { + it('should set the client for the given robot name', async () => { + connectionStore.setPendingConnection(MOCK_ROBOT) + await connectionStore.setConnected(MOCK_ROBOT, MOCK_CLIENT) + connectionStore.associateIPWithRobotName(MOCK_IP, MOCK_ROBOT) + expect(connectionStore.getClient(MOCK_IP)).toBe(MOCK_CLIENT) + }) + + it('should reject with an error if there is already a connection for the given robot name', async () => { + const MOCK_CLIENT_2 = {} as any + await connectionStore.setPendingConnection(MOCK_ROBOT) + await connectionStore.setConnected(MOCK_ROBOT, MOCK_CLIENT) + connectionStore.associateIPWithRobotName(MOCK_IP, MOCK_ROBOT) + await expect( + connectionStore.setConnected(MOCK_ROBOT, MOCK_CLIENT_2) + ).rejects.toThrowError('Connection already exists for MOCK_ROBOT') + }) + + it('should reject with an error if the given robot name is not associated with a connection', async () => { + await expect( + connectionStore.setConnected(MOCK_ROBOT, MOCK_CLIENT) + ).rejects.toThrowError('IP is not associated with a connection') + }) + }) + + describe('setSubStatus', () => { + it('should set the pending sub status for the given IP and topic', async () => { + connectionStore.associateIPWithRobotName(MOCK_IP, MOCK_ROBOT) + await connectionStore.setPendingConnection(MOCK_ROBOT) + await connectionStore.setConnected(MOCK_ROBOT, MOCK_CLIENT) + await connectionStore.setSubStatus(MOCK_IP, MOCK_TOPIC, 'pending') + expect(connectionStore.isPendingSub(MOCK_ROBOT, MOCK_TOPIC)).toBe(true) + }) + + it('should set the subscribed status for the given IP and topic', async () => { + connectionStore.associateIPWithRobotName(MOCK_IP, MOCK_ROBOT) + await connectionStore.setPendingConnection(MOCK_ROBOT) + await connectionStore.setConnected(MOCK_ROBOT, MOCK_CLIENT) + await connectionStore.setSubStatus(MOCK_IP, MOCK_TOPIC, 'subscribed') + expect(connectionStore.isActiveSub(MOCK_ROBOT, MOCK_TOPIC)).toBe(true) + expect(connectionStore.isPendingSub(MOCK_ROBOT, MOCK_TOPIC)).toBe(false) + }) + + it('should throw an error if the given IP is not associated with a connection', async () => { + await expect( + connectionStore.setSubStatus(MOCK_IP, MOCK_TOPIC, 'pending') + ).rejects.toThrowError('IP is not associated with a connection') + }) + }) + + describe('setUnsubStatus', () => { + it('should set the pending unsub status for the given IP and topic if it is currently subscribed', async () => { + connectionStore.associateIPWithRobotName(MOCK_IP, MOCK_ROBOT) + await connectionStore.setPendingConnection(MOCK_ROBOT) + await connectionStore.setConnected(MOCK_ROBOT, MOCK_CLIENT) + await connectionStore.setSubStatus(MOCK_IP, MOCK_TOPIC, 'subscribed') + await connectionStore.setUnsubStatus(MOCK_IP, MOCK_TOPIC, 'pending') + expect(connectionStore.isPendingUnsub(MOCK_IP, MOCK_TOPIC)).toBe(true) + expect(connectionStore.isActiveSub(MOCK_ROBOT, MOCK_TOPIC)).toBe(true) + }) + + it('should set the unsubscribed status for the given IP and topic if it is currently subscribed', async () => { + connectionStore.associateIPWithRobotName(MOCK_IP, MOCK_ROBOT) + await connectionStore.setPendingConnection(MOCK_ROBOT) + await connectionStore.setConnected(MOCK_ROBOT, MOCK_CLIENT) + await connectionStore.setSubStatus(MOCK_IP, MOCK_TOPIC, 'subscribed') + await connectionStore.setUnsubStatus(MOCK_IP, MOCK_TOPIC, 'unsubscribed') + expect(connectionStore.isActiveSub(MOCK_ROBOT, MOCK_TOPIC)).toBe(false) + expect(connectionStore.isPendingUnsub(MOCK_IP, MOCK_TOPIC)).toBe(false) + }) + + it('should not do anything if the given IP is not associated with a connection', async () => { + await expect( + connectionStore.setUnsubStatus(MOCK_IP, MOCK_TOPIC, 'pending') + ).rejects.toThrowError('IP is not associated with a connection') + }) + }) + + describe('associateIPWithRobotName', () => { + it('should associate the given IP with the given robot name', () => { + connectionStore.associateIPWithRobotName(MOCK_IP, MOCK_ROBOT) + expect(connectionStore.getRobotNameByIP(MOCK_IP)).toBe(MOCK_ROBOT) + }) + + it('should update the association if the IP is already associated with a different robot name', () => { + connectionStore.associateIPWithRobotName(MOCK_IP, MOCK_ROBOT) + connectionStore.associateIPWithRobotName(MOCK_IP, 'robot2') + expect(connectionStore.getRobotNameByIP(MOCK_IP)).toBe('robot2') + }) + }) + + describe('clearStore', () => { + it('should clear all connections and robot names', async () => { + await connectionStore.setPendingConnection(MOCK_ROBOT) + connectionStore.setBrowserWindow(MOCK_WINDOW) + expect(connectionStore.getAllBrokersInStore()).not.toEqual([]) + expect(connectionStore.getBrowserWindow()).not.toBeNull() + connectionStore.clearStore() + expect(connectionStore.getAllBrokersInStore()).toEqual([]) + expect(connectionStore.getBrowserWindow()).toBeNull() + }) + }) + + describe('isConnectedToBroker', () => { + it('should return false if the given robot name is not associated with a connection', () => { + expect(connectionStore.isConnectedToBroker(MOCK_ROBOT)).toBe(false) + }) + + it('should return false if the connection client is null', async () => { + await connectionStore.setPendingConnection(MOCK_ROBOT) + expect(connectionStore.isConnectedToBroker(MOCK_ROBOT)).toBe(false) + }) + + it('should return true if the connection client is not null', async () => { + await connectionStore.setPendingConnection(MOCK_ROBOT) + await connectionStore.setConnected(MOCK_ROBOT, MOCK_CLIENT) + expect(connectionStore.isConnectedToBroker(MOCK_ROBOT)).toBe(true) + }) + }) + + describe('isConnectingToBroker', () => { + it('should return false if the given robot name is not associated with a connection', () => { + expect(connectionStore.isConnectingToBroker(MOCK_ROBOT)).toBe(false) + }) + + it('should return false if the connection client is not null', () => { + connectionStore.setPendingConnection(MOCK_ROBOT) + connectionStore.setConnected(MOCK_ROBOT, MOCK_CLIENT) + expect(connectionStore.isConnectingToBroker(MOCK_ROBOT)).toBe(false) + }) + + it('should return true if the connection client is null and the connection is not terminated', () => { + connectionStore.setPendingConnection(MOCK_ROBOT) + expect(connectionStore.isConnectingToBroker(MOCK_ROBOT)).toBe(true) + }) + }) + + describe('isPendingSub', () => { + it('should return false if the given IP is not associated with a connection', () => { + expect(connectionStore.isPendingSub(MOCK_ROBOT, MOCK_TOPIC)).toBe(false) + }) + + it('should return false if the topic is not pending', () => { + connectionStore.associateIPWithRobotName(MOCK_IP, MOCK_ROBOT) + expect(connectionStore.isPendingSub(MOCK_ROBOT, MOCK_TOPIC)).toBe(false) + }) + + it('should return true if the topic is pending', async () => { + connectionStore.associateIPWithRobotName(MOCK_IP, MOCK_ROBOT) + await connectionStore.setPendingConnection(MOCK_ROBOT) + await connectionStore.setConnected(MOCK_ROBOT, MOCK_CLIENT) + connectionStore.setSubStatus(MOCK_IP, MOCK_TOPIC, 'pending') + expect(connectionStore.isPendingSub(MOCK_ROBOT, MOCK_TOPIC)).toBe(true) + }) + }) + + describe('isActiveSub', () => { + it('should return false if the given IP is not associated with a connection', () => { + expect(connectionStore.isActiveSub(MOCK_ROBOT, MOCK_TOPIC)).toBe(false) + }) + + it('should return false if the topic is not subscribed', () => { + connectionStore.associateIPWithRobotName(MOCK_IP, MOCK_ROBOT) + expect(connectionStore.isActiveSub(MOCK_ROBOT, MOCK_TOPIC)).toBe(false) + }) + + it('should return true if the topic is subscribed', async () => { + connectionStore.associateIPWithRobotName(MOCK_IP, MOCK_ROBOT) + await connectionStore.setPendingConnection(MOCK_ROBOT) + await connectionStore.setConnected(MOCK_ROBOT, MOCK_CLIENT) + await connectionStore.setSubStatus(MOCK_IP, MOCK_TOPIC, 'subscribed') + expect(connectionStore.isActiveSub(MOCK_ROBOT, MOCK_TOPIC)).toBe(true) + }) + }) + + describe('isPendingUnsub', () => { + it('should return false if the given IP is not associated with a connection', () => { + expect(connectionStore.isPendingUnsub(MOCK_IP, MOCK_TOPIC)).toBe(false) + }) + + it('should return false if the topic is not pending', () => { + connectionStore.associateIPWithRobotName(MOCK_IP, MOCK_ROBOT) + expect(connectionStore.isPendingUnsub(MOCK_IP, MOCK_TOPIC)).toBe(false) + }) + + it('should return true if the topic is pending', async () => { + connectionStore.associateIPWithRobotName(MOCK_IP, MOCK_ROBOT) + await connectionStore.setPendingConnection(MOCK_ROBOT) + await connectionStore.setConnected(MOCK_ROBOT, MOCK_CLIENT) + await connectionStore.setSubStatus(MOCK_IP, MOCK_TOPIC, 'subscribed') + await connectionStore.setUnsubStatus(MOCK_IP, MOCK_TOPIC, 'pending') + expect(connectionStore.isPendingUnsub(MOCK_IP, MOCK_TOPIC)).toBe(true) + }) + }) + + describe('isConnectionTerminated', () => { + it('should return true if the given robot name is not associated with a connection', () => { + expect(connectionStore.isConnectionTerminated(MOCK_ROBOT)).toBe(true) + }) + + it('should return true if the unreachable status is not null', async () => { + connectionStore.associateIPWithRobotName(MOCK_IP, MOCK_ROBOT) + await connectionStore.setPendingConnection(MOCK_ROBOT) + await connectionStore.setConnected(MOCK_ROBOT, MOCK_CLIENT) + await connectionStore.setErrorStatus(MOCK_IP, 'Connection refused') + expect(connectionStore.isConnectionTerminated(MOCK_ROBOT)).toBe(true) + }) + + it('should return false if the unreachable status is null', async () => { + connectionStore.associateIPWithRobotName(MOCK_IP, MOCK_ROBOT) + await connectionStore.setPendingConnection(MOCK_ROBOT) + await connectionStore.setConnected(MOCK_ROBOT, MOCK_CLIENT) + expect(connectionStore.isConnectionTerminated(MOCK_ROBOT)).toBe(false) + }) + }) + + describe('isKnownPortBlockedIP', () => { + it('should return false if the given IP is not in the known port blocked IPs set', () => { + expect(connectionStore.isKnownPortBlockedIP('MOCK_IP_2')).toBe(false) + }) + + it('should return true if the given IP is in the known port blocked IPs set', async () => { + connectionStore.associateIPWithRobotName(MOCK_IP, MOCK_ROBOT) + await connectionStore.setPendingConnection(MOCK_ROBOT) + connectionStore.setErrorStatus(MOCK_IP, 'ECONNREFUSED') + expect(connectionStore.isKnownPortBlockedIP(MOCK_IP)).toBe(true) + }) + }) +}) diff --git a/app-shell/src/notifications/deserialize.ts b/app-shell/src/notifications/deserialize.ts index c96d6d19203..53752b32a0f 100644 --- a/app-shell/src/notifications/deserialize.ts +++ b/app-shell/src/notifications/deserialize.ts @@ -18,7 +18,7 @@ interface SendToBrowserParams { } const VALID_NOTIFY_RESPONSES: [NotifyRefetchData, NotifyUnsubscribeData] = [ - { refetchUsingHTTP: true }, + { refetch: true }, { unsubscribe: true }, ] diff --git a/app-shell/src/notifications/store.ts b/app-shell/src/notifications/store.ts index 9968080258e..c9742ec6f90 100644 --- a/app-shell/src/notifications/store.ts +++ b/app-shell/src/notifications/store.ts @@ -207,7 +207,8 @@ class ConnectionStore { public isConnectingToBroker(robotName: string): boolean { return ( - (this.hostsByRobotName[robotName]?.client == null ?? false) && + robotName in this.hostsByRobotName && + this.hostsByRobotName[robotName].client == null && !this.isConnectionTerminated(robotName) ) } diff --git a/app-shell/src/protocol-analysis/__tests__/writeFailedAnalysis.test.ts b/app-shell/src/protocol-analysis/__tests__/writeFailedAnalysis.test.ts index 2c4d5a911ae..4514887cb6d 100644 --- a/app-shell/src/protocol-analysis/__tests__/writeFailedAnalysis.test.ts +++ b/app-shell/src/protocol-analysis/__tests__/writeFailedAnalysis.test.ts @@ -41,6 +41,7 @@ describe('write failed analysis', () => { modules: [], pipettes: [], liquids: [], + runTimeParameters: [], }) }) }) diff --git a/app-shell/src/protocol-analysis/writeFailedAnalysis.ts b/app-shell/src/protocol-analysis/writeFailedAnalysis.ts index 519184a3d41..8723cd52d04 100644 --- a/app-shell/src/protocol-analysis/writeFailedAnalysis.ts +++ b/app-shell/src/protocol-analysis/writeFailedAnalysis.ts @@ -27,6 +27,7 @@ export function createFailedAnalysis( pipettes: [], modules: [], liquids: [], + runTimeParameters: [], // TODO(mc, 2022-05-04): this field does not make sense for an // analysis that was unable to complete, but is required by // ProtocolAnalysisOutput diff --git a/app-shell/src/protocol-storage/__tests__/protocol-storage.test.ts b/app-shell/src/protocol-storage/__tests__/protocol-storage.test.ts index c873f47242c..3ac1a106dbe 100644 --- a/app-shell/src/protocol-storage/__tests__/protocol-storage.test.ts +++ b/app-shell/src/protocol-storage/__tests__/protocol-storage.test.ts @@ -119,6 +119,7 @@ describe('protocol storage directory utilities', () => { pipettes: [], modules: [], labware: [], + runTimeParameters: [], }) }) }) diff --git a/app-shell/src/robot-update/constants.ts b/app-shell/src/robot-update/constants.ts index 48f8ef8e611..22a494d07d7 100644 --- a/app-shell/src/robot-update/constants.ts +++ b/app-shell/src/robot-update/constants.ts @@ -4,6 +4,8 @@ import type { UpdateManifestUrls } from './types' import type { RobotUpdateTarget } from '@opentrons/app/src/redux/robot-update/types' import { CURRENT_VERSION } from '../update' +const OPENTRONS_PROJECT: string = _OPENTRONS_PROJECT_ + const UPDATE_MANIFEST_URLS_RELEASE = { ot2: 'https://builds.opentrons.com/ot2-br/releases.json', flex: 'https://builds.opentrons.com/ot3-oe/releases.json', @@ -15,8 +17,7 @@ const UPDATE_MANIFEST_URLS_INTERNAL_RELEASE = { } export const getUpdateManifestUrls = (): UpdateManifestUrls => - // @ts-expect-error can't get TS to recognize global.d.ts - global._OPENTRONS_PROJECT_.includes('robot-stack') + OPENTRONS_PROJECT.includes('robot-stack') ? UPDATE_MANIFEST_URLS_RELEASE : UPDATE_MANIFEST_URLS_INTERNAL_RELEASE diff --git a/app-shell/typings/global.d.ts b/app-shell/typings/global.d.ts index 8bdea90e637..67f9a5a1955 100644 --- a/app-shell/typings/global.d.ts +++ b/app-shell/typings/global.d.ts @@ -1,8 +1,9 @@ /* eslint-disable no-var */ declare global { - var _PKG_VERSION_: string - var _PKG_PRODUCT_NAME_: string - var _PKG_BUGS_URL_: string - var _OPENTRONS_PROJECT_: string var APP_SHELL_REMOTE: { ipcRenderer: IpcRenderer; [key: string]: any } } + +declare const _PKG_VERSION_: string +declare const _PKG_PRODUCT_NAME_: string +declare const _PKG_BUGS_URL_: string +declare const _OPENTRONS_PROJECT_: string diff --git a/app-shell/vite.config.ts b/app-shell/vite.config.ts index 80ca80b0aa4..546fe19e23f 100644 --- a/app-shell/vite.config.ts +++ b/app-shell/vite.config.ts @@ -1,7 +1,8 @@ -import { versionForProject } from '../scripts/git-version' +import { versionForProject } from '../scripts/git-version.mjs' import pkg from './package.json' import path from 'path' -import { UserConfig, defineConfig } from 'vite' +import { defineConfig } from 'vite' +import type { UserConfig } from 'vite' export default defineConfig( async (): Promise => { diff --git a/app-testing/files/protocols/OT2_S_v2_17_P300M_P20S_HS_TC_TM_SmokeTestV3.py b/app-testing/files/protocols/OT2_S_v2_17_P300M_P20S_HS_TC_TM_SmokeTestV3.py index 55c52f60ad5..1c0c1f9802d 100644 --- a/app-testing/files/protocols/OT2_S_v2_17_P300M_P20S_HS_TC_TM_SmokeTestV3.py +++ b/app-testing/files/protocols/OT2_S_v2_17_P300M_P20S_HS_TC_TM_SmokeTestV3.py @@ -4,23 +4,370 @@ from opentrons import protocol_api metadata = { - "protocolName": "🛠️ 2.17 Smoke Test", + "protocolName": "🛠️ 2.17 Smoke Test V3 🪄", "author": "Opentrons Engineering ", "source": "Software Testing Team", - "description": ("Placeholder - 2.17 Smoke Test is the same a 2.16 Smoke Test."), + "description": ("Description of the protocol that is longish \n has \n returns and \n emoji 😊 ⬆️ "), } -requirements = {"robotType": "OT-2", "apiLevel": "2.16"} +requirements = {"robotType": "OT-2", "apiLevel": "2.17"} + + +######################### +#### LOOK AT THIS ####### +######################### + +# This protocol is exactly the same as 2.16 Smoke Test V3 +# The only difference is the API version in the metadata +# There were no new positive test cases for 2.17 +# The negative test cases are captured in the 2.17 dispense changes protcol + +######################### +#### LOOK AT THIS ####### +######################### def run(ctx: protocol_api.ProtocolContext) -> None: """This method is run by the protocol engine.""" - # The only change in api version 2.17 is an error is thrown when you try to dispense more than the current volume of liquid in the pipette. - # Since the smoke test protocol should be able to be ran through without any errors, the test for the dispense error should not be added to the smoke test protocol. + ctx.set_rail_lights(True) + ctx.comment(f"Let there be light! {ctx.rail_lights_on} 🌠🌠🌠") + ctx.comment(f"Is the door is closed? {ctx.door_closed} 🚪🚪🚪") + ctx.comment(f"Is this a simulation? {ctx.is_simulating()} 🔮🔮🔮") + ctx.comment(f"Running against API Version: {ctx.api_version}") + + # deck positions + tips_300ul_position = "5" + tips_20ul_position = "4" + dye_source_position = "3" + logo_position = "2" + temperature_position = "9" + custom_lw_position = "6" + hs_position = "1" + + # Thermocycler has a default position that covers Slots 7, 8, 10, and 11. + # This is the only valid location for the Thermocycler on the OT-2 deck. + # This position is a default parameter when declaring the TC so you do not need to specify. + + # 300ul tips + tips_300ul = [ + ctx.load_labware( + load_name="opentrons_96_tiprack_300ul", + location=tips_300ul_position, + label="300ul tips", + ) + ] + + # 20ul tips + tips_20ul = [ + ctx.load_labware( + load_name="opentrons_96_tiprack_20ul", + location=tips_20ul_position, + label="20ul tips", + ) + ] + + # pipettes + pipette_left = ctx.load_instrument(instrument_name="p300_multi_gen2", mount="left", tip_racks=tips_300ul) + + pipette_right = ctx.load_instrument(instrument_name="p20_single_gen2", mount="right", tip_racks=tips_20ul) + + # modules https://docs.opentrons.com/v2/new_modules.html#available-modules + hs_module = ctx.load_module("heaterShakerModuleV1", hs_position) + temperature_module = ctx.load_module("temperature module gen2", temperature_position) + thermocycler_module = ctx.load_module("thermocycler module gen2") + + # module labware + temp_adapter = temperature_module.load_adapter("opentrons_96_well_aluminum_block") + temp_plate = temp_adapter.load_labware( + "nest_96_wellplate_100ul_pcr_full_skirt", + label="Temperature-Controlled plate", + ) + hs_plate = hs_module.load_labware(name="nest_96_wellplate_100ul_pcr_full_skirt", adapter="opentrons_96_pcr_adapter") + tc_plate = thermocycler_module.load_labware("nest_96_wellplate_100ul_pcr_full_skirt") + + # A 2.14 difference, no params specified, still should find it. + custom_labware = ctx.load_labware( + "cpx_4_tuberack_100ul", + custom_lw_position, + label="4 custom tubes", + ) + + # create plates and pattern list + logo_destination_plate = ctx.load_labware( + load_name="nest_96_wellplate_100ul_pcr_full_skirt", + location=logo_position, + label="logo destination", + ) + + dye_container = ctx.load_labware( + load_name="nest_12_reservoir_15ml", + location=dye_source_position, + label="dye container", + ) + + dye_source = dye_container.wells_by_name()["A2"] + + # Well Location set-up + dye_destination_wells = [ + logo_destination_plate.wells_by_name()["C7"], + logo_destination_plate.wells_by_name()["D6"], + logo_destination_plate.wells_by_name()["D7"], + logo_destination_plate.wells_by_name()["D8"], + logo_destination_plate.wells_by_name()["E5"], + ] + + # >= 2.14 define_liquid and load_liquid + water = ctx.define_liquid( + name="water", description="H₂O", display_color="#42AB2D" + ) # subscript 2 https://www.compart.com/en/unicode/U+2082 + + acetone = ctx.define_liquid( + name="acetone", description="C₃H₆O", display_color="#38588a" + ) # subscript 3 https://www.compart.com/en/unicode/U+2083 + # subscript 6 https://www.compart.com/en/unicode/U+2086 + + dye_container.wells_by_name()["A1"].load_liquid(liquid=water, volume=4000) + dye_container.wells_by_name()["A2"].load_liquid(liquid=water, volume=2000) + dye_container.wells_by_name()["A5"].load_liquid(liquid=acetone, volume=555.55555) + + # 2 different liquids in the same well + dye_container.wells_by_name()["A8"].load_liquid(liquid=water, volume=900.00) + dye_container.wells_by_name()["A8"].load_liquid(liquid=acetone, volume=1001.11) + + hs_module.close_labware_latch() + + pipette_right.pick_up_tip() + + ################################## + # Manual Deck State Modification # + ################################## + + # -------------------------- # + # Added in API version: 2.15 # + # -------------------------- # + + # Putting steps for this at beginning of protocol so you can do the manual stuff + # then walk away to let the rest of the protocol execute + + # The test flow is as follows: + # 1. Remove the existing PCR plate from slot 2 + # 2. Move the reservoir from slot 3 to slot 2 + # 3. Pickup P20 tip, move pipette to reservoir A1 in slot 2 + # 4. Pause and ask user to validate that the tip is in the middle of reservoir A1 in slot 2 + # 5. Move the reservoir back to slot 3 from slot 2 + # 6. Move pipette to reservoir A1 in slot 3 + # 7. Pause and ask user to validate that the tip is in the middle of reservoir A1 in slot 3 + # 8. Move custom labware from slot 6 to slot 2 + # 9. Move pipette to well A1 in slot 2 + # 10. Pause and ask user to validate that the tip is in the middle of well A1 in slot 2 + # 11. Move the custom labware back to slot 6 from slot 2 + # 12. Move pipette to well A1 in slot 6 + # 13. Pause and ask user to validate that the tip is in the middle of well A1 in slot 6 + # 14. Move the offdeck PCR plate back to slot 2 + # 15. Move pipette to well A1 in slot 2 + # 16. Pause and ask user to validate that the tip is in the middle of well A1 in slot 2 + + # In effect, nothing will actually change to the protocol, + # but we will be able to test that the UI responds appropriately. + + # Note: + # logo_destination_plate is a nest_96_wellplate_100ul_pcr_full_skirt - starting position is slot 2 + # dye_container is a nest_12_reservoir_15ml - starting position is slot 3 + + # Step 1 + ctx.move_labware( + labware=logo_destination_plate, + new_location=protocol_api.OFF_DECK, + ) + + # Step 2 + ctx.move_labware(labware=dye_container, new_location="2") + + # Step 3 + pipette_right.move_to(location=dye_container.wells_by_name()["A1"].top()) + + # Step 4 + ctx.pause("Is the pipette tip in the middle of reservoir A1 in slot 2?") + + # Step 5 + ctx.move_labware(labware=dye_container, new_location="3") + + # Step 6 + pipette_right.move_to(location=dye_container.wells_by_name()["A1"].top()) + + # Step 7 + ctx.pause("Is the pipette tip in the middle of reservoir A1 in slot 3?") + + # Step 8 + ctx.move_labware(labware=custom_labware, new_location="2") + + # Step 9 + pipette_right.move_to(location=custom_labware.wells_by_name()["A1"].top()) + + # Step 10 + ctx.pause("Is the pipette tip in the middle of custom labware A1 in slot 2?") + + # Step 11 + ctx.move_labware(labware=custom_labware, new_location="6") + + # Step 12 + pipette_right.move_to(location=custom_labware.wells_by_name()["A1"].top()) + + # Step 13 + ctx.pause("Is the pipette tip in the middle of custom labware A1 in slot 6?") + + # Step 14 + ctx.move_labware(labware=logo_destination_plate, new_location="2") + + # Step 15 + pipette_right.move_to(location=logo_destination_plate.wells_by_name()["A1"].top()) + + # Step 16 + ctx.pause("Is the pipette tip in the middle of well A1 in slot 2?") + + ####################### + # prepare_to_aspirate # + ####################### + + # -------------------------- # + # Added in API version: 2.16 # + # -------------------------- # + + pipette_right.prepare_to_aspirate() + pipette_right.move_to(dye_container.wells_by_name()["A1"].bottom(z=2)) + ctx.pause( + "Testing prepare_to_aspirate - watch pipette until next pause.\n The pipette should only move up out of the well after it has aspirated." + ) + pipette_right.aspirate(10, dye_container.wells_by_name()["A1"].bottom(z=2)) + ctx.pause("Did the pipette move up out of the well, only once, after aspirating?") + pipette_right.dispense(10, dye_container.wells_by_name()["A1"].bottom(z=2)) + + ######################################### + # protocol_context.fixed_trash property # + ######################################### + + # ---------------------------- # + # Changed in API version: 2.16 # + # ---------------------------- # + + pipette_right.move_to(ctx.fixed_trash) + ctx.pause("Is the pipette over the trash? Pipette will home after this pause.") + ctx.home() + + ############################################### + # instrument_context.trash_container property # + ############################################### + + # ---------------------------- # + # Changed in API version: 2.16 # + # ---------------------------- # + + pipette_right.move_to(pipette_right.trash_container) + ctx.pause("Is the pipette over the trash?") + + # Distribute dye + pipette_right.distribute( + volume=18, + source=dye_source, + dest=dye_destination_wells, + new_tip="never", + ) + pipette_right.drop_tip() + + # transfer + transfer_destinations = [ + logo_destination_plate.wells_by_name()["A11"], + logo_destination_plate.wells_by_name()["B11"], + logo_destination_plate.wells_by_name()["C11"], + ] + pipette_right.pick_up_tip() + pipette_right.transfer( + volume=60, + source=dye_container.wells_by_name()["A2"], + dest=transfer_destinations, + new_tip="never", + touch_tip=True, + blow_out=True, + blowout_location="destination well", + mix_before=(3, 20), + mix_after=(1, 20), + mix_touch_tip=True, + ) + + # consolidate + pipette_right.consolidate( + volume=20, + source=transfer_destinations, + dest=dye_container.wells_by_name()["A5"], + new_tip="never", + touch_tip=False, + blow_out=True, + blowout_location="destination well", + mix_before=(3, 20), + ) + + # well to well + pipette_right.return_tip() + pipette_right.pick_up_tip() + pipette_right.aspirate(volume=5, location=logo_destination_plate.wells_by_name()["A11"]) + pipette_right.air_gap(volume=10) + ctx.delay(seconds=3) + pipette_right.dispense(volume=5, location=logo_destination_plate.wells_by_name()["H11"]) + + # move to + pipette_right.move_to(logo_destination_plate.wells_by_name()["E12"].top()) + pipette_right.move_to(logo_destination_plate.wells_by_name()["E11"].bottom()) + pipette_right.blow_out() + # touch tip + # pipette ends in the middle of the well as of 6.3.0 in all touch_tip + pipette_right.touch_tip(location=logo_destination_plate.wells_by_name()["H1"]) + ctx.pause("Is the pipette tip in the middle of the well?") + pipette_right.return_tip() + + # Play with the modules + temperature_module.await_temperature(25) + + hs_module.set_and_wait_for_shake_speed(466) + ctx.delay(seconds=5) + + hs_module.set_and_wait_for_temperature(38) + + thermocycler_module.open_lid() + thermocycler_module.close_lid() + thermocycler_module.set_lid_temperature(38) # 37 is the minimum + thermocycler_module.set_block_temperature(temperature=28, hold_time_seconds=5) + thermocycler_module.deactivate_block() + thermocycler_module.deactivate_lid() + thermocycler_module.open_lid() + + hs_module.deactivate_shaker() + + # dispense to modules + + # to temperature module + pipette_right.pick_up_tip() + pipette_right.aspirate(volume=15, location=dye_source) + pipette_right.dispense(volume=15, location=temp_plate.well(0)) + pipette_right.drop_tip() - # Instead it should be added to a separate test protocol - OT2_P300M_P20S_TC_HS_TM_2_17_dispense_changes.py + # to heater shaker + pipette_left.pick_up_tip() + pipette_left.aspirate(volume=50, location=dye_source) + pipette_left.dispense(volume=50, location=hs_plate.well(0)) + hs_module.set_and_wait_for_shake_speed(350) + ctx.delay(seconds=5) + hs_module.deactivate_shaker() - # Therefore the 2.17 smoke test protocol is the same as the 2.16 smoke test protocol. Instead of copying and pasting the 2.16 smoke test protocol, we will noop this protocol and add a comment to explain the situation. + # to custom labware + # This labware does not EXIST!!!! so... + # Use tip rack lid to catch dye on wet run + pipette_right.pick_up_tip() + pipette_right.aspirate(volume=10, location=dye_source, rate=2.0) + pipette_right.dispense(volume=10, location=custom_labware.well(3), rate=1.5) + pipette_right.drop_tip() - pass + # to thermocycler + pipette_left.aspirate(volume=75, location=dye_source) + pipette_left.dispense(volume=60, location=tc_plate.wells_by_name()["A6"]) + pipette_left.drop_tip() diff --git a/app-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[0400decc88][Flex_P1000MLeft_P50MRight_HS_TM_MM_TC_2_15_ABR4_Illumina_DNA_Prep_24x].json b/app-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[0400decc88][Flex_P1000MLeft_P50MRight_HS_TM_MM_TC_2_15_ABR4_Illumina_DNA_Prep_24x].json index d321c3f1579..69b643fbc46 100644 --- a/app-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[0400decc88][Flex_P1000MLeft_P50MRight_HS_TM_MM_TC_2_15_ABR4_Illumina_DNA_Prep_24x].json +++ b/app-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[0400decc88][Flex_P1000MLeft_P50MRight_HS_TM_MM_TC_2_15_ABR4_Illumina_DNA_Prep_24x].json @@ -8513,7 +8513,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -8538,7 +8538,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -11.280000000000001 + "z": -11.279999999999998 }, "origin": "top" }, @@ -8563,7 +8563,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -11.280000000000001 + "z": -11.279999999999998 }, "origin": "top" }, @@ -8588,7 +8588,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -8613,7 +8613,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -8639,7 +8639,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -8664,7 +8664,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -11.280000000000001 + "z": -11.279999999999998 }, "origin": "top" }, @@ -8689,7 +8689,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -11.280000000000001 + "z": -11.279999999999998 }, "origin": "top" }, @@ -8715,7 +8715,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -11.280000000000001 + "z": -11.279999999999998 }, "origin": "top" }, @@ -8740,7 +8740,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -8765,7 +8765,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -8791,7 +8791,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -8816,7 +8816,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -11.280000000000001 + "z": -11.279999999999998 }, "origin": "top" }, @@ -8841,7 +8841,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -11.280000000000001 + "z": -11.279999999999998 }, "origin": "top" }, @@ -8866,7 +8866,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": 2.0 + "z": 2.000000000000007 }, "origin": "top" }, @@ -8890,7 +8890,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": 5.0 + "z": 5.000000000000007 }, "origin": "top" }, @@ -8924,7 +8924,7 @@ "position": { "x": 14.38, "y": 74.24, - "z": 34.650000000000006 + "z": 34.65 } }, "status": "succeeded" @@ -8938,7 +8938,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": 5.0 + "z": 5.000000000000007 }, "origin": "top" }, @@ -9195,7 +9195,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -9220,7 +9220,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -11.280000000000001 + "z": -11.279999999999998 }, "origin": "top" }, @@ -9245,7 +9245,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -11.280000000000001 + "z": -11.279999999999998 }, "origin": "top" }, @@ -9270,7 +9270,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -9295,7 +9295,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -9321,7 +9321,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -9346,7 +9346,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -11.280000000000001 + "z": -11.279999999999998 }, "origin": "top" }, @@ -9371,7 +9371,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -11.280000000000001 + "z": -11.279999999999998 }, "origin": "top" }, @@ -9397,7 +9397,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -11.280000000000001 + "z": -11.279999999999998 }, "origin": "top" }, @@ -9422,7 +9422,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -9447,7 +9447,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -9473,7 +9473,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -9498,7 +9498,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -11.280000000000001 + "z": -11.279999999999998 }, "origin": "top" }, @@ -9523,7 +9523,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -11.280000000000001 + "z": -11.279999999999998 }, "origin": "top" }, @@ -9548,7 +9548,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": 2.0 + "z": 2.000000000000007 }, "origin": "top" }, @@ -9572,7 +9572,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": 5.0 + "z": 5.000000000000007 }, "origin": "top" }, @@ -9606,7 +9606,7 @@ "position": { "x": 23.38, "y": 74.24, - "z": 34.650000000000006 + "z": 34.65 } }, "status": "succeeded" @@ -9620,7 +9620,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": 5.0 + "z": 5.000000000000007 }, "origin": "top" }, @@ -9877,7 +9877,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -9902,7 +9902,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -11.280000000000001 + "z": -11.279999999999998 }, "origin": "top" }, @@ -9927,7 +9927,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -11.280000000000001 + "z": -11.279999999999998 }, "origin": "top" }, @@ -9952,7 +9952,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -9977,7 +9977,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -10003,7 +10003,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -10028,7 +10028,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -11.280000000000001 + "z": -11.279999999999998 }, "origin": "top" }, @@ -10053,7 +10053,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -11.280000000000001 + "z": -11.279999999999998 }, "origin": "top" }, @@ -10079,7 +10079,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -11.280000000000001 + "z": -11.279999999999998 }, "origin": "top" }, @@ -10104,7 +10104,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -10129,7 +10129,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -10155,7 +10155,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -10180,7 +10180,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -11.280000000000001 + "z": -11.279999999999998 }, "origin": "top" }, @@ -10205,7 +10205,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -11.280000000000001 + "z": -11.279999999999998 }, "origin": "top" }, @@ -10230,7 +10230,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": 2.0 + "z": 2.000000000000007 }, "origin": "top" }, @@ -10254,7 +10254,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": 5.0 + "z": 5.000000000000007 }, "origin": "top" }, @@ -10288,7 +10288,7 @@ "position": { "x": 32.38, "y": 74.24, - "z": 34.650000000000006 + "z": 34.65 } }, "status": "succeeded" @@ -10302,7 +10302,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": 5.0 + "z": 5.000000000000007 }, "origin": "top" }, @@ -10534,7 +10534,7 @@ "position": { "x": -5.624999999999998, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 10.0 }, @@ -10559,7 +10559,7 @@ "position": { "x": -5.624999999999998, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 } }, "status": "succeeded" @@ -10584,7 +10584,7 @@ "position": { "x": -5.624999999999998, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 20.0 }, @@ -10610,7 +10610,7 @@ "position": { "x": -5.624999999999998, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 20.0 }, @@ -10636,7 +10636,7 @@ "position": { "x": -5.624999999999998, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 20.0 }, @@ -10662,7 +10662,7 @@ "position": { "x": -5.624999999999998, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 20.0 }, @@ -10688,7 +10688,7 @@ "position": { "x": -5.624999999999998, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 20.0 }, @@ -10714,7 +10714,7 @@ "position": { "x": -5.624999999999998, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 20.0 }, @@ -10740,7 +10740,7 @@ "position": { "x": -5.624999999999998, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 20.0 }, @@ -10766,7 +10766,7 @@ "position": { "x": -5.624999999999998, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 20.0 }, @@ -10792,7 +10792,7 @@ "position": { "x": -5.624999999999998, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 20.0 }, @@ -10818,7 +10818,7 @@ "position": { "x": -5.624999999999998, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 20.0 }, @@ -10844,7 +10844,7 @@ "position": { "x": -5.624999999999998, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 20.0 }, @@ -10870,7 +10870,7 @@ "position": { "x": -5.624999999999998, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 20.0 }, @@ -10896,7 +10896,7 @@ "position": { "x": -5.624999999999998, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 20.0 }, @@ -10922,7 +10922,7 @@ "position": { "x": -5.624999999999998, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 20.0 }, @@ -10948,7 +10948,7 @@ "position": { "x": -5.624999999999998, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 20.0 }, @@ -10974,7 +10974,7 @@ "position": { "x": -5.624999999999998, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 20.0 }, @@ -11000,7 +11000,7 @@ "position": { "x": -5.624999999999998, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 20.0 }, @@ -11026,7 +11026,7 @@ "position": { "x": -5.624999999999998, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 20.0 }, @@ -11052,7 +11052,7 @@ "position": { "x": -5.624999999999998, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 20.0 }, @@ -11078,7 +11078,7 @@ "position": { "x": -5.624999999999998, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 20.0 }, @@ -11180,7 +11180,7 @@ "position": { "x": 3.375, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 10.0 }, @@ -11205,7 +11205,7 @@ "position": { "x": 3.375, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 } }, "status": "succeeded" @@ -11230,7 +11230,7 @@ "position": { "x": 3.375, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 20.0 }, @@ -11256,7 +11256,7 @@ "position": { "x": 3.375, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 20.0 }, @@ -11282,7 +11282,7 @@ "position": { "x": 3.375, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 20.0 }, @@ -11308,7 +11308,7 @@ "position": { "x": 3.375, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 20.0 }, @@ -11334,7 +11334,7 @@ "position": { "x": 3.375, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 20.0 }, @@ -11360,7 +11360,7 @@ "position": { "x": 3.375, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 20.0 }, @@ -11386,7 +11386,7 @@ "position": { "x": 3.375, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 20.0 }, @@ -11412,7 +11412,7 @@ "position": { "x": 3.375, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 20.0 }, @@ -11438,7 +11438,7 @@ "position": { "x": 3.375, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 20.0 }, @@ -11464,7 +11464,7 @@ "position": { "x": 3.375, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 20.0 }, @@ -11490,7 +11490,7 @@ "position": { "x": 3.375, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 20.0 }, @@ -11516,7 +11516,7 @@ "position": { "x": 3.375, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 20.0 }, @@ -11542,7 +11542,7 @@ "position": { "x": 3.375, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 20.0 }, @@ -11568,7 +11568,7 @@ "position": { "x": 3.375, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 20.0 }, @@ -11594,7 +11594,7 @@ "position": { "x": 3.375, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 20.0 }, @@ -11620,7 +11620,7 @@ "position": { "x": 3.375, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 20.0 }, @@ -11646,7 +11646,7 @@ "position": { "x": 3.375, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 20.0 }, @@ -11672,7 +11672,7 @@ "position": { "x": 3.375, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 20.0 }, @@ -11698,7 +11698,7 @@ "position": { "x": 3.375, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 20.0 }, @@ -11724,7 +11724,7 @@ "position": { "x": 3.375, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 20.0 }, @@ -11826,7 +11826,7 @@ "position": { "x": 12.375000000000004, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 10.0 }, @@ -11851,7 +11851,7 @@ "position": { "x": 12.375000000000004, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 } }, "status": "succeeded" @@ -11876,7 +11876,7 @@ "position": { "x": 12.375000000000004, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 20.0 }, @@ -11902,7 +11902,7 @@ "position": { "x": 12.375000000000004, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 20.0 }, @@ -11928,7 +11928,7 @@ "position": { "x": 12.375000000000004, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 20.0 }, @@ -11954,7 +11954,7 @@ "position": { "x": 12.375000000000004, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 20.0 }, @@ -11980,7 +11980,7 @@ "position": { "x": 12.375000000000004, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 20.0 }, @@ -12006,7 +12006,7 @@ "position": { "x": 12.375000000000004, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 20.0 }, @@ -12032,7 +12032,7 @@ "position": { "x": 12.375000000000004, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 20.0 }, @@ -12058,7 +12058,7 @@ "position": { "x": 12.375000000000004, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 20.0 }, @@ -12084,7 +12084,7 @@ "position": { "x": 12.375000000000004, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 20.0 }, @@ -12110,7 +12110,7 @@ "position": { "x": 12.375000000000004, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 20.0 }, @@ -12136,7 +12136,7 @@ "position": { "x": 12.375000000000004, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 20.0 }, @@ -12162,7 +12162,7 @@ "position": { "x": 12.375000000000004, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 20.0 }, @@ -12188,7 +12188,7 @@ "position": { "x": 12.375000000000004, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 20.0 }, @@ -12214,7 +12214,7 @@ "position": { "x": 12.375000000000004, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 20.0 }, @@ -12240,7 +12240,7 @@ "position": { "x": 12.375000000000004, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 20.0 }, @@ -12266,7 +12266,7 @@ "position": { "x": 12.375000000000004, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 20.0 }, @@ -12292,7 +12292,7 @@ "position": { "x": 12.375000000000004, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 20.0 }, @@ -12318,7 +12318,7 @@ "position": { "x": 12.375000000000004, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 20.0 }, @@ -12344,7 +12344,7 @@ "position": { "x": 12.375000000000004, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 20.0 }, @@ -12370,7 +12370,7 @@ "position": { "x": 12.375000000000004, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 20.0 }, @@ -13428,7 +13428,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -13453,7 +13453,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -13479,7 +13479,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -13505,7 +13505,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -13531,7 +13531,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -13557,7 +13557,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -13592,7 +13592,7 @@ "position": { "x": 14.38, "y": 74.24, - "z": 35.650000000000006 + "z": 35.65 } }, "status": "succeeded" @@ -13625,7 +13625,7 @@ "position": { "x": 14.38, "y": 74.24, - "z": 35.650000000000006 + "z": 35.65 } }, "status": "succeeded" @@ -13650,7 +13650,7 @@ "position": { "x": 14.38, "y": 74.24, - "z": 35.650000000000006 + "z": 35.65 }, "volume": 20.0 }, @@ -13741,7 +13741,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -13766,7 +13766,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -13792,7 +13792,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -13818,7 +13818,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -13844,7 +13844,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -13870,7 +13870,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -13905,7 +13905,7 @@ "position": { "x": 23.38, "y": 74.24, - "z": 35.650000000000006 + "z": 35.65 } }, "status": "succeeded" @@ -13938,7 +13938,7 @@ "position": { "x": 23.38, "y": 74.24, - "z": 35.650000000000006 + "z": 35.65 } }, "status": "succeeded" @@ -13963,7 +13963,7 @@ "position": { "x": 23.38, "y": 74.24, - "z": 35.650000000000006 + "z": 35.65 }, "volume": 20.0 }, @@ -14054,7 +14054,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -14079,7 +14079,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -14105,7 +14105,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -14131,7 +14131,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -14157,7 +14157,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -14183,7 +14183,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -14218,7 +14218,7 @@ "position": { "x": 32.38, "y": 74.24, - "z": 35.650000000000006 + "z": 35.65 } }, "status": "succeeded" @@ -14251,7 +14251,7 @@ "position": { "x": 32.38, "y": 74.24, - "z": 35.650000000000006 + "z": 35.65 } }, "status": "succeeded" @@ -14276,7 +14276,7 @@ "position": { "x": 32.38, "y": 74.24, - "z": 35.650000000000006 + "z": 35.65 }, "volume": 20.0 }, @@ -15315,7 +15315,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -15340,7 +15340,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -15366,7 +15366,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -15392,7 +15392,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -15418,7 +15418,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -15444,7 +15444,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -15479,7 +15479,7 @@ "position": { "x": 14.38, "y": 74.24, - "z": 35.650000000000006 + "z": 35.65 } }, "status": "succeeded" @@ -15512,7 +15512,7 @@ "position": { "x": 14.38, "y": 74.24, - "z": 35.650000000000006 + "z": 35.65 } }, "status": "succeeded" @@ -15537,7 +15537,7 @@ "position": { "x": 14.38, "y": 74.24, - "z": 35.650000000000006 + "z": 35.65 }, "volume": 20.0 }, @@ -15628,7 +15628,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -15653,7 +15653,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -15679,7 +15679,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -15705,7 +15705,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -15731,7 +15731,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -15757,7 +15757,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -15792,7 +15792,7 @@ "position": { "x": 23.38, "y": 74.24, - "z": 35.650000000000006 + "z": 35.65 } }, "status": "succeeded" @@ -15825,7 +15825,7 @@ "position": { "x": 23.38, "y": 74.24, - "z": 35.650000000000006 + "z": 35.65 } }, "status": "succeeded" @@ -15850,7 +15850,7 @@ "position": { "x": 23.38, "y": 74.24, - "z": 35.650000000000006 + "z": 35.65 }, "volume": 20.0 }, @@ -15941,7 +15941,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -15966,7 +15966,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -15992,7 +15992,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -16018,7 +16018,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -16044,7 +16044,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -16070,7 +16070,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -16105,7 +16105,7 @@ "position": { "x": 32.38, "y": 74.24, - "z": 35.650000000000006 + "z": 35.65 } }, "status": "succeeded" @@ -16138,7 +16138,7 @@ "position": { "x": 32.38, "y": 74.24, - "z": 35.650000000000006 + "z": 35.65 } }, "status": "succeeded" @@ -16163,7 +16163,7 @@ "position": { "x": 32.38, "y": 74.24, - "z": 35.650000000000006 + "z": 35.65 }, "volume": 20.0 }, @@ -17202,7 +17202,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -17227,7 +17227,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -17253,7 +17253,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -17279,7 +17279,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -17305,7 +17305,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -17331,7 +17331,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -17366,7 +17366,7 @@ "position": { "x": 14.38, "y": 74.24, - "z": 35.650000000000006 + "z": 35.65 } }, "status": "succeeded" @@ -17399,7 +17399,7 @@ "position": { "x": 14.38, "y": 74.24, - "z": 35.650000000000006 + "z": 35.65 } }, "status": "succeeded" @@ -17424,7 +17424,7 @@ "position": { "x": 14.38, "y": 74.24, - "z": 35.650000000000006 + "z": 35.65 }, "volume": 20.0 }, @@ -17515,7 +17515,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -17540,7 +17540,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -17566,7 +17566,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -17592,7 +17592,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -17618,7 +17618,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -17644,7 +17644,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -17679,7 +17679,7 @@ "position": { "x": 23.38, "y": 74.24, - "z": 35.650000000000006 + "z": 35.65 } }, "status": "succeeded" @@ -17712,7 +17712,7 @@ "position": { "x": 23.38, "y": 74.24, - "z": 35.650000000000006 + "z": 35.65 } }, "status": "succeeded" @@ -17737,7 +17737,7 @@ "position": { "x": 23.38, "y": 74.24, - "z": 35.650000000000006 + "z": 35.65 }, "volume": 20.0 }, @@ -17828,7 +17828,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -17853,7 +17853,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -17879,7 +17879,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -17905,7 +17905,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -17931,7 +17931,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -17957,7 +17957,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -17992,7 +17992,7 @@ "position": { "x": 32.38, "y": 74.24, - "z": 35.650000000000006 + "z": 35.65 } }, "status": "succeeded" @@ -18025,7 +18025,7 @@ "position": { "x": 32.38, "y": 74.24, - "z": 35.650000000000006 + "z": 35.65 } }, "status": "succeeded" @@ -18050,7 +18050,7 @@ "position": { "x": 32.38, "y": 74.24, - "z": 35.650000000000006 + "z": 35.65 }, "volume": 20.0 }, @@ -19417,7 +19417,7 @@ "offset": { "x": 1.040000000000001, "y": 0.0, - "z": -11.39 + "z": -11.389999999999997 }, "origin": "top" }, @@ -19442,7 +19442,7 @@ "offset": { "x": 1.040000000000001, "y": 0.0, - "z": -11.39 + "z": -11.389999999999997 }, "origin": "top" }, @@ -19467,7 +19467,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -19492,7 +19492,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -19517,7 +19517,7 @@ "offset": { "x": 0.0, "y": 1.0400000000000063, - "z": -11.39 + "z": -11.389999999999997 }, "origin": "top" }, @@ -19542,7 +19542,7 @@ "offset": { "x": 0.0, "y": 1.0400000000000063, - "z": -11.39 + "z": -11.389999999999997 }, "origin": "top" }, @@ -19567,7 +19567,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -19592,7 +19592,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -19617,7 +19617,7 @@ "offset": { "x": -1.040000000000001, "y": 0.0, - "z": -11.39 + "z": -11.389999999999997 }, "origin": "top" }, @@ -19642,7 +19642,7 @@ "offset": { "x": -1.040000000000001, "y": 0.0, - "z": -11.39 + "z": -11.389999999999997 }, "origin": "top" }, @@ -19667,7 +19667,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -19692,7 +19692,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -19717,7 +19717,7 @@ "offset": { "x": 0.0, "y": -1.0400000000000063, - "z": -11.39 + "z": -11.389999999999997 }, "origin": "top" }, @@ -19742,7 +19742,7 @@ "offset": { "x": 0.0, "y": -1.0400000000000063, - "z": -11.39 + "z": -11.389999999999997 }, "origin": "top" }, @@ -19767,7 +19767,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -19792,7 +19792,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -19818,7 +19818,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -19843,7 +19843,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -7.390000000000001 + "z": -7.389999999999997 }, "origin": "top" }, @@ -19867,7 +19867,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": 5.0 + "z": 5.000000000000007 }, "origin": "top" }, @@ -19901,7 +19901,7 @@ "position": { "x": 14.38, "y": 74.24, - "z": 34.650000000000006 + "z": 34.65 } }, "status": "succeeded" @@ -19915,7 +19915,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": 5.0 + "z": 5.000000000000007 }, "origin": "top" }, @@ -20015,7 +20015,7 @@ "offset": { "x": 1.0399999999999991, "y": 0.0, - "z": -11.39 + "z": -11.389999999999997 }, "origin": "top" }, @@ -20040,7 +20040,7 @@ "offset": { "x": 1.0399999999999991, "y": 0.0, - "z": -11.39 + "z": -11.389999999999997 }, "origin": "top" }, @@ -20065,7 +20065,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -20090,7 +20090,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -20115,7 +20115,7 @@ "offset": { "x": 0.0, "y": 1.0400000000000063, - "z": -11.39 + "z": -11.389999999999997 }, "origin": "top" }, @@ -20140,7 +20140,7 @@ "offset": { "x": 0.0, "y": 1.0400000000000063, - "z": -11.39 + "z": -11.389999999999997 }, "origin": "top" }, @@ -20165,7 +20165,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -20190,7 +20190,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -20215,7 +20215,7 @@ "offset": { "x": -1.0399999999999991, "y": 0.0, - "z": -11.39 + "z": -11.389999999999997 }, "origin": "top" }, @@ -20240,7 +20240,7 @@ "offset": { "x": -1.0399999999999991, "y": 0.0, - "z": -11.39 + "z": -11.389999999999997 }, "origin": "top" }, @@ -20265,7 +20265,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -20290,7 +20290,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -20315,7 +20315,7 @@ "offset": { "x": 0.0, "y": -1.0400000000000063, - "z": -11.39 + "z": -11.389999999999997 }, "origin": "top" }, @@ -20340,7 +20340,7 @@ "offset": { "x": 0.0, "y": -1.0400000000000063, - "z": -11.39 + "z": -11.389999999999997 }, "origin": "top" }, @@ -20365,7 +20365,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -20390,7 +20390,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -20416,7 +20416,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -20441,7 +20441,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -7.390000000000001 + "z": -7.389999999999997 }, "origin": "top" }, @@ -20465,7 +20465,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": 5.0 + "z": 5.000000000000007 }, "origin": "top" }, @@ -20499,7 +20499,7 @@ "position": { "x": 23.38, "y": 74.24, - "z": 34.650000000000006 + "z": 34.65 } }, "status": "succeeded" @@ -20513,7 +20513,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": 5.0 + "z": 5.000000000000007 }, "origin": "top" }, @@ -20613,7 +20613,7 @@ "offset": { "x": 1.0399999999999991, "y": 0.0, - "z": -11.39 + "z": -11.389999999999997 }, "origin": "top" }, @@ -20638,7 +20638,7 @@ "offset": { "x": 1.0399999999999991, "y": 0.0, - "z": -11.39 + "z": -11.389999999999997 }, "origin": "top" }, @@ -20663,7 +20663,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -20688,7 +20688,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -20713,7 +20713,7 @@ "offset": { "x": 0.0, "y": 1.0400000000000063, - "z": -11.39 + "z": -11.389999999999997 }, "origin": "top" }, @@ -20738,7 +20738,7 @@ "offset": { "x": 0.0, "y": 1.0400000000000063, - "z": -11.39 + "z": -11.389999999999997 }, "origin": "top" }, @@ -20763,7 +20763,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -20788,7 +20788,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -20813,7 +20813,7 @@ "offset": { "x": -1.0399999999999991, "y": 0.0, - "z": -11.39 + "z": -11.389999999999997 }, "origin": "top" }, @@ -20838,7 +20838,7 @@ "offset": { "x": -1.0399999999999991, "y": 0.0, - "z": -11.39 + "z": -11.389999999999997 }, "origin": "top" }, @@ -20863,7 +20863,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -20888,7 +20888,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -20913,7 +20913,7 @@ "offset": { "x": 0.0, "y": -1.0400000000000063, - "z": -11.39 + "z": -11.389999999999997 }, "origin": "top" }, @@ -20938,7 +20938,7 @@ "offset": { "x": 0.0, "y": -1.0400000000000063, - "z": -11.39 + "z": -11.389999999999997 }, "origin": "top" }, @@ -20963,7 +20963,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -20988,7 +20988,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -21014,7 +21014,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -21039,7 +21039,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -7.390000000000001 + "z": -7.389999999999997 }, "origin": "top" }, @@ -21063,7 +21063,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": 5.0 + "z": 5.000000000000007 }, "origin": "top" }, @@ -21097,7 +21097,7 @@ "position": { "x": 32.38, "y": 74.24, - "z": 34.650000000000006 + "z": 34.65 } }, "status": "succeeded" @@ -21111,7 +21111,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": 5.0 + "z": 5.000000000000007 }, "origin": "top" }, @@ -21311,7 +21311,7 @@ "position": { "x": -5.624999999999998, "y": 356.2, - "z": 2.079999999999993 + "z": 2.08 }, "volume": 10.0 }, @@ -21337,7 +21337,7 @@ "position": { "x": -5.624999999999998, "y": 356.2, - "z": 2.079999999999993 + "z": 2.08 }, "volume": 10.0 }, @@ -21363,7 +21363,7 @@ "position": { "x": -5.624999999999998, "y": 356.2, - "z": 2.079999999999993 + "z": 2.08 }, "volume": 10.0 }, @@ -21389,7 +21389,7 @@ "position": { "x": -5.624999999999998, "y": 356.2, - "z": 2.079999999999993 + "z": 2.08 }, "volume": 10.0 }, @@ -21415,7 +21415,7 @@ "position": { "x": -5.624999999999998, "y": 356.2, - "z": 2.079999999999993 + "z": 2.08 }, "volume": 10.0 }, @@ -21441,7 +21441,7 @@ "position": { "x": -5.624999999999998, "y": 356.2, - "z": 2.079999999999993 + "z": 2.08 }, "volume": 10.0 }, @@ -21467,7 +21467,7 @@ "position": { "x": -5.624999999999998, "y": 356.2, - "z": 2.079999999999993 + "z": 2.08 }, "volume": 10.0 }, @@ -21569,7 +21569,7 @@ "position": { "x": 3.375, "y": 356.2, - "z": 2.079999999999993 + "z": 2.08 }, "volume": 10.0 }, @@ -21595,7 +21595,7 @@ "position": { "x": 3.375, "y": 356.2, - "z": 2.079999999999993 + "z": 2.08 }, "volume": 10.0 }, @@ -21621,7 +21621,7 @@ "position": { "x": 3.375, "y": 356.2, - "z": 2.079999999999993 + "z": 2.08 }, "volume": 10.0 }, @@ -21647,7 +21647,7 @@ "position": { "x": 3.375, "y": 356.2, - "z": 2.079999999999993 + "z": 2.08 }, "volume": 10.0 }, @@ -21673,7 +21673,7 @@ "position": { "x": 3.375, "y": 356.2, - "z": 2.079999999999993 + "z": 2.08 }, "volume": 10.0 }, @@ -21699,7 +21699,7 @@ "position": { "x": 3.375, "y": 356.2, - "z": 2.079999999999993 + "z": 2.08 }, "volume": 10.0 }, @@ -21725,7 +21725,7 @@ "position": { "x": 3.375, "y": 356.2, - "z": 2.079999999999993 + "z": 2.08 }, "volume": 10.0 }, @@ -21827,7 +21827,7 @@ "position": { "x": 12.375000000000004, "y": 356.2, - "z": 2.079999999999993 + "z": 2.08 }, "volume": 10.0 }, @@ -21853,7 +21853,7 @@ "position": { "x": 12.375000000000004, "y": 356.2, - "z": 2.079999999999993 + "z": 2.08 }, "volume": 10.0 }, @@ -21879,7 +21879,7 @@ "position": { "x": 12.375000000000004, "y": 356.2, - "z": 2.079999999999993 + "z": 2.08 }, "volume": 10.0 }, @@ -21905,7 +21905,7 @@ "position": { "x": 12.375000000000004, "y": 356.2, - "z": 2.079999999999993 + "z": 2.08 }, "volume": 10.0 }, @@ -21931,7 +21931,7 @@ "position": { "x": 12.375000000000004, "y": 356.2, - "z": 2.079999999999993 + "z": 2.08 }, "volume": 10.0 }, @@ -21957,7 +21957,7 @@ "position": { "x": 12.375000000000004, "y": 356.2, - "z": 2.079999999999993 + "z": 2.08 }, "volume": 10.0 }, @@ -21983,7 +21983,7 @@ "position": { "x": 12.375000000000004, "y": 356.2, - "z": 2.079999999999993 + "z": 2.08 }, "volume": 10.0 }, @@ -22307,7 +22307,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -22333,7 +22333,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -14.780000000000001 + "z": -14.779999999999998 }, "origin": "top" }, @@ -22359,7 +22359,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -22461,7 +22461,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -22487,7 +22487,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -14.780000000000001 + "z": -14.779999999999998 }, "origin": "top" }, @@ -22513,7 +22513,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -22615,7 +22615,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -22641,7 +22641,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -14.780000000000001 + "z": -14.779999999999998 }, "origin": "top" }, @@ -22667,7 +22667,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -22935,7 +22935,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -22960,7 +22960,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -9.780000000000001 + "z": -9.779999999999998 }, "origin": "top" }, @@ -22985,7 +22985,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -9.780000000000001 + "z": -9.779999999999998 }, "origin": "top" }, @@ -23010,7 +23010,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -23035,7 +23035,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -23061,7 +23061,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -23086,7 +23086,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -9.780000000000001 + "z": -9.779999999999998 }, "origin": "top" }, @@ -23111,7 +23111,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -9.780000000000001 + "z": -9.779999999999998 }, "origin": "top" }, @@ -23137,7 +23137,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -9.780000000000001 + "z": -9.779999999999998 }, "origin": "top" }, @@ -23162,7 +23162,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -23187,7 +23187,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -23213,7 +23213,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -23238,7 +23238,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -9.780000000000001 + "z": -9.779999999999998 }, "origin": "top" }, @@ -23263,7 +23263,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -9.780000000000001 + "z": -9.779999999999998 }, "origin": "top" }, @@ -23288,7 +23288,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": 2.0 + "z": 2.000000000000007 }, "origin": "top" }, @@ -23312,7 +23312,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": 5.0 + "z": 5.000000000000007 }, "origin": "top" }, @@ -23346,7 +23346,7 @@ "position": { "x": 50.38, "y": 74.24, - "z": 34.650000000000006 + "z": 34.65 } }, "status": "succeeded" @@ -23360,7 +23360,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": 5.0 + "z": 5.000000000000007 }, "origin": "top" }, @@ -23617,7 +23617,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -23642,7 +23642,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -9.780000000000001 + "z": -9.779999999999998 }, "origin": "top" }, @@ -23667,7 +23667,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -9.780000000000001 + "z": -9.779999999999998 }, "origin": "top" }, @@ -23692,7 +23692,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -23717,7 +23717,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -23743,7 +23743,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -23768,7 +23768,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -9.780000000000001 + "z": -9.779999999999998 }, "origin": "top" }, @@ -23793,7 +23793,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -9.780000000000001 + "z": -9.779999999999998 }, "origin": "top" }, @@ -23819,7 +23819,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -9.780000000000001 + "z": -9.779999999999998 }, "origin": "top" }, @@ -23844,7 +23844,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -23869,7 +23869,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -23895,7 +23895,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -23920,7 +23920,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -9.780000000000001 + "z": -9.779999999999998 }, "origin": "top" }, @@ -23945,7 +23945,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -9.780000000000001 + "z": -9.779999999999998 }, "origin": "top" }, @@ -23970,7 +23970,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": 2.0 + "z": 2.000000000000007 }, "origin": "top" }, @@ -23994,7 +23994,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": 5.0 + "z": 5.000000000000007 }, "origin": "top" }, @@ -24028,7 +24028,7 @@ "position": { "x": 59.38, "y": 74.24, - "z": 34.650000000000006 + "z": 34.65 } }, "status": "succeeded" @@ -24042,7 +24042,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": 5.0 + "z": 5.000000000000007 }, "origin": "top" }, @@ -24299,7 +24299,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -24324,7 +24324,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -9.780000000000001 + "z": -9.779999999999998 }, "origin": "top" }, @@ -24349,7 +24349,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -9.780000000000001 + "z": -9.779999999999998 }, "origin": "top" }, @@ -24374,7 +24374,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -24399,7 +24399,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -24425,7 +24425,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -24450,7 +24450,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -9.780000000000001 + "z": -9.779999999999998 }, "origin": "top" }, @@ -24475,7 +24475,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -9.780000000000001 + "z": -9.779999999999998 }, "origin": "top" }, @@ -24501,7 +24501,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -9.780000000000001 + "z": -9.779999999999998 }, "origin": "top" }, @@ -24526,7 +24526,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -24551,7 +24551,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -24577,7 +24577,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -24602,7 +24602,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -9.780000000000001 + "z": -9.779999999999998 }, "origin": "top" }, @@ -24627,7 +24627,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -9.780000000000001 + "z": -9.779999999999998 }, "origin": "top" }, @@ -24652,7 +24652,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": 2.0 + "z": 2.000000000000007 }, "origin": "top" }, @@ -24676,7 +24676,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": 5.0 + "z": 5.000000000000007 }, "origin": "top" }, @@ -24710,7 +24710,7 @@ "position": { "x": 68.38, "y": 74.24, - "z": 34.650000000000006 + "z": 34.65 } }, "status": "succeeded" @@ -24724,7 +24724,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": 5.0 + "z": 5.000000000000007 }, "origin": "top" }, @@ -29887,7 +29887,7 @@ "offset": { "x": 1.0399999999999991, "y": 0.0, - "z": -11.39 + "z": -11.389999999999997 }, "origin": "top" }, @@ -29912,7 +29912,7 @@ "offset": { "x": 1.0399999999999991, "y": 0.0, - "z": -11.39 + "z": -11.389999999999997 }, "origin": "top" }, @@ -29937,7 +29937,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -29962,7 +29962,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -29987,7 +29987,7 @@ "offset": { "x": 0.0, "y": 1.0400000000000063, - "z": -11.39 + "z": -11.389999999999997 }, "origin": "top" }, @@ -30012,7 +30012,7 @@ "offset": { "x": 0.0, "y": 1.0400000000000063, - "z": -11.39 + "z": -11.389999999999997 }, "origin": "top" }, @@ -30037,7 +30037,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -30062,7 +30062,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -30087,7 +30087,7 @@ "offset": { "x": -1.0399999999999991, "y": 0.0, - "z": -11.39 + "z": -11.389999999999997 }, "origin": "top" }, @@ -30112,7 +30112,7 @@ "offset": { "x": -1.0399999999999991, "y": 0.0, - "z": -11.39 + "z": -11.389999999999997 }, "origin": "top" }, @@ -30137,7 +30137,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -30162,7 +30162,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -30187,7 +30187,7 @@ "offset": { "x": 0.0, "y": -1.0400000000000063, - "z": -11.39 + "z": -11.389999999999997 }, "origin": "top" }, @@ -30212,7 +30212,7 @@ "offset": { "x": 0.0, "y": -1.0400000000000063, - "z": -11.39 + "z": -11.389999999999997 }, "origin": "top" }, @@ -30237,7 +30237,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -30262,7 +30262,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -30288,7 +30288,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -30313,7 +30313,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -7.390000000000001 + "z": -7.389999999999997 }, "origin": "top" }, @@ -30337,7 +30337,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": 5.0 + "z": 5.000000000000007 }, "origin": "top" }, @@ -30371,7 +30371,7 @@ "position": { "x": 50.38, "y": 74.24, - "z": 34.650000000000006 + "z": 34.65 } }, "status": "succeeded" @@ -30385,7 +30385,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": 5.0 + "z": 5.000000000000007 }, "origin": "top" }, @@ -30485,7 +30485,7 @@ "offset": { "x": 1.0399999999999991, "y": 0.0, - "z": -11.39 + "z": -11.389999999999997 }, "origin": "top" }, @@ -30510,7 +30510,7 @@ "offset": { "x": 1.0399999999999991, "y": 0.0, - "z": -11.39 + "z": -11.389999999999997 }, "origin": "top" }, @@ -30535,7 +30535,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -30560,7 +30560,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -30585,7 +30585,7 @@ "offset": { "x": 0.0, "y": 1.0400000000000063, - "z": -11.39 + "z": -11.389999999999997 }, "origin": "top" }, @@ -30610,7 +30610,7 @@ "offset": { "x": 0.0, "y": 1.0400000000000063, - "z": -11.39 + "z": -11.389999999999997 }, "origin": "top" }, @@ -30635,7 +30635,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -30660,7 +30660,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -30685,7 +30685,7 @@ "offset": { "x": -1.0399999999999991, "y": 0.0, - "z": -11.39 + "z": -11.389999999999997 }, "origin": "top" }, @@ -30710,7 +30710,7 @@ "offset": { "x": -1.0399999999999991, "y": 0.0, - "z": -11.39 + "z": -11.389999999999997 }, "origin": "top" }, @@ -30735,7 +30735,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -30760,7 +30760,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -30785,7 +30785,7 @@ "offset": { "x": 0.0, "y": -1.0400000000000063, - "z": -11.39 + "z": -11.389999999999997 }, "origin": "top" }, @@ -30810,7 +30810,7 @@ "offset": { "x": 0.0, "y": -1.0400000000000063, - "z": -11.39 + "z": -11.389999999999997 }, "origin": "top" }, @@ -30835,7 +30835,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -30860,7 +30860,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -30886,7 +30886,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -30911,7 +30911,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -7.390000000000001 + "z": -7.389999999999997 }, "origin": "top" }, @@ -30935,7 +30935,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": 5.0 + "z": 5.000000000000007 }, "origin": "top" }, @@ -30969,7 +30969,7 @@ "position": { "x": 59.38, "y": 74.24, - "z": 34.650000000000006 + "z": 34.65 } }, "status": "succeeded" @@ -30983,7 +30983,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": 5.0 + "z": 5.000000000000007 }, "origin": "top" }, @@ -31083,7 +31083,7 @@ "offset": { "x": 1.0400000000000063, "y": 0.0, - "z": -11.39 + "z": -11.389999999999997 }, "origin": "top" }, @@ -31108,7 +31108,7 @@ "offset": { "x": 1.0400000000000063, "y": 0.0, - "z": -11.39 + "z": -11.389999999999997 }, "origin": "top" }, @@ -31133,7 +31133,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -31158,7 +31158,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -31183,7 +31183,7 @@ "offset": { "x": 0.0, "y": 1.0400000000000063, - "z": -11.39 + "z": -11.389999999999997 }, "origin": "top" }, @@ -31208,7 +31208,7 @@ "offset": { "x": 0.0, "y": 1.0400000000000063, - "z": -11.39 + "z": -11.389999999999997 }, "origin": "top" }, @@ -31233,7 +31233,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -31258,7 +31258,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -31283,7 +31283,7 @@ "offset": { "x": -1.0400000000000063, "y": 0.0, - "z": -11.39 + "z": -11.389999999999997 }, "origin": "top" }, @@ -31308,7 +31308,7 @@ "offset": { "x": -1.0400000000000063, "y": 0.0, - "z": -11.39 + "z": -11.389999999999997 }, "origin": "top" }, @@ -31333,7 +31333,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -31358,7 +31358,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -31383,7 +31383,7 @@ "offset": { "x": 0.0, "y": -1.0400000000000063, - "z": -11.39 + "z": -11.389999999999997 }, "origin": "top" }, @@ -31408,7 +31408,7 @@ "offset": { "x": 0.0, "y": -1.0400000000000063, - "z": -11.39 + "z": -11.389999999999997 }, "origin": "top" }, @@ -31433,7 +31433,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -31458,7 +31458,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -31484,7 +31484,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -13.780000000000001 + "z": -13.779999999999998 }, "origin": "top" }, @@ -31509,7 +31509,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": -7.390000000000001 + "z": -7.389999999999997 }, "origin": "top" }, @@ -31533,7 +31533,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": 5.0 + "z": 5.000000000000007 }, "origin": "top" }, @@ -31567,7 +31567,7 @@ "position": { "x": 68.38, "y": 74.24, - "z": 34.650000000000006 + "z": 34.65 } }, "status": "succeeded" @@ -31581,7 +31581,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": 5.0 + "z": 5.000000000000007 }, "origin": "top" }, diff --git a/app-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[0f71566d05][OT2_P20S_None_2_7_Walkthrough].json b/app-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[0f71566d05][OT2_P20S_None_2_7_Walkthrough].json index a50062b2e14..3d18e932a56 100644 --- a/app-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[0f71566d05][OT2_P20S_None_2_7_Walkthrough].json +++ b/app-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[0f71566d05][OT2_P20S_None_2_7_Walkthrough].json @@ -3293,7 +3293,7 @@ "errorInfo": { "args": "()", "class": "AssertionError", - "traceback": " File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_engine/protocol_engine.py\", line 441, in finish\n await exit_stack.aclose()\n\n File \"/usr/local/lib/python3.10/contextlib.py\", line 656, in aclose\n await self.__aexit__(None, None, None)\n\n File \"/usr/local/lib/python3.10/contextlib.py\", line 714, in __aexit__\n raise exc_details[1]\n\n File \"/usr/local/lib/python3.10/contextlib.py\", line 697, in __aexit__\n cb_suppress = await cb(*exc_details)\n\n File \"/usr/local/lib/python3.10/contextlib.py\", line 608, in _exit_wrapper\n await callback(*args, **kwds)\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_engine/plugins.py\", line 102, in stop\n await p.teardown()\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_runner/legacy_context_plugin.py\", line 112, in teardown\n await self._action_dispatching_task\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_runner/legacy_context_plugin.py\", line 160, in _dispatch_all_actions\n self.dispatch(action)\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_engine/plugins.py\", line 37, in dispatch\n return self._action_dispatcher.dispatch(action)\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_engine/actions/action_dispatcher.py\", line 30, in dispatch\n self._sink.handle_action(action)\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_engine/state/state.py\", line 204, in handle_action\n substore.handle_action(action)\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_engine/state/commands.py\", line 283, in handle_action\n assert self._state.running_command_id is None\n" + "traceback": " File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_engine/protocol_engine.py\", line 512, in finish\n await exit_stack.aclose()\n\n File \"/usr/local/lib/python3.10/contextlib.py\", line 656, in aclose\n await self.__aexit__(None, None, None)\n\n File \"/usr/local/lib/python3.10/contextlib.py\", line 714, in __aexit__\n raise exc_details[1]\n\n File \"/usr/local/lib/python3.10/contextlib.py\", line 697, in __aexit__\n cb_suppress = await cb(*exc_details)\n\n File \"/usr/local/lib/python3.10/contextlib.py\", line 608, in _exit_wrapper\n await callback(*args, **kwds)\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_engine/plugins.py\", line 102, in stop\n await p.teardown()\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_runner/legacy_context_plugin.py\", line 112, in teardown\n await self._action_dispatching_task\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_runner/legacy_context_plugin.py\", line 160, in _dispatch_all_actions\n self.dispatch(action)\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_engine/plugins.py\", line 37, in dispatch\n return self._action_dispatcher.dispatch(action)\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_engine/actions/action_dispatcher.py\", line 30, in dispatch\n self._sink.handle_action(action)\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_engine/state/state.py\", line 209, in handle_action\n substore.handle_action(action)\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_engine/state/commands.py\", line 261, in handle_action\n self._state.command_history.set_command_running(running_command)\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_engine/state/command_history.py\", line 175, in set_command_running\n assert self.get_running_command() is None\n" }, "errorType": "PythonException", "wrappedErrors": [] diff --git a/app-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[20cefcac62][OT2_P300M_P20S_TC_HS_TM_2_13_SmokeTestV3].json b/app-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[20cefcac62][OT2_P300M_P20S_TC_HS_TM_2_13_SmokeTestV3].json index c93a79f99e2..ef9f55e77e7 100644 --- a/app-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[20cefcac62][OT2_P300M_P20S_TC_HS_TM_2_13_SmokeTestV3].json +++ b/app-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[20cefcac62][OT2_P300M_P20S_TC_HS_TM_2_13_SmokeTestV3].json @@ -11889,7 +11889,7 @@ "errorInfo": { "args": "()", "class": "AssertionError", - "traceback": " File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_engine/protocol_engine.py\", line 441, in finish\n await exit_stack.aclose()\n\n File \"/usr/local/lib/python3.10/contextlib.py\", line 656, in aclose\n await self.__aexit__(None, None, None)\n\n File \"/usr/local/lib/python3.10/contextlib.py\", line 714, in __aexit__\n raise exc_details[1]\n\n File \"/usr/local/lib/python3.10/contextlib.py\", line 697, in __aexit__\n cb_suppress = await cb(*exc_details)\n\n File \"/usr/local/lib/python3.10/contextlib.py\", line 608, in _exit_wrapper\n await callback(*args, **kwds)\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_engine/plugins.py\", line 102, in stop\n await p.teardown()\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_runner/legacy_context_plugin.py\", line 112, in teardown\n await self._action_dispatching_task\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_runner/legacy_context_plugin.py\", line 160, in _dispatch_all_actions\n self.dispatch(action)\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_engine/plugins.py\", line 37, in dispatch\n return self._action_dispatcher.dispatch(action)\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_engine/actions/action_dispatcher.py\", line 30, in dispatch\n self._sink.handle_action(action)\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_engine/state/state.py\", line 204, in handle_action\n substore.handle_action(action)\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_engine/state/commands.py\", line 283, in handle_action\n assert self._state.running_command_id is None\n" + "traceback": " File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_engine/protocol_engine.py\", line 512, in finish\n await exit_stack.aclose()\n\n File \"/usr/local/lib/python3.10/contextlib.py\", line 656, in aclose\n await self.__aexit__(None, None, None)\n\n File \"/usr/local/lib/python3.10/contextlib.py\", line 714, in __aexit__\n raise exc_details[1]\n\n File \"/usr/local/lib/python3.10/contextlib.py\", line 697, in __aexit__\n cb_suppress = await cb(*exc_details)\n\n File \"/usr/local/lib/python3.10/contextlib.py\", line 608, in _exit_wrapper\n await callback(*args, **kwds)\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_engine/plugins.py\", line 102, in stop\n await p.teardown()\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_runner/legacy_context_plugin.py\", line 112, in teardown\n await self._action_dispatching_task\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_runner/legacy_context_plugin.py\", line 160, in _dispatch_all_actions\n self.dispatch(action)\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_engine/plugins.py\", line 37, in dispatch\n return self._action_dispatcher.dispatch(action)\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_engine/actions/action_dispatcher.py\", line 30, in dispatch\n self._sink.handle_action(action)\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_engine/state/state.py\", line 209, in handle_action\n substore.handle_action(action)\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_engine/state/commands.py\", line 261, in handle_action\n self._state.command_history.set_command_running(running_command)\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_engine/state/command_history.py\", line 175, in set_command_running\n assert self.get_running_command() is None\n" }, "errorType": "PythonException", "wrappedErrors": [] diff --git a/app-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[240b279ac3][OT2_P300S_Thermocycler_Moam_Error].json b/app-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[240b279ac3][OT2_P300S_Thermocycler_Moam_Error].json index 0581fee8962..35ec253ed42 100644 --- a/app-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[240b279ac3][OT2_P300S_Thermocycler_Moam_Error].json +++ b/app-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[240b279ac3][OT2_P300S_Thermocycler_Moam_Error].json @@ -2680,7 +2680,7 @@ "errorInfo": { "args": "('thermocyclerModuleV2 in slot 7 prevents thermocyclerModuleV1 from using slot 7.',)", "class": "DeckConflictError", - "traceback": " File \"/usr/local/lib/python3.10/site-packages/opentrons/protocols/execution/execute_python.py\", line 124, in run_python\n exec(\"run(__context)\", new_globs)\n\n File \"\", line 1, in \n\n File \"OT2_P300S_Thermocycler_Moam_Error.py\", line 19, in run\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocols/api_support/util.py\", line 383, in _check_version_wrapper\n return decorated_obj(*args, **kwargs)\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_api/protocol_context.py\", line 810, in load_module\n module_core = self._core.load_module(\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_api/core/legacy/legacy_protocol_core.py\", line 333, in load_module\n self._deck_layout[resolved_location] = geometry\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_api/core/legacy/deck.py\", line 186, in __setitem__\n deck_conflict.check(\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/motion_planning/deck_conflict.py\", line 210, in check\n raise DeckConflictError(\n" + "traceback": " File \"/usr/local/lib/python3.10/site-packages/opentrons/protocols/execution/execute_python.py\", line 124, in run_python\n exec(\"run(__context)\", new_globs)\n\n File \"\", line 1, in \n\n File \"OT2_P300S_Thermocycler_Moam_Error.py\", line 19, in run\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocols/api_support/util.py\", line 383, in _check_version_wrapper\n return decorated_obj(*args, **kwargs)\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_api/protocol_context.py\", line 814, in load_module\n module_core = self._core.load_module(\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_api/core/legacy/legacy_protocol_core.py\", line 333, in load_module\n self._deck_layout[resolved_location] = geometry\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_api/core/legacy/deck.py\", line 186, in __setitem__\n deck_conflict.check(\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/motion_planning/deck_conflict.py\", line 210, in check\n raise DeckConflictError(\n" }, "errorType": "PythonException", "wrappedErrors": [] diff --git a/app-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[25f79fd65e][Flex_None_None_TM_2_16_AnalysisError_ModuleInStagingAreaCol3].json b/app-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[25f79fd65e][Flex_None_None_TM_2_16_AnalysisError_ModuleInStagingAreaCol3].json index bf492fe0746..87642f0e06f 100644 --- a/app-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[25f79fd65e][Flex_None_None_TM_2_16_AnalysisError_ModuleInStagingAreaCol3].json +++ b/app-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[25f79fd65e][Flex_None_None_TM_2_16_AnalysisError_ModuleInStagingAreaCol3].json @@ -96,6 +96,13 @@ }, { "commandType": "loadModule", + "error": { + "detail": "Cannot use Temperature Module in C3, not compatible with one or more of the following fixtures: Slot C4", + "errorCode": "4000", + "errorInfo": {}, + "errorType": "IncompatibleAddressableAreaError", + "wrappedErrors": [] + }, "notes": [], "params": { "location": { @@ -103,448 +110,7 @@ }, "model": "temperatureModuleV2" }, - "result": { - "definition": { - "calibrationPoint": { - "x": 11.7, - "y": 8.75, - "z": 80.09 - }, - "compatibleWith": [ - "temperatureModuleV1" - ], - "dimensions": { - "bareOverallHeight": 84.0, - "overLabwareHeight": 0.0 - }, - "displayName": "Temperature Module GEN2", - "gripperOffsets": { - "default": { - "dropOffset": { - "x": 0.0, - "y": 0.0, - "z": 1.0 - }, - "pickUpOffset": { - "x": 0.0, - "y": 0.0, - "z": 0.0 - } - } - }, - "labwareOffset": { - "x": -1.45, - "y": -0.15, - "z": 80.09 - }, - "model": "temperatureModuleV2", - "moduleType": "temperatureModuleType", - "otSharedSchema": "module/schemas/2", - "quirks": [], - "slotTransforms": { - "ot2_short_trash": { - "3": { - "labwareOffset": [ - [ - -1, - -0.15, - 0, - 0 - ], - [ - 0, - 0, - 0, - 1 - ], - [ - 0, - 0, - 0, - 1 - ], - [ - 0, - 0, - 0, - 1 - ] - ] - }, - "6": { - "labwareOffset": [ - [ - -1, - -0.15, - 0, - 0 - ], - [ - 0, - 0, - 0, - 1 - ], - [ - 0, - 0, - 0, - 1 - ], - [ - 0, - 0, - 0, - 1 - ] - ] - }, - "9": { - "labwareOffset": [ - [ - -1, - -0.15, - 0, - 0 - ], - [ - 0, - 0, - 0, - 1 - ], - [ - 0, - 0, - 0, - 1 - ], - [ - 0, - 0, - 0, - 1 - ] - ] - } - }, - "ot2_standard": { - "3": { - "labwareOffset": [ - [ - -1, - -0.3, - 0, - 0 - ], - [ - 0, - 0, - 0, - 1 - ], - [ - 0, - 0, - 0, - 1 - ], - [ - 0, - 0, - 0, - 1 - ] - ] - }, - "6": { - "labwareOffset": [ - [ - -1, - -0.3, - 0, - 0 - ], - [ - 0, - 0, - 0, - 1 - ], - [ - 0, - 0, - 0, - 1 - ], - [ - 0, - 0, - 0, - 1 - ] - ] - }, - "9": { - "labwareOffset": [ - [ - -1, - -0.3, - 0, - 0 - ], - [ - 0, - 0, - 0, - 1 - ], - [ - 0, - 0, - 0, - 1 - ], - [ - 0, - 0, - 0, - 1 - ] - ] - } - }, - "ot3_standard": { - "A1": { - "labwareOffset": [ - [ - -71.09, - 0, - 0, - 1 - ], - [ - 0, - 0, - 0, - 1 - ], - [ - 0, - 0, - 0.15, - 1 - ], - [ - 0, - 0, - 1, - 1.45 - ] - ] - }, - "A3": { - "labwareOffset": [ - [ - -71.09, - 0, - 0, - 1 - ], - [ - 0, - 0, - 0, - 1 - ], - [ - 0, - 0, - 0.15, - 1 - ], - [ - 0, - 0, - 1, - 1.45 - ] - ] - }, - "B1": { - "labwareOffset": [ - [ - -71.09, - 0, - 0, - 1 - ], - [ - 0, - 0, - 0, - 1 - ], - [ - 0, - 0, - 0.15, - 1 - ], - [ - 0, - 0, - 1, - 1.45 - ] - ] - }, - "B3": { - "labwareOffset": [ - [ - -71.09, - 0, - 0, - 1 - ], - [ - 0, - 0, - 0, - 1 - ], - [ - 0, - 0, - 0.15, - 1 - ], - [ - 0, - 0, - 1, - 1.45 - ] - ] - }, - "C1": { - "labwareOffset": [ - [ - -71.09, - 0, - 0, - 1 - ], - [ - 0, - 0, - 0, - 1 - ], - [ - 0, - 0, - 0.15, - 1 - ], - [ - 0, - 0, - 1, - 1.45 - ] - ] - }, - "C3": { - "labwareOffset": [ - [ - -71.09, - 0, - 0, - 1 - ], - [ - 0, - 0, - 0, - 1 - ], - [ - 0, - 0, - 0.15, - 1 - ], - [ - 0, - 0, - 1, - 1.45 - ] - ] - }, - "D1": { - "labwareOffset": [ - [ - -71.09, - 0, - 0, - 1 - ], - [ - 0, - 0, - 0, - 1 - ], - [ - 0, - 0, - 0.15, - 1 - ], - [ - 0, - 0, - 1, - 1.45 - ] - ] - }, - "D3": { - "labwareOffset": [ - [ - -71.09, - 0, - 0, - 1 - ], - [ - 0, - 0, - 0, - 1 - ], - [ - 0, - 0, - 0.15, - 1 - ], - [ - 0, - 0, - 1, - 1.45 - ] - ] - } - } - } - }, - "model": "temperatureModuleV2" - }, - "status": "succeeded" + "status": "failed" } ], "config": { @@ -556,21 +122,25 @@ }, "errors": [ { - "detail": "DeckConflictError [line 17]: nest_1_reservoir_290ml in slot C4 prevents temperatureModuleV2 from using slot C3.", + "detail": "ProtocolCommandFailedError [line 17]: Error 4000 GENERAL_ERROR (ProtocolCommandFailedError): IncompatibleAddressableAreaError: Cannot use Temperature Module in C3, not compatible with one or more of the following fixtures: Slot C4", "errorCode": "4000", "errorInfo": {}, "errorType": "ExceptionInProtocolError", "wrappedErrors": [ { - "detail": "opentrons.motion_planning.deck_conflict.DeckConflictError: nest_1_reservoir_290ml in slot C4 prevents temperatureModuleV2 from using slot C3.", + "detail": "IncompatibleAddressableAreaError: Cannot use Temperature Module in C3, not compatible with one or more of the following fixtures: Slot C4", "errorCode": "4000", - "errorInfo": { - "args": "('nest_1_reservoir_290ml in slot C4 prevents temperatureModuleV2 from using slot C3.',)", - "class": "DeckConflictError", - "traceback": " File \"/usr/local/lib/python3.10/site-packages/opentrons/protocols/execution/execute_python.py\", line 124, in run_python\n exec(\"run(__context)\", new_globs)\n\n File \"\", line 1, in \n\n File \"Flex_None_None_TM_2_16_AnalysisError_ModuleInStagingAreaCol3.py\", line 17, in run\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocols/api_support/util.py\", line 383, in _check_version_wrapper\n return decorated_obj(*args, **kwargs)\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_api/protocol_context.py\", line 810, in load_module\n module_core = self._core.load_module(\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_api/core/engine/protocol.py\", line 424, in load_module\n deck_conflict.check(\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_api/core/engine/deck_conflict.py\", line 203, in check\n wrapped_deck_conflict.check(\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/motion_planning/deck_conflict.py\", line 223, in check\n raise DeckConflictError(\n" - }, - "errorType": "PythonException", - "wrappedErrors": [] + "errorInfo": {}, + "errorType": "ProtocolCommandFailedError", + "wrappedErrors": [ + { + "detail": "Cannot use Temperature Module in C3, not compatible with one or more of the following fixtures: Slot C4", + "errorCode": "4000", + "errorInfo": {}, + "errorType": "IncompatibleAddressableAreaError", + "wrappedErrors": [] + } + ] } ] } @@ -615,14 +185,7 @@ "author": "Derek Maggio ", "protocolName": "QA Protocol - Analysis Error - Module in Staging Area Column 3" }, - "modules": [ - { - "location": { - "slotName": "C3" - }, - "model": "temperatureModuleV2" - } - ], + "modules": [], "pipettes": [], "robotType": "OT-3 Standard", "runTimeParameters": [] diff --git a/app-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[37c9086bf4][Flex_P1000MLeft_P50MRight_HS_MM_TC_TM_2_15_ABR3_Illumina_DNA_Enrichment_v4].json b/app-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[37c9086bf4][Flex_P1000MLeft_P50MRight_HS_MM_TC_TM_2_15_ABR3_Illumina_DNA_Enrichment_v4].json index 58867a05b3f..0693b30cc54 100644 --- a/app-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[37c9086bf4][Flex_P1000MLeft_P50MRight_HS_MM_TC_TM_2_15_ABR3_Illumina_DNA_Enrichment_v4].json +++ b/app-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[37c9086bf4][Flex_P1000MLeft_P50MRight_HS_MM_TC_TM_2_15_ABR3_Illumina_DNA_Enrichment_v4].json @@ -11809,7 +11809,7 @@ "position": { "x": -5.624999999999998, "y": 356.2, - "z": 1.329999999999993 + "z": 1.33 } }, "status": "succeeded" @@ -11834,7 +11834,7 @@ "position": { "x": -5.624999999999998, "y": 356.2, - "z": 1.329999999999993 + "z": 1.33 }, "volume": 101.0 }, @@ -11860,7 +11860,7 @@ "position": { "x": 14.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 101.0 }, @@ -11935,7 +11935,7 @@ "position": { "x": 3.375, "y": 356.2, - "z": 1.329999999999993 + "z": 1.33 } }, "status": "succeeded" @@ -11960,7 +11960,7 @@ "position": { "x": 3.375, "y": 356.2, - "z": 1.329999999999993 + "z": 1.33 }, "volume": 101.0 }, @@ -11986,7 +11986,7 @@ "position": { "x": 23.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 101.0 }, @@ -12061,7 +12061,7 @@ "position": { "x": 12.375000000000004, "y": 356.2, - "z": 1.329999999999993 + "z": 1.33 } }, "status": "succeeded" @@ -12086,7 +12086,7 @@ "position": { "x": 12.375000000000004, "y": 356.2, - "z": 1.329999999999993 + "z": 1.33 }, "volume": 101.0 }, @@ -12112,7 +12112,7 @@ "position": { "x": 32.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 101.0 }, @@ -12335,7 +12335,7 @@ "position": { "x": 14.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 125.0 }, @@ -12360,7 +12360,7 @@ "position": { "x": 14.3, "y": 74.15, - "z": 26.950000000000003 + "z": 26.95 } }, "status": "succeeded" @@ -12385,7 +12385,7 @@ "position": { "x": 14.3, "y": 74.15, - "z": 26.950000000000003 + "z": 26.95 }, "volume": 100.0 }, @@ -12410,7 +12410,7 @@ "position": { "x": 14.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 } }, "status": "succeeded" @@ -12435,7 +12435,7 @@ "position": { "x": 14.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 80.0 }, @@ -12461,7 +12461,7 @@ "position": { "x": 14.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 80.0 }, @@ -12486,7 +12486,7 @@ "position": { "x": 14.3, "y": 74.15, - "z": 26.950000000000003 + "z": 26.95 } }, "status": "succeeded" @@ -12511,7 +12511,7 @@ "position": { "x": 14.3, "y": 74.15, - "z": 26.950000000000003 + "z": 26.95 }, "volume": 100.0 }, @@ -12537,7 +12537,7 @@ "position": { "x": 14.3, "y": 74.15, - "z": 26.950000000000003 + "z": 26.95 }, "volume": 100.0 }, @@ -12562,7 +12562,7 @@ "position": { "x": 14.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 } }, "status": "succeeded" @@ -12587,7 +12587,7 @@ "position": { "x": 14.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 80.0 }, @@ -12613,7 +12613,7 @@ "position": { "x": 14.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 80.0 }, @@ -12638,7 +12638,7 @@ "position": { "x": 14.3, "y": 74.15, - "z": 26.950000000000003 + "z": 26.95 } }, "status": "succeeded" @@ -12663,7 +12663,7 @@ "position": { "x": 14.3, "y": 74.15, - "z": 26.950000000000003 + "z": 26.95 }, "volume": 100.0 }, @@ -12965,7 +12965,7 @@ "position": { "x": 23.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 125.0 }, @@ -12990,7 +12990,7 @@ "position": { "x": 23.3, "y": 74.15, - "z": 26.950000000000003 + "z": 26.95 } }, "status": "succeeded" @@ -13015,7 +13015,7 @@ "position": { "x": 23.3, "y": 74.15, - "z": 26.950000000000003 + "z": 26.95 }, "volume": 100.0 }, @@ -13040,7 +13040,7 @@ "position": { "x": 23.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 } }, "status": "succeeded" @@ -13065,7 +13065,7 @@ "position": { "x": 23.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 80.0 }, @@ -13091,7 +13091,7 @@ "position": { "x": 23.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 80.0 }, @@ -13116,7 +13116,7 @@ "position": { "x": 23.3, "y": 74.15, - "z": 26.950000000000003 + "z": 26.95 } }, "status": "succeeded" @@ -13141,7 +13141,7 @@ "position": { "x": 23.3, "y": 74.15, - "z": 26.950000000000003 + "z": 26.95 }, "volume": 100.0 }, @@ -13167,7 +13167,7 @@ "position": { "x": 23.3, "y": 74.15, - "z": 26.950000000000003 + "z": 26.95 }, "volume": 100.0 }, @@ -13192,7 +13192,7 @@ "position": { "x": 23.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 } }, "status": "succeeded" @@ -13217,7 +13217,7 @@ "position": { "x": 23.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 80.0 }, @@ -13243,7 +13243,7 @@ "position": { "x": 23.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 80.0 }, @@ -13268,7 +13268,7 @@ "position": { "x": 23.3, "y": 74.15, - "z": 26.950000000000003 + "z": 26.95 } }, "status": "succeeded" @@ -13293,7 +13293,7 @@ "position": { "x": 23.3, "y": 74.15, - "z": 26.950000000000003 + "z": 26.95 }, "volume": 100.0 }, @@ -13595,7 +13595,7 @@ "position": { "x": 32.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 125.0 }, @@ -13620,7 +13620,7 @@ "position": { "x": 32.3, "y": 74.15, - "z": 26.950000000000003 + "z": 26.95 } }, "status": "succeeded" @@ -13645,7 +13645,7 @@ "position": { "x": 32.3, "y": 74.15, - "z": 26.950000000000003 + "z": 26.95 }, "volume": 100.0 }, @@ -13670,7 +13670,7 @@ "position": { "x": 32.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 } }, "status": "succeeded" @@ -13695,7 +13695,7 @@ "position": { "x": 32.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 80.0 }, @@ -13721,7 +13721,7 @@ "position": { "x": 32.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 80.0 }, @@ -13746,7 +13746,7 @@ "position": { "x": 32.3, "y": 74.15, - "z": 26.950000000000003 + "z": 26.95 } }, "status": "succeeded" @@ -13771,7 +13771,7 @@ "position": { "x": 32.3, "y": 74.15, - "z": 26.950000000000003 + "z": 26.95 }, "volume": 100.0 }, @@ -13797,7 +13797,7 @@ "position": { "x": 32.3, "y": 74.15, - "z": 26.950000000000003 + "z": 26.95 }, "volume": 100.0 }, @@ -13822,7 +13822,7 @@ "position": { "x": 32.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 } }, "status": "succeeded" @@ -13847,7 +13847,7 @@ "position": { "x": 32.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 80.0 }, @@ -13873,7 +13873,7 @@ "position": { "x": 32.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 80.0 }, @@ -13898,7 +13898,7 @@ "position": { "x": 32.3, "y": 74.15, - "z": 26.950000000000003 + "z": 26.95 } }, "status": "succeeded" @@ -13923,7 +13923,7 @@ "position": { "x": 32.3, "y": 74.15, - "z": 26.950000000000003 + "z": 26.95 }, "volume": 100.0 }, @@ -15118,7 +15118,7 @@ "position": { "x": 95.3, "y": 74.15, - "z": 21.950000000000003 + "z": 21.95 }, "volume": 200.0 }, @@ -15144,7 +15144,7 @@ "position": { "x": 14.3, "y": 74.15, - "z": 21.950000000000003 + "z": 21.95 }, "volume": 200.0 }, @@ -15220,7 +15220,7 @@ "position": { "x": 104.3, "y": 74.15, - "z": 21.950000000000003 + "z": 21.95 }, "volume": 200.0 }, @@ -15246,7 +15246,7 @@ "position": { "x": 23.3, "y": 74.15, - "z": 21.950000000000003 + "z": 21.95 }, "volume": 200.0 }, @@ -15322,7 +15322,7 @@ "position": { "x": 113.3, "y": 74.15, - "z": 21.950000000000003 + "z": 21.95 }, "volume": 200.0 }, @@ -15348,7 +15348,7 @@ "position": { "x": 32.3, "y": 74.15, - "z": 21.950000000000003 + "z": 21.95 }, "volume": 200.0 }, @@ -16356,7 +16356,7 @@ "position": { "x": 95.3, "y": 74.15, - "z": 21.950000000000003 + "z": 21.95 }, "volume": 200.0 }, @@ -16382,7 +16382,7 @@ "position": { "x": 14.3, "y": 74.15, - "z": 21.950000000000003 + "z": 21.95 }, "volume": 200.0 }, @@ -16458,7 +16458,7 @@ "position": { "x": 104.3, "y": 74.15, - "z": 21.950000000000003 + "z": 21.95 }, "volume": 200.0 }, @@ -16484,7 +16484,7 @@ "position": { "x": 23.3, "y": 74.15, - "z": 21.950000000000003 + "z": 21.95 }, "volume": 200.0 }, @@ -16560,7 +16560,7 @@ "position": { "x": 113.3, "y": 74.15, - "z": 21.950000000000003 + "z": 21.95 }, "volume": 200.0 }, @@ -16586,7 +16586,7 @@ "position": { "x": 32.3, "y": 74.15, - "z": 21.950000000000003 + "z": 21.95 }, "volume": 200.0 }, @@ -17594,7 +17594,7 @@ "position": { "x": 95.3, "y": 74.15, - "z": 21.950000000000003 + "z": 21.95 }, "volume": 200.0 }, @@ -17620,7 +17620,7 @@ "position": { "x": 14.3, "y": 74.15, - "z": 21.950000000000003 + "z": 21.95 }, "volume": 200.0 }, @@ -17696,7 +17696,7 @@ "position": { "x": 104.3, "y": 74.15, - "z": 21.950000000000003 + "z": 21.95 }, "volume": 200.0 }, @@ -17722,7 +17722,7 @@ "position": { "x": 23.3, "y": 74.15, - "z": 21.950000000000003 + "z": 21.95 }, "volume": 200.0 }, @@ -17798,7 +17798,7 @@ "position": { "x": 113.3, "y": 74.15, - "z": 21.950000000000003 + "z": 21.95 }, "volume": 200.0 }, @@ -17824,7 +17824,7 @@ "position": { "x": 32.3, "y": 74.15, - "z": 21.950000000000003 + "z": 21.95 }, "volume": 200.0 }, @@ -18832,7 +18832,7 @@ "position": { "x": 95.3, "y": 74.15, - "z": 21.950000000000003 + "z": 21.95 }, "volume": 200.0 }, @@ -18858,7 +18858,7 @@ "position": { "x": 14.3, "y": 74.15, - "z": 21.950000000000003 + "z": 21.95 }, "volume": 200.0 }, @@ -18934,7 +18934,7 @@ "position": { "x": 104.3, "y": 74.15, - "z": 21.950000000000003 + "z": 21.95 }, "volume": 200.0 }, @@ -18960,7 +18960,7 @@ "position": { "x": 23.3, "y": 74.15, - "z": 21.950000000000003 + "z": 21.95 }, "volume": 200.0 }, @@ -19036,7 +19036,7 @@ "position": { "x": 113.3, "y": 74.15, - "z": 21.950000000000003 + "z": 21.95 }, "volume": 200.0 }, @@ -19062,7 +19062,7 @@ "position": { "x": 32.3, "y": 74.15, - "z": 21.950000000000003 + "z": 21.95 }, "volume": 200.0 }, @@ -19165,7 +19165,7 @@ "position": { "x": 14.3, "y": 74.15, - "z": 22.200000000000003 + "z": 22.2 } }, "status": "succeeded" @@ -19190,7 +19190,7 @@ "position": { "x": 14.3, "y": 74.15, - "z": 22.200000000000003 + "z": 22.2 }, "volume": 200.0 }, @@ -19216,7 +19216,7 @@ "position": { "x": 41.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 200.0 }, @@ -19291,7 +19291,7 @@ "position": { "x": 23.3, "y": 74.15, - "z": 22.200000000000003 + "z": 22.2 } }, "status": "succeeded" @@ -19316,7 +19316,7 @@ "position": { "x": 23.3, "y": 74.15, - "z": 22.200000000000003 + "z": 22.2 }, "volume": 200.0 }, @@ -19342,7 +19342,7 @@ "position": { "x": 50.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 200.0 }, @@ -19417,7 +19417,7 @@ "position": { "x": 32.3, "y": 74.15, - "z": 22.200000000000003 + "z": 22.2 } }, "status": "succeeded" @@ -19442,7 +19442,7 @@ "position": { "x": 32.3, "y": 74.15, - "z": 22.200000000000003 + "z": 22.2 }, "volume": 200.0 }, @@ -19468,7 +19468,7 @@ "position": { "x": 59.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 200.0 }, @@ -21541,7 +21541,7 @@ "position": { "x": 21.375000000000004, "y": 356.2, - "z": 2.079999999999993 + "z": 2.08 }, "volume": 22.0 }, @@ -21667,7 +21667,7 @@ "position": { "x": 30.375000000000004, "y": 356.2, - "z": 2.079999999999993 + "z": 2.08 }, "volume": 22.0 }, @@ -21793,7 +21793,7 @@ "position": { "x": 39.375, "y": 356.2, - "z": 2.079999999999993 + "z": 2.08 }, "volume": 22.0 }, @@ -21905,7 +21905,7 @@ "position": { "x": 21.375000000000004, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 4.0 }, @@ -21930,7 +21930,7 @@ "position": { "x": 21.375000000000004, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 } }, "status": "succeeded" @@ -21955,7 +21955,7 @@ "position": { "x": 21.375000000000004, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 20.0 }, @@ -21981,7 +21981,7 @@ "position": { "x": 21.375000000000004, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 20.0 }, @@ -22083,7 +22083,7 @@ "position": { "x": 30.375000000000004, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 4.0 }, @@ -22108,7 +22108,7 @@ "position": { "x": 30.375000000000004, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 } }, "status": "succeeded" @@ -22133,7 +22133,7 @@ "position": { "x": 30.375000000000004, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 20.0 }, @@ -22159,7 +22159,7 @@ "position": { "x": 30.375000000000004, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 20.0 }, @@ -22261,7 +22261,7 @@ "position": { "x": 39.375, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 4.0 }, @@ -22286,7 +22286,7 @@ "position": { "x": 39.375, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 } }, "status": "succeeded" @@ -22311,7 +22311,7 @@ "position": { "x": 39.375, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 20.0 }, @@ -22337,7 +22337,7 @@ "position": { "x": 39.375, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 20.0 }, @@ -22479,7 +22479,7 @@ "position": { "x": 21.375000000000004, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 5.0 }, @@ -22581,7 +22581,7 @@ "position": { "x": 30.375000000000004, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 5.0 }, @@ -22683,7 +22683,7 @@ "position": { "x": 39.375, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 5.0 }, @@ -22795,7 +22795,7 @@ "position": { "x": 21.375000000000004, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 20.0 }, @@ -22820,7 +22820,7 @@ "position": { "x": 21.375000000000004, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 } }, "status": "succeeded" @@ -22845,7 +22845,7 @@ "position": { "x": 21.375000000000004, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 45.0 }, @@ -22871,7 +22871,7 @@ "position": { "x": 21.375000000000004, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 45.0 }, @@ -22973,7 +22973,7 @@ "position": { "x": 30.375000000000004, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 20.0 }, @@ -22998,7 +22998,7 @@ "position": { "x": 30.375000000000004, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 } }, "status": "succeeded" @@ -23023,7 +23023,7 @@ "position": { "x": 30.375000000000004, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 45.0 }, @@ -23049,7 +23049,7 @@ "position": { "x": 30.375000000000004, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 45.0 }, @@ -23151,7 +23151,7 @@ "position": { "x": 39.375, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 20.0 }, @@ -23176,7 +23176,7 @@ "position": { "x": 39.375, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 } }, "status": "succeeded" @@ -23201,7 +23201,7 @@ "position": { "x": 39.375, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 45.0 }, @@ -23227,7 +23227,7 @@ "position": { "x": 39.375, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 45.0 }, @@ -23378,7 +23378,7 @@ "position": { "x": 21.375000000000004, "y": 356.2, - "z": 1.329999999999993 + "z": 1.33 } }, "status": "succeeded" @@ -23403,7 +23403,7 @@ "position": { "x": 21.375000000000004, "y": 356.2, - "z": 1.329999999999993 + "z": 1.33 }, "volume": 46.0 }, @@ -23429,7 +23429,7 @@ "position": { "x": 68.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 46.0 }, @@ -23504,7 +23504,7 @@ "position": { "x": 30.375000000000004, "y": 356.2, - "z": 1.329999999999993 + "z": 1.33 } }, "status": "succeeded" @@ -23529,7 +23529,7 @@ "position": { "x": 30.375000000000004, "y": 356.2, - "z": 1.329999999999993 + "z": 1.33 }, "volume": 46.0 }, @@ -23555,7 +23555,7 @@ "position": { "x": 77.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 46.0 }, @@ -23630,7 +23630,7 @@ "position": { "x": 39.375, "y": 356.2, - "z": 1.329999999999993 + "z": 1.33 } }, "status": "succeeded" @@ -23655,7 +23655,7 @@ "position": { "x": 39.375, "y": 356.2, - "z": 1.329999999999993 + "z": 1.33 }, "volume": 46.0 }, @@ -23681,7 +23681,7 @@ "position": { "x": 86.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 46.0 }, @@ -23845,7 +23845,7 @@ "position": { "x": 68.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 40.5 }, @@ -23870,7 +23870,7 @@ "position": { "x": 68.3, "y": 74.15, - "z": 26.950000000000003 + "z": 26.95 } }, "status": "succeeded" @@ -23895,7 +23895,7 @@ "position": { "x": 68.3, "y": 74.15, - "z": 26.950000000000003 + "z": 26.95 }, "volume": 60.0 }, @@ -23920,7 +23920,7 @@ "position": { "x": 68.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 } }, "status": "succeeded" @@ -23945,7 +23945,7 @@ "position": { "x": 68.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 60.0 }, @@ -23971,7 +23971,7 @@ "position": { "x": 68.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 60.0 }, @@ -23996,7 +23996,7 @@ "position": { "x": 68.3, "y": 74.15, - "z": 26.950000000000003 + "z": 26.95 } }, "status": "succeeded" @@ -24021,7 +24021,7 @@ "position": { "x": 68.3, "y": 74.15, - "z": 26.950000000000003 + "z": 26.95 }, "volume": 30.0 }, @@ -24047,7 +24047,7 @@ "position": { "x": 68.3, "y": 74.15, - "z": 26.950000000000003 + "z": 26.95 }, "volume": 60.0 }, @@ -24072,7 +24072,7 @@ "position": { "x": 68.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 } }, "status": "succeeded" @@ -24097,7 +24097,7 @@ "position": { "x": 68.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 60.0 }, @@ -24123,7 +24123,7 @@ "position": { "x": 68.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 60.0 }, @@ -24148,7 +24148,7 @@ "position": { "x": 68.3, "y": 74.15, - "z": 26.950000000000003 + "z": 26.95 } }, "status": "succeeded" @@ -24173,7 +24173,7 @@ "position": { "x": 68.3, "y": 74.15, - "z": 26.950000000000003 + "z": 26.95 }, "volume": 30.0 }, @@ -24423,7 +24423,7 @@ "position": { "x": 77.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 40.5 }, @@ -24448,7 +24448,7 @@ "position": { "x": 77.3, "y": 74.15, - "z": 26.950000000000003 + "z": 26.95 } }, "status": "succeeded" @@ -24473,7 +24473,7 @@ "position": { "x": 77.3, "y": 74.15, - "z": 26.950000000000003 + "z": 26.95 }, "volume": 60.0 }, @@ -24498,7 +24498,7 @@ "position": { "x": 77.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 } }, "status": "succeeded" @@ -24523,7 +24523,7 @@ "position": { "x": 77.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 60.0 }, @@ -24549,7 +24549,7 @@ "position": { "x": 77.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 60.0 }, @@ -24574,7 +24574,7 @@ "position": { "x": 77.3, "y": 74.15, - "z": 26.950000000000003 + "z": 26.95 } }, "status": "succeeded" @@ -24599,7 +24599,7 @@ "position": { "x": 77.3, "y": 74.15, - "z": 26.950000000000003 + "z": 26.95 }, "volume": 30.0 }, @@ -24625,7 +24625,7 @@ "position": { "x": 77.3, "y": 74.15, - "z": 26.950000000000003 + "z": 26.95 }, "volume": 60.0 }, @@ -24650,7 +24650,7 @@ "position": { "x": 77.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 } }, "status": "succeeded" @@ -24675,7 +24675,7 @@ "position": { "x": 77.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 60.0 }, @@ -24701,7 +24701,7 @@ "position": { "x": 77.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 60.0 }, @@ -24726,7 +24726,7 @@ "position": { "x": 77.3, "y": 74.15, - "z": 26.950000000000003 + "z": 26.95 } }, "status": "succeeded" @@ -24751,7 +24751,7 @@ "position": { "x": 77.3, "y": 74.15, - "z": 26.950000000000003 + "z": 26.95 }, "volume": 30.0 }, @@ -25001,7 +25001,7 @@ "position": { "x": 86.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 40.5 }, @@ -25026,7 +25026,7 @@ "position": { "x": 86.3, "y": 74.15, - "z": 26.950000000000003 + "z": 26.95 } }, "status": "succeeded" @@ -25051,7 +25051,7 @@ "position": { "x": 86.3, "y": 74.15, - "z": 26.950000000000003 + "z": 26.95 }, "volume": 60.0 }, @@ -25076,7 +25076,7 @@ "position": { "x": 86.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 } }, "status": "succeeded" @@ -25101,7 +25101,7 @@ "position": { "x": 86.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 60.0 }, @@ -25127,7 +25127,7 @@ "position": { "x": 86.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 60.0 }, @@ -25152,7 +25152,7 @@ "position": { "x": 86.3, "y": 74.15, - "z": 26.950000000000003 + "z": 26.95 } }, "status": "succeeded" @@ -25177,7 +25177,7 @@ "position": { "x": 86.3, "y": 74.15, - "z": 26.950000000000003 + "z": 26.95 }, "volume": 30.0 }, @@ -25203,7 +25203,7 @@ "position": { "x": 86.3, "y": 74.15, - "z": 26.950000000000003 + "z": 26.95 }, "volume": 60.0 }, @@ -25228,7 +25228,7 @@ "position": { "x": 86.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 } }, "status": "succeeded" @@ -25253,7 +25253,7 @@ "position": { "x": 86.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 60.0 }, @@ -25279,7 +25279,7 @@ "position": { "x": 86.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 60.0 }, @@ -25304,7 +25304,7 @@ "position": { "x": 86.3, "y": 74.15, - "z": 26.950000000000003 + "z": 26.95 } }, "status": "succeeded" @@ -25329,7 +25329,7 @@ "position": { "x": 86.3, "y": 74.15, - "z": 26.950000000000003 + "z": 26.95 }, "volume": 30.0 }, @@ -30796,7 +30796,7 @@ "position": { "x": 68.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 } }, "status": "succeeded" @@ -30821,7 +30821,7 @@ "position": { "x": 68.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 32.0 }, @@ -30896,7 +30896,7 @@ "position": { "x": 68.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 } }, "status": "succeeded" @@ -30921,7 +30921,7 @@ "position": { "x": 68.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 32.0 }, @@ -30996,7 +30996,7 @@ "position": { "x": 68.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 } }, "status": "succeeded" @@ -31021,7 +31021,7 @@ "position": { "x": 68.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 32.0 }, @@ -31096,7 +31096,7 @@ "position": { "x": 68.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 } }, "status": "succeeded" @@ -31121,7 +31121,7 @@ "position": { "x": 68.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 32.0 }, @@ -31147,7 +31147,7 @@ "position": { "x": 68.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 32.0 }, @@ -31394,7 +31394,7 @@ "position": { "x": 77.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 } }, "status": "succeeded" @@ -31419,7 +31419,7 @@ "position": { "x": 77.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 32.0 }, @@ -31494,7 +31494,7 @@ "position": { "x": 77.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 } }, "status": "succeeded" @@ -31519,7 +31519,7 @@ "position": { "x": 77.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 32.0 }, @@ -31594,7 +31594,7 @@ "position": { "x": 77.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 } }, "status": "succeeded" @@ -31619,7 +31619,7 @@ "position": { "x": 77.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 32.0 }, @@ -31694,7 +31694,7 @@ "position": { "x": 77.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 } }, "status": "succeeded" @@ -31719,7 +31719,7 @@ "position": { "x": 77.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 32.0 }, @@ -31745,7 +31745,7 @@ "position": { "x": 77.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 32.0 }, @@ -31992,7 +31992,7 @@ "position": { "x": 86.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 } }, "status": "succeeded" @@ -32017,7 +32017,7 @@ "position": { "x": 86.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 32.0 }, @@ -32092,7 +32092,7 @@ "position": { "x": 86.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 } }, "status": "succeeded" @@ -32117,7 +32117,7 @@ "position": { "x": 86.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 32.0 }, @@ -32192,7 +32192,7 @@ "position": { "x": 86.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 } }, "status": "succeeded" @@ -32217,7 +32217,7 @@ "position": { "x": 86.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 32.0 }, @@ -32292,7 +32292,7 @@ "position": { "x": 86.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 } }, "status": "succeeded" @@ -32317,7 +32317,7 @@ "position": { "x": 86.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 32.0 }, @@ -32343,7 +32343,7 @@ "position": { "x": 86.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 32.0 }, @@ -32613,7 +32613,7 @@ "position": { "x": 48.375, "y": 356.2, - "z": 2.079999999999993 + "z": 2.08 }, "volume": 31.0 }, @@ -32739,7 +32739,7 @@ "position": { "x": 57.375, "y": 356.2, - "z": 2.079999999999993 + "z": 2.08 }, "volume": 31.0 }, @@ -32865,7 +32865,7 @@ "position": { "x": 66.375, "y": 356.2, - "z": 2.079999999999993 + "z": 2.08 }, "volume": 31.0 }, @@ -33501,7 +33501,7 @@ "position": { "x": 95.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 200.0 }, @@ -33553,7 +33553,7 @@ "position": { "x": 95.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 200.0 }, @@ -33605,7 +33605,7 @@ "position": { "x": 104.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 200.0 }, @@ -33657,7 +33657,7 @@ "position": { "x": 104.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 200.0 }, @@ -33709,7 +33709,7 @@ "position": { "x": 113.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 200.0 }, @@ -33761,7 +33761,7 @@ "position": { "x": 113.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 200.0 }, @@ -33813,7 +33813,7 @@ "position": { "x": 95.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 200.0 }, @@ -33865,7 +33865,7 @@ "position": { "x": 95.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 200.0 }, @@ -33917,7 +33917,7 @@ "position": { "x": 104.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 200.0 }, @@ -33969,7 +33969,7 @@ "position": { "x": 104.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 200.0 }, @@ -34021,7 +34021,7 @@ "position": { "x": 113.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 200.0 }, @@ -34073,7 +34073,7 @@ "position": { "x": 113.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 200.0 }, @@ -34125,7 +34125,7 @@ "position": { "x": 95.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 200.0 }, @@ -34177,7 +34177,7 @@ "position": { "x": 95.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 200.0 }, @@ -34229,7 +34229,7 @@ "position": { "x": 104.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 200.0 }, @@ -34281,7 +34281,7 @@ "position": { "x": 104.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 200.0 }, @@ -34333,7 +34333,7 @@ "position": { "x": 113.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 200.0 }, @@ -34385,7 +34385,7 @@ "position": { "x": 113.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 200.0 }, @@ -34437,7 +34437,7 @@ "position": { "x": 95.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 200.0 }, @@ -34489,7 +34489,7 @@ "position": { "x": 95.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 200.0 }, @@ -34541,7 +34541,7 @@ "position": { "x": 104.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 200.0 }, @@ -34593,7 +34593,7 @@ "position": { "x": 104.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 200.0 }, @@ -34645,7 +34645,7 @@ "position": { "x": 113.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 200.0 }, @@ -34697,7 +34697,7 @@ "position": { "x": 113.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 200.0 }, @@ -34749,7 +34749,7 @@ "position": { "x": 95.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 200.0 }, @@ -34801,7 +34801,7 @@ "position": { "x": 95.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 200.0 }, @@ -34853,7 +34853,7 @@ "position": { "x": 104.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 200.0 }, @@ -34905,7 +34905,7 @@ "position": { "x": 104.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 200.0 }, @@ -34957,7 +34957,7 @@ "position": { "x": 113.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 200.0 }, @@ -35009,7 +35009,7 @@ "position": { "x": 113.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 200.0 }, @@ -35061,7 +35061,7 @@ "position": { "x": 95.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 200.0 }, @@ -35113,7 +35113,7 @@ "position": { "x": 95.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 200.0 }, @@ -35165,7 +35165,7 @@ "position": { "x": 104.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 200.0 }, @@ -35217,7 +35217,7 @@ "position": { "x": 104.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 200.0 }, @@ -35269,7 +35269,7 @@ "position": { "x": 113.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 200.0 }, @@ -35321,7 +35321,7 @@ "position": { "x": 113.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 200.0 }, @@ -36073,7 +36073,7 @@ "position": { "x": 48.375, "y": 356.2, - "z": 2.079999999999993 + "z": 2.08 }, "volume": 32.0 }, @@ -36125,7 +36125,7 @@ "position": { "x": 57.375, "y": 356.2, - "z": 2.079999999999993 + "z": 2.08 }, "volume": 32.0 }, @@ -36177,7 +36177,7 @@ "position": { "x": 66.375, "y": 356.2, - "z": 2.079999999999993 + "z": 2.08 }, "volume": 32.0 }, @@ -36255,7 +36255,7 @@ "position": { "x": -5.624999999999998, "y": 356.2, - "z": 2.079999999999993 + "z": 2.08 }, "volume": 30.0 }, @@ -36307,7 +36307,7 @@ "position": { "x": 3.375, "y": 356.2, - "z": 2.079999999999993 + "z": 2.08 }, "volume": 30.0 }, @@ -36359,7 +36359,7 @@ "position": { "x": 12.375000000000004, "y": 356.2, - "z": 2.079999999999993 + "z": 2.08 }, "volume": 30.0 }, @@ -36506,7 +36506,7 @@ "position": { "x": -5.624999999999998, "y": 356.2, - "z": 1.329999999999993 + "z": 1.33 } }, "status": "succeeded" @@ -36531,7 +36531,7 @@ "position": { "x": -5.624999999999998, "y": 356.2, - "z": 1.329999999999993 + "z": 1.33 }, "volume": 101.0 }, @@ -36557,7 +36557,7 @@ "position": { "x": 14.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 101.0 }, @@ -36632,7 +36632,7 @@ "position": { "x": 3.375, "y": 356.2, - "z": 1.329999999999993 + "z": 1.33 } }, "status": "succeeded" @@ -36657,7 +36657,7 @@ "position": { "x": 3.375, "y": 356.2, - "z": 1.329999999999993 + "z": 1.33 }, "volume": 101.0 }, @@ -36683,7 +36683,7 @@ "position": { "x": 23.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 101.0 }, @@ -36758,7 +36758,7 @@ "position": { "x": 12.375000000000004, "y": 356.2, - "z": 1.329999999999993 + "z": 1.33 } }, "status": "succeeded" @@ -36783,7 +36783,7 @@ "position": { "x": 12.375000000000004, "y": 356.2, - "z": 1.329999999999993 + "z": 1.33 }, "volume": 101.0 }, @@ -36809,7 +36809,7 @@ "position": { "x": 32.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 101.0 }, @@ -37032,7 +37032,7 @@ "position": { "x": 14.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 125.0 }, @@ -37057,7 +37057,7 @@ "position": { "x": 14.3, "y": 74.15, - "z": 26.950000000000003 + "z": 26.95 } }, "status": "succeeded" @@ -37082,7 +37082,7 @@ "position": { "x": 14.3, "y": 74.15, - "z": 26.950000000000003 + "z": 26.95 }, "volume": 100.0 }, @@ -37107,7 +37107,7 @@ "position": { "x": 14.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 } }, "status": "succeeded" @@ -37132,7 +37132,7 @@ "position": { "x": 14.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 80.0 }, @@ -37158,7 +37158,7 @@ "position": { "x": 14.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 80.0 }, @@ -37183,7 +37183,7 @@ "position": { "x": 14.3, "y": 74.15, - "z": 26.950000000000003 + "z": 26.95 } }, "status": "succeeded" @@ -37208,7 +37208,7 @@ "position": { "x": 14.3, "y": 74.15, - "z": 26.950000000000003 + "z": 26.95 }, "volume": 100.0 }, @@ -37234,7 +37234,7 @@ "position": { "x": 14.3, "y": 74.15, - "z": 26.950000000000003 + "z": 26.95 }, "volume": 100.0 }, @@ -37259,7 +37259,7 @@ "position": { "x": 14.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 } }, "status": "succeeded" @@ -37284,7 +37284,7 @@ "position": { "x": 14.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 80.0 }, @@ -37310,7 +37310,7 @@ "position": { "x": 14.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 80.0 }, @@ -37335,7 +37335,7 @@ "position": { "x": 14.3, "y": 74.15, - "z": 26.950000000000003 + "z": 26.95 } }, "status": "succeeded" @@ -37360,7 +37360,7 @@ "position": { "x": 14.3, "y": 74.15, - "z": 26.950000000000003 + "z": 26.95 }, "volume": 100.0 }, @@ -37662,7 +37662,7 @@ "position": { "x": 23.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 125.0 }, @@ -37687,7 +37687,7 @@ "position": { "x": 23.3, "y": 74.15, - "z": 26.950000000000003 + "z": 26.95 } }, "status": "succeeded" @@ -37712,7 +37712,7 @@ "position": { "x": 23.3, "y": 74.15, - "z": 26.950000000000003 + "z": 26.95 }, "volume": 100.0 }, @@ -37737,7 +37737,7 @@ "position": { "x": 23.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 } }, "status": "succeeded" @@ -37762,7 +37762,7 @@ "position": { "x": 23.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 80.0 }, @@ -37788,7 +37788,7 @@ "position": { "x": 23.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 80.0 }, @@ -37813,7 +37813,7 @@ "position": { "x": 23.3, "y": 74.15, - "z": 26.950000000000003 + "z": 26.95 } }, "status": "succeeded" @@ -37838,7 +37838,7 @@ "position": { "x": 23.3, "y": 74.15, - "z": 26.950000000000003 + "z": 26.95 }, "volume": 100.0 }, @@ -37864,7 +37864,7 @@ "position": { "x": 23.3, "y": 74.15, - "z": 26.950000000000003 + "z": 26.95 }, "volume": 100.0 }, @@ -37889,7 +37889,7 @@ "position": { "x": 23.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 } }, "status": "succeeded" @@ -37914,7 +37914,7 @@ "position": { "x": 23.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 80.0 }, @@ -37940,7 +37940,7 @@ "position": { "x": 23.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 80.0 }, @@ -37965,7 +37965,7 @@ "position": { "x": 23.3, "y": 74.15, - "z": 26.950000000000003 + "z": 26.95 } }, "status": "succeeded" @@ -37990,7 +37990,7 @@ "position": { "x": 23.3, "y": 74.15, - "z": 26.950000000000003 + "z": 26.95 }, "volume": 100.0 }, @@ -38292,7 +38292,7 @@ "position": { "x": 32.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 125.0 }, @@ -38317,7 +38317,7 @@ "position": { "x": 32.3, "y": 74.15, - "z": 26.950000000000003 + "z": 26.95 } }, "status": "succeeded" @@ -38342,7 +38342,7 @@ "position": { "x": 32.3, "y": 74.15, - "z": 26.950000000000003 + "z": 26.95 }, "volume": 100.0 }, @@ -38367,7 +38367,7 @@ "position": { "x": 32.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 } }, "status": "succeeded" @@ -38392,7 +38392,7 @@ "position": { "x": 32.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 80.0 }, @@ -38418,7 +38418,7 @@ "position": { "x": 32.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 80.0 }, @@ -38443,7 +38443,7 @@ "position": { "x": 32.3, "y": 74.15, - "z": 26.950000000000003 + "z": 26.95 } }, "status": "succeeded" @@ -38468,7 +38468,7 @@ "position": { "x": 32.3, "y": 74.15, - "z": 26.950000000000003 + "z": 26.95 }, "volume": 100.0 }, @@ -38494,7 +38494,7 @@ "position": { "x": 32.3, "y": 74.15, - "z": 26.950000000000003 + "z": 26.95 }, "volume": 100.0 }, @@ -38519,7 +38519,7 @@ "position": { "x": 32.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 } }, "status": "succeeded" @@ -38544,7 +38544,7 @@ "position": { "x": 32.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 80.0 }, @@ -38570,7 +38570,7 @@ "position": { "x": 32.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 80.0 }, @@ -38595,7 +38595,7 @@ "position": { "x": 32.3, "y": 74.15, - "z": 26.950000000000003 + "z": 26.95 } }, "status": "succeeded" @@ -38620,7 +38620,7 @@ "position": { "x": 32.3, "y": 74.15, - "z": 26.950000000000003 + "z": 26.95 }, "volume": 100.0 }, @@ -39815,7 +39815,7 @@ "position": { "x": 95.3, "y": 74.15, - "z": 21.950000000000003 + "z": 21.95 }, "volume": 200.0 }, @@ -39841,7 +39841,7 @@ "position": { "x": 14.3, "y": 74.15, - "z": 21.950000000000003 + "z": 21.95 }, "volume": 200.0 }, @@ -39917,7 +39917,7 @@ "position": { "x": 104.3, "y": 74.15, - "z": 21.950000000000003 + "z": 21.95 }, "volume": 200.0 }, @@ -39943,7 +39943,7 @@ "position": { "x": 23.3, "y": 74.15, - "z": 21.950000000000003 + "z": 21.95 }, "volume": 200.0 }, @@ -40019,7 +40019,7 @@ "position": { "x": 113.3, "y": 74.15, - "z": 21.950000000000003 + "z": 21.95 }, "volume": 200.0 }, @@ -40045,7 +40045,7 @@ "position": { "x": 32.3, "y": 74.15, - "z": 21.950000000000003 + "z": 21.95 }, "volume": 200.0 }, @@ -41053,7 +41053,7 @@ "position": { "x": 95.3, "y": 74.15, - "z": 21.950000000000003 + "z": 21.95 }, "volume": 200.0 }, @@ -41079,7 +41079,7 @@ "position": { "x": 14.3, "y": 74.15, - "z": 21.950000000000003 + "z": 21.95 }, "volume": 200.0 }, @@ -41155,7 +41155,7 @@ "position": { "x": 104.3, "y": 74.15, - "z": 21.950000000000003 + "z": 21.95 }, "volume": 200.0 }, @@ -41181,7 +41181,7 @@ "position": { "x": 23.3, "y": 74.15, - "z": 21.950000000000003 + "z": 21.95 }, "volume": 200.0 }, @@ -41257,7 +41257,7 @@ "position": { "x": 113.3, "y": 74.15, - "z": 21.950000000000003 + "z": 21.95 }, "volume": 200.0 }, @@ -41283,7 +41283,7 @@ "position": { "x": 32.3, "y": 74.15, - "z": 21.950000000000003 + "z": 21.95 }, "volume": 200.0 }, @@ -42291,7 +42291,7 @@ "position": { "x": 95.3, "y": 74.15, - "z": 21.950000000000003 + "z": 21.95 }, "volume": 200.0 }, @@ -42317,7 +42317,7 @@ "position": { "x": 14.3, "y": 74.15, - "z": 21.950000000000003 + "z": 21.95 }, "volume": 200.0 }, @@ -42393,7 +42393,7 @@ "position": { "x": 104.3, "y": 74.15, - "z": 21.950000000000003 + "z": 21.95 }, "volume": 200.0 }, @@ -42419,7 +42419,7 @@ "position": { "x": 23.3, "y": 74.15, - "z": 21.950000000000003 + "z": 21.95 }, "volume": 200.0 }, @@ -42495,7 +42495,7 @@ "position": { "x": 113.3, "y": 74.15, - "z": 21.950000000000003 + "z": 21.95 }, "volume": 200.0 }, @@ -42521,7 +42521,7 @@ "position": { "x": 32.3, "y": 74.15, - "z": 21.950000000000003 + "z": 21.95 }, "volume": 200.0 }, @@ -43529,7 +43529,7 @@ "position": { "x": 95.3, "y": 74.15, - "z": 21.950000000000003 + "z": 21.95 }, "volume": 200.0 }, @@ -43555,7 +43555,7 @@ "position": { "x": 14.3, "y": 74.15, - "z": 21.950000000000003 + "z": 21.95 }, "volume": 200.0 }, @@ -43631,7 +43631,7 @@ "position": { "x": 104.3, "y": 74.15, - "z": 21.950000000000003 + "z": 21.95 }, "volume": 200.0 }, @@ -43657,7 +43657,7 @@ "position": { "x": 23.3, "y": 74.15, - "z": 21.950000000000003 + "z": 21.95 }, "volume": 200.0 }, @@ -43733,7 +43733,7 @@ "position": { "x": 113.3, "y": 74.15, - "z": 21.950000000000003 + "z": 21.95 }, "volume": 200.0 }, @@ -43759,7 +43759,7 @@ "position": { "x": 32.3, "y": 74.15, - "z": 21.950000000000003 + "z": 21.95 }, "volume": 200.0 }, @@ -43862,7 +43862,7 @@ "position": { "x": 14.3, "y": 74.15, - "z": 22.200000000000003 + "z": 22.2 } }, "status": "succeeded" @@ -43887,7 +43887,7 @@ "position": { "x": 14.3, "y": 74.15, - "z": 22.200000000000003 + "z": 22.2 }, "volume": 200.0 }, @@ -43913,7 +43913,7 @@ "position": { "x": 41.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 200.0 }, @@ -43988,7 +43988,7 @@ "position": { "x": 23.3, "y": 74.15, - "z": 22.200000000000003 + "z": 22.2 } }, "status": "succeeded" @@ -44013,7 +44013,7 @@ "position": { "x": 23.3, "y": 74.15, - "z": 22.200000000000003 + "z": 22.2 }, "volume": 200.0 }, @@ -44039,7 +44039,7 @@ "position": { "x": 50.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 200.0 }, @@ -44114,7 +44114,7 @@ "position": { "x": 32.3, "y": 74.15, - "z": 22.200000000000003 + "z": 22.2 } }, "status": "succeeded" @@ -44139,7 +44139,7 @@ "position": { "x": 32.3, "y": 74.15, - "z": 22.200000000000003 + "z": 22.2 }, "volume": 200.0 }, @@ -44165,7 +44165,7 @@ "position": { "x": 59.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 200.0 }, @@ -46238,7 +46238,7 @@ "position": { "x": 21.375000000000004, "y": 356.2, - "z": 2.079999999999993 + "z": 2.08 }, "volume": 22.0 }, @@ -46364,7 +46364,7 @@ "position": { "x": 30.375000000000004, "y": 356.2, - "z": 2.079999999999993 + "z": 2.08 }, "volume": 22.0 }, @@ -46490,7 +46490,7 @@ "position": { "x": 39.375, "y": 356.2, - "z": 2.079999999999993 + "z": 2.08 }, "volume": 22.0 }, @@ -46602,7 +46602,7 @@ "position": { "x": 21.375000000000004, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 4.0 }, @@ -46627,7 +46627,7 @@ "position": { "x": 21.375000000000004, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 } }, "status": "succeeded" @@ -46652,7 +46652,7 @@ "position": { "x": 21.375000000000004, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 20.0 }, @@ -46678,7 +46678,7 @@ "position": { "x": 21.375000000000004, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 20.0 }, @@ -46780,7 +46780,7 @@ "position": { "x": 30.375000000000004, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 4.0 }, @@ -46805,7 +46805,7 @@ "position": { "x": 30.375000000000004, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 } }, "status": "succeeded" @@ -46830,7 +46830,7 @@ "position": { "x": 30.375000000000004, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 20.0 }, @@ -46856,7 +46856,7 @@ "position": { "x": 30.375000000000004, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 20.0 }, @@ -46958,7 +46958,7 @@ "position": { "x": 39.375, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 4.0 }, @@ -46983,7 +46983,7 @@ "position": { "x": 39.375, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 } }, "status": "succeeded" @@ -47008,7 +47008,7 @@ "position": { "x": 39.375, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 20.0 }, @@ -47034,7 +47034,7 @@ "position": { "x": 39.375, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 20.0 }, @@ -47176,7 +47176,7 @@ "position": { "x": 21.375000000000004, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 5.0 }, @@ -47278,7 +47278,7 @@ "position": { "x": 30.375000000000004, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 5.0 }, @@ -47380,7 +47380,7 @@ "position": { "x": 39.375, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 5.0 }, @@ -47492,7 +47492,7 @@ "position": { "x": 21.375000000000004, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 20.0 }, @@ -47517,7 +47517,7 @@ "position": { "x": 21.375000000000004, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 } }, "status": "succeeded" @@ -47542,7 +47542,7 @@ "position": { "x": 21.375000000000004, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 45.0 }, @@ -47568,7 +47568,7 @@ "position": { "x": 21.375000000000004, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 45.0 }, @@ -47670,7 +47670,7 @@ "position": { "x": 30.375000000000004, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 20.0 }, @@ -47695,7 +47695,7 @@ "position": { "x": 30.375000000000004, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 } }, "status": "succeeded" @@ -47720,7 +47720,7 @@ "position": { "x": 30.375000000000004, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 45.0 }, @@ -47746,7 +47746,7 @@ "position": { "x": 30.375000000000004, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 45.0 }, @@ -47848,7 +47848,7 @@ "position": { "x": 39.375, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 20.0 }, @@ -47873,7 +47873,7 @@ "position": { "x": 39.375, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 } }, "status": "succeeded" @@ -47898,7 +47898,7 @@ "position": { "x": 39.375, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 45.0 }, @@ -47924,7 +47924,7 @@ "position": { "x": 39.375, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 45.0 }, @@ -48075,7 +48075,7 @@ "position": { "x": 21.375000000000004, "y": 356.2, - "z": 1.329999999999993 + "z": 1.33 } }, "status": "succeeded" @@ -48100,7 +48100,7 @@ "position": { "x": 21.375000000000004, "y": 356.2, - "z": 1.329999999999993 + "z": 1.33 }, "volume": 46.0 }, @@ -48126,7 +48126,7 @@ "position": { "x": 68.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 46.0 }, @@ -48201,7 +48201,7 @@ "position": { "x": 30.375000000000004, "y": 356.2, - "z": 1.329999999999993 + "z": 1.33 } }, "status": "succeeded" @@ -48226,7 +48226,7 @@ "position": { "x": 30.375000000000004, "y": 356.2, - "z": 1.329999999999993 + "z": 1.33 }, "volume": 46.0 }, @@ -48252,7 +48252,7 @@ "position": { "x": 77.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 46.0 }, @@ -48327,7 +48327,7 @@ "position": { "x": 39.375, "y": 356.2, - "z": 1.329999999999993 + "z": 1.33 } }, "status": "succeeded" @@ -48352,7 +48352,7 @@ "position": { "x": 39.375, "y": 356.2, - "z": 1.329999999999993 + "z": 1.33 }, "volume": 46.0 }, @@ -48378,7 +48378,7 @@ "position": { "x": 86.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 46.0 }, @@ -48542,7 +48542,7 @@ "position": { "x": 68.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 40.5 }, @@ -48567,7 +48567,7 @@ "position": { "x": 68.3, "y": 74.15, - "z": 26.950000000000003 + "z": 26.95 } }, "status": "succeeded" @@ -48592,7 +48592,7 @@ "position": { "x": 68.3, "y": 74.15, - "z": 26.950000000000003 + "z": 26.95 }, "volume": 60.0 }, @@ -48617,7 +48617,7 @@ "position": { "x": 68.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 } }, "status": "succeeded" @@ -48642,7 +48642,7 @@ "position": { "x": 68.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 60.0 }, @@ -48668,7 +48668,7 @@ "position": { "x": 68.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 60.0 }, @@ -48693,7 +48693,7 @@ "position": { "x": 68.3, "y": 74.15, - "z": 26.950000000000003 + "z": 26.95 } }, "status": "succeeded" @@ -48718,7 +48718,7 @@ "position": { "x": 68.3, "y": 74.15, - "z": 26.950000000000003 + "z": 26.95 }, "volume": 30.0 }, @@ -48744,7 +48744,7 @@ "position": { "x": 68.3, "y": 74.15, - "z": 26.950000000000003 + "z": 26.95 }, "volume": 60.0 }, @@ -48769,7 +48769,7 @@ "position": { "x": 68.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 } }, "status": "succeeded" @@ -48794,7 +48794,7 @@ "position": { "x": 68.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 60.0 }, @@ -48820,7 +48820,7 @@ "position": { "x": 68.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 60.0 }, @@ -48845,7 +48845,7 @@ "position": { "x": 68.3, "y": 74.15, - "z": 26.950000000000003 + "z": 26.95 } }, "status": "succeeded" @@ -48870,7 +48870,7 @@ "position": { "x": 68.3, "y": 74.15, - "z": 26.950000000000003 + "z": 26.95 }, "volume": 30.0 }, @@ -49120,7 +49120,7 @@ "position": { "x": 77.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 40.5 }, @@ -49145,7 +49145,7 @@ "position": { "x": 77.3, "y": 74.15, - "z": 26.950000000000003 + "z": 26.95 } }, "status": "succeeded" @@ -49170,7 +49170,7 @@ "position": { "x": 77.3, "y": 74.15, - "z": 26.950000000000003 + "z": 26.95 }, "volume": 60.0 }, @@ -49195,7 +49195,7 @@ "position": { "x": 77.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 } }, "status": "succeeded" @@ -49220,7 +49220,7 @@ "position": { "x": 77.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 60.0 }, @@ -49246,7 +49246,7 @@ "position": { "x": 77.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 60.0 }, @@ -49271,7 +49271,7 @@ "position": { "x": 77.3, "y": 74.15, - "z": 26.950000000000003 + "z": 26.95 } }, "status": "succeeded" @@ -49296,7 +49296,7 @@ "position": { "x": 77.3, "y": 74.15, - "z": 26.950000000000003 + "z": 26.95 }, "volume": 30.0 }, @@ -49322,7 +49322,7 @@ "position": { "x": 77.3, "y": 74.15, - "z": 26.950000000000003 + "z": 26.95 }, "volume": 60.0 }, @@ -49347,7 +49347,7 @@ "position": { "x": 77.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 } }, "status": "succeeded" @@ -49372,7 +49372,7 @@ "position": { "x": 77.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 60.0 }, @@ -49398,7 +49398,7 @@ "position": { "x": 77.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 60.0 }, @@ -49423,7 +49423,7 @@ "position": { "x": 77.3, "y": 74.15, - "z": 26.950000000000003 + "z": 26.95 } }, "status": "succeeded" @@ -49448,7 +49448,7 @@ "position": { "x": 77.3, "y": 74.15, - "z": 26.950000000000003 + "z": 26.95 }, "volume": 30.0 }, @@ -49698,7 +49698,7 @@ "position": { "x": 86.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 40.5 }, @@ -49723,7 +49723,7 @@ "position": { "x": 86.3, "y": 74.15, - "z": 26.950000000000003 + "z": 26.95 } }, "status": "succeeded" @@ -49748,7 +49748,7 @@ "position": { "x": 86.3, "y": 74.15, - "z": 26.950000000000003 + "z": 26.95 }, "volume": 60.0 }, @@ -49773,7 +49773,7 @@ "position": { "x": 86.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 } }, "status": "succeeded" @@ -49798,7 +49798,7 @@ "position": { "x": 86.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 60.0 }, @@ -49824,7 +49824,7 @@ "position": { "x": 86.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 60.0 }, @@ -49849,7 +49849,7 @@ "position": { "x": 86.3, "y": 74.15, - "z": 26.950000000000003 + "z": 26.95 } }, "status": "succeeded" @@ -49874,7 +49874,7 @@ "position": { "x": 86.3, "y": 74.15, - "z": 26.950000000000003 + "z": 26.95 }, "volume": 30.0 }, @@ -49900,7 +49900,7 @@ "position": { "x": 86.3, "y": 74.15, - "z": 26.950000000000003 + "z": 26.95 }, "volume": 60.0 }, @@ -49925,7 +49925,7 @@ "position": { "x": 86.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 } }, "status": "succeeded" @@ -49950,7 +49950,7 @@ "position": { "x": 86.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 60.0 }, @@ -49976,7 +49976,7 @@ "position": { "x": 86.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 60.0 }, @@ -50001,7 +50001,7 @@ "position": { "x": 86.3, "y": 74.15, - "z": 26.950000000000003 + "z": 26.95 } }, "status": "succeeded" @@ -50026,7 +50026,7 @@ "position": { "x": 86.3, "y": 74.15, - "z": 26.950000000000003 + "z": 26.95 }, "volume": 30.0 }, @@ -55493,7 +55493,7 @@ "position": { "x": 68.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 } }, "status": "succeeded" @@ -55518,7 +55518,7 @@ "position": { "x": 68.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 32.0 }, @@ -55593,7 +55593,7 @@ "position": { "x": 68.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 } }, "status": "succeeded" @@ -55618,7 +55618,7 @@ "position": { "x": 68.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 32.0 }, @@ -55693,7 +55693,7 @@ "position": { "x": 68.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 } }, "status": "succeeded" @@ -55718,7 +55718,7 @@ "position": { "x": 68.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 32.0 }, @@ -55793,7 +55793,7 @@ "position": { "x": 68.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 } }, "status": "succeeded" @@ -55818,7 +55818,7 @@ "position": { "x": 68.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 32.0 }, @@ -55844,7 +55844,7 @@ "position": { "x": 68.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 32.0 }, @@ -56091,7 +56091,7 @@ "position": { "x": 77.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 } }, "status": "succeeded" @@ -56116,7 +56116,7 @@ "position": { "x": 77.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 32.0 }, @@ -56191,7 +56191,7 @@ "position": { "x": 77.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 } }, "status": "succeeded" @@ -56216,7 +56216,7 @@ "position": { "x": 77.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 32.0 }, @@ -56291,7 +56291,7 @@ "position": { "x": 77.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 } }, "status": "succeeded" @@ -56316,7 +56316,7 @@ "position": { "x": 77.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 32.0 }, @@ -56391,7 +56391,7 @@ "position": { "x": 77.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 } }, "status": "succeeded" @@ -56416,7 +56416,7 @@ "position": { "x": 77.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 32.0 }, @@ -56442,7 +56442,7 @@ "position": { "x": 77.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 32.0 }, @@ -56689,7 +56689,7 @@ "position": { "x": 86.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 } }, "status": "succeeded" @@ -56714,7 +56714,7 @@ "position": { "x": 86.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 32.0 }, @@ -56789,7 +56789,7 @@ "position": { "x": 86.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 } }, "status": "succeeded" @@ -56814,7 +56814,7 @@ "position": { "x": 86.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 32.0 }, @@ -56889,7 +56889,7 @@ "position": { "x": 86.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 } }, "status": "succeeded" @@ -56914,7 +56914,7 @@ "position": { "x": 86.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 32.0 }, @@ -56989,7 +56989,7 @@ "position": { "x": 86.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 } }, "status": "succeeded" @@ -57014,7 +57014,7 @@ "position": { "x": 86.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 32.0 }, @@ -57040,7 +57040,7 @@ "position": { "x": 86.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 32.0 }, @@ -57310,7 +57310,7 @@ "position": { "x": 48.375, "y": 356.2, - "z": 2.079999999999993 + "z": 2.08 }, "volume": 31.0 }, @@ -57436,7 +57436,7 @@ "position": { "x": 57.375, "y": 356.2, - "z": 2.079999999999993 + "z": 2.08 }, "volume": 31.0 }, @@ -57562,7 +57562,7 @@ "position": { "x": 66.375, "y": 356.2, - "z": 2.079999999999993 + "z": 2.08 }, "volume": 31.0 }, @@ -58198,7 +58198,7 @@ "position": { "x": 95.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 200.0 }, @@ -58250,7 +58250,7 @@ "position": { "x": 95.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 200.0 }, @@ -58302,7 +58302,7 @@ "position": { "x": 104.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 200.0 }, @@ -58354,7 +58354,7 @@ "position": { "x": 104.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 200.0 }, @@ -58406,7 +58406,7 @@ "position": { "x": 113.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 200.0 }, @@ -58458,7 +58458,7 @@ "position": { "x": 113.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 200.0 }, @@ -58510,7 +58510,7 @@ "position": { "x": 95.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 200.0 }, @@ -58562,7 +58562,7 @@ "position": { "x": 95.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 200.0 }, @@ -58614,7 +58614,7 @@ "position": { "x": 104.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 200.0 }, @@ -58666,7 +58666,7 @@ "position": { "x": 104.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 200.0 }, @@ -58718,7 +58718,7 @@ "position": { "x": 113.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 200.0 }, @@ -58770,7 +58770,7 @@ "position": { "x": 113.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 200.0 }, @@ -58822,7 +58822,7 @@ "position": { "x": 95.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 200.0 }, @@ -58874,7 +58874,7 @@ "position": { "x": 95.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 200.0 }, @@ -58926,7 +58926,7 @@ "position": { "x": 104.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 200.0 }, @@ -58978,7 +58978,7 @@ "position": { "x": 104.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 200.0 }, @@ -59030,7 +59030,7 @@ "position": { "x": 113.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 200.0 }, @@ -59082,7 +59082,7 @@ "position": { "x": 113.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 200.0 }, @@ -59134,7 +59134,7 @@ "position": { "x": 95.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 200.0 }, @@ -59186,7 +59186,7 @@ "position": { "x": 95.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 200.0 }, @@ -59238,7 +59238,7 @@ "position": { "x": 104.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 200.0 }, @@ -59290,7 +59290,7 @@ "position": { "x": 104.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 200.0 }, @@ -59342,7 +59342,7 @@ "position": { "x": 113.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 200.0 }, @@ -59394,7 +59394,7 @@ "position": { "x": 113.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 200.0 }, @@ -59446,7 +59446,7 @@ "position": { "x": 95.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 200.0 }, @@ -59498,7 +59498,7 @@ "position": { "x": 95.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 200.0 }, @@ -59550,7 +59550,7 @@ "position": { "x": 104.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 200.0 }, @@ -59602,7 +59602,7 @@ "position": { "x": 104.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 200.0 }, @@ -59654,7 +59654,7 @@ "position": { "x": 113.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 200.0 }, @@ -59706,7 +59706,7 @@ "position": { "x": 113.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 200.0 }, @@ -59758,7 +59758,7 @@ "position": { "x": 95.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 200.0 }, @@ -59810,7 +59810,7 @@ "position": { "x": 95.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 200.0 }, @@ -59862,7 +59862,7 @@ "position": { "x": 104.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 200.0 }, @@ -59914,7 +59914,7 @@ "position": { "x": 104.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 200.0 }, @@ -59966,7 +59966,7 @@ "position": { "x": 113.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 200.0 }, @@ -60018,7 +60018,7 @@ "position": { "x": 113.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 200.0 }, @@ -60770,7 +60770,7 @@ "position": { "x": 48.375, "y": 356.2, - "z": 2.079999999999993 + "z": 2.08 }, "volume": 32.0 }, @@ -60822,7 +60822,7 @@ "position": { "x": 57.375, "y": 356.2, - "z": 2.079999999999993 + "z": 2.08 }, "volume": 32.0 }, @@ -60874,7 +60874,7 @@ "position": { "x": 66.375, "y": 356.2, - "z": 2.079999999999993 + "z": 2.08 }, "volume": 32.0 }, @@ -60952,7 +60952,7 @@ "position": { "x": -5.624999999999998, "y": 356.2, - "z": 2.079999999999993 + "z": 2.08 }, "volume": 30.0 }, @@ -61004,7 +61004,7 @@ "position": { "x": 3.375, "y": 356.2, - "z": 2.079999999999993 + "z": 2.08 }, "volume": 30.0 }, @@ -61056,7 +61056,7 @@ "position": { "x": 12.375000000000004, "y": 356.2, - "z": 2.079999999999993 + "z": 2.08 }, "volume": 30.0 }, @@ -61203,7 +61203,7 @@ "position": { "x": -5.624999999999998, "y": 356.2, - "z": 1.329999999999993 + "z": 1.33 } }, "status": "succeeded" @@ -61228,7 +61228,7 @@ "position": { "x": -5.624999999999998, "y": 356.2, - "z": 1.329999999999993 + "z": 1.33 }, "volume": 101.0 }, @@ -61254,7 +61254,7 @@ "position": { "x": 14.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 101.0 }, @@ -61329,7 +61329,7 @@ "position": { "x": 3.375, "y": 356.2, - "z": 1.329999999999993 + "z": 1.33 } }, "status": "succeeded" @@ -61354,7 +61354,7 @@ "position": { "x": 3.375, "y": 356.2, - "z": 1.329999999999993 + "z": 1.33 }, "volume": 101.0 }, @@ -61380,7 +61380,7 @@ "position": { "x": 23.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 101.0 }, @@ -61455,7 +61455,7 @@ "position": { "x": 12.375000000000004, "y": 356.2, - "z": 1.329999999999993 + "z": 1.33 } }, "status": "succeeded" @@ -61480,7 +61480,7 @@ "position": { "x": 12.375000000000004, "y": 356.2, - "z": 1.329999999999993 + "z": 1.33 }, "volume": 101.0 }, @@ -61506,7 +61506,7 @@ "position": { "x": 32.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 101.0 }, @@ -61729,7 +61729,7 @@ "position": { "x": 14.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 125.0 }, @@ -61754,7 +61754,7 @@ "position": { "x": 14.3, "y": 74.15, - "z": 26.950000000000003 + "z": 26.95 } }, "status": "succeeded" @@ -61779,7 +61779,7 @@ "position": { "x": 14.3, "y": 74.15, - "z": 26.950000000000003 + "z": 26.95 }, "volume": 100.0 }, @@ -61804,7 +61804,7 @@ "position": { "x": 14.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 } }, "status": "succeeded" @@ -61829,7 +61829,7 @@ "position": { "x": 14.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 80.0 }, @@ -61855,7 +61855,7 @@ "position": { "x": 14.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 80.0 }, @@ -61880,7 +61880,7 @@ "position": { "x": 14.3, "y": 74.15, - "z": 26.950000000000003 + "z": 26.95 } }, "status": "succeeded" @@ -61905,7 +61905,7 @@ "position": { "x": 14.3, "y": 74.15, - "z": 26.950000000000003 + "z": 26.95 }, "volume": 100.0 }, @@ -61931,7 +61931,7 @@ "position": { "x": 14.3, "y": 74.15, - "z": 26.950000000000003 + "z": 26.95 }, "volume": 100.0 }, @@ -61956,7 +61956,7 @@ "position": { "x": 14.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 } }, "status": "succeeded" @@ -61981,7 +61981,7 @@ "position": { "x": 14.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 80.0 }, @@ -62007,7 +62007,7 @@ "position": { "x": 14.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 80.0 }, @@ -62032,7 +62032,7 @@ "position": { "x": 14.3, "y": 74.15, - "z": 26.950000000000003 + "z": 26.95 } }, "status": "succeeded" @@ -62057,7 +62057,7 @@ "position": { "x": 14.3, "y": 74.15, - "z": 26.950000000000003 + "z": 26.95 }, "volume": 100.0 }, @@ -62359,7 +62359,7 @@ "position": { "x": 23.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 125.0 }, @@ -62384,7 +62384,7 @@ "position": { "x": 23.3, "y": 74.15, - "z": 26.950000000000003 + "z": 26.95 } }, "status": "succeeded" @@ -62409,7 +62409,7 @@ "position": { "x": 23.3, "y": 74.15, - "z": 26.950000000000003 + "z": 26.95 }, "volume": 100.0 }, @@ -62434,7 +62434,7 @@ "position": { "x": 23.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 } }, "status": "succeeded" @@ -62459,7 +62459,7 @@ "position": { "x": 23.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 80.0 }, @@ -62485,7 +62485,7 @@ "position": { "x": 23.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 80.0 }, @@ -62510,7 +62510,7 @@ "position": { "x": 23.3, "y": 74.15, - "z": 26.950000000000003 + "z": 26.95 } }, "status": "succeeded" @@ -62535,7 +62535,7 @@ "position": { "x": 23.3, "y": 74.15, - "z": 26.950000000000003 + "z": 26.95 }, "volume": 100.0 }, @@ -62561,7 +62561,7 @@ "position": { "x": 23.3, "y": 74.15, - "z": 26.950000000000003 + "z": 26.95 }, "volume": 100.0 }, @@ -62586,7 +62586,7 @@ "position": { "x": 23.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 } }, "status": "succeeded" @@ -62611,7 +62611,7 @@ "position": { "x": 23.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 80.0 }, @@ -62637,7 +62637,7 @@ "position": { "x": 23.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 80.0 }, @@ -62662,7 +62662,7 @@ "position": { "x": 23.3, "y": 74.15, - "z": 26.950000000000003 + "z": 26.95 } }, "status": "succeeded" @@ -62687,7 +62687,7 @@ "position": { "x": 23.3, "y": 74.15, - "z": 26.950000000000003 + "z": 26.95 }, "volume": 100.0 }, @@ -62989,7 +62989,7 @@ "position": { "x": 32.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 125.0 }, @@ -63014,7 +63014,7 @@ "position": { "x": 32.3, "y": 74.15, - "z": 26.950000000000003 + "z": 26.95 } }, "status": "succeeded" @@ -63039,7 +63039,7 @@ "position": { "x": 32.3, "y": 74.15, - "z": 26.950000000000003 + "z": 26.95 }, "volume": 100.0 }, @@ -63064,7 +63064,7 @@ "position": { "x": 32.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 } }, "status": "succeeded" @@ -63089,7 +63089,7 @@ "position": { "x": 32.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 80.0 }, @@ -63115,7 +63115,7 @@ "position": { "x": 32.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 80.0 }, @@ -63140,7 +63140,7 @@ "position": { "x": 32.3, "y": 74.15, - "z": 26.950000000000003 + "z": 26.95 } }, "status": "succeeded" @@ -63165,7 +63165,7 @@ "position": { "x": 32.3, "y": 74.15, - "z": 26.950000000000003 + "z": 26.95 }, "volume": 100.0 }, @@ -63191,7 +63191,7 @@ "position": { "x": 32.3, "y": 74.15, - "z": 26.950000000000003 + "z": 26.95 }, "volume": 100.0 }, @@ -63216,7 +63216,7 @@ "position": { "x": 32.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 } }, "status": "succeeded" @@ -63241,7 +63241,7 @@ "position": { "x": 32.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 80.0 }, @@ -63267,7 +63267,7 @@ "position": { "x": 32.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 80.0 }, @@ -63292,7 +63292,7 @@ "position": { "x": 32.3, "y": 74.15, - "z": 26.950000000000003 + "z": 26.95 } }, "status": "succeeded" @@ -63317,7 +63317,7 @@ "position": { "x": 32.3, "y": 74.15, - "z": 26.950000000000003 + "z": 26.95 }, "volume": 100.0 }, @@ -64512,7 +64512,7 @@ "position": { "x": 95.3, "y": 74.15, - "z": 21.950000000000003 + "z": 21.95 }, "volume": 200.0 }, @@ -64538,7 +64538,7 @@ "position": { "x": 14.3, "y": 74.15, - "z": 21.950000000000003 + "z": 21.95 }, "volume": 200.0 }, @@ -64614,7 +64614,7 @@ "position": { "x": 104.3, "y": 74.15, - "z": 21.950000000000003 + "z": 21.95 }, "volume": 200.0 }, @@ -64640,7 +64640,7 @@ "position": { "x": 23.3, "y": 74.15, - "z": 21.950000000000003 + "z": 21.95 }, "volume": 200.0 }, @@ -64716,7 +64716,7 @@ "position": { "x": 113.3, "y": 74.15, - "z": 21.950000000000003 + "z": 21.95 }, "volume": 200.0 }, @@ -64742,7 +64742,7 @@ "position": { "x": 32.3, "y": 74.15, - "z": 21.950000000000003 + "z": 21.95 }, "volume": 200.0 }, @@ -65750,7 +65750,7 @@ "position": { "x": 95.3, "y": 74.15, - "z": 21.950000000000003 + "z": 21.95 }, "volume": 200.0 }, @@ -65776,7 +65776,7 @@ "position": { "x": 14.3, "y": 74.15, - "z": 21.950000000000003 + "z": 21.95 }, "volume": 200.0 }, @@ -65852,7 +65852,7 @@ "position": { "x": 104.3, "y": 74.15, - "z": 21.950000000000003 + "z": 21.95 }, "volume": 200.0 }, @@ -65878,7 +65878,7 @@ "position": { "x": 23.3, "y": 74.15, - "z": 21.950000000000003 + "z": 21.95 }, "volume": 200.0 }, @@ -65954,7 +65954,7 @@ "position": { "x": 113.3, "y": 74.15, - "z": 21.950000000000003 + "z": 21.95 }, "volume": 200.0 }, @@ -65980,7 +65980,7 @@ "position": { "x": 32.3, "y": 74.15, - "z": 21.950000000000003 + "z": 21.95 }, "volume": 200.0 }, @@ -66988,7 +66988,7 @@ "position": { "x": 95.3, "y": 74.15, - "z": 21.950000000000003 + "z": 21.95 }, "volume": 200.0 }, @@ -67014,7 +67014,7 @@ "position": { "x": 14.3, "y": 74.15, - "z": 21.950000000000003 + "z": 21.95 }, "volume": 200.0 }, @@ -67090,7 +67090,7 @@ "position": { "x": 104.3, "y": 74.15, - "z": 21.950000000000003 + "z": 21.95 }, "volume": 200.0 }, @@ -67116,7 +67116,7 @@ "position": { "x": 23.3, "y": 74.15, - "z": 21.950000000000003 + "z": 21.95 }, "volume": 200.0 }, @@ -67192,7 +67192,7 @@ "position": { "x": 113.3, "y": 74.15, - "z": 21.950000000000003 + "z": 21.95 }, "volume": 200.0 }, @@ -67218,7 +67218,7 @@ "position": { "x": 32.3, "y": 74.15, - "z": 21.950000000000003 + "z": 21.95 }, "volume": 200.0 }, @@ -68226,7 +68226,7 @@ "position": { "x": 95.3, "y": 74.15, - "z": 21.950000000000003 + "z": 21.95 }, "volume": 200.0 }, @@ -68252,7 +68252,7 @@ "position": { "x": 14.3, "y": 74.15, - "z": 21.950000000000003 + "z": 21.95 }, "volume": 200.0 }, @@ -68328,7 +68328,7 @@ "position": { "x": 104.3, "y": 74.15, - "z": 21.950000000000003 + "z": 21.95 }, "volume": 200.0 }, @@ -68354,7 +68354,7 @@ "position": { "x": 23.3, "y": 74.15, - "z": 21.950000000000003 + "z": 21.95 }, "volume": 200.0 }, @@ -68430,7 +68430,7 @@ "position": { "x": 113.3, "y": 74.15, - "z": 21.950000000000003 + "z": 21.95 }, "volume": 200.0 }, @@ -68456,7 +68456,7 @@ "position": { "x": 32.3, "y": 74.15, - "z": 21.950000000000003 + "z": 21.95 }, "volume": 200.0 }, @@ -68559,7 +68559,7 @@ "position": { "x": 14.3, "y": 74.15, - "z": 22.200000000000003 + "z": 22.2 } }, "status": "succeeded" @@ -68584,7 +68584,7 @@ "position": { "x": 14.3, "y": 74.15, - "z": 22.200000000000003 + "z": 22.2 }, "volume": 200.0 }, @@ -68610,7 +68610,7 @@ "position": { "x": 41.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 200.0 }, @@ -68685,7 +68685,7 @@ "position": { "x": 23.3, "y": 74.15, - "z": 22.200000000000003 + "z": 22.2 } }, "status": "succeeded" @@ -68710,7 +68710,7 @@ "position": { "x": 23.3, "y": 74.15, - "z": 22.200000000000003 + "z": 22.2 }, "volume": 200.0 }, @@ -68736,7 +68736,7 @@ "position": { "x": 50.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 200.0 }, @@ -68811,7 +68811,7 @@ "position": { "x": 32.3, "y": 74.15, - "z": 22.200000000000003 + "z": 22.2 } }, "status": "succeeded" @@ -68836,7 +68836,7 @@ "position": { "x": 32.3, "y": 74.15, - "z": 22.200000000000003 + "z": 22.2 }, "volume": 200.0 }, @@ -68862,7 +68862,7 @@ "position": { "x": 59.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 200.0 }, @@ -70935,7 +70935,7 @@ "position": { "x": 21.375000000000004, "y": 356.2, - "z": 2.079999999999993 + "z": 2.08 }, "volume": 22.0 }, @@ -71061,7 +71061,7 @@ "position": { "x": 30.375000000000004, "y": 356.2, - "z": 2.079999999999993 + "z": 2.08 }, "volume": 22.0 }, @@ -71187,7 +71187,7 @@ "position": { "x": 39.375, "y": 356.2, - "z": 2.079999999999993 + "z": 2.08 }, "volume": 22.0 }, @@ -71299,7 +71299,7 @@ "position": { "x": 21.375000000000004, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 4.0 }, @@ -71324,7 +71324,7 @@ "position": { "x": 21.375000000000004, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 } }, "status": "succeeded" @@ -71349,7 +71349,7 @@ "position": { "x": 21.375000000000004, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 20.0 }, @@ -71375,7 +71375,7 @@ "position": { "x": 21.375000000000004, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 20.0 }, @@ -71477,7 +71477,7 @@ "position": { "x": 30.375000000000004, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 4.0 }, @@ -71502,7 +71502,7 @@ "position": { "x": 30.375000000000004, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 } }, "status": "succeeded" @@ -71527,7 +71527,7 @@ "position": { "x": 30.375000000000004, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 20.0 }, @@ -71553,7 +71553,7 @@ "position": { "x": 30.375000000000004, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 20.0 }, @@ -71655,7 +71655,7 @@ "position": { "x": 39.375, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 4.0 }, @@ -71680,7 +71680,7 @@ "position": { "x": 39.375, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 } }, "status": "succeeded" @@ -71705,7 +71705,7 @@ "position": { "x": 39.375, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 20.0 }, @@ -71731,7 +71731,7 @@ "position": { "x": 39.375, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 20.0 }, @@ -71873,7 +71873,7 @@ "position": { "x": 21.375000000000004, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 5.0 }, @@ -71975,7 +71975,7 @@ "position": { "x": 30.375000000000004, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 5.0 }, @@ -72077,7 +72077,7 @@ "position": { "x": 39.375, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 5.0 }, @@ -72189,7 +72189,7 @@ "position": { "x": 21.375000000000004, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 20.0 }, @@ -72214,7 +72214,7 @@ "position": { "x": 21.375000000000004, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 } }, "status": "succeeded" @@ -72239,7 +72239,7 @@ "position": { "x": 21.375000000000004, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 45.0 }, @@ -72265,7 +72265,7 @@ "position": { "x": 21.375000000000004, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 45.0 }, @@ -72367,7 +72367,7 @@ "position": { "x": 30.375000000000004, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 20.0 }, @@ -72392,7 +72392,7 @@ "position": { "x": 30.375000000000004, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 } }, "status": "succeeded" @@ -72417,7 +72417,7 @@ "position": { "x": 30.375000000000004, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 45.0 }, @@ -72443,7 +72443,7 @@ "position": { "x": 30.375000000000004, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 45.0 }, @@ -72545,7 +72545,7 @@ "position": { "x": 39.375, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 20.0 }, @@ -72570,7 +72570,7 @@ "position": { "x": 39.375, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 } }, "status": "succeeded" @@ -72595,7 +72595,7 @@ "position": { "x": 39.375, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 45.0 }, @@ -72621,7 +72621,7 @@ "position": { "x": 39.375, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 45.0 }, @@ -72772,7 +72772,7 @@ "position": { "x": 21.375000000000004, "y": 356.2, - "z": 1.329999999999993 + "z": 1.33 } }, "status": "succeeded" @@ -72797,7 +72797,7 @@ "position": { "x": 21.375000000000004, "y": 356.2, - "z": 1.329999999999993 + "z": 1.33 }, "volume": 46.0 }, @@ -72823,7 +72823,7 @@ "position": { "x": 68.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 46.0 }, @@ -72898,7 +72898,7 @@ "position": { "x": 30.375000000000004, "y": 356.2, - "z": 1.329999999999993 + "z": 1.33 } }, "status": "succeeded" @@ -72923,7 +72923,7 @@ "position": { "x": 30.375000000000004, "y": 356.2, - "z": 1.329999999999993 + "z": 1.33 }, "volume": 46.0 }, @@ -72949,7 +72949,7 @@ "position": { "x": 77.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 46.0 }, @@ -73024,7 +73024,7 @@ "position": { "x": 39.375, "y": 356.2, - "z": 1.329999999999993 + "z": 1.33 } }, "status": "succeeded" @@ -73049,7 +73049,7 @@ "position": { "x": 39.375, "y": 356.2, - "z": 1.329999999999993 + "z": 1.33 }, "volume": 46.0 }, @@ -73075,7 +73075,7 @@ "position": { "x": 86.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 46.0 }, @@ -73239,7 +73239,7 @@ "position": { "x": 68.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 40.5 }, @@ -73264,7 +73264,7 @@ "position": { "x": 68.3, "y": 74.15, - "z": 26.950000000000003 + "z": 26.95 } }, "status": "succeeded" @@ -73289,7 +73289,7 @@ "position": { "x": 68.3, "y": 74.15, - "z": 26.950000000000003 + "z": 26.95 }, "volume": 60.0 }, @@ -73314,7 +73314,7 @@ "position": { "x": 68.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 } }, "status": "succeeded" @@ -73339,7 +73339,7 @@ "position": { "x": 68.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 60.0 }, @@ -73365,7 +73365,7 @@ "position": { "x": 68.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 60.0 }, @@ -73390,7 +73390,7 @@ "position": { "x": 68.3, "y": 74.15, - "z": 26.950000000000003 + "z": 26.95 } }, "status": "succeeded" @@ -73415,7 +73415,7 @@ "position": { "x": 68.3, "y": 74.15, - "z": 26.950000000000003 + "z": 26.95 }, "volume": 30.0 }, @@ -73441,7 +73441,7 @@ "position": { "x": 68.3, "y": 74.15, - "z": 26.950000000000003 + "z": 26.95 }, "volume": 60.0 }, @@ -73466,7 +73466,7 @@ "position": { "x": 68.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 } }, "status": "succeeded" @@ -73491,7 +73491,7 @@ "position": { "x": 68.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 60.0 }, @@ -73517,7 +73517,7 @@ "position": { "x": 68.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 60.0 }, @@ -73542,7 +73542,7 @@ "position": { "x": 68.3, "y": 74.15, - "z": 26.950000000000003 + "z": 26.95 } }, "status": "succeeded" @@ -73567,7 +73567,7 @@ "position": { "x": 68.3, "y": 74.15, - "z": 26.950000000000003 + "z": 26.95 }, "volume": 30.0 }, @@ -73817,7 +73817,7 @@ "position": { "x": 77.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 40.5 }, @@ -73842,7 +73842,7 @@ "position": { "x": 77.3, "y": 74.15, - "z": 26.950000000000003 + "z": 26.95 } }, "status": "succeeded" @@ -73867,7 +73867,7 @@ "position": { "x": 77.3, "y": 74.15, - "z": 26.950000000000003 + "z": 26.95 }, "volume": 60.0 }, @@ -73892,7 +73892,7 @@ "position": { "x": 77.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 } }, "status": "succeeded" @@ -73917,7 +73917,7 @@ "position": { "x": 77.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 60.0 }, @@ -73943,7 +73943,7 @@ "position": { "x": 77.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 60.0 }, @@ -73968,7 +73968,7 @@ "position": { "x": 77.3, "y": 74.15, - "z": 26.950000000000003 + "z": 26.95 } }, "status": "succeeded" @@ -73993,7 +73993,7 @@ "position": { "x": 77.3, "y": 74.15, - "z": 26.950000000000003 + "z": 26.95 }, "volume": 30.0 }, @@ -74019,7 +74019,7 @@ "position": { "x": 77.3, "y": 74.15, - "z": 26.950000000000003 + "z": 26.95 }, "volume": 60.0 }, @@ -74044,7 +74044,7 @@ "position": { "x": 77.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 } }, "status": "succeeded" @@ -74069,7 +74069,7 @@ "position": { "x": 77.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 60.0 }, @@ -74095,7 +74095,7 @@ "position": { "x": 77.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 60.0 }, @@ -74120,7 +74120,7 @@ "position": { "x": 77.3, "y": 74.15, - "z": 26.950000000000003 + "z": 26.95 } }, "status": "succeeded" @@ -74145,7 +74145,7 @@ "position": { "x": 77.3, "y": 74.15, - "z": 26.950000000000003 + "z": 26.95 }, "volume": 30.0 }, @@ -74395,7 +74395,7 @@ "position": { "x": 86.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 40.5 }, @@ -74420,7 +74420,7 @@ "position": { "x": 86.3, "y": 74.15, - "z": 26.950000000000003 + "z": 26.95 } }, "status": "succeeded" @@ -74445,7 +74445,7 @@ "position": { "x": 86.3, "y": 74.15, - "z": 26.950000000000003 + "z": 26.95 }, "volume": 60.0 }, @@ -74470,7 +74470,7 @@ "position": { "x": 86.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 } }, "status": "succeeded" @@ -74495,7 +74495,7 @@ "position": { "x": 86.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 60.0 }, @@ -74521,7 +74521,7 @@ "position": { "x": 86.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 60.0 }, @@ -74546,7 +74546,7 @@ "position": { "x": 86.3, "y": 74.15, - "z": 26.950000000000003 + "z": 26.95 } }, "status": "succeeded" @@ -74571,7 +74571,7 @@ "position": { "x": 86.3, "y": 74.15, - "z": 26.950000000000003 + "z": 26.95 }, "volume": 30.0 }, @@ -74597,7 +74597,7 @@ "position": { "x": 86.3, "y": 74.15, - "z": 26.950000000000003 + "z": 26.95 }, "volume": 60.0 }, @@ -74622,7 +74622,7 @@ "position": { "x": 86.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 } }, "status": "succeeded" @@ -74647,7 +74647,7 @@ "position": { "x": 86.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 60.0 }, @@ -74673,7 +74673,7 @@ "position": { "x": 86.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 60.0 }, @@ -74698,7 +74698,7 @@ "position": { "x": 86.3, "y": 74.15, - "z": 26.950000000000003 + "z": 26.95 } }, "status": "succeeded" @@ -74723,7 +74723,7 @@ "position": { "x": 86.3, "y": 74.15, - "z": 26.950000000000003 + "z": 26.95 }, "volume": 30.0 }, @@ -80190,7 +80190,7 @@ "position": { "x": 68.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 } }, "status": "succeeded" @@ -80215,7 +80215,7 @@ "position": { "x": 68.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 32.0 }, @@ -80290,7 +80290,7 @@ "position": { "x": 68.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 } }, "status": "succeeded" @@ -80315,7 +80315,7 @@ "position": { "x": 68.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 32.0 }, @@ -80390,7 +80390,7 @@ "position": { "x": 68.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 } }, "status": "succeeded" @@ -80415,7 +80415,7 @@ "position": { "x": 68.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 32.0 }, @@ -80490,7 +80490,7 @@ "position": { "x": 68.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 } }, "status": "succeeded" @@ -80515,7 +80515,7 @@ "position": { "x": 68.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 32.0 }, @@ -80541,7 +80541,7 @@ "position": { "x": 68.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 32.0 }, @@ -80788,7 +80788,7 @@ "position": { "x": 77.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 } }, "status": "succeeded" @@ -80813,7 +80813,7 @@ "position": { "x": 77.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 32.0 }, @@ -80888,7 +80888,7 @@ "position": { "x": 77.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 } }, "status": "succeeded" @@ -80913,7 +80913,7 @@ "position": { "x": 77.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 32.0 }, @@ -80988,7 +80988,7 @@ "position": { "x": 77.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 } }, "status": "succeeded" @@ -81013,7 +81013,7 @@ "position": { "x": 77.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 32.0 }, @@ -81088,7 +81088,7 @@ "position": { "x": 77.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 } }, "status": "succeeded" @@ -81113,7 +81113,7 @@ "position": { "x": 77.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 32.0 }, @@ -81139,7 +81139,7 @@ "position": { "x": 77.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 32.0 }, @@ -81386,7 +81386,7 @@ "position": { "x": 86.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 } }, "status": "succeeded" @@ -81411,7 +81411,7 @@ "position": { "x": 86.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 32.0 }, @@ -81486,7 +81486,7 @@ "position": { "x": 86.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 } }, "status": "succeeded" @@ -81511,7 +81511,7 @@ "position": { "x": 86.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 32.0 }, @@ -81586,7 +81586,7 @@ "position": { "x": 86.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 } }, "status": "succeeded" @@ -81611,7 +81611,7 @@ "position": { "x": 86.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 32.0 }, @@ -81686,7 +81686,7 @@ "position": { "x": 86.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 } }, "status": "succeeded" @@ -81711,7 +81711,7 @@ "position": { "x": 86.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 32.0 }, @@ -81737,7 +81737,7 @@ "position": { "x": 86.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 32.0 }, @@ -82007,7 +82007,7 @@ "position": { "x": 48.375, "y": 356.2, - "z": 2.079999999999993 + "z": 2.08 }, "volume": 31.0 }, @@ -82133,7 +82133,7 @@ "position": { "x": 57.375, "y": 356.2, - "z": 2.079999999999993 + "z": 2.08 }, "volume": 31.0 }, @@ -82259,7 +82259,7 @@ "position": { "x": 66.375, "y": 356.2, - "z": 2.079999999999993 + "z": 2.08 }, "volume": 31.0 }, @@ -82895,7 +82895,7 @@ "position": { "x": 95.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 200.0 }, @@ -82947,7 +82947,7 @@ "position": { "x": 95.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 200.0 }, @@ -82999,7 +82999,7 @@ "position": { "x": 104.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 200.0 }, @@ -83051,7 +83051,7 @@ "position": { "x": 104.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 200.0 }, @@ -83103,7 +83103,7 @@ "position": { "x": 113.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 200.0 }, @@ -83155,7 +83155,7 @@ "position": { "x": 113.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 200.0 }, @@ -83207,7 +83207,7 @@ "position": { "x": 95.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 200.0 }, @@ -83259,7 +83259,7 @@ "position": { "x": 95.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 200.0 }, @@ -83311,7 +83311,7 @@ "position": { "x": 104.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 200.0 }, @@ -83363,7 +83363,7 @@ "position": { "x": 104.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 200.0 }, @@ -83415,7 +83415,7 @@ "position": { "x": 113.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 200.0 }, @@ -83467,7 +83467,7 @@ "position": { "x": 113.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 200.0 }, @@ -83519,7 +83519,7 @@ "position": { "x": 95.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 200.0 }, @@ -83571,7 +83571,7 @@ "position": { "x": 95.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 200.0 }, @@ -83623,7 +83623,7 @@ "position": { "x": 104.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 200.0 }, @@ -83675,7 +83675,7 @@ "position": { "x": 104.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 200.0 }, @@ -83727,7 +83727,7 @@ "position": { "x": 113.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 200.0 }, @@ -83779,7 +83779,7 @@ "position": { "x": 113.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 200.0 }, @@ -83831,7 +83831,7 @@ "position": { "x": 95.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 200.0 }, @@ -83883,7 +83883,7 @@ "position": { "x": 95.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 200.0 }, @@ -83935,7 +83935,7 @@ "position": { "x": 104.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 200.0 }, @@ -83987,7 +83987,7 @@ "position": { "x": 104.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 200.0 }, @@ -84039,7 +84039,7 @@ "position": { "x": 113.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 200.0 }, @@ -84091,7 +84091,7 @@ "position": { "x": 113.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 200.0 }, @@ -84143,7 +84143,7 @@ "position": { "x": 95.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 200.0 }, @@ -84195,7 +84195,7 @@ "position": { "x": 95.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 200.0 }, @@ -84247,7 +84247,7 @@ "position": { "x": 104.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 200.0 }, @@ -84299,7 +84299,7 @@ "position": { "x": 104.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 200.0 }, @@ -84351,7 +84351,7 @@ "position": { "x": 113.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 200.0 }, @@ -84403,7 +84403,7 @@ "position": { "x": 113.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 200.0 }, @@ -84455,7 +84455,7 @@ "position": { "x": 95.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 200.0 }, @@ -84507,7 +84507,7 @@ "position": { "x": 95.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 200.0 }, @@ -84559,7 +84559,7 @@ "position": { "x": 104.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 200.0 }, @@ -84611,7 +84611,7 @@ "position": { "x": 104.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 200.0 }, @@ -84663,7 +84663,7 @@ "position": { "x": 113.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 200.0 }, @@ -84715,7 +84715,7 @@ "position": { "x": 113.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 200.0 }, @@ -85467,7 +85467,7 @@ "position": { "x": 48.375, "y": 356.2, - "z": 2.079999999999993 + "z": 2.08 }, "volume": 32.0 }, @@ -85519,7 +85519,7 @@ "position": { "x": 57.375, "y": 356.2, - "z": 2.079999999999993 + "z": 2.08 }, "volume": 32.0 }, @@ -85571,7 +85571,7 @@ "position": { "x": 66.375, "y": 356.2, - "z": 2.079999999999993 + "z": 2.08 }, "volume": 32.0 }, @@ -85649,7 +85649,7 @@ "position": { "x": -5.624999999999998, "y": 356.2, - "z": 2.079999999999993 + "z": 2.08 }, "volume": 30.0 }, @@ -85701,7 +85701,7 @@ "position": { "x": 3.375, "y": 356.2, - "z": 2.079999999999993 + "z": 2.08 }, "volume": 30.0 }, @@ -85753,7 +85753,7 @@ "position": { "x": 12.375000000000004, "y": 356.2, - "z": 2.079999999999993 + "z": 2.08 }, "volume": 30.0 }, diff --git a/app-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[3b1bfd0d2d][OT2_None_None_HS_2_16_AnalysisError_HeaterShakerConflictWithTrashBin2].json b/app-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[3b1bfd0d2d][OT2_None_None_HS_2_16_AnalysisError_HeaterShakerConflictWithTrashBin2].json index 6a28756037c..9ccf2c716e0 100644 --- a/app-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[3b1bfd0d2d][OT2_None_None_HS_2_16_AnalysisError_HeaterShakerConflictWithTrashBin2].json +++ b/app-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[3b1bfd0d2d][OT2_None_None_HS_2_16_AnalysisError_HeaterShakerConflictWithTrashBin2].json @@ -478,7 +478,7 @@ "errorInfo": { "args": "('trash bin in slot 12 prevents heaterShakerModuleV1 from using slot 9.',)", "class": "DeckConflictError", - "traceback": " File \"/usr/local/lib/python3.10/site-packages/opentrons/protocols/execution/execute_python.py\", line 124, in run_python\n exec(\"run(__context)\", new_globs)\n\n File \"\", line 1, in \n\n File \"OT2_None_None_HS_2_16_AnalysisError_HeaterShakerConflictWithTrashBin2.py\", line 11, in run\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocols/api_support/util.py\", line 383, in _check_version_wrapper\n return decorated_obj(*args, **kwargs)\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_api/protocol_context.py\", line 810, in load_module\n module_core = self._core.load_module(\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_api/core/engine/protocol.py\", line 424, in load_module\n deck_conflict.check(\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_api/core/engine/deck_conflict.py\", line 203, in check\n wrapped_deck_conflict.check(\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/motion_planning/deck_conflict.py\", line 210, in check\n raise DeckConflictError(\n" + "traceback": " File \"/usr/local/lib/python3.10/site-packages/opentrons/protocols/execution/execute_python.py\", line 124, in run_python\n exec(\"run(__context)\", new_globs)\n\n File \"\", line 1, in \n\n File \"OT2_None_None_HS_2_16_AnalysisError_HeaterShakerConflictWithTrashBin2.py\", line 11, in run\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocols/api_support/util.py\", line 383, in _check_version_wrapper\n return decorated_obj(*args, **kwargs)\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_api/protocol_context.py\", line 814, in load_module\n module_core = self._core.load_module(\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_api/core/engine/protocol.py\", line 425, in load_module\n deck_conflict.check(\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_api/core/engine/deck_conflict.py\", line 203, in check\n wrapped_deck_conflict.check(\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/motion_planning/deck_conflict.py\", line 210, in check\n raise DeckConflictError(\n" }, "errorType": "PythonException", "wrappedErrors": [] diff --git a/app-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[4835239037][OT2_P300M_P20S_MM_TM_TC1_5_2_6_PD40Error].json b/app-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[4835239037][OT2_P300M_P20S_MM_TM_TC1_5_2_6_PD40Error].json index aadb742ef09..636e3ae1cbc 100644 --- a/app-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[4835239037][OT2_P300M_P20S_MM_TM_TC1_5_2_6_PD40Error].json +++ b/app-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[4835239037][OT2_P300M_P20S_MM_TM_TC1_5_2_6_PD40Error].json @@ -6924,7 +6924,7 @@ "errorInfo": { "args": "('Cannot aspirate more than pipette max volume',)", "class": "AssertionError", - "traceback": " File \"/usr/local/lib/python3.10/site-packages/opentrons/commands/publisher.py\", line 113, in publish_context\n yield\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_api/instrument_context.py\", line 270, in aspirate\n self._core.aspirate(\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_api/core/legacy_simulator/legacy_instrument_core.py\", line 119, in aspirate\n new_volume <= self._pipette_dict[\"working_volume\"]\n" + "traceback": " File \"/usr/local/lib/python3.10/site-packages/opentrons/legacy_commands/publisher.py\", line 113, in publish_context\n yield\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_api/instrument_context.py\", line 270, in aspirate\n self._core.aspirate(\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_api/core/legacy_simulator/legacy_instrument_core.py\", line 119, in aspirate\n new_volume <= self._pipette_dict[\"working_volume\"]\n" }, "errorType": "PythonException", "wrappedErrors": [] @@ -6965,7 +6965,7 @@ "errorInfo": { "args": "('Cannot aspirate more than pipette max volume',)", "class": "AssertionError", - "traceback": " File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_runner/task_queue.py\", line 90, in _run\n await self._run_func()\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_runner/protocol_runner.py\", line 218, in run_func\n await self._legacy_executor.execute(\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_runner/legacy_wrappers.py\", line 180, in execute\n await to_thread.run_sync(\n\n File \"/usr/local/lib/python3.10/site-packages/anyio/to_thread.py\", line 33, in run_sync\n return await get_asynclib().run_sync_in_worker_thread(\n\n File \"/usr/local/lib/python3.10/site-packages/anyio/_backends/_asyncio.py\", line 877, in run_sync_in_worker_thread\n return await future\n\n File \"/usr/local/lib/python3.10/site-packages/anyio/_backends/_asyncio.py\", line 807, in run\n result = context.run(func, *args)\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocols/execution/execute.py\", line 63, in run_protocol\n execute_json_v4.dispatch_json(\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocols/execution/execute_json_v4.py\", line 272, in dispatch_json\n pipette_command_map[command_type]( # type: ignore\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocols/execution/execute_json_v3.py\", line 159, in _aspirate\n pipette.aspirate(volume, location)\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocols/api_support/util.py\", line 383, in _check_version_wrapper\n return decorated_obj(*args, **kwargs)\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_api/instrument_context.py\", line 270, in aspirate\n self._core.aspirate(\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_api/core/legacy_simulator/legacy_instrument_core.py\", line 119, in aspirate\n new_volume <= self._pipette_dict[\"working_volume\"]\n" + "traceback": " File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_runner/task_queue.py\", line 84, in _run\n await self._run_func()\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_runner/task_queue.py\", line 61, in _do_run\n await func(*args, **kwargs)\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_runner/protocol_runner.py\", line 219, in run_func\n await self._legacy_executor.execute(\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_runner/legacy_wrappers.py\", line 180, in execute\n await to_thread.run_sync(\n\n File \"/usr/local/lib/python3.10/site-packages/anyio/to_thread.py\", line 33, in run_sync\n return await get_asynclib().run_sync_in_worker_thread(\n\n File \"/usr/local/lib/python3.10/site-packages/anyio/_backends/_asyncio.py\", line 877, in run_sync_in_worker_thread\n return await future\n\n File \"/usr/local/lib/python3.10/site-packages/anyio/_backends/_asyncio.py\", line 807, in run\n result = context.run(func, *args)\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocols/execution/execute.py\", line 63, in run_protocol\n execute_json_v4.dispatch_json(\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocols/execution/execute_json_v4.py\", line 272, in dispatch_json\n pipette_command_map[command_type]( # type: ignore\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocols/execution/execute_json_v3.py\", line 159, in _aspirate\n pipette.aspirate(volume, location)\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocols/api_support/util.py\", line 383, in _check_version_wrapper\n return decorated_obj(*args, **kwargs)\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_api/instrument_context.py\", line 270, in aspirate\n self._core.aspirate(\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_api/core/legacy_simulator/legacy_instrument_core.py\", line 119, in aspirate\n new_volume <= self._pipette_dict[\"working_volume\"]\n" }, "errorType": "PythonException", "wrappedErrors": [] diff --git a/app-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[4e17da0b57][Flex_P1000_96_Gripper_TC_TM_HS_AnalysisError_GripperCollisionWithTips].json b/app-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[4e17da0b57][Flex_P1000_96_Gripper_TC_TM_HS_AnalysisError_GripperCollisionWithTips].json index 5bd1dec9c82..babe140d830 100644 --- a/app-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[4e17da0b57][Flex_P1000_96_Gripper_TC_TM_HS_AnalysisError_GripperCollisionWithTips].json +++ b/app-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[4e17da0b57][Flex_P1000_96_Gripper_TC_TM_HS_AnalysisError_GripperCollisionWithTips].json @@ -12252,316 +12252,6 @@ "strategy": "usingGripper" }, "status": "failed" - }, - { - "commandType": "moveLabware", - "params": { - "newLocation": {}, - "strategy": "usingGripper" - }, - "status": "failed" - }, - { - "commandType": "waitForDuration", - "params": { - "message": "", - "seconds": 60.0 - }, - "status": "failed" - }, - { - "commandType": "temperatureModule/deactivate", - "params": {}, - "status": "failed" - }, - { - "commandType": "heaterShaker/deactivateHeater", - "params": {}, - "status": "failed" - }, - { - "commandType": "heaterShaker/openLabwareLatch", - "params": {}, - "status": "failed" - }, - { - "commandType": "moveLabware", - "params": { - "newLocation": {}, - "strategy": "usingGripper" - }, - "status": "failed" - }, - { - "commandType": "heaterShaker/closeLabwareLatch", - "params": {}, - "status": "failed" - }, - { - "commandType": "heaterShaker/setTargetTemperature", - "params": { - "celsius": 40.0 - }, - "status": "failed" - }, - { - "commandType": "heaterShaker/setAndWaitForShakeSpeed", - "params": { - "rpm": 1000.0 - }, - "status": "failed" - }, - { - "commandType": "thermocycler/openLid", - "params": {}, - "status": "failed" - }, - { - "commandType": "thermocycler/deactivateBlock", - "params": {}, - "status": "failed" - }, - { - "commandType": "thermocycler/deactivateLid", - "params": {}, - "status": "failed" - }, - { - "commandType": "moveLabware", - "params": { - "newLocation": { - "slotName": "B2" - }, - "strategy": "usingGripper" - }, - "status": "failed" - }, - { - "commandType": "waitForDuration", - "params": { - "message": "", - "seconds": 60.0 - }, - "status": "failed" - }, - { - "commandType": "heaterShaker/deactivateHeater", - "params": {}, - "status": "failed" - }, - { - "commandType": "heaterShaker/deactivateShaker", - "params": {}, - "status": "failed" - }, - { - "commandType": "heaterShaker/openLabwareLatch", - "params": {}, - "status": "failed" - }, - { - "commandType": "moveLabware", - "params": { - "newLocation": { - "slotName": "D2" - }, - "strategy": "usingGripper" - }, - "status": "failed" - }, - { - "commandType": "aspirate", - "params": { - "flowRate": 6.0, - "volume": 150.0, - "wellLocation": { - "offset": { - "x": 0, - "y": 0, - "z": 0.5 - }, - "origin": "bottom" - }, - "wellName": "A1" - }, - "status": "failed" - }, - { - "commandType": "dispense", - "params": { - "flowRate": 6.0, - "volume": 150.0, - "wellLocation": { - "offset": { - "x": 0, - "y": 0, - "z": 0.5 - }, - "origin": "bottom" - }, - "wellName": "A1" - }, - "status": "failed" - }, - { - "commandType": "aspirate", - "params": { - "flowRate": 6.0, - "volume": 150.0, - "wellLocation": { - "offset": { - "x": 0, - "y": 0, - "z": 0.5 - }, - "origin": "bottom" - }, - "wellName": "A1" - }, - "status": "failed" - }, - { - "commandType": "dispense", - "params": { - "flowRate": 6.0, - "volume": 150.0, - "wellLocation": { - "offset": { - "x": 0, - "y": 0, - "z": 0.5 - }, - "origin": "bottom" - }, - "wellName": "A1" - }, - "status": "failed" - }, - { - "commandType": "aspirate", - "params": { - "flowRate": 6.0, - "volume": 50.0, - "wellLocation": { - "offset": { - "x": 0, - "y": 0, - "z": 1.0 - }, - "origin": "bottom" - }, - "wellName": "A1" - }, - "status": "failed" - }, - { - "commandType": "moveToAddressableArea", - "params": { - "addressableAreaName": "movableTrashA3", - "forceDirect": false, - "offset": { - "x": 0.0, - "y": 0.0, - "z": 0.0 - }, - "stayAtHighestPossibleZ": false - }, - "status": "failed" - }, - { - "commandType": "dispenseInPlace", - "params": { - "flowRate": 6.0, - "volume": 50.0 - }, - "status": "failed" - }, - { - "commandType": "moveToAddressableArea", - "params": { - "addressableAreaName": "movableTrashA3", - "forceDirect": false, - "offset": { - "x": 0.0, - "y": 0.0, - "z": 0.0 - }, - "stayAtHighestPossibleZ": false - }, - "status": "failed" - }, - { - "commandType": "dropTipInPlace", - "params": {}, - "status": "failed" - }, - { - "commandType": "pickUpTip", - "params": { - "wellLocation": { - "offset": { - "x": 0, - "y": 0, - "z": 0 - }, - "origin": "top" - }, - "wellName": "A1" - }, - "status": "failed" - }, - { - "commandType": "aspirate", - "params": { - "flowRate": 6.0, - "volume": 50.0, - "wellLocation": { - "offset": { - "x": 0, - "y": 0, - "z": 1.0 - }, - "origin": "bottom" - }, - "wellName": "A1" - }, - "status": "failed" - }, - { - "commandType": "dispense", - "params": { - "flowRate": 6.0, - "volume": 50.0, - "wellLocation": { - "offset": { - "x": 0, - "y": 0, - "z": 0.5 - }, - "origin": "bottom" - }, - "wellName": "A1" - }, - "status": "failed" - }, - { - "commandType": "moveToAddressableArea", - "params": { - "addressableAreaName": "movableTrashA3", - "forceDirect": false, - "offset": { - "x": 0.0, - "y": 0.0, - "z": 0.0 - }, - "stayAtHighestPossibleZ": false - }, - "status": "failed" - }, - { - "commandType": "dropTipInPlace", - "params": {}, - "status": "failed" } ], "config": { diff --git a/app-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[512a897a47][Flex_None_None_TM_2_16_AnalysisError_ModuleInStagingAreaCol4].json b/app-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[512a897a47][Flex_None_None_TM_2_16_AnalysisError_ModuleInStagingAreaCol4].json index 3fcada17001..ce2f5357e41 100644 --- a/app-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[512a897a47][Flex_None_None_TM_2_16_AnalysisError_ModuleInStagingAreaCol4].json +++ b/app-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[512a897a47][Flex_None_None_TM_2_16_AnalysisError_ModuleInStagingAreaCol4].json @@ -28,7 +28,7 @@ "errorInfo": { "args": "('Cannot load a module onto a staging slot.',)", "class": "ValueError", - "traceback": " File \"/usr/local/lib/python3.10/site-packages/opentrons/protocols/execution/execute_python.py\", line 124, in run_python\n exec(\"run(__context)\", new_globs)\n\n File \"\", line 1, in \n\n File \"Flex_None_None_TM_2_16_AnalysisError_ModuleInStagingAreaCol4.py\", line 15, in run\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocols/api_support/util.py\", line 383, in _check_version_wrapper\n return decorated_obj(*args, **kwargs)\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_api/protocol_context.py\", line 808, in load_module\n raise ValueError(\"Cannot load a module onto a staging slot.\")\n" + "traceback": " File \"/usr/local/lib/python3.10/site-packages/opentrons/protocols/execution/execute_python.py\", line 124, in run_python\n exec(\"run(__context)\", new_globs)\n\n File \"\", line 1, in \n\n File \"Flex_None_None_TM_2_16_AnalysisError_ModuleInStagingAreaCol4.py\", line 15, in run\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocols/api_support/util.py\", line 383, in _check_version_wrapper\n return decorated_obj(*args, **kwargs)\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_api/protocol_context.py\", line 812, in load_module\n raise ValueError(\"Cannot load a module onto a staging slot.\")\n" }, "errorType": "PythonException", "wrappedErrors": [] diff --git a/app-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[52a42597a5][OT2_P300M_P20S_MM_HS_TD_TC_6_1_AllMods_Error].json b/app-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[52a42597a5][OT2_P300M_P20S_MM_HS_TD_TC_6_1_AllMods_Error].json index 6dfd0ab19b6..b36a44a5457 100644 --- a/app-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[52a42597a5][OT2_P300M_P20S_MM_HS_TD_TC_6_1_AllMods_Error].json +++ b/app-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[52a42597a5][OT2_P300M_P20S_MM_HS_TD_TC_6_1_AllMods_Error].json @@ -7280,31 +7280,6 @@ "notes": [], "params": {}, "status": "failed" - }, - { - "commandType": "heaterShaker/deactivateHeater", - "params": {}, - "status": "failed" - }, - { - "commandType": "heaterShaker/deactivateShaker", - "params": {}, - "status": "failed" - }, - { - "commandType": "thermocycler/openLid", - "params": {}, - "status": "failed" - }, - { - "commandType": "thermocycler/deactivateBlock", - "params": {}, - "status": "failed" - }, - { - "commandType": "thermocycler/deactivateLid", - "params": {}, - "status": "failed" } ], "config": { diff --git a/app-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[5931902632][Flex_None_None_TC_2_16_AnalysisError_TrashBinAndThermocyclerConflict].json b/app-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[5931902632][Flex_None_None_TC_2_16_AnalysisError_TrashBinAndThermocyclerConflict].json index 9ac5392e5ff..c43a9f80c61 100644 --- a/app-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[5931902632][Flex_None_None_TC_2_16_AnalysisError_TrashBinAndThermocyclerConflict].json +++ b/app-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[5931902632][Flex_None_None_TC_2_16_AnalysisError_TrashBinAndThermocyclerConflict].json @@ -137,7 +137,7 @@ "errorInfo": { "args": "('thermocyclerModuleV2 in slot B1 prevents trash bin from using slot A1.',)", "class": "DeckConflictError", - "traceback": " File \"/usr/local/lib/python3.10/site-packages/opentrons/protocols/execution/execute_python.py\", line 124, in run_python\n exec(\"run(__context)\", new_globs)\n\n File \"\", line 1, in \n\n File \"Flex_None_None_TC_2_16_AnalysisError_TrashBinAndThermocyclerConflict.py\", line 13, in run\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocols/api_support/util.py\", line 383, in _check_version_wrapper\n return decorated_obj(*args, **kwargs)\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_api/protocol_context.py\", line 514, in load_trash_bin\n trash_bin = self._core.load_trash_bin(slot_name, addressable_area_name)\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_api/core/engine/protocol.py\", line 529, in load_trash_bin\n self._add_disposal_location_to_engine(trash_bin)\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_api/core/engine/protocol.py\", line 148, in _add_disposal_location_to_engine\n deck_conflict.check(\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_api/core/engine/deck_conflict.py\", line 203, in check\n wrapped_deck_conflict.check(\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/motion_planning/deck_conflict.py\", line 210, in check\n raise DeckConflictError(\n" + "traceback": " File \"/usr/local/lib/python3.10/site-packages/opentrons/protocols/execution/execute_python.py\", line 124, in run_python\n exec(\"run(__context)\", new_globs)\n\n File \"\", line 1, in \n\n File \"Flex_None_None_TC_2_16_AnalysisError_TrashBinAndThermocyclerConflict.py\", line 13, in run\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocols/api_support/util.py\", line 383, in _check_version_wrapper\n return decorated_obj(*args, **kwargs)\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_api/protocol_context.py\", line 518, in load_trash_bin\n trash_bin = self._core.load_trash_bin(slot_name, addressable_area_name)\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_api/core/engine/protocol.py\", line 530, in load_trash_bin\n self._add_disposal_location_to_engine(trash_bin)\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_api/core/engine/protocol.py\", line 149, in _add_disposal_location_to_engine\n deck_conflict.check(\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_api/core/engine/deck_conflict.py\", line 203, in check\n wrapped_deck_conflict.check(\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/motion_planning/deck_conflict.py\", line 210, in check\n raise DeckConflictError(\n" }, "errorType": "PythonException", "wrappedErrors": [] diff --git a/app-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[5fc4f3adbc][OT2_P20SRight_None_6_1_SimpleTransferError].json b/app-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[5fc4f3adbc][OT2_P20SRight_None_6_1_SimpleTransferError].json index 3e3b00c26a8..a5d379ba794 100644 --- a/app-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[5fc4f3adbc][OT2_P20SRight_None_6_1_SimpleTransferError].json +++ b/app-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[5fc4f3adbc][OT2_P20SRight_None_6_1_SimpleTransferError].json @@ -1718,39 +1718,6 @@ "wellName": "A1" }, "status": "failed" - }, - { - "commandType": "dispense", - "params": { - "flowRate": 3.78, - "volume": 10.0, - "wellLocation": { - "offset": { - "x": 0, - "y": 0, - "z": 0.5 - }, - "origin": "bottom" - }, - "wellName": "B1" - }, - "status": "failed" - }, - { - "commandType": "dropTip", - "params": { - "alternateDropLocation": false, - "wellLocation": { - "offset": { - "x": 0, - "y": 0, - "z": 0 - }, - "origin": "default" - }, - "wellName": "A1" - }, - "status": "failed" } ], "config": { diff --git a/app-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[753ac8811f][OT2_None_None_2_13_PythonSyntaxError].json b/app-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[753ac8811f][OT2_None_None_2_13_PythonSyntaxError].json index 04709c61b18..af560dfb9f3 100644 --- a/app-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[753ac8811f][OT2_None_None_2_13_PythonSyntaxError].json +++ b/app-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[753ac8811f][OT2_None_None_2_13_PythonSyntaxError].json @@ -31,7 +31,7 @@ "msg": "No module named 'superspecialmagic'", "name": "superspecialmagic", "path": "None", - "traceback": " File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_runner/task_queue.py\", line 90, in _run\n await self._run_func()\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_runner/protocol_runner.py\", line 218, in run_func\n await self._legacy_executor.execute(\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_runner/legacy_wrappers.py\", line 180, in execute\n await to_thread.run_sync(\n\n File \"/usr/local/lib/python3.10/site-packages/anyio/to_thread.py\", line 33, in run_sync\n return await get_asynclib().run_sync_in_worker_thread(\n\n File \"/usr/local/lib/python3.10/site-packages/anyio/_backends/_asyncio.py\", line 877, in run_sync_in_worker_thread\n return await future\n\n File \"/usr/local/lib/python3.10/site-packages/anyio/_backends/_asyncio.py\", line 807, in run\n result = context.run(func, *args)\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocols/execution/execute.py\", line 40, in run_protocol\n run_python(\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocols/execution/execute_python.py\", line 95, in run_python\n exec(proto.contents, new_globs)\n\n File \"OT2_None_None_2_13_PythonSyntaxError.py\", line 4, in \n" + "traceback": " File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_runner/task_queue.py\", line 84, in _run\n await self._run_func()\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_runner/task_queue.py\", line 61, in _do_run\n await func(*args, **kwargs)\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_runner/protocol_runner.py\", line 219, in run_func\n await self._legacy_executor.execute(\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_runner/legacy_wrappers.py\", line 180, in execute\n await to_thread.run_sync(\n\n File \"/usr/local/lib/python3.10/site-packages/anyio/to_thread.py\", line 33, in run_sync\n return await get_asynclib().run_sync_in_worker_thread(\n\n File \"/usr/local/lib/python3.10/site-packages/anyio/_backends/_asyncio.py\", line 877, in run_sync_in_worker_thread\n return await future\n\n File \"/usr/local/lib/python3.10/site-packages/anyio/_backends/_asyncio.py\", line 807, in run\n result = context.run(func, *args)\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocols/execution/execute.py\", line 40, in run_protocol\n run_python(\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocols/execution/execute_python.py\", line 95, in run_python\n exec(proto.contents, new_globs)\n\n File \"OT2_None_None_2_13_PythonSyntaxError.py\", line 4, in \n" }, "errorType": "PythonException", "wrappedErrors": [] diff --git a/app-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[7be98bf838][Flex_None_None_2_16_AnalysisError_TrashBinInCol2].json b/app-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[7be98bf838][Flex_None_None_2_16_AnalysisError_TrashBinInCol2].json index 031b3816aa9..89c43a035a9 100644 --- a/app-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[7be98bf838][Flex_None_None_2_16_AnalysisError_TrashBinInCol2].json +++ b/app-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[7be98bf838][Flex_None_None_2_16_AnalysisError_TrashBinInCol2].json @@ -28,7 +28,7 @@ "errorInfo": { "args": "('Invalid location for trash bin: C2.\\nValid slots: Any slot in column 1 or 3.',)", "class": "InvalidTrashBinLocationError", - "traceback": " File \"/usr/local/lib/python3.10/site-packages/opentrons/protocols/execution/execute_python.py\", line 124, in run_python\n exec(\"run(__context)\", new_globs)\n\n File \"\", line 1, in \n\n File \"Flex_None_None_2_16_AnalysisError_TrashBinInCol2.py\", line 15, in run\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocols/api_support/util.py\", line 383, in _check_version_wrapper\n return decorated_obj(*args, **kwargs)\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_api/protocol_context.py\", line 509, in load_trash_bin\n addressable_area_name = validation.ensure_and_convert_trash_bin_location(\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_api/validation.py\", line 327, in ensure_and_convert_trash_bin_location\n raise InvalidTrashBinLocationError(\n" + "traceback": " File \"/usr/local/lib/python3.10/site-packages/opentrons/protocols/execution/execute_python.py\", line 124, in run_python\n exec(\"run(__context)\", new_globs)\n\n File \"\", line 1, in \n\n File \"Flex_None_None_2_16_AnalysisError_TrashBinInCol2.py\", line 15, in run\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocols/api_support/util.py\", line 383, in _check_version_wrapper\n return decorated_obj(*args, **kwargs)\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_api/protocol_context.py\", line 513, in load_trash_bin\n addressable_area_name = validation.ensure_and_convert_trash_bin_location(\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_api/validation.py\", line 331, in ensure_and_convert_trash_bin_location\n raise InvalidTrashBinLocationError(\n" }, "errorType": "PythonException", "wrappedErrors": [] diff --git a/app-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[7ea2fdcec4][Flex_P1000MLeft_P50MRight_HS_MM_TC_TM_2_15_ABR3_Illumina_DNA_Enrichment].json b/app-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[7ea2fdcec4][Flex_P1000MLeft_P50MRight_HS_MM_TC_TM_2_15_ABR3_Illumina_DNA_Enrichment].json index bcdd4fb2a95..54003322788 100644 --- a/app-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[7ea2fdcec4][Flex_P1000MLeft_P50MRight_HS_MM_TC_TM_2_15_ABR3_Illumina_DNA_Enrichment].json +++ b/app-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[7ea2fdcec4][Flex_P1000MLeft_P50MRight_HS_MM_TC_TM_2_15_ABR3_Illumina_DNA_Enrichment].json @@ -9597,7 +9597,7 @@ "position": { "x": 12.375000000000004, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 50.0 }, @@ -9709,7 +9709,7 @@ "position": { "x": 12.375000000000004, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 10.0 }, @@ -9821,7 +9821,7 @@ "position": { "x": 12.375000000000004, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 10.0 }, @@ -9846,7 +9846,7 @@ "position": { "x": 12.375000000000004, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 } }, "status": "succeeded" @@ -9871,7 +9871,7 @@ "position": { "x": 12.375000000000004, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 90.0 }, @@ -9897,7 +9897,7 @@ "position": { "x": 12.375000000000004, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 90.0 }, @@ -10033,7 +10033,7 @@ "position": { "x": 12.375000000000004, "y": 356.2, - "z": 2.079999999999993 + "z": 2.08 }, "volume": 121.0 }, @@ -10085,7 +10085,7 @@ "position": { "x": 12.375000000000004, "y": 356.2, - "z": 2.079999999999993 + "z": 2.08 }, "volume": 121.0 }, @@ -10137,7 +10137,7 @@ "position": { "x": 12.375000000000004, "y": 356.2, - "z": 2.079999999999993 + "z": 2.08 }, "volume": 121.0 }, @@ -10189,7 +10189,7 @@ "position": { "x": 12.375000000000004, "y": 356.2, - "z": 2.079999999999993 + "z": 2.08 }, "volume": 121.0 }, @@ -10274,7 +10274,7 @@ "position": { "x": 12.375000000000004, "y": 356.2, - "z": 1.329999999999993 + "z": 1.33 } }, "status": "succeeded" @@ -10299,7 +10299,7 @@ "position": { "x": 12.375000000000004, "y": 356.2, - "z": 1.329999999999993 + "z": 1.33 }, "volume": 101.0 }, @@ -10325,7 +10325,7 @@ "position": { "x": 32.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 101.0 }, @@ -10808,7 +10808,7 @@ "position": { "x": 32.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 125.0 }, @@ -10833,7 +10833,7 @@ "position": { "x": 32.3, "y": 74.15, - "z": 26.950000000000003 + "z": 26.95 } }, "status": "succeeded" @@ -10858,7 +10858,7 @@ "position": { "x": 32.3, "y": 74.15, - "z": 26.950000000000003 + "z": 26.95 }, "volume": 100.0 }, @@ -10883,7 +10883,7 @@ "position": { "x": 32.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 } }, "status": "succeeded" @@ -10908,7 +10908,7 @@ "position": { "x": 32.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 80.0 }, @@ -10934,7 +10934,7 @@ "position": { "x": 32.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 80.0 }, @@ -10959,7 +10959,7 @@ "position": { "x": 32.3, "y": 74.15, - "z": 26.950000000000003 + "z": 26.95 } }, "status": "succeeded" @@ -10984,7 +10984,7 @@ "position": { "x": 32.3, "y": 74.15, - "z": 26.950000000000003 + "z": 26.95 }, "volume": 100.0 }, @@ -11010,7 +11010,7 @@ "position": { "x": 32.3, "y": 74.15, - "z": 26.950000000000003 + "z": 26.95 }, "volume": 100.0 }, @@ -11035,7 +11035,7 @@ "position": { "x": 32.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 } }, "status": "succeeded" @@ -11060,7 +11060,7 @@ "position": { "x": 32.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 80.0 }, @@ -11086,7 +11086,7 @@ "position": { "x": 32.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 80.0 }, @@ -11111,7 +11111,7 @@ "position": { "x": 32.3, "y": 74.15, - "z": 26.950000000000003 + "z": 26.95 } }, "status": "succeeded" @@ -11136,7 +11136,7 @@ "position": { "x": 32.3, "y": 74.15, - "z": 26.950000000000003 + "z": 26.95 }, "volume": 100.0 }, @@ -11717,7 +11717,7 @@ "position": { "x": 57.375, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 200.0 }, @@ -11743,7 +11743,7 @@ "position": { "x": 32.3, "y": 74.15, - "z": 21.950000000000003 + "z": 21.95 }, "volume": 200.0 }, @@ -12222,7 +12222,7 @@ "position": { "x": 66.375, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 200.0 }, @@ -12248,7 +12248,7 @@ "position": { "x": 32.3, "y": 74.15, - "z": 21.950000000000003 + "z": 21.95 }, "volume": 200.0 }, @@ -12727,7 +12727,7 @@ "position": { "x": 75.375, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 200.0 }, @@ -12753,7 +12753,7 @@ "position": { "x": 32.3, "y": 74.15, - "z": 21.950000000000003 + "z": 21.95 }, "volume": 200.0 }, @@ -13232,7 +13232,7 @@ "position": { "x": 84.375, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 200.0 }, @@ -13258,7 +13258,7 @@ "position": { "x": 32.3, "y": 74.15, - "z": 21.950000000000003 + "z": 21.95 }, "volume": 200.0 }, @@ -13361,7 +13361,7 @@ "position": { "x": 32.3, "y": 74.15, - "z": 22.200000000000003 + "z": 22.2 } }, "status": "succeeded" @@ -13386,7 +13386,7 @@ "position": { "x": 32.3, "y": 74.15, - "z": 22.200000000000003 + "z": 22.2 }, "volume": 200.0 }, @@ -13412,7 +13412,7 @@ "position": { "x": 41.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 200.0 }, @@ -14336,7 +14336,7 @@ "position": { "x": 30.375000000000004, "y": 356.2, - "z": 2.079999999999993 + "z": 2.08 }, "volume": 22.0 }, @@ -14448,7 +14448,7 @@ "position": { "x": 30.375000000000004, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 4.0 }, @@ -14473,7 +14473,7 @@ "position": { "x": 84.375, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 } }, "status": "succeeded" @@ -14498,7 +14498,7 @@ "position": { "x": 84.375, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 20.0 }, @@ -14524,7 +14524,7 @@ "position": { "x": 84.375, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 20.0 }, @@ -14666,7 +14666,7 @@ "position": { "x": 30.375000000000004, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 5.0 }, @@ -14778,7 +14778,7 @@ "position": { "x": 30.375000000000004, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 20.0 }, @@ -14803,7 +14803,7 @@ "position": { "x": 30.375000000000004, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 } }, "status": "succeeded" @@ -14828,7 +14828,7 @@ "position": { "x": 30.375000000000004, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 45.0 }, @@ -14854,7 +14854,7 @@ "position": { "x": 30.375000000000004, "y": 356.2, - "z": 1.079999999999993 + "z": 1.08 }, "volume": 45.0 }, @@ -15012,7 +15012,7 @@ "position": { "x": 30.375000000000004, "y": 356.2, - "z": 1.329999999999993 + "z": 1.33 } }, "status": "succeeded" @@ -15037,7 +15037,7 @@ "position": { "x": 30.375000000000004, "y": 356.2, - "z": 1.329999999999993 + "z": 1.33 }, "volume": 46.0 }, @@ -15063,7 +15063,7 @@ "position": { "x": 50.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 46.0 }, @@ -15227,7 +15227,7 @@ "position": { "x": 50.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 40.5 }, @@ -15252,7 +15252,7 @@ "position": { "x": 50.3, "y": 74.15, - "z": 26.950000000000003 + "z": 26.95 } }, "status": "succeeded" @@ -15277,7 +15277,7 @@ "position": { "x": 50.3, "y": 74.15, - "z": 26.950000000000003 + "z": 26.95 }, "volume": 60.0 }, @@ -15302,7 +15302,7 @@ "position": { "x": 50.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 } }, "status": "succeeded" @@ -15327,7 +15327,7 @@ "position": { "x": 50.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 60.0 }, @@ -15353,7 +15353,7 @@ "position": { "x": 50.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 60.0 }, @@ -15378,7 +15378,7 @@ "position": { "x": 50.3, "y": 74.15, - "z": 26.950000000000003 + "z": 26.95 } }, "status": "succeeded" @@ -15403,7 +15403,7 @@ "position": { "x": 50.3, "y": 74.15, - "z": 26.950000000000003 + "z": 26.95 }, "volume": 30.0 }, @@ -15429,7 +15429,7 @@ "position": { "x": 50.3, "y": 74.15, - "z": 26.950000000000003 + "z": 26.95 }, "volume": 60.0 }, @@ -15454,7 +15454,7 @@ "position": { "x": 50.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 } }, "status": "succeeded" @@ -15479,7 +15479,7 @@ "position": { "x": 50.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 60.0 }, @@ -15505,7 +15505,7 @@ "position": { "x": 50.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 60.0 }, @@ -15530,7 +15530,7 @@ "position": { "x": 50.3, "y": 74.15, - "z": 26.950000000000003 + "z": 26.95 } }, "status": "succeeded" @@ -15555,7 +15555,7 @@ "position": { "x": 50.3, "y": 74.15, - "z": 26.950000000000003 + "z": 26.95 }, "volume": 30.0 }, @@ -17654,7 +17654,7 @@ "position": { "x": 50.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 } }, "status": "succeeded" @@ -17679,7 +17679,7 @@ "position": { "x": 50.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 32.0 }, @@ -17754,7 +17754,7 @@ "position": { "x": 50.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 } }, "status": "succeeded" @@ -17779,7 +17779,7 @@ "position": { "x": 50.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 32.0 }, @@ -17854,7 +17854,7 @@ "position": { "x": 50.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 } }, "status": "succeeded" @@ -17879,7 +17879,7 @@ "position": { "x": 50.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 32.0 }, @@ -17954,7 +17954,7 @@ "position": { "x": 50.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 } }, "status": "succeeded" @@ -17979,7 +17979,7 @@ "position": { "x": 50.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 32.0 }, @@ -18005,7 +18005,7 @@ "position": { "x": 50.3, "y": 74.15, - "z": 22.950000000000003 + "z": 22.95 }, "volume": 32.0 }, diff --git a/app-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[8df082e960][OT2_P300MLeft_MM_TM_2_4_Zymo].json b/app-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[8df082e960][OT2_P300MLeft_MM_TM_2_4_Zymo].json index 6ab08090f78..d27e70c456f 100644 --- a/app-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[8df082e960][OT2_P300MLeft_MM_TM_2_4_Zymo].json +++ b/app-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[8df082e960][OT2_P300MLeft_MM_TM_2_4_Zymo].json @@ -10913,7 +10913,7 @@ "errorInfo": { "args": "()", "class": "AssertionError", - "traceback": " File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_engine/protocol_engine.py\", line 441, in finish\n await exit_stack.aclose()\n\n File \"/usr/local/lib/python3.10/contextlib.py\", line 656, in aclose\n await self.__aexit__(None, None, None)\n\n File \"/usr/local/lib/python3.10/contextlib.py\", line 714, in __aexit__\n raise exc_details[1]\n\n File \"/usr/local/lib/python3.10/contextlib.py\", line 697, in __aexit__\n cb_suppress = await cb(*exc_details)\n\n File \"/usr/local/lib/python3.10/contextlib.py\", line 608, in _exit_wrapper\n await callback(*args, **kwds)\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_engine/plugins.py\", line 102, in stop\n await p.teardown()\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_runner/legacy_context_plugin.py\", line 112, in teardown\n await self._action_dispatching_task\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_runner/legacy_context_plugin.py\", line 160, in _dispatch_all_actions\n self.dispatch(action)\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_engine/plugins.py\", line 37, in dispatch\n return self._action_dispatcher.dispatch(action)\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_engine/actions/action_dispatcher.py\", line 30, in dispatch\n self._sink.handle_action(action)\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_engine/state/state.py\", line 204, in handle_action\n substore.handle_action(action)\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_engine/state/commands.py\", line 283, in handle_action\n assert self._state.running_command_id is None\n" + "traceback": " File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_engine/protocol_engine.py\", line 512, in finish\n await exit_stack.aclose()\n\n File \"/usr/local/lib/python3.10/contextlib.py\", line 656, in aclose\n await self.__aexit__(None, None, None)\n\n File \"/usr/local/lib/python3.10/contextlib.py\", line 714, in __aexit__\n raise exc_details[1]\n\n File \"/usr/local/lib/python3.10/contextlib.py\", line 697, in __aexit__\n cb_suppress = await cb(*exc_details)\n\n File \"/usr/local/lib/python3.10/contextlib.py\", line 608, in _exit_wrapper\n await callback(*args, **kwds)\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_engine/plugins.py\", line 102, in stop\n await p.teardown()\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_runner/legacy_context_plugin.py\", line 112, in teardown\n await self._action_dispatching_task\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_runner/legacy_context_plugin.py\", line 160, in _dispatch_all_actions\n self.dispatch(action)\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_engine/plugins.py\", line 37, in dispatch\n return self._action_dispatcher.dispatch(action)\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_engine/actions/action_dispatcher.py\", line 30, in dispatch\n self._sink.handle_action(action)\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_engine/state/state.py\", line 209, in handle_action\n substore.handle_action(action)\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_engine/state/commands.py\", line 261, in handle_action\n self._state.command_history.set_command_running(running_command)\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_engine/state/command_history.py\", line 175, in set_command_running\n assert self.get_running_command() is None\n" }, "errorType": "PythonException", "wrappedErrors": [] diff --git a/app-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[ac35bb394d][Flex_None_None_2_16_AnalysisError_TrashBinInStagingAreaCol4].json b/app-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[ac35bb394d][Flex_None_None_2_16_AnalysisError_TrashBinInStagingAreaCol4].json index ba5644090a2..4beea85705a 100644 --- a/app-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[ac35bb394d][Flex_None_None_2_16_AnalysisError_TrashBinInStagingAreaCol4].json +++ b/app-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[ac35bb394d][Flex_None_None_2_16_AnalysisError_TrashBinInStagingAreaCol4].json @@ -28,7 +28,7 @@ "errorInfo": { "args": "('Staging areas not permitted for trash bin.',)", "class": "ValueError", - "traceback": " File \"/usr/local/lib/python3.10/site-packages/opentrons/protocols/execution/execute_python.py\", line 124, in run_python\n exec(\"run(__context)\", new_globs)\n\n File \"\", line 1, in \n\n File \"Flex_None_None_2_16_AnalysisError_TrashBinInStagingAreaCol4.py\", line 15, in run\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocols/api_support/util.py\", line 383, in _check_version_wrapper\n return decorated_obj(*args, **kwargs)\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_api/protocol_context.py\", line 508, in load_trash_bin\n raise ValueError(\"Staging areas not permitted for trash bin.\")\n" + "traceback": " File \"/usr/local/lib/python3.10/site-packages/opentrons/protocols/execution/execute_python.py\", line 124, in run_python\n exec(\"run(__context)\", new_globs)\n\n File \"\", line 1, in \n\n File \"Flex_None_None_2_16_AnalysisError_TrashBinInStagingAreaCol4.py\", line 15, in run\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocols/api_support/util.py\", line 383, in _check_version_wrapper\n return decorated_obj(*args, **kwargs)\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_api/protocol_context.py\", line 512, in load_trash_bin\n raise ValueError(\"Staging areas not permitted for trash bin.\")\n" }, "errorType": "PythonException", "wrappedErrors": [] diff --git a/app-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[cda954ef1e][Flex_None_None_MM_2_16_AnalysisError_MagneticModuleInFlexProtocol].json b/app-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[cda954ef1e][Flex_None_None_MM_2_16_AnalysisError_MagneticModuleInFlexProtocol].json index fbcb54a5e13..baba4ad26fa 100644 --- a/app-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[cda954ef1e][Flex_None_None_MM_2_16_AnalysisError_MagneticModuleInFlexProtocol].json +++ b/app-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[cda954ef1e][Flex_None_None_MM_2_16_AnalysisError_MagneticModuleInFlexProtocol].json @@ -17,18 +17,18 @@ }, "errors": [ { - "detail": "ValueError [line 15]: A magneticModuleType cannot be loaded into slot C1", + "detail": "ValueError [line 15]: Module Type magneticModuleType does not have a related fixture ID.", "errorCode": "4000", "errorInfo": {}, "errorType": "ExceptionInProtocolError", "wrappedErrors": [ { - "detail": "ValueError: A magneticModuleType cannot be loaded into slot C1", + "detail": "ValueError: Module Type magneticModuleType does not have a related fixture ID.", "errorCode": "4000", "errorInfo": { - "args": "('A magneticModuleType cannot be loaded into slot C1',)", + "args": "('Module Type magneticModuleType does not have a related fixture ID.',)", "class": "ValueError", - "traceback": " File \"/usr/local/lib/python3.10/site-packages/opentrons/protocols/execution/execute_python.py\", line 124, in run_python\n exec(\"run(__context)\", new_globs)\n\n File \"\", line 1, in \n\n File \"Flex_None_None_MM_2_16_AnalysisError_MagneticModuleInFlexProtocol.py\", line 15, in run\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocols/api_support/util.py\", line 383, in _check_version_wrapper\n return decorated_obj(*args, **kwargs)\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_api/protocol_context.py\", line 810, in load_module\n module_core = self._core.load_module(\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_api/core/engine/protocol.py\", line 412, in load_module\n self._ensure_module_location(normalized_deck_slot, module_type)\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_api/core/engine/protocol.py\", line 631, in _ensure_module_location\n raise ValueError(f\"A {module_type.value} cannot be loaded into slot {slot}\")\n" + "traceback": " File \"/usr/local/lib/python3.10/site-packages/opentrons/protocols/execution/execute_python.py\", line 124, in run_python\n exec(\"run(__context)\", new_globs)\n\n File \"\", line 1, in \n\n File \"Flex_None_None_MM_2_16_AnalysisError_MagneticModuleInFlexProtocol.py\", line 15, in run\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocols/api_support/util.py\", line 383, in _check_version_wrapper\n return decorated_obj(*args, **kwargs)\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_api/protocol_context.py\", line 814, in load_module\n module_core = self._core.load_module(\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_api/core/engine/protocol.py\", line 413, in load_module\n self._ensure_module_location(normalized_deck_slot, module_type)\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_api/core/engine/protocol.py\", line 637, in _ensure_module_location\n cutout_fixture_id = ModuleType.to_module_fixture_id(module_type)\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/hardware_control/modules/types.py\", line 79, in to_module_fixture_id\n raise ValueError(\n" }, "errorType": "PythonException", "wrappedErrors": [] diff --git a/app-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[ce0f35b3c6][Flex_None_None_TM_2_16_AnalysisError_ModuleInCol2].json b/app-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[ce0f35b3c6][Flex_None_None_TM_2_16_AnalysisError_ModuleInCol2].json index e1ab5bc6247..515359e1672 100644 --- a/app-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[ce0f35b3c6][Flex_None_None_TM_2_16_AnalysisError_ModuleInCol2].json +++ b/app-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[ce0f35b3c6][Flex_None_None_TM_2_16_AnalysisError_ModuleInCol2].json @@ -28,7 +28,7 @@ "errorInfo": { "args": "('A temperatureModuleType cannot be loaded into slot C2',)", "class": "ValueError", - "traceback": " File \"/usr/local/lib/python3.10/site-packages/opentrons/protocols/execution/execute_python.py\", line 124, in run_python\n exec(\"run(__context)\", new_globs)\n\n File \"\", line 1, in \n\n File \"Flex_None_None_TM_2_16_AnalysisError_ModuleInCol2.py\", line 15, in run\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocols/api_support/util.py\", line 383, in _check_version_wrapper\n return decorated_obj(*args, **kwargs)\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_api/protocol_context.py\", line 810, in load_module\n module_core = self._core.load_module(\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_api/core/engine/protocol.py\", line 412, in load_module\n self._ensure_module_location(normalized_deck_slot, module_type)\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_api/core/engine/protocol.py\", line 631, in _ensure_module_location\n raise ValueError(f\"A {module_type.value} cannot be loaded into slot {slot}\")\n" + "traceback": " File \"/usr/local/lib/python3.10/site-packages/opentrons/protocols/execution/execute_python.py\", line 124, in run_python\n exec(\"run(__context)\", new_globs)\n\n File \"\", line 1, in \n\n File \"Flex_None_None_TM_2_16_AnalysisError_ModuleInCol2.py\", line 15, in run\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocols/api_support/util.py\", line 383, in _check_version_wrapper\n return decorated_obj(*args, **kwargs)\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_api/protocol_context.py\", line 814, in load_module\n module_core = self._core.load_module(\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_api/core/engine/protocol.py\", line 413, in load_module\n self._ensure_module_location(normalized_deck_slot, module_type)\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_api/core/engine/protocol.py\", line 646, in _ensure_module_location\n raise ValueError(\n" }, "errorType": "PythonException", "wrappedErrors": [] diff --git a/app-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[d8ec3534d4][Flex_P1000_96_TM_2_16_AnalysisError_ModuleAndWasteChuteConflict].json b/app-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[d8ec3534d4][Flex_P1000_96_TM_2_16_AnalysisError_ModuleAndWasteChuteConflict].json index 85e9ca095c0..64864e5d715 100644 --- a/app-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[d8ec3534d4][Flex_P1000_96_TM_2_16_AnalysisError_ModuleAndWasteChuteConflict].json +++ b/app-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[d8ec3534d4][Flex_P1000_96_TM_2_16_AnalysisError_ModuleAndWasteChuteConflict].json @@ -1219,6 +1219,24 @@ } }, "status": "succeeded" + }, + { + "commandType": "loadModule", + "error": { + "detail": "Cannot use Temperature Module in D3, not compatible with one or more of the following fixtures: Waste Chute", + "errorCode": "4000", + "errorInfo": {}, + "errorType": "IncompatibleAddressableAreaError", + "wrappedErrors": [] + }, + "notes": [], + "params": { + "location": { + "slotName": "D3" + }, + "model": "temperatureModuleV2" + }, + "status": "failed" } ], "config": { @@ -1230,17 +1248,25 @@ }, "errors": [ { - "detail": "IncompatibleAddressableAreaError [line 19]: Error 4000 GENERAL_ERROR (IncompatibleAddressableAreaError): Cannot use Slot D3, not compatible with one or more of the following fixtures: Waste Chute", + "detail": "ProtocolCommandFailedError [line 19]: Error 4000 GENERAL_ERROR (ProtocolCommandFailedError): IncompatibleAddressableAreaError: Cannot use Temperature Module in D3, not compatible with one or more of the following fixtures: Waste Chute", "errorCode": "4000", "errorInfo": {}, "errorType": "ExceptionInProtocolError", "wrappedErrors": [ { - "detail": "Cannot use Slot D3, not compatible with one or more of the following fixtures: Waste Chute", + "detail": "IncompatibleAddressableAreaError: Cannot use Temperature Module in D3, not compatible with one or more of the following fixtures: Waste Chute", "errorCode": "4000", "errorInfo": {}, - "errorType": "IncompatibleAddressableAreaError", - "wrappedErrors": [] + "errorType": "ProtocolCommandFailedError", + "wrappedErrors": [ + { + "detail": "Cannot use Temperature Module in D3, not compatible with one or more of the following fixtures: Waste Chute", + "errorCode": "4000", + "errorInfo": {}, + "errorType": "IncompatibleAddressableAreaError", + "wrappedErrors": [] + } + ] } ] } diff --git a/app-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[dc8ac87114][Flex_None_None_2_16_AnalysisError_AccessToFixedTrashProp].json b/app-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[dc8ac87114][Flex_None_None_2_16_AnalysisError_AccessToFixedTrashProp].json index 57381580c07..257a29f5a73 100644 --- a/app-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[dc8ac87114][Flex_None_None_2_16_AnalysisError_AccessToFixedTrashProp].json +++ b/app-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[dc8ac87114][Flex_None_None_2_16_AnalysisError_AccessToFixedTrashProp].json @@ -28,7 +28,7 @@ "errorInfo": { "args": "('Fixed Trash is not supported on Flex protocols in API Version 2.16 and above.',)", "class": "APIVersionError", - "traceback": " File \"/usr/local/lib/python3.10/site-packages/opentrons/protocols/execution/execute_python.py\", line 124, in run_python\n exec(\"run(__context)\", new_globs)\n\n File \"\", line 1, in \n\n File \"Flex_None_None_2_16_AnalysisError_AccessToFixedTrashProp.py\", line 15, in run\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocols/api_support/util.py\", line 383, in _check_version_wrapper\n return decorated_obj(*args, **kwargs)\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_api/protocol_context.py\", line 1114, in fixed_trash\n raise APIVersionError(\n" + "traceback": " File \"/usr/local/lib/python3.10/site-packages/opentrons/protocols/execution/execute_python.py\", line 124, in run_python\n exec(\"run(__context)\", new_globs)\n\n File \"\", line 1, in \n\n File \"Flex_None_None_2_16_AnalysisError_AccessToFixedTrashProp.py\", line 15, in run\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocols/api_support/util.py\", line 383, in _check_version_wrapper\n return decorated_obj(*args, **kwargs)\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_api/protocol_context.py\", line 1118, in fixed_trash\n raise APIVersionError(\n" }, "errorType": "PythonException", "wrappedErrors": [] diff --git a/app-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[e49dae5293][OT2_None_None_HS_2_16_AnalysisError_HeaterShakerConflictWithTrashBin1].json b/app-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[e49dae5293][OT2_None_None_HS_2_16_AnalysisError_HeaterShakerConflictWithTrashBin1].json index 4cf6892135d..a54c74b3347 100644 --- a/app-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[e49dae5293][OT2_None_None_HS_2_16_AnalysisError_HeaterShakerConflictWithTrashBin1].json +++ b/app-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[e49dae5293][OT2_None_None_HS_2_16_AnalysisError_HeaterShakerConflictWithTrashBin1].json @@ -478,7 +478,7 @@ "errorInfo": { "args": "('trash bin in slot 12 prevents heaterShakerModuleV1 from using slot 11.',)", "class": "DeckConflictError", - "traceback": " File \"/usr/local/lib/python3.10/site-packages/opentrons/protocols/execution/execute_python.py\", line 124, in run_python\n exec(\"run(__context)\", new_globs)\n\n File \"\", line 1, in \n\n File \"OT2_None_None_HS_2_16_AnalysisError_HeaterShakerConflictWithTrashBin1.py\", line 11, in run\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocols/api_support/util.py\", line 383, in _check_version_wrapper\n return decorated_obj(*args, **kwargs)\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_api/protocol_context.py\", line 810, in load_module\n module_core = self._core.load_module(\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_api/core/engine/protocol.py\", line 424, in load_module\n deck_conflict.check(\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_api/core/engine/deck_conflict.py\", line 203, in check\n wrapped_deck_conflict.check(\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/motion_planning/deck_conflict.py\", line 223, in check\n raise DeckConflictError(\n" + "traceback": " File \"/usr/local/lib/python3.10/site-packages/opentrons/protocols/execution/execute_python.py\", line 124, in run_python\n exec(\"run(__context)\", new_globs)\n\n File \"\", line 1, in \n\n File \"OT2_None_None_HS_2_16_AnalysisError_HeaterShakerConflictWithTrashBin1.py\", line 11, in run\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocols/api_support/util.py\", line 383, in _check_version_wrapper\n return decorated_obj(*args, **kwargs)\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_api/protocol_context.py\", line 814, in load_module\n module_core = self._core.load_module(\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_api/core/engine/protocol.py\", line 425, in load_module\n deck_conflict.check(\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_api/core/engine/deck_conflict.py\", line 203, in check\n wrapped_deck_conflict.check(\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/motion_planning/deck_conflict.py\", line 223, in check\n raise DeckConflictError(\n" }, "errorType": "PythonException", "wrappedErrors": [] diff --git a/app-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[f91ecb541c][OT2_P300M_P20S_TC_HS_TM_2_17_SmokeTestV3].json b/app-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[f91ecb541c][OT2_P300M_P20S_TC_HS_TM_2_17_SmokeTestV3].json index 1912a8a3d55..0245a572ca9 100644 --- a/app-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[f91ecb541c][OT2_P300M_P20S_TC_HS_TM_2_17_SmokeTestV3].json +++ b/app-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[f91ecb541c][OT2_P300M_P20S_TC_HS_TM_2_17_SmokeTestV3].json @@ -6,12 +6,15543 @@ "params": {}, "result": {}, "status": "succeeded" + }, + { + "commandType": "setRailLights", + "notes": [], + "params": { + "on": true + }, + "result": {}, + "status": "succeeded" + }, + { + "commandType": "custom", + "notes": [], + "params": { + "legacyCommandText": "Let there be light! True 🌠🌠🌠", + "legacyCommandType": "command.COMMENT" + }, + "result": {}, + "status": "succeeded" + }, + { + "commandType": "custom", + "notes": [], + "params": { + "legacyCommandText": "Is the door is closed? True 🚪🚪🚪", + "legacyCommandType": "command.COMMENT" + }, + "result": {}, + "status": "succeeded" + }, + { + "commandType": "custom", + "notes": [], + "params": { + "legacyCommandText": "Is this a simulation? True 🔮🔮🔮", + "legacyCommandType": "command.COMMENT" + }, + "result": {}, + "status": "succeeded" + }, + { + "commandType": "custom", + "notes": [], + "params": { + "legacyCommandText": "Running against API Version: 2.17", + "legacyCommandType": "command.COMMENT" + }, + "result": {}, + "status": "succeeded" + }, + { + "commandType": "loadLabware", + "notes": [], + "params": { + "displayName": "300ul tips", + "loadName": "opentrons_96_tiprack_300ul", + "location": { + "slotName": "5" + }, + "namespace": "opentrons", + "version": 1 + }, + "result": { + "definition": { + "allowedRoles": [], + "brand": { + "brand": "Opentrons", + "brandId": [], + "links": [ + "https://shop.opentrons.com/collections/opentrons-tips/products/opentrons-300ul-tips" + ] + }, + "cornerOffsetFromSlot": { + "x": 0, + "y": 0, + "z": 0 + }, + "dimensions": { + "xDimension": 127.76, + "yDimension": 85.48, + "zDimension": 64.49 + }, + "gripperOffsets": {}, + "groups": [ + { + "metadata": {}, + "wells": [ + "A1", + "A10", + "A11", + "A12", + "A2", + "A3", + "A4", + "A5", + "A6", + "A7", + "A8", + "A9", + "B1", + "B10", + "B11", + "B12", + "B2", + "B3", + "B4", + "B5", + "B6", + "B7", + "B8", + "B9", + "C1", + "C10", + "C11", + "C12", + "C2", + "C3", + "C4", + "C5", + "C6", + "C7", + "C8", + "C9", + "D1", + "D10", + "D11", + "D12", + "D2", + "D3", + "D4", + "D5", + "D6", + "D7", + "D8", + "D9", + "E1", + "E10", + "E11", + "E12", + "E2", + "E3", + "E4", + "E5", + "E6", + "E7", + "E8", + "E9", + "F1", + "F10", + "F11", + "F12", + "F2", + "F3", + "F4", + "F5", + "F6", + "F7", + "F8", + "F9", + "G1", + "G10", + "G11", + "G12", + "G2", + "G3", + "G4", + "G5", + "G6", + "G7", + "G8", + "G9", + "H1", + "H10", + "H11", + "H12", + "H2", + "H3", + "H4", + "H5", + "H6", + "H7", + "H8", + "H9" + ] + } + ], + "metadata": { + "displayCategory": "tipRack", + "displayName": "Opentrons OT-2 96 Tip Rack 300 µL", + "displayVolumeUnits": "µL", + "tags": [] + }, + "namespace": "opentrons", + "ordering": [ + [ + "A1", + "B1", + "C1", + "D1", + "E1", + "F1", + "G1", + "H1" + ], + [ + "A10", + "B10", + "C10", + "D10", + "E10", + "F10", + "G10", + "H10" + ], + [ + "A11", + "B11", + "C11", + "D11", + "E11", + "F11", + "G11", + "H11" + ], + [ + "A12", + "B12", + "C12", + "D12", + "E12", + "F12", + "G12", + "H12" + ], + [ + "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" + ] + ], + "parameters": { + "format": "96Standard", + "isMagneticModuleCompatible": false, + "isTiprack": true, + "loadName": "opentrons_96_tiprack_300ul", + "tipLength": 59.3, + "tipOverlap": 7.47 + }, + "schemaVersion": 2, + "stackingOffsetWithLabware": {}, + "stackingOffsetWithModule": {}, + "version": 1, + "wells": { + "A1": { + "depth": 59.3, + "diameter": 5.23, + "shape": "circular", + "totalLiquidVolume": 300, + "x": 14.38, + "y": 74.24, + "z": 5.39 + }, + "A10": { + "depth": 59.3, + "diameter": 5.23, + "shape": "circular", + "totalLiquidVolume": 300, + "x": 95.38, + "y": 74.24, + "z": 5.39 + }, + "A11": { + "depth": 59.3, + "diameter": 5.23, + "shape": "circular", + "totalLiquidVolume": 300, + "x": 104.38, + "y": 74.24, + "z": 5.39 + }, + "A12": { + "depth": 59.3, + "diameter": 5.23, + "shape": "circular", + "totalLiquidVolume": 300, + "x": 113.38, + "y": 74.24, + "z": 5.39 + }, + "A2": { + "depth": 59.3, + "diameter": 5.23, + "shape": "circular", + "totalLiquidVolume": 300, + "x": 23.38, + "y": 74.24, + "z": 5.39 + }, + "A3": { + "depth": 59.3, + "diameter": 5.23, + "shape": "circular", + "totalLiquidVolume": 300, + "x": 32.38, + "y": 74.24, + "z": 5.39 + }, + "A4": { + "depth": 59.3, + "diameter": 5.23, + "shape": "circular", + "totalLiquidVolume": 300, + "x": 41.38, + "y": 74.24, + "z": 5.39 + }, + "A5": { + "depth": 59.3, + "diameter": 5.23, + "shape": "circular", + "totalLiquidVolume": 300, + "x": 50.38, + "y": 74.24, + "z": 5.39 + }, + "A6": { + "depth": 59.3, + "diameter": 5.23, + "shape": "circular", + "totalLiquidVolume": 300, + "x": 59.38, + "y": 74.24, + "z": 5.39 + }, + "A7": { + "depth": 59.3, + "diameter": 5.23, + "shape": "circular", + "totalLiquidVolume": 300, + "x": 68.38, + "y": 74.24, + "z": 5.39 + }, + "A8": { + "depth": 59.3, + "diameter": 5.23, + "shape": "circular", + "totalLiquidVolume": 300, + "x": 77.38, + "y": 74.24, + "z": 5.39 + }, + "A9": { + "depth": 59.3, + "diameter": 5.23, + "shape": "circular", + "totalLiquidVolume": 300, + "x": 86.38, + "y": 74.24, + "z": 5.39 + }, + "B1": { + "depth": 59.3, + "diameter": 5.23, + "shape": "circular", + "totalLiquidVolume": 300, + "x": 14.38, + "y": 65.24, + "z": 5.39 + }, + "B10": { + "depth": 59.3, + "diameter": 5.23, + "shape": "circular", + "totalLiquidVolume": 300, + "x": 95.38, + "y": 65.24, + "z": 5.39 + }, + "B11": { + "depth": 59.3, + "diameter": 5.23, + "shape": "circular", + "totalLiquidVolume": 300, + "x": 104.38, + "y": 65.24, + "z": 5.39 + }, + "B12": { + "depth": 59.3, + "diameter": 5.23, + "shape": "circular", + "totalLiquidVolume": 300, + "x": 113.38, + "y": 65.24, + "z": 5.39 + }, + "B2": { + "depth": 59.3, + "diameter": 5.23, + "shape": "circular", + "totalLiquidVolume": 300, + "x": 23.38, + "y": 65.24, + "z": 5.39 + }, + "B3": { + "depth": 59.3, + "diameter": 5.23, + "shape": "circular", + "totalLiquidVolume": 300, + "x": 32.38, + "y": 65.24, + "z": 5.39 + }, + "B4": { + "depth": 59.3, + "diameter": 5.23, + "shape": "circular", + "totalLiquidVolume": 300, + "x": 41.38, + "y": 65.24, + "z": 5.39 + }, + "B5": { + "depth": 59.3, + "diameter": 5.23, + "shape": "circular", + "totalLiquidVolume": 300, + "x": 50.38, + "y": 65.24, + "z": 5.39 + }, + "B6": { + "depth": 59.3, + "diameter": 5.23, + "shape": "circular", + "totalLiquidVolume": 300, + "x": 59.38, + "y": 65.24, + "z": 5.39 + }, + "B7": { + "depth": 59.3, + "diameter": 5.23, + "shape": "circular", + "totalLiquidVolume": 300, + "x": 68.38, + "y": 65.24, + "z": 5.39 + }, + "B8": { + "depth": 59.3, + "diameter": 5.23, + "shape": "circular", + "totalLiquidVolume": 300, + "x": 77.38, + "y": 65.24, + "z": 5.39 + }, + "B9": { + "depth": 59.3, + "diameter": 5.23, + "shape": "circular", + "totalLiquidVolume": 300, + "x": 86.38, + "y": 65.24, + "z": 5.39 + }, + "C1": { + "depth": 59.3, + "diameter": 5.23, + "shape": "circular", + "totalLiquidVolume": 300, + "x": 14.38, + "y": 56.24, + "z": 5.39 + }, + "C10": { + "depth": 59.3, + "diameter": 5.23, + "shape": "circular", + "totalLiquidVolume": 300, + "x": 95.38, + "y": 56.24, + "z": 5.39 + }, + "C11": { + "depth": 59.3, + "diameter": 5.23, + "shape": "circular", + "totalLiquidVolume": 300, + "x": 104.38, + "y": 56.24, + "z": 5.39 + }, + "C12": { + "depth": 59.3, + "diameter": 5.23, + "shape": "circular", + "totalLiquidVolume": 300, + "x": 113.38, + "y": 56.24, + "z": 5.39 + }, + "C2": { + "depth": 59.3, + "diameter": 5.23, + "shape": "circular", + "totalLiquidVolume": 300, + "x": 23.38, + "y": 56.24, + "z": 5.39 + }, + "C3": { + "depth": 59.3, + "diameter": 5.23, + "shape": "circular", + "totalLiquidVolume": 300, + "x": 32.38, + "y": 56.24, + "z": 5.39 + }, + "C4": { + "depth": 59.3, + "diameter": 5.23, + "shape": "circular", + "totalLiquidVolume": 300, + "x": 41.38, + "y": 56.24, + "z": 5.39 + }, + "C5": { + "depth": 59.3, + "diameter": 5.23, + "shape": "circular", + "totalLiquidVolume": 300, + "x": 50.38, + "y": 56.24, + "z": 5.39 + }, + "C6": { + "depth": 59.3, + "diameter": 5.23, + "shape": "circular", + "totalLiquidVolume": 300, + "x": 59.38, + "y": 56.24, + "z": 5.39 + }, + "C7": { + "depth": 59.3, + "diameter": 5.23, + "shape": "circular", + "totalLiquidVolume": 300, + "x": 68.38, + "y": 56.24, + "z": 5.39 + }, + "C8": { + "depth": 59.3, + "diameter": 5.23, + "shape": "circular", + "totalLiquidVolume": 300, + "x": 77.38, + "y": 56.24, + "z": 5.39 + }, + "C9": { + "depth": 59.3, + "diameter": 5.23, + "shape": "circular", + "totalLiquidVolume": 300, + "x": 86.38, + "y": 56.24, + "z": 5.39 + }, + "D1": { + "depth": 59.3, + "diameter": 5.23, + "shape": "circular", + "totalLiquidVolume": 300, + "x": 14.38, + "y": 47.24, + "z": 5.39 + }, + "D10": { + "depth": 59.3, + "diameter": 5.23, + "shape": "circular", + "totalLiquidVolume": 300, + "x": 95.38, + "y": 47.24, + "z": 5.39 + }, + "D11": { + "depth": 59.3, + "diameter": 5.23, + "shape": "circular", + "totalLiquidVolume": 300, + "x": 104.38, + "y": 47.24, + "z": 5.39 + }, + "D12": { + "depth": 59.3, + "diameter": 5.23, + "shape": "circular", + "totalLiquidVolume": 300, + "x": 113.38, + "y": 47.24, + "z": 5.39 + }, + "D2": { + "depth": 59.3, + "diameter": 5.23, + "shape": "circular", + "totalLiquidVolume": 300, + "x": 23.38, + "y": 47.24, + "z": 5.39 + }, + "D3": { + "depth": 59.3, + "diameter": 5.23, + "shape": "circular", + "totalLiquidVolume": 300, + "x": 32.38, + "y": 47.24, + "z": 5.39 + }, + "D4": { + "depth": 59.3, + "diameter": 5.23, + "shape": "circular", + "totalLiquidVolume": 300, + "x": 41.38, + "y": 47.24, + "z": 5.39 + }, + "D5": { + "depth": 59.3, + "diameter": 5.23, + "shape": "circular", + "totalLiquidVolume": 300, + "x": 50.38, + "y": 47.24, + "z": 5.39 + }, + "D6": { + "depth": 59.3, + "diameter": 5.23, + "shape": "circular", + "totalLiquidVolume": 300, + "x": 59.38, + "y": 47.24, + "z": 5.39 + }, + "D7": { + "depth": 59.3, + "diameter": 5.23, + "shape": "circular", + "totalLiquidVolume": 300, + "x": 68.38, + "y": 47.24, + "z": 5.39 + }, + "D8": { + "depth": 59.3, + "diameter": 5.23, + "shape": "circular", + "totalLiquidVolume": 300, + "x": 77.38, + "y": 47.24, + "z": 5.39 + }, + "D9": { + "depth": 59.3, + "diameter": 5.23, + "shape": "circular", + "totalLiquidVolume": 300, + "x": 86.38, + "y": 47.24, + "z": 5.39 + }, + "E1": { + "depth": 59.3, + "diameter": 5.23, + "shape": "circular", + "totalLiquidVolume": 300, + "x": 14.38, + "y": 38.24, + "z": 5.39 + }, + "E10": { + "depth": 59.3, + "diameter": 5.23, + "shape": "circular", + "totalLiquidVolume": 300, + "x": 95.38, + "y": 38.24, + "z": 5.39 + }, + "E11": { + "depth": 59.3, + "diameter": 5.23, + "shape": "circular", + "totalLiquidVolume": 300, + "x": 104.38, + "y": 38.24, + "z": 5.39 + }, + "E12": { + "depth": 59.3, + "diameter": 5.23, + "shape": "circular", + "totalLiquidVolume": 300, + "x": 113.38, + "y": 38.24, + "z": 5.39 + }, + "E2": { + "depth": 59.3, + "diameter": 5.23, + "shape": "circular", + "totalLiquidVolume": 300, + "x": 23.38, + "y": 38.24, + "z": 5.39 + }, + "E3": { + "depth": 59.3, + "diameter": 5.23, + "shape": "circular", + "totalLiquidVolume": 300, + "x": 32.38, + "y": 38.24, + "z": 5.39 + }, + "E4": { + "depth": 59.3, + "diameter": 5.23, + "shape": "circular", + "totalLiquidVolume": 300, + "x": 41.38, + "y": 38.24, + "z": 5.39 + }, + "E5": { + "depth": 59.3, + "diameter": 5.23, + "shape": "circular", + "totalLiquidVolume": 300, + "x": 50.38, + "y": 38.24, + "z": 5.39 + }, + "E6": { + "depth": 59.3, + "diameter": 5.23, + "shape": "circular", + "totalLiquidVolume": 300, + "x": 59.38, + "y": 38.24, + "z": 5.39 + }, + "E7": { + "depth": 59.3, + "diameter": 5.23, + "shape": "circular", + "totalLiquidVolume": 300, + "x": 68.38, + "y": 38.24, + "z": 5.39 + }, + "E8": { + "depth": 59.3, + "diameter": 5.23, + "shape": "circular", + "totalLiquidVolume": 300, + "x": 77.38, + "y": 38.24, + "z": 5.39 + }, + "E9": { + "depth": 59.3, + "diameter": 5.23, + "shape": "circular", + "totalLiquidVolume": 300, + "x": 86.38, + "y": 38.24, + "z": 5.39 + }, + "F1": { + "depth": 59.3, + "diameter": 5.23, + "shape": "circular", + "totalLiquidVolume": 300, + "x": 14.38, + "y": 29.24, + "z": 5.39 + }, + "F10": { + "depth": 59.3, + "diameter": 5.23, + "shape": "circular", + "totalLiquidVolume": 300, + "x": 95.38, + "y": 29.24, + "z": 5.39 + }, + "F11": { + "depth": 59.3, + "diameter": 5.23, + "shape": "circular", + "totalLiquidVolume": 300, + "x": 104.38, + "y": 29.24, + "z": 5.39 + }, + "F12": { + "depth": 59.3, + "diameter": 5.23, + "shape": "circular", + "totalLiquidVolume": 300, + "x": 113.38, + "y": 29.24, + "z": 5.39 + }, + "F2": { + "depth": 59.3, + "diameter": 5.23, + "shape": "circular", + "totalLiquidVolume": 300, + "x": 23.38, + "y": 29.24, + "z": 5.39 + }, + "F3": { + "depth": 59.3, + "diameter": 5.23, + "shape": "circular", + "totalLiquidVolume": 300, + "x": 32.38, + "y": 29.24, + "z": 5.39 + }, + "F4": { + "depth": 59.3, + "diameter": 5.23, + "shape": "circular", + "totalLiquidVolume": 300, + "x": 41.38, + "y": 29.24, + "z": 5.39 + }, + "F5": { + "depth": 59.3, + "diameter": 5.23, + "shape": "circular", + "totalLiquidVolume": 300, + "x": 50.38, + "y": 29.24, + "z": 5.39 + }, + "F6": { + "depth": 59.3, + "diameter": 5.23, + "shape": "circular", + "totalLiquidVolume": 300, + "x": 59.38, + "y": 29.24, + "z": 5.39 + }, + "F7": { + "depth": 59.3, + "diameter": 5.23, + "shape": "circular", + "totalLiquidVolume": 300, + "x": 68.38, + "y": 29.24, + "z": 5.39 + }, + "F8": { + "depth": 59.3, + "diameter": 5.23, + "shape": "circular", + "totalLiquidVolume": 300, + "x": 77.38, + "y": 29.24, + "z": 5.39 + }, + "F9": { + "depth": 59.3, + "diameter": 5.23, + "shape": "circular", + "totalLiquidVolume": 300, + "x": 86.38, + "y": 29.24, + "z": 5.39 + }, + "G1": { + "depth": 59.3, + "diameter": 5.23, + "shape": "circular", + "totalLiquidVolume": 300, + "x": 14.38, + "y": 20.24, + "z": 5.39 + }, + "G10": { + "depth": 59.3, + "diameter": 5.23, + "shape": "circular", + "totalLiquidVolume": 300, + "x": 95.38, + "y": 20.24, + "z": 5.39 + }, + "G11": { + "depth": 59.3, + "diameter": 5.23, + "shape": "circular", + "totalLiquidVolume": 300, + "x": 104.38, + "y": 20.24, + "z": 5.39 + }, + "G12": { + "depth": 59.3, + "diameter": 5.23, + "shape": "circular", + "totalLiquidVolume": 300, + "x": 113.38, + "y": 20.24, + "z": 5.39 + }, + "G2": { + "depth": 59.3, + "diameter": 5.23, + "shape": "circular", + "totalLiquidVolume": 300, + "x": 23.38, + "y": 20.24, + "z": 5.39 + }, + "G3": { + "depth": 59.3, + "diameter": 5.23, + "shape": "circular", + "totalLiquidVolume": 300, + "x": 32.38, + "y": 20.24, + "z": 5.39 + }, + "G4": { + "depth": 59.3, + "diameter": 5.23, + "shape": "circular", + "totalLiquidVolume": 300, + "x": 41.38, + "y": 20.24, + "z": 5.39 + }, + "G5": { + "depth": 59.3, + "diameter": 5.23, + "shape": "circular", + "totalLiquidVolume": 300, + "x": 50.38, + "y": 20.24, + "z": 5.39 + }, + "G6": { + "depth": 59.3, + "diameter": 5.23, + "shape": "circular", + "totalLiquidVolume": 300, + "x": 59.38, + "y": 20.24, + "z": 5.39 + }, + "G7": { + "depth": 59.3, + "diameter": 5.23, + "shape": "circular", + "totalLiquidVolume": 300, + "x": 68.38, + "y": 20.24, + "z": 5.39 + }, + "G8": { + "depth": 59.3, + "diameter": 5.23, + "shape": "circular", + "totalLiquidVolume": 300, + "x": 77.38, + "y": 20.24, + "z": 5.39 + }, + "G9": { + "depth": 59.3, + "diameter": 5.23, + "shape": "circular", + "totalLiquidVolume": 300, + "x": 86.38, + "y": 20.24, + "z": 5.39 + }, + "H1": { + "depth": 59.3, + "diameter": 5.23, + "shape": "circular", + "totalLiquidVolume": 300, + "x": 14.38, + "y": 11.24, + "z": 5.39 + }, + "H10": { + "depth": 59.3, + "diameter": 5.23, + "shape": "circular", + "totalLiquidVolume": 300, + "x": 95.38, + "y": 11.24, + "z": 5.39 + }, + "H11": { + "depth": 59.3, + "diameter": 5.23, + "shape": "circular", + "totalLiquidVolume": 300, + "x": 104.38, + "y": 11.24, + "z": 5.39 + }, + "H12": { + "depth": 59.3, + "diameter": 5.23, + "shape": "circular", + "totalLiquidVolume": 300, + "x": 113.38, + "y": 11.24, + "z": 5.39 + }, + "H2": { + "depth": 59.3, + "diameter": 5.23, + "shape": "circular", + "totalLiquidVolume": 300, + "x": 23.38, + "y": 11.24, + "z": 5.39 + }, + "H3": { + "depth": 59.3, + "diameter": 5.23, + "shape": "circular", + "totalLiquidVolume": 300, + "x": 32.38, + "y": 11.24, + "z": 5.39 + }, + "H4": { + "depth": 59.3, + "diameter": 5.23, + "shape": "circular", + "totalLiquidVolume": 300, + "x": 41.38, + "y": 11.24, + "z": 5.39 + }, + "H5": { + "depth": 59.3, + "diameter": 5.23, + "shape": "circular", + "totalLiquidVolume": 300, + "x": 50.38, + "y": 11.24, + "z": 5.39 + }, + "H6": { + "depth": 59.3, + "diameter": 5.23, + "shape": "circular", + "totalLiquidVolume": 300, + "x": 59.38, + "y": 11.24, + "z": 5.39 + }, + "H7": { + "depth": 59.3, + "diameter": 5.23, + "shape": "circular", + "totalLiquidVolume": 300, + "x": 68.38, + "y": 11.24, + "z": 5.39 + }, + "H8": { + "depth": 59.3, + "diameter": 5.23, + "shape": "circular", + "totalLiquidVolume": 300, + "x": 77.38, + "y": 11.24, + "z": 5.39 + }, + "H9": { + "depth": 59.3, + "diameter": 5.23, + "shape": "circular", + "totalLiquidVolume": 300, + "x": 86.38, + "y": 11.24, + "z": 5.39 + } + } + } + }, + "status": "succeeded" + }, + { + "commandType": "loadLabware", + "notes": [], + "params": { + "displayName": "20ul tips", + "loadName": "opentrons_96_tiprack_20ul", + "location": { + "slotName": "4" + }, + "namespace": "opentrons", + "version": 1 + }, + "result": { + "definition": { + "allowedRoles": [], + "brand": { + "brand": "Opentrons", + "brandId": [], + "links": [ + "https://shop.opentrons.com/collections/opentrons-tips/products/opentrons-10ul-tips" + ] + }, + "cornerOffsetFromSlot": { + "x": 0, + "y": 0, + "z": 0 + }, + "dimensions": { + "xDimension": 127.76, + "yDimension": 85.48, + "zDimension": 64.69 + }, + "gripperOffsets": {}, + "groups": [ + { + "metadata": {}, + "wells": [ + "A1", + "A10", + "A11", + "A12", + "A2", + "A3", + "A4", + "A5", + "A6", + "A7", + "A8", + "A9", + "B1", + "B10", + "B11", + "B12", + "B2", + "B3", + "B4", + "B5", + "B6", + "B7", + "B8", + "B9", + "C1", + "C10", + "C11", + "C12", + "C2", + "C3", + "C4", + "C5", + "C6", + "C7", + "C8", + "C9", + "D1", + "D10", + "D11", + "D12", + "D2", + "D3", + "D4", + "D5", + "D6", + "D7", + "D8", + "D9", + "E1", + "E10", + "E11", + "E12", + "E2", + "E3", + "E4", + "E5", + "E6", + "E7", + "E8", + "E9", + "F1", + "F10", + "F11", + "F12", + "F2", + "F3", + "F4", + "F5", + "F6", + "F7", + "F8", + "F9", + "G1", + "G10", + "G11", + "G12", + "G2", + "G3", + "G4", + "G5", + "G6", + "G7", + "G8", + "G9", + "H1", + "H10", + "H11", + "H12", + "H2", + "H3", + "H4", + "H5", + "H6", + "H7", + "H8", + "H9" + ] + } + ], + "metadata": { + "displayCategory": "tipRack", + "displayName": "Opentrons OT-2 96 Tip Rack 20 µL", + "displayVolumeUnits": "µL", + "tags": [] + }, + "namespace": "opentrons", + "ordering": [ + [ + "A1", + "B1", + "C1", + "D1", + "E1", + "F1", + "G1", + "H1" + ], + [ + "A10", + "B10", + "C10", + "D10", + "E10", + "F10", + "G10", + "H10" + ], + [ + "A11", + "B11", + "C11", + "D11", + "E11", + "F11", + "G11", + "H11" + ], + [ + "A12", + "B12", + "C12", + "D12", + "E12", + "F12", + "G12", + "H12" + ], + [ + "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" + ] + ], + "parameters": { + "format": "96Standard", + "isMagneticModuleCompatible": false, + "isTiprack": true, + "loadName": "opentrons_96_tiprack_20ul", + "tipLength": 39.2, + "tipOverlap": 8.25 + }, + "schemaVersion": 2, + "stackingOffsetWithLabware": {}, + "stackingOffsetWithModule": {}, + "version": 1, + "wells": { + "A1": { + "depth": 39.2, + "diameter": 3.27, + "shape": "circular", + "totalLiquidVolume": 20, + "x": 14.38, + "y": 74.24, + "z": 25.49 + }, + "A10": { + "depth": 39.2, + "diameter": 3.27, + "shape": "circular", + "totalLiquidVolume": 20, + "x": 95.38, + "y": 74.24, + "z": 25.49 + }, + "A11": { + "depth": 39.2, + "diameter": 3.27, + "shape": "circular", + "totalLiquidVolume": 20, + "x": 104.38, + "y": 74.24, + "z": 25.49 + }, + "A12": { + "depth": 39.2, + "diameter": 3.27, + "shape": "circular", + "totalLiquidVolume": 20, + "x": 113.38, + "y": 74.24, + "z": 25.49 + }, + "A2": { + "depth": 39.2, + "diameter": 3.27, + "shape": "circular", + "totalLiquidVolume": 20, + "x": 23.38, + "y": 74.24, + "z": 25.49 + }, + "A3": { + "depth": 39.2, + "diameter": 3.27, + "shape": "circular", + "totalLiquidVolume": 20, + "x": 32.38, + "y": 74.24, + "z": 25.49 + }, + "A4": { + "depth": 39.2, + "diameter": 3.27, + "shape": "circular", + "totalLiquidVolume": 20, + "x": 41.38, + "y": 74.24, + "z": 25.49 + }, + "A5": { + "depth": 39.2, + "diameter": 3.27, + "shape": "circular", + "totalLiquidVolume": 20, + "x": 50.38, + "y": 74.24, + "z": 25.49 + }, + "A6": { + "depth": 39.2, + "diameter": 3.27, + "shape": "circular", + "totalLiquidVolume": 20, + "x": 59.38, + "y": 74.24, + "z": 25.49 + }, + "A7": { + "depth": 39.2, + "diameter": 3.27, + "shape": "circular", + "totalLiquidVolume": 20, + "x": 68.38, + "y": 74.24, + "z": 25.49 + }, + "A8": { + "depth": 39.2, + "diameter": 3.27, + "shape": "circular", + "totalLiquidVolume": 20, + "x": 77.38, + "y": 74.24, + "z": 25.49 + }, + "A9": { + "depth": 39.2, + "diameter": 3.27, + "shape": "circular", + "totalLiquidVolume": 20, + "x": 86.38, + "y": 74.24, + "z": 25.49 + }, + "B1": { + "depth": 39.2, + "diameter": 3.27, + "shape": "circular", + "totalLiquidVolume": 20, + "x": 14.38, + "y": 65.24, + "z": 25.49 + }, + "B10": { + "depth": 39.2, + "diameter": 3.27, + "shape": "circular", + "totalLiquidVolume": 20, + "x": 95.38, + "y": 65.24, + "z": 25.49 + }, + "B11": { + "depth": 39.2, + "diameter": 3.27, + "shape": "circular", + "totalLiquidVolume": 20, + "x": 104.38, + "y": 65.24, + "z": 25.49 + }, + "B12": { + "depth": 39.2, + "diameter": 3.27, + "shape": "circular", + "totalLiquidVolume": 20, + "x": 113.38, + "y": 65.24, + "z": 25.49 + }, + "B2": { + "depth": 39.2, + "diameter": 3.27, + "shape": "circular", + "totalLiquidVolume": 20, + "x": 23.38, + "y": 65.24, + "z": 25.49 + }, + "B3": { + "depth": 39.2, + "diameter": 3.27, + "shape": "circular", + "totalLiquidVolume": 20, + "x": 32.38, + "y": 65.24, + "z": 25.49 + }, + "B4": { + "depth": 39.2, + "diameter": 3.27, + "shape": "circular", + "totalLiquidVolume": 20, + "x": 41.38, + "y": 65.24, + "z": 25.49 + }, + "B5": { + "depth": 39.2, + "diameter": 3.27, + "shape": "circular", + "totalLiquidVolume": 20, + "x": 50.38, + "y": 65.24, + "z": 25.49 + }, + "B6": { + "depth": 39.2, + "diameter": 3.27, + "shape": "circular", + "totalLiquidVolume": 20, + "x": 59.38, + "y": 65.24, + "z": 25.49 + }, + "B7": { + "depth": 39.2, + "diameter": 3.27, + "shape": "circular", + "totalLiquidVolume": 20, + "x": 68.38, + "y": 65.24, + "z": 25.49 + }, + "B8": { + "depth": 39.2, + "diameter": 3.27, + "shape": "circular", + "totalLiquidVolume": 20, + "x": 77.38, + "y": 65.24, + "z": 25.49 + }, + "B9": { + "depth": 39.2, + "diameter": 3.27, + "shape": "circular", + "totalLiquidVolume": 20, + "x": 86.38, + "y": 65.24, + "z": 25.49 + }, + "C1": { + "depth": 39.2, + "diameter": 3.27, + "shape": "circular", + "totalLiquidVolume": 20, + "x": 14.38, + "y": 56.24, + "z": 25.49 + }, + "C10": { + "depth": 39.2, + "diameter": 3.27, + "shape": "circular", + "totalLiquidVolume": 20, + "x": 95.38, + "y": 56.24, + "z": 25.49 + }, + "C11": { + "depth": 39.2, + "diameter": 3.27, + "shape": "circular", + "totalLiquidVolume": 20, + "x": 104.38, + "y": 56.24, + "z": 25.49 + }, + "C12": { + "depth": 39.2, + "diameter": 3.27, + "shape": "circular", + "totalLiquidVolume": 20, + "x": 113.38, + "y": 56.24, + "z": 25.49 + }, + "C2": { + "depth": 39.2, + "diameter": 3.27, + "shape": "circular", + "totalLiquidVolume": 20, + "x": 23.38, + "y": 56.24, + "z": 25.49 + }, + "C3": { + "depth": 39.2, + "diameter": 3.27, + "shape": "circular", + "totalLiquidVolume": 20, + "x": 32.38, + "y": 56.24, + "z": 25.49 + }, + "C4": { + "depth": 39.2, + "diameter": 3.27, + "shape": "circular", + "totalLiquidVolume": 20, + "x": 41.38, + "y": 56.24, + "z": 25.49 + }, + "C5": { + "depth": 39.2, + "diameter": 3.27, + "shape": "circular", + "totalLiquidVolume": 20, + "x": 50.38, + "y": 56.24, + "z": 25.49 + }, + "C6": { + "depth": 39.2, + "diameter": 3.27, + "shape": "circular", + "totalLiquidVolume": 20, + "x": 59.38, + "y": 56.24, + "z": 25.49 + }, + "C7": { + "depth": 39.2, + "diameter": 3.27, + "shape": "circular", + "totalLiquidVolume": 20, + "x": 68.38, + "y": 56.24, + "z": 25.49 + }, + "C8": { + "depth": 39.2, + "diameter": 3.27, + "shape": "circular", + "totalLiquidVolume": 20, + "x": 77.38, + "y": 56.24, + "z": 25.49 + }, + "C9": { + "depth": 39.2, + "diameter": 3.27, + "shape": "circular", + "totalLiquidVolume": 20, + "x": 86.38, + "y": 56.24, + "z": 25.49 + }, + "D1": { + "depth": 39.2, + "diameter": 3.27, + "shape": "circular", + "totalLiquidVolume": 20, + "x": 14.38, + "y": 47.24, + "z": 25.49 + }, + "D10": { + "depth": 39.2, + "diameter": 3.27, + "shape": "circular", + "totalLiquidVolume": 20, + "x": 95.38, + "y": 47.24, + "z": 25.49 + }, + "D11": { + "depth": 39.2, + "diameter": 3.27, + "shape": "circular", + "totalLiquidVolume": 20, + "x": 104.38, + "y": 47.24, + "z": 25.49 + }, + "D12": { + "depth": 39.2, + "diameter": 3.27, + "shape": "circular", + "totalLiquidVolume": 20, + "x": 113.38, + "y": 47.24, + "z": 25.49 + }, + "D2": { + "depth": 39.2, + "diameter": 3.27, + "shape": "circular", + "totalLiquidVolume": 20, + "x": 23.38, + "y": 47.24, + "z": 25.49 + }, + "D3": { + "depth": 39.2, + "diameter": 3.27, + "shape": "circular", + "totalLiquidVolume": 20, + "x": 32.38, + "y": 47.24, + "z": 25.49 + }, + "D4": { + "depth": 39.2, + "diameter": 3.27, + "shape": "circular", + "totalLiquidVolume": 20, + "x": 41.38, + "y": 47.24, + "z": 25.49 + }, + "D5": { + "depth": 39.2, + "diameter": 3.27, + "shape": "circular", + "totalLiquidVolume": 20, + "x": 50.38, + "y": 47.24, + "z": 25.49 + }, + "D6": { + "depth": 39.2, + "diameter": 3.27, + "shape": "circular", + "totalLiquidVolume": 20, + "x": 59.38, + "y": 47.24, + "z": 25.49 + }, + "D7": { + "depth": 39.2, + "diameter": 3.27, + "shape": "circular", + "totalLiquidVolume": 20, + "x": 68.38, + "y": 47.24, + "z": 25.49 + }, + "D8": { + "depth": 39.2, + "diameter": 3.27, + "shape": "circular", + "totalLiquidVolume": 20, + "x": 77.38, + "y": 47.24, + "z": 25.49 + }, + "D9": { + "depth": 39.2, + "diameter": 3.27, + "shape": "circular", + "totalLiquidVolume": 20, + "x": 86.38, + "y": 47.24, + "z": 25.49 + }, + "E1": { + "depth": 39.2, + "diameter": 3.27, + "shape": "circular", + "totalLiquidVolume": 20, + "x": 14.38, + "y": 38.24, + "z": 25.49 + }, + "E10": { + "depth": 39.2, + "diameter": 3.27, + "shape": "circular", + "totalLiquidVolume": 20, + "x": 95.38, + "y": 38.24, + "z": 25.49 + }, + "E11": { + "depth": 39.2, + "diameter": 3.27, + "shape": "circular", + "totalLiquidVolume": 20, + "x": 104.38, + "y": 38.24, + "z": 25.49 + }, + "E12": { + "depth": 39.2, + "diameter": 3.27, + "shape": "circular", + "totalLiquidVolume": 20, + "x": 113.38, + "y": 38.24, + "z": 25.49 + }, + "E2": { + "depth": 39.2, + "diameter": 3.27, + "shape": "circular", + "totalLiquidVolume": 20, + "x": 23.38, + "y": 38.24, + "z": 25.49 + }, + "E3": { + "depth": 39.2, + "diameter": 3.27, + "shape": "circular", + "totalLiquidVolume": 20, + "x": 32.38, + "y": 38.24, + "z": 25.49 + }, + "E4": { + "depth": 39.2, + "diameter": 3.27, + "shape": "circular", + "totalLiquidVolume": 20, + "x": 41.38, + "y": 38.24, + "z": 25.49 + }, + "E5": { + "depth": 39.2, + "diameter": 3.27, + "shape": "circular", + "totalLiquidVolume": 20, + "x": 50.38, + "y": 38.24, + "z": 25.49 + }, + "E6": { + "depth": 39.2, + "diameter": 3.27, + "shape": "circular", + "totalLiquidVolume": 20, + "x": 59.38, + "y": 38.24, + "z": 25.49 + }, + "E7": { + "depth": 39.2, + "diameter": 3.27, + "shape": "circular", + "totalLiquidVolume": 20, + "x": 68.38, + "y": 38.24, + "z": 25.49 + }, + "E8": { + "depth": 39.2, + "diameter": 3.27, + "shape": "circular", + "totalLiquidVolume": 20, + "x": 77.38, + "y": 38.24, + "z": 25.49 + }, + "E9": { + "depth": 39.2, + "diameter": 3.27, + "shape": "circular", + "totalLiquidVolume": 20, + "x": 86.38, + "y": 38.24, + "z": 25.49 + }, + "F1": { + "depth": 39.2, + "diameter": 3.27, + "shape": "circular", + "totalLiquidVolume": 20, + "x": 14.38, + "y": 29.24, + "z": 25.49 + }, + "F10": { + "depth": 39.2, + "diameter": 3.27, + "shape": "circular", + "totalLiquidVolume": 20, + "x": 95.38, + "y": 29.24, + "z": 25.49 + }, + "F11": { + "depth": 39.2, + "diameter": 3.27, + "shape": "circular", + "totalLiquidVolume": 20, + "x": 104.38, + "y": 29.24, + "z": 25.49 + }, + "F12": { + "depth": 39.2, + "diameter": 3.27, + "shape": "circular", + "totalLiquidVolume": 20, + "x": 113.38, + "y": 29.24, + "z": 25.49 + }, + "F2": { + "depth": 39.2, + "diameter": 3.27, + "shape": "circular", + "totalLiquidVolume": 20, + "x": 23.38, + "y": 29.24, + "z": 25.49 + }, + "F3": { + "depth": 39.2, + "diameter": 3.27, + "shape": "circular", + "totalLiquidVolume": 20, + "x": 32.38, + "y": 29.24, + "z": 25.49 + }, + "F4": { + "depth": 39.2, + "diameter": 3.27, + "shape": "circular", + "totalLiquidVolume": 20, + "x": 41.38, + "y": 29.24, + "z": 25.49 + }, + "F5": { + "depth": 39.2, + "diameter": 3.27, + "shape": "circular", + "totalLiquidVolume": 20, + "x": 50.38, + "y": 29.24, + "z": 25.49 + }, + "F6": { + "depth": 39.2, + "diameter": 3.27, + "shape": "circular", + "totalLiquidVolume": 20, + "x": 59.38, + "y": 29.24, + "z": 25.49 + }, + "F7": { + "depth": 39.2, + "diameter": 3.27, + "shape": "circular", + "totalLiquidVolume": 20, + "x": 68.38, + "y": 29.24, + "z": 25.49 + }, + "F8": { + "depth": 39.2, + "diameter": 3.27, + "shape": "circular", + "totalLiquidVolume": 20, + "x": 77.38, + "y": 29.24, + "z": 25.49 + }, + "F9": { + "depth": 39.2, + "diameter": 3.27, + "shape": "circular", + "totalLiquidVolume": 20, + "x": 86.38, + "y": 29.24, + "z": 25.49 + }, + "G1": { + "depth": 39.2, + "diameter": 3.27, + "shape": "circular", + "totalLiquidVolume": 20, + "x": 14.38, + "y": 20.24, + "z": 25.49 + }, + "G10": { + "depth": 39.2, + "diameter": 3.27, + "shape": "circular", + "totalLiquidVolume": 20, + "x": 95.38, + "y": 20.24, + "z": 25.49 + }, + "G11": { + "depth": 39.2, + "diameter": 3.27, + "shape": "circular", + "totalLiquidVolume": 20, + "x": 104.38, + "y": 20.24, + "z": 25.49 + }, + "G12": { + "depth": 39.2, + "diameter": 3.27, + "shape": "circular", + "totalLiquidVolume": 20, + "x": 113.38, + "y": 20.24, + "z": 25.49 + }, + "G2": { + "depth": 39.2, + "diameter": 3.27, + "shape": "circular", + "totalLiquidVolume": 20, + "x": 23.38, + "y": 20.24, + "z": 25.49 + }, + "G3": { + "depth": 39.2, + "diameter": 3.27, + "shape": "circular", + "totalLiquidVolume": 20, + "x": 32.38, + "y": 20.24, + "z": 25.49 + }, + "G4": { + "depth": 39.2, + "diameter": 3.27, + "shape": "circular", + "totalLiquidVolume": 20, + "x": 41.38, + "y": 20.24, + "z": 25.49 + }, + "G5": { + "depth": 39.2, + "diameter": 3.27, + "shape": "circular", + "totalLiquidVolume": 20, + "x": 50.38, + "y": 20.24, + "z": 25.49 + }, + "G6": { + "depth": 39.2, + "diameter": 3.27, + "shape": "circular", + "totalLiquidVolume": 20, + "x": 59.38, + "y": 20.24, + "z": 25.49 + }, + "G7": { + "depth": 39.2, + "diameter": 3.27, + "shape": "circular", + "totalLiquidVolume": 20, + "x": 68.38, + "y": 20.24, + "z": 25.49 + }, + "G8": { + "depth": 39.2, + "diameter": 3.27, + "shape": "circular", + "totalLiquidVolume": 20, + "x": 77.38, + "y": 20.24, + "z": 25.49 + }, + "G9": { + "depth": 39.2, + "diameter": 3.27, + "shape": "circular", + "totalLiquidVolume": 20, + "x": 86.38, + "y": 20.24, + "z": 25.49 + }, + "H1": { + "depth": 39.2, + "diameter": 3.27, + "shape": "circular", + "totalLiquidVolume": 20, + "x": 14.38, + "y": 11.24, + "z": 25.49 + }, + "H10": { + "depth": 39.2, + "diameter": 3.27, + "shape": "circular", + "totalLiquidVolume": 20, + "x": 95.38, + "y": 11.24, + "z": 25.49 + }, + "H11": { + "depth": 39.2, + "diameter": 3.27, + "shape": "circular", + "totalLiquidVolume": 20, + "x": 104.38, + "y": 11.24, + "z": 25.49 + }, + "H12": { + "depth": 39.2, + "diameter": 3.27, + "shape": "circular", + "totalLiquidVolume": 20, + "x": 113.38, + "y": 11.24, + "z": 25.49 + }, + "H2": { + "depth": 39.2, + "diameter": 3.27, + "shape": "circular", + "totalLiquidVolume": 20, + "x": 23.38, + "y": 11.24, + "z": 25.49 + }, + "H3": { + "depth": 39.2, + "diameter": 3.27, + "shape": "circular", + "totalLiquidVolume": 20, + "x": 32.38, + "y": 11.24, + "z": 25.49 + }, + "H4": { + "depth": 39.2, + "diameter": 3.27, + "shape": "circular", + "totalLiquidVolume": 20, + "x": 41.38, + "y": 11.24, + "z": 25.49 + }, + "H5": { + "depth": 39.2, + "diameter": 3.27, + "shape": "circular", + "totalLiquidVolume": 20, + "x": 50.38, + "y": 11.24, + "z": 25.49 + }, + "H6": { + "depth": 39.2, + "diameter": 3.27, + "shape": "circular", + "totalLiquidVolume": 20, + "x": 59.38, + "y": 11.24, + "z": 25.49 + }, + "H7": { + "depth": 39.2, + "diameter": 3.27, + "shape": "circular", + "totalLiquidVolume": 20, + "x": 68.38, + "y": 11.24, + "z": 25.49 + }, + "H8": { + "depth": 39.2, + "diameter": 3.27, + "shape": "circular", + "totalLiquidVolume": 20, + "x": 77.38, + "y": 11.24, + "z": 25.49 + }, + "H9": { + "depth": 39.2, + "diameter": 3.27, + "shape": "circular", + "totalLiquidVolume": 20, + "x": 86.38, + "y": 11.24, + "z": 25.49 + } + } + } + }, + "status": "succeeded" + }, + { + "commandType": "loadPipette", + "notes": [], + "params": { + "mount": "left", + "pipetteName": "p300_multi_gen2" + }, + "result": {}, + "status": "succeeded" + }, + { + "commandType": "loadPipette", + "notes": [], + "params": { + "mount": "right", + "pipetteName": "p20_single_gen2" + }, + "result": {}, + "status": "succeeded" + }, + { + "commandType": "loadModule", + "notes": [], + "params": { + "location": { + "slotName": "1" + }, + "model": "heaterShakerModuleV1" + }, + "result": { + "definition": { + "calibrationPoint": { + "x": 12.0, + "y": 8.75, + "z": 68.275 + }, + "compatibleWith": [], + "dimensions": { + "bareOverallHeight": 82.0, + "overLabwareHeight": 0.0 + }, + "displayName": "Heater-Shaker Module GEN1", + "gripperOffsets": { + "default": { + "dropOffset": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + }, + "pickUpOffset": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + }, + "labwareOffset": { + "x": -0.125, + "y": 1.125, + "z": 68.275 + }, + "model": "heaterShakerModuleV1", + "moduleType": "heaterShakerModuleType", + "otSharedSchema": "module/schemas/2", + "quirks": [], + "slotTransforms": { + "ot2_short_trash": { + "3": { + "labwareOffset": [ + [ + -1, + 0, + 0, + 0 + ], + [ + -1, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 1 + ], + [ + 0, + 0, + 0, + 1 + ] + ] + }, + "6": { + "labwareOffset": [ + [ + -1, + 0, + 0, + 0 + ], + [ + -1, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 1 + ], + [ + 0, + 0, + 0, + 1 + ] + ] + }, + "9": { + "labwareOffset": [ + [ + -1, + 0, + 0, + 0 + ], + [ + -1, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 1 + ], + [ + 0, + 0, + 0, + 1 + ] + ] + } + }, + "ot2_standard": { + "3": { + "labwareOffset": [ + [ + -1, + 0, + 0, + 0 + ], + [ + -1, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 1 + ], + [ + 0, + 0, + 0, + 1 + ] + ] + }, + "6": { + "labwareOffset": [ + [ + -1, + 0, + 0, + 0 + ], + [ + -1, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 1 + ], + [ + 0, + 0, + 0, + 1 + ] + ] + }, + "9": { + "labwareOffset": [ + [ + -1, + 0, + 0, + 0 + ], + [ + -1, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 1 + ], + [ + 0, + 0, + 0, + 1 + ] + ] + } + }, + "ot3_standard": { + "A1": { + "labwareOffset": [ + [ + -49.325, + 0, + 0, + 1 + ], + [ + -1.125, + 0, + 0, + 1 + ], + [ + 0, + 0, + 0, + 1 + ], + [ + 0, + 0, + 0.125, + 1 + ] + ] + }, + "A3": { + "labwareOffset": [ + [ + -49.325, + 0, + 0, + 1 + ], + [ + -1.125, + 0, + 0, + 1 + ], + [ + 0, + 0, + 0, + 1 + ], + [ + 0, + 0, + 0.125, + 1 + ] + ] + }, + "B1": { + "labwareOffset": [ + [ + -49.325, + 0, + 0, + 1 + ], + [ + -1.125, + 0, + 0, + 1 + ], + [ + 0, + 0, + 0, + 1 + ], + [ + 0, + 0, + 0.125, + 1 + ] + ] + }, + "B3": { + "labwareOffset": [ + [ + -49.325, + 0, + 0, + 1 + ], + [ + -1.125, + 0, + 0, + 1 + ], + [ + 0, + 0, + 0, + 1 + ], + [ + 0, + 0, + 0.125, + 1 + ] + ] + }, + "C1": { + "labwareOffset": [ + [ + -49.325, + 0, + 0, + 1 + ], + [ + -1.125, + 0, + 0, + 1 + ], + [ + 0, + 0, + 0, + 1 + ], + [ + 0, + 0, + 0.125, + 1 + ] + ] + }, + "C3": { + "labwareOffset": [ + [ + -49.325, + 0, + 0, + 1 + ], + [ + -1.125, + 0, + 0, + 1 + ], + [ + 0, + 0, + 0, + 1 + ], + [ + 0, + 0, + 0.125, + 1 + ] + ] + }, + "D1": { + "labwareOffset": [ + [ + -49.325, + 0, + 0, + 1 + ], + [ + -1.125, + 0, + 0, + 1 + ], + [ + 0, + 0, + 0, + 1 + ], + [ + 0, + 0, + 0.125, + 1 + ] + ] + }, + "D3": { + "labwareOffset": [ + [ + -49.325, + 0, + 0, + 1 + ], + [ + -1.125, + 0, + 0, + 1 + ], + [ + 0, + 0, + 0, + 1 + ], + [ + 0, + 0, + 0.125, + 1 + ] + ] + } + } + } + }, + "model": "heaterShakerModuleV1" + }, + "status": "succeeded" + }, + { + "commandType": "loadModule", + "notes": [], + "params": { + "location": { + "slotName": "9" + }, + "model": "temperatureModuleV2" + }, + "result": { + "definition": { + "calibrationPoint": { + "x": 11.7, + "y": 8.75, + "z": 80.09 + }, + "compatibleWith": [ + "temperatureModuleV1" + ], + "dimensions": { + "bareOverallHeight": 84.0, + "overLabwareHeight": 0.0 + }, + "displayName": "Temperature Module GEN2", + "gripperOffsets": { + "default": { + "dropOffset": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + }, + "pickUpOffset": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + }, + "labwareOffset": { + "x": -1.45, + "y": -0.15, + "z": 80.09 + }, + "model": "temperatureModuleV2", + "moduleType": "temperatureModuleType", + "otSharedSchema": "module/schemas/2", + "quirks": [], + "slotTransforms": { + "ot2_short_trash": { + "3": { + "labwareOffset": [ + [ + -1, + -0.15, + 0, + 0 + ], + [ + 0, + 0, + 0, + 1 + ], + [ + 0, + 0, + 0, + 1 + ], + [ + 0, + 0, + 0, + 1 + ] + ] + }, + "6": { + "labwareOffset": [ + [ + -1, + -0.15, + 0, + 0 + ], + [ + 0, + 0, + 0, + 1 + ], + [ + 0, + 0, + 0, + 1 + ], + [ + 0, + 0, + 0, + 1 + ] + ] + }, + "9": { + "labwareOffset": [ + [ + -1, + -0.15, + 0, + 0 + ], + [ + 0, + 0, + 0, + 1 + ], + [ + 0, + 0, + 0, + 1 + ], + [ + 0, + 0, + 0, + 1 + ] + ] + } + }, + "ot2_standard": { + "3": { + "labwareOffset": [ + [ + -1, + -0.3, + 0, + 0 + ], + [ + 0, + 0, + 0, + 1 + ], + [ + 0, + 0, + 0, + 1 + ], + [ + 0, + 0, + 0, + 1 + ] + ] + }, + "6": { + "labwareOffset": [ + [ + -1, + -0.3, + 0, + 0 + ], + [ + 0, + 0, + 0, + 1 + ], + [ + 0, + 0, + 0, + 1 + ], + [ + 0, + 0, + 0, + 1 + ] + ] + }, + "9": { + "labwareOffset": [ + [ + -1, + -0.3, + 0, + 0 + ], + [ + 0, + 0, + 0, + 1 + ], + [ + 0, + 0, + 0, + 1 + ], + [ + 0, + 0, + 0, + 1 + ] + ] + } + }, + "ot3_standard": { + "A1": { + "labwareOffset": [ + [ + -71.09, + 0, + 0, + 1 + ], + [ + 0, + 0, + 0, + 1 + ], + [ + 0, + 0, + 0.15, + 1 + ], + [ + 0, + 0, + 1, + 1.45 + ] + ] + }, + "A3": { + "labwareOffset": [ + [ + -71.09, + 0, + 0, + 1 + ], + [ + 0, + 0, + 0, + 1 + ], + [ + 0, + 0, + 0.15, + 1 + ], + [ + 0, + 0, + 1, + 1.45 + ] + ] + }, + "B1": { + "labwareOffset": [ + [ + -71.09, + 0, + 0, + 1 + ], + [ + 0, + 0, + 0, + 1 + ], + [ + 0, + 0, + 0.15, + 1 + ], + [ + 0, + 0, + 1, + 1.45 + ] + ] + }, + "B3": { + "labwareOffset": [ + [ + -71.09, + 0, + 0, + 1 + ], + [ + 0, + 0, + 0, + 1 + ], + [ + 0, + 0, + 0.15, + 1 + ], + [ + 0, + 0, + 1, + 1.45 + ] + ] + }, + "C1": { + "labwareOffset": [ + [ + -71.09, + 0, + 0, + 1 + ], + [ + 0, + 0, + 0, + 1 + ], + [ + 0, + 0, + 0.15, + 1 + ], + [ + 0, + 0, + 1, + 1.45 + ] + ] + }, + "C3": { + "labwareOffset": [ + [ + -71.09, + 0, + 0, + 1 + ], + [ + 0, + 0, + 0, + 1 + ], + [ + 0, + 0, + 0.15, + 1 + ], + [ + 0, + 0, + 1, + 1.45 + ] + ] + }, + "D1": { + "labwareOffset": [ + [ + -71.09, + 0, + 0, + 1 + ], + [ + 0, + 0, + 0, + 1 + ], + [ + 0, + 0, + 0.15, + 1 + ], + [ + 0, + 0, + 1, + 1.45 + ] + ] + }, + "D3": { + "labwareOffset": [ + [ + -71.09, + 0, + 0, + 1 + ], + [ + 0, + 0, + 0, + 1 + ], + [ + 0, + 0, + 0.15, + 1 + ], + [ + 0, + 0, + 1, + 1.45 + ] + ] + } + } + } + }, + "model": "temperatureModuleV2" + }, + "status": "succeeded" + }, + { + "commandType": "loadModule", + "notes": [], + "params": { + "location": { + "slotName": "7" + }, + "model": "thermocyclerModuleV2" + }, + "result": { + "definition": { + "calibrationPoint": { + "x": 14.4, + "y": 64.93, + "z": 97.8 + }, + "compatibleWith": [], + "dimensions": { + "bareOverallHeight": 108.96, + "lidHeight": 61.7, + "overLabwareHeight": 0.0 + }, + "displayName": "Thermocycler Module GEN2", + "gripperOffsets": { + "default": { + "dropOffset": { + "x": 0.0, + "y": 0.0, + "z": 5.6 + }, + "pickUpOffset": { + "x": 0.0, + "y": 0.0, + "z": 4.6 + } + } + }, + "labwareOffset": { + "x": 0.0, + "y": 68.8, + "z": 108.96 + }, + "model": "thermocyclerModuleV2", + "moduleType": "thermocyclerModuleType", + "otSharedSchema": "module/schemas/2", + "quirks": [], + "slotTransforms": { + "ot3_standard": { + "B1": { + "cornerOffsetFromSlot": [ + [ + -98, + 0, + 0, + 1 + ], + [ + -20.005, + 0, + 0, + 1 + ], + [ + -0.84, + 0, + 0, + 1 + ], + [ + 0, + 0, + 0, + 1 + ] + ], + "labwareOffset": [ + [ + -98, + 0, + 0, + 1 + ], + [ + -20.005, + 0, + 0, + 1 + ], + [ + -0.84, + 0, + 0, + 1 + ], + [ + 0, + 0, + 0, + 1 + ] + ] + } + } + } + }, + "model": "thermocyclerModuleV2" + }, + "status": "succeeded" + }, + { + "commandType": "loadLabware", + "notes": [], + "params": { + "loadName": "opentrons_96_well_aluminum_block", + "location": {}, + "namespace": "opentrons", + "version": 1 + }, + "result": { + "definition": { + "allowedRoles": [ + "adapter" + ], + "brand": { + "brand": "Opentrons", + "brandId": [] + }, + "cornerOffsetFromSlot": { + "x": 0, + "y": 0, + "z": 0 + }, + "dimensions": { + "xDimension": 127.76, + "yDimension": 85.48, + "zDimension": 18.16 + }, + "gripperOffsets": { + "default": { + "dropOffset": { + "x": 0, + "y": 0, + "z": 1.0 + }, + "pickUpOffset": { + "x": 0, + "y": 0, + "z": 0 + } + } + }, + "groups": [ + { + "metadata": { + "wellBottomShape": "v" + }, + "wells": [ + "A1", + "A10", + "A11", + "A12", + "A2", + "A3", + "A4", + "A5", + "A6", + "A7", + "A8", + "A9", + "B1", + "B10", + "B11", + "B12", + "B2", + "B3", + "B4", + "B5", + "B6", + "B7", + "B8", + "B9", + "C1", + "C10", + "C11", + "C12", + "C2", + "C3", + "C4", + "C5", + "C6", + "C7", + "C8", + "C9", + "D1", + "D10", + "D11", + "D12", + "D2", + "D3", + "D4", + "D5", + "D6", + "D7", + "D8", + "D9", + "E1", + "E10", + "E11", + "E12", + "E2", + "E3", + "E4", + "E5", + "E6", + "E7", + "E8", + "E9", + "F1", + "F10", + "F11", + "F12", + "F2", + "F3", + "F4", + "F5", + "F6", + "F7", + "F8", + "F9", + "G1", + "G10", + "G11", + "G12", + "G2", + "G3", + "G4", + "G5", + "G6", + "G7", + "G8", + "G9", + "H1", + "H10", + "H11", + "H12", + "H2", + "H3", + "H4", + "H5", + "H6", + "H7", + "H8", + "H9" + ] + } + ], + "metadata": { + "displayCategory": "adapter", + "displayName": "Opentrons 96 Well Aluminum Block", + "displayVolumeUnits": "µL", + "tags": [] + }, + "namespace": "opentrons", + "ordering": [ + [ + "A1", + "B1", + "C1", + "D1", + "E1", + "F1", + "G1", + "H1" + ], + [ + "A10", + "B10", + "C10", + "D10", + "E10", + "F10", + "G10", + "H10" + ], + [ + "A11", + "B11", + "C11", + "D11", + "E11", + "F11", + "G11", + "H11" + ], + [ + "A12", + "B12", + "C12", + "D12", + "E12", + "F12", + "G12", + "H12" + ], + [ + "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" + ] + ], + "parameters": { + "format": "96Standard", + "isMagneticModuleCompatible": false, + "isTiprack": false, + "loadName": "opentrons_96_well_aluminum_block", + "quirks": [] + }, + "schemaVersion": 2, + "stackingOffsetWithLabware": {}, + "stackingOffsetWithModule": {}, + "version": 1, + "wells": { + "A1": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 14.38, + "y": 74.24, + "z": 3.38 + }, + "A10": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 95.38, + "y": 74.24, + "z": 3.38 + }, + "A11": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 104.38, + "y": 74.24, + "z": 3.38 + }, + "A12": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 113.38, + "y": 74.24, + "z": 3.38 + }, + "A2": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 23.38, + "y": 74.24, + "z": 3.38 + }, + "A3": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 32.38, + "y": 74.24, + "z": 3.38 + }, + "A4": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 41.38, + "y": 74.24, + "z": 3.38 + }, + "A5": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 50.38, + "y": 74.24, + "z": 3.38 + }, + "A6": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 59.38, + "y": 74.24, + "z": 3.38 + }, + "A7": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 68.38, + "y": 74.24, + "z": 3.38 + }, + "A8": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 77.38, + "y": 74.24, + "z": 3.38 + }, + "A9": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 86.38, + "y": 74.24, + "z": 3.38 + }, + "B1": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 14.38, + "y": 65.24, + "z": 3.38 + }, + "B10": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 95.38, + "y": 65.24, + "z": 3.38 + }, + "B11": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 104.38, + "y": 65.24, + "z": 3.38 + }, + "B12": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 113.38, + "y": 65.24, + "z": 3.38 + }, + "B2": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 23.38, + "y": 65.24, + "z": 3.38 + }, + "B3": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 32.38, + "y": 65.24, + "z": 3.38 + }, + "B4": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 41.38, + "y": 65.24, + "z": 3.38 + }, + "B5": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 50.38, + "y": 65.24, + "z": 3.38 + }, + "B6": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 59.38, + "y": 65.24, + "z": 3.38 + }, + "B7": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 68.38, + "y": 65.24, + "z": 3.38 + }, + "B8": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 77.38, + "y": 65.24, + "z": 3.38 + }, + "B9": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 86.38, + "y": 65.24, + "z": 3.38 + }, + "C1": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 14.38, + "y": 56.24, + "z": 3.38 + }, + "C10": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 95.38, + "y": 56.24, + "z": 3.38 + }, + "C11": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 104.38, + "y": 56.24, + "z": 3.38 + }, + "C12": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 113.38, + "y": 56.24, + "z": 3.38 + }, + "C2": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 23.38, + "y": 56.24, + "z": 3.38 + }, + "C3": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 32.38, + "y": 56.24, + "z": 3.38 + }, + "C4": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 41.38, + "y": 56.24, + "z": 3.38 + }, + "C5": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 50.38, + "y": 56.24, + "z": 3.38 + }, + "C6": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 59.38, + "y": 56.24, + "z": 3.38 + }, + "C7": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 68.38, + "y": 56.24, + "z": 3.38 + }, + "C8": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 77.38, + "y": 56.24, + "z": 3.38 + }, + "C9": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 86.38, + "y": 56.24, + "z": 3.38 + }, + "D1": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 14.38, + "y": 47.24, + "z": 3.38 + }, + "D10": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 95.38, + "y": 47.24, + "z": 3.38 + }, + "D11": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 104.38, + "y": 47.24, + "z": 3.38 + }, + "D12": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 113.38, + "y": 47.24, + "z": 3.38 + }, + "D2": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 23.38, + "y": 47.24, + "z": 3.38 + }, + "D3": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 32.38, + "y": 47.24, + "z": 3.38 + }, + "D4": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 41.38, + "y": 47.24, + "z": 3.38 + }, + "D5": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 50.38, + "y": 47.24, + "z": 3.38 + }, + "D6": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 59.38, + "y": 47.24, + "z": 3.38 + }, + "D7": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 68.38, + "y": 47.24, + "z": 3.38 + }, + "D8": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 77.38, + "y": 47.24, + "z": 3.38 + }, + "D9": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 86.38, + "y": 47.24, + "z": 3.38 + }, + "E1": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 14.38, + "y": 38.24, + "z": 3.38 + }, + "E10": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 95.38, + "y": 38.24, + "z": 3.38 + }, + "E11": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 104.38, + "y": 38.24, + "z": 3.38 + }, + "E12": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 113.38, + "y": 38.24, + "z": 3.38 + }, + "E2": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 23.38, + "y": 38.24, + "z": 3.38 + }, + "E3": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 32.38, + "y": 38.24, + "z": 3.38 + }, + "E4": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 41.38, + "y": 38.24, + "z": 3.38 + }, + "E5": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 50.38, + "y": 38.24, + "z": 3.38 + }, + "E6": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 59.38, + "y": 38.24, + "z": 3.38 + }, + "E7": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 68.38, + "y": 38.24, + "z": 3.38 + }, + "E8": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 77.38, + "y": 38.24, + "z": 3.38 + }, + "E9": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 86.38, + "y": 38.24, + "z": 3.38 + }, + "F1": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 14.38, + "y": 29.24, + "z": 3.38 + }, + "F10": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 95.38, + "y": 29.24, + "z": 3.38 + }, + "F11": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 104.38, + "y": 29.24, + "z": 3.38 + }, + "F12": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 113.38, + "y": 29.24, + "z": 3.38 + }, + "F2": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 23.38, + "y": 29.24, + "z": 3.38 + }, + "F3": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 32.38, + "y": 29.24, + "z": 3.38 + }, + "F4": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 41.38, + "y": 29.24, + "z": 3.38 + }, + "F5": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 50.38, + "y": 29.24, + "z": 3.38 + }, + "F6": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 59.38, + "y": 29.24, + "z": 3.38 + }, + "F7": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 68.38, + "y": 29.24, + "z": 3.38 + }, + "F8": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 77.38, + "y": 29.24, + "z": 3.38 + }, + "F9": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 86.38, + "y": 29.24, + "z": 3.38 + }, + "G1": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 14.38, + "y": 20.24, + "z": 3.38 + }, + "G10": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 95.38, + "y": 20.24, + "z": 3.38 + }, + "G11": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 104.38, + "y": 20.24, + "z": 3.38 + }, + "G12": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 113.38, + "y": 20.24, + "z": 3.38 + }, + "G2": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 23.38, + "y": 20.24, + "z": 3.38 + }, + "G3": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 32.38, + "y": 20.24, + "z": 3.38 + }, + "G4": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 41.38, + "y": 20.24, + "z": 3.38 + }, + "G5": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 50.38, + "y": 20.24, + "z": 3.38 + }, + "G6": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 59.38, + "y": 20.24, + "z": 3.38 + }, + "G7": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 68.38, + "y": 20.24, + "z": 3.38 + }, + "G8": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 77.38, + "y": 20.24, + "z": 3.38 + }, + "G9": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 86.38, + "y": 20.24, + "z": 3.38 + }, + "H1": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 14.38, + "y": 11.24, + "z": 3.38 + }, + "H10": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 95.38, + "y": 11.24, + "z": 3.38 + }, + "H11": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 104.38, + "y": 11.24, + "z": 3.38 + }, + "H12": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 113.38, + "y": 11.24, + "z": 3.38 + }, + "H2": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 23.38, + "y": 11.24, + "z": 3.38 + }, + "H3": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 32.38, + "y": 11.24, + "z": 3.38 + }, + "H4": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 41.38, + "y": 11.24, + "z": 3.38 + }, + "H5": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 50.38, + "y": 11.24, + "z": 3.38 + }, + "H6": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 59.38, + "y": 11.24, + "z": 3.38 + }, + "H7": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 68.38, + "y": 11.24, + "z": 3.38 + }, + "H8": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 77.38, + "y": 11.24, + "z": 3.38 + }, + "H9": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 86.38, + "y": 11.24, + "z": 3.38 + } + } + } + }, + "status": "succeeded" + }, + { + "commandType": "loadLabware", + "notes": [], + "params": { + "displayName": "Temperature-Controlled plate", + "loadName": "nest_96_wellplate_100ul_pcr_full_skirt", + "location": {}, + "namespace": "opentrons", + "version": 2 + }, + "result": { + "definition": { + "allowedRoles": [], + "brand": { + "brand": "NEST", + "brandId": [ + "402501" + ], + "links": [ + "https://www.nest-biotech.com/pcr-plates/58773587.html" + ] + }, + "cornerOffsetFromSlot": { + "x": 0, + "y": 0, + "z": 0 + }, + "dimensions": { + "xDimension": 127.76, + "yDimension": 85.48, + "zDimension": 15.7 + }, + "gripForce": 15.0, + "gripHeightFromLabwareBottom": 10.65, + "gripperOffsets": {}, + "groups": [ + { + "metadata": { + "wellBottomShape": "v" + }, + "wells": [ + "A1", + "A10", + "A11", + "A12", + "A2", + "A3", + "A4", + "A5", + "A6", + "A7", + "A8", + "A9", + "B1", + "B10", + "B11", + "B12", + "B2", + "B3", + "B4", + "B5", + "B6", + "B7", + "B8", + "B9", + "C1", + "C10", + "C11", + "C12", + "C2", + "C3", + "C4", + "C5", + "C6", + "C7", + "C8", + "C9", + "D1", + "D10", + "D11", + "D12", + "D2", + "D3", + "D4", + "D5", + "D6", + "D7", + "D8", + "D9", + "E1", + "E10", + "E11", + "E12", + "E2", + "E3", + "E4", + "E5", + "E6", + "E7", + "E8", + "E9", + "F1", + "F10", + "F11", + "F12", + "F2", + "F3", + "F4", + "F5", + "F6", + "F7", + "F8", + "F9", + "G1", + "G10", + "G11", + "G12", + "G2", + "G3", + "G4", + "G5", + "G6", + "G7", + "G8", + "G9", + "H1", + "H10", + "H11", + "H12", + "H2", + "H3", + "H4", + "H5", + "H6", + "H7", + "H8", + "H9" + ] + } + ], + "metadata": { + "displayCategory": "wellPlate", + "displayName": "NEST 96 Well Plate 100 µL PCR Full Skirt", + "displayVolumeUnits": "µL", + "tags": [] + }, + "namespace": "opentrons", + "ordering": [ + [ + "A1", + "B1", + "C1", + "D1", + "E1", + "F1", + "G1", + "H1" + ], + [ + "A10", + "B10", + "C10", + "D10", + "E10", + "F10", + "G10", + "H10" + ], + [ + "A11", + "B11", + "C11", + "D11", + "E11", + "F11", + "G11", + "H11" + ], + [ + "A12", + "B12", + "C12", + "D12", + "E12", + "F12", + "G12", + "H12" + ], + [ + "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" + ] + ], + "parameters": { + "format": "96Standard", + "isMagneticModuleCompatible": true, + "isTiprack": false, + "loadName": "nest_96_wellplate_100ul_pcr_full_skirt", + "magneticModuleEngageHeight": 20 + }, + "schemaVersion": 2, + "stackingOffsetWithLabware": { + "opentrons_96_pcr_adapter": { + "x": 0, + "y": 0, + "z": 10.2 + }, + "opentrons_96_well_aluminum_block": { + "x": 0, + "y": 0, + "z": 12.66 + } + }, + "stackingOffsetWithModule": { + "thermocyclerModuleV2": { + "x": 0, + "y": 0, + "z": 10.8 + } + }, + "version": 2, + "wells": { + "A1": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 14.38, + "y": 74.24, + "z": 0.92 + }, + "A10": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 95.38, + "y": 74.24, + "z": 0.92 + }, + "A11": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 104.38, + "y": 74.24, + "z": 0.92 + }, + "A12": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 113.38, + "y": 74.24, + "z": 0.92 + }, + "A2": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 23.38, + "y": 74.24, + "z": 0.92 + }, + "A3": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 32.38, + "y": 74.24, + "z": 0.92 + }, + "A4": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 41.38, + "y": 74.24, + "z": 0.92 + }, + "A5": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 50.38, + "y": 74.24, + "z": 0.92 + }, + "A6": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 59.38, + "y": 74.24, + "z": 0.92 + }, + "A7": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 68.38, + "y": 74.24, + "z": 0.92 + }, + "A8": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 77.38, + "y": 74.24, + "z": 0.92 + }, + "A9": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 86.38, + "y": 74.24, + "z": 0.92 + }, + "B1": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 14.38, + "y": 65.24, + "z": 0.92 + }, + "B10": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 95.38, + "y": 65.24, + "z": 0.92 + }, + "B11": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 104.38, + "y": 65.24, + "z": 0.92 + }, + "B12": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 113.38, + "y": 65.24, + "z": 0.92 + }, + "B2": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 23.38, + "y": 65.24, + "z": 0.92 + }, + "B3": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 32.38, + "y": 65.24, + "z": 0.92 + }, + "B4": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 41.38, + "y": 65.24, + "z": 0.92 + }, + "B5": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 50.38, + "y": 65.24, + "z": 0.92 + }, + "B6": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 59.38, + "y": 65.24, + "z": 0.92 + }, + "B7": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 68.38, + "y": 65.24, + "z": 0.92 + }, + "B8": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 77.38, + "y": 65.24, + "z": 0.92 + }, + "B9": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 86.38, + "y": 65.24, + "z": 0.92 + }, + "C1": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 14.38, + "y": 56.24, + "z": 0.92 + }, + "C10": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 95.38, + "y": 56.24, + "z": 0.92 + }, + "C11": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 104.38, + "y": 56.24, + "z": 0.92 + }, + "C12": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 113.38, + "y": 56.24, + "z": 0.92 + }, + "C2": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 23.38, + "y": 56.24, + "z": 0.92 + }, + "C3": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 32.38, + "y": 56.24, + "z": 0.92 + }, + "C4": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 41.38, + "y": 56.24, + "z": 0.92 + }, + "C5": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 50.38, + "y": 56.24, + "z": 0.92 + }, + "C6": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 59.38, + "y": 56.24, + "z": 0.92 + }, + "C7": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 68.38, + "y": 56.24, + "z": 0.92 + }, + "C8": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 77.38, + "y": 56.24, + "z": 0.92 + }, + "C9": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 86.38, + "y": 56.24, + "z": 0.92 + }, + "D1": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 14.38, + "y": 47.24, + "z": 0.92 + }, + "D10": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 95.38, + "y": 47.24, + "z": 0.92 + }, + "D11": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 104.38, + "y": 47.24, + "z": 0.92 + }, + "D12": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 113.38, + "y": 47.24, + "z": 0.92 + }, + "D2": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 23.38, + "y": 47.24, + "z": 0.92 + }, + "D3": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 32.38, + "y": 47.24, + "z": 0.92 + }, + "D4": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 41.38, + "y": 47.24, + "z": 0.92 + }, + "D5": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 50.38, + "y": 47.24, + "z": 0.92 + }, + "D6": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 59.38, + "y": 47.24, + "z": 0.92 + }, + "D7": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 68.38, + "y": 47.24, + "z": 0.92 + }, + "D8": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 77.38, + "y": 47.24, + "z": 0.92 + }, + "D9": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 86.38, + "y": 47.24, + "z": 0.92 + }, + "E1": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 14.38, + "y": 38.24, + "z": 0.92 + }, + "E10": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 95.38, + "y": 38.24, + "z": 0.92 + }, + "E11": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 104.38, + "y": 38.24, + "z": 0.92 + }, + "E12": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 113.38, + "y": 38.24, + "z": 0.92 + }, + "E2": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 23.38, + "y": 38.24, + "z": 0.92 + }, + "E3": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 32.38, + "y": 38.24, + "z": 0.92 + }, + "E4": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 41.38, + "y": 38.24, + "z": 0.92 + }, + "E5": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 50.38, + "y": 38.24, + "z": 0.92 + }, + "E6": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 59.38, + "y": 38.24, + "z": 0.92 + }, + "E7": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 68.38, + "y": 38.24, + "z": 0.92 + }, + "E8": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 77.38, + "y": 38.24, + "z": 0.92 + }, + "E9": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 86.38, + "y": 38.24, + "z": 0.92 + }, + "F1": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 14.38, + "y": 29.24, + "z": 0.92 + }, + "F10": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 95.38, + "y": 29.24, + "z": 0.92 + }, + "F11": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 104.38, + "y": 29.24, + "z": 0.92 + }, + "F12": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 113.38, + "y": 29.24, + "z": 0.92 + }, + "F2": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 23.38, + "y": 29.24, + "z": 0.92 + }, + "F3": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 32.38, + "y": 29.24, + "z": 0.92 + }, + "F4": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 41.38, + "y": 29.24, + "z": 0.92 + }, + "F5": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 50.38, + "y": 29.24, + "z": 0.92 + }, + "F6": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 59.38, + "y": 29.24, + "z": 0.92 + }, + "F7": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 68.38, + "y": 29.24, + "z": 0.92 + }, + "F8": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 77.38, + "y": 29.24, + "z": 0.92 + }, + "F9": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 86.38, + "y": 29.24, + "z": 0.92 + }, + "G1": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 14.38, + "y": 20.24, + "z": 0.92 + }, + "G10": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 95.38, + "y": 20.24, + "z": 0.92 + }, + "G11": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 104.38, + "y": 20.24, + "z": 0.92 + }, + "G12": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 113.38, + "y": 20.24, + "z": 0.92 + }, + "G2": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 23.38, + "y": 20.24, + "z": 0.92 + }, + "G3": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 32.38, + "y": 20.24, + "z": 0.92 + }, + "G4": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 41.38, + "y": 20.24, + "z": 0.92 + }, + "G5": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 50.38, + "y": 20.24, + "z": 0.92 + }, + "G6": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 59.38, + "y": 20.24, + "z": 0.92 + }, + "G7": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 68.38, + "y": 20.24, + "z": 0.92 + }, + "G8": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 77.38, + "y": 20.24, + "z": 0.92 + }, + "G9": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 86.38, + "y": 20.24, + "z": 0.92 + }, + "H1": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 14.38, + "y": 11.24, + "z": 0.92 + }, + "H10": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 95.38, + "y": 11.24, + "z": 0.92 + }, + "H11": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 104.38, + "y": 11.24, + "z": 0.92 + }, + "H12": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 113.38, + "y": 11.24, + "z": 0.92 + }, + "H2": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 23.38, + "y": 11.24, + "z": 0.92 + }, + "H3": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 32.38, + "y": 11.24, + "z": 0.92 + }, + "H4": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 41.38, + "y": 11.24, + "z": 0.92 + }, + "H5": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 50.38, + "y": 11.24, + "z": 0.92 + }, + "H6": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 59.38, + "y": 11.24, + "z": 0.92 + }, + "H7": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 68.38, + "y": 11.24, + "z": 0.92 + }, + "H8": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 77.38, + "y": 11.24, + "z": 0.92 + }, + "H9": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 86.38, + "y": 11.24, + "z": 0.92 + } + } + } + }, + "status": "succeeded" + }, + { + "commandType": "loadLabware", + "notes": [], + "params": { + "loadName": "opentrons_96_pcr_adapter", + "location": {}, + "namespace": "opentrons", + "version": 1 + }, + "result": { + "definition": { + "allowedRoles": [ + "adapter" + ], + "brand": { + "brand": "Opentrons", + "brandId": [] + }, + "cornerOffsetFromSlot": { + "x": 8.5, + "y": 5.5, + "z": 0 + }, + "dimensions": { + "xDimension": 111, + "yDimension": 75, + "zDimension": 13.85 + }, + "gripperOffsets": { + "default": { + "dropOffset": { + "x": 0, + "y": 0, + "z": 1.0 + }, + "pickUpOffset": { + "x": 0, + "y": 0, + "z": 0 + } + } + }, + "groups": [ + { + "metadata": { + "wellBottomShape": "v" + }, + "wells": [ + "A1", + "A10", + "A11", + "A12", + "A2", + "A3", + "A4", + "A5", + "A6", + "A7", + "A8", + "A9", + "B1", + "B10", + "B11", + "B12", + "B2", + "B3", + "B4", + "B5", + "B6", + "B7", + "B8", + "B9", + "C1", + "C10", + "C11", + "C12", + "C2", + "C3", + "C4", + "C5", + "C6", + "C7", + "C8", + "C9", + "D1", + "D10", + "D11", + "D12", + "D2", + "D3", + "D4", + "D5", + "D6", + "D7", + "D8", + "D9", + "E1", + "E10", + "E11", + "E12", + "E2", + "E3", + "E4", + "E5", + "E6", + "E7", + "E8", + "E9", + "F1", + "F10", + "F11", + "F12", + "F2", + "F3", + "F4", + "F5", + "F6", + "F7", + "F8", + "F9", + "G1", + "G10", + "G11", + "G12", + "G2", + "G3", + "G4", + "G5", + "G6", + "G7", + "G8", + "G9", + "H1", + "H10", + "H11", + "H12", + "H2", + "H3", + "H4", + "H5", + "H6", + "H7", + "H8", + "H9" + ] + } + ], + "metadata": { + "displayCategory": "adapter", + "displayName": "Opentrons 96 PCR Heater-Shaker Adapter", + "displayVolumeUnits": "µL", + "tags": [] + }, + "namespace": "opentrons", + "ordering": [ + [ + "A1", + "B1", + "C1", + "D1", + "E1", + "F1", + "G1", + "H1" + ], + [ + "A10", + "B10", + "C10", + "D10", + "E10", + "F10", + "G10", + "H10" + ], + [ + "A11", + "B11", + "C11", + "D11", + "E11", + "F11", + "G11", + "H11" + ], + [ + "A12", + "B12", + "C12", + "D12", + "E12", + "F12", + "G12", + "H12" + ], + [ + "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" + ] + ], + "parameters": { + "format": "96Standard", + "isMagneticModuleCompatible": false, + "isTiprack": false, + "loadName": "opentrons_96_pcr_adapter", + "quirks": [] + }, + "schemaVersion": 2, + "stackingOffsetWithLabware": {}, + "stackingOffsetWithModule": {}, + "version": 1, + "wells": { + "A1": { + "depth": 12, + "diameter": 5.64, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 6, + "y": 69, + "z": 1.85 + }, + "A10": { + "depth": 12, + "diameter": 5.64, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 87, + "y": 69, + "z": 1.85 + }, + "A11": { + "depth": 12, + "diameter": 5.64, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 96, + "y": 69, + "z": 1.85 + }, + "A12": { + "depth": 12, + "diameter": 5.64, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 105, + "y": 69, + "z": 1.85 + }, + "A2": { + "depth": 12, + "diameter": 5.64, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 15, + "y": 69, + "z": 1.85 + }, + "A3": { + "depth": 12, + "diameter": 5.64, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 24, + "y": 69, + "z": 1.85 + }, + "A4": { + "depth": 12, + "diameter": 5.64, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 33, + "y": 69, + "z": 1.85 + }, + "A5": { + "depth": 12, + "diameter": 5.64, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 42, + "y": 69, + "z": 1.85 + }, + "A6": { + "depth": 12, + "diameter": 5.64, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 51, + "y": 69, + "z": 1.85 + }, + "A7": { + "depth": 12, + "diameter": 5.64, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 60, + "y": 69, + "z": 1.85 + }, + "A8": { + "depth": 12, + "diameter": 5.64, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 69, + "y": 69, + "z": 1.85 + }, + "A9": { + "depth": 12, + "diameter": 5.64, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 78, + "y": 69, + "z": 1.85 + }, + "B1": { + "depth": 12, + "diameter": 5.64, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 6, + "y": 60, + "z": 1.85 + }, + "B10": { + "depth": 12, + "diameter": 5.64, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 87, + "y": 60, + "z": 1.85 + }, + "B11": { + "depth": 12, + "diameter": 5.64, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 96, + "y": 60, + "z": 1.85 + }, + "B12": { + "depth": 12, + "diameter": 5.64, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 105, + "y": 60, + "z": 1.85 + }, + "B2": { + "depth": 12, + "diameter": 5.64, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 15, + "y": 60, + "z": 1.85 + }, + "B3": { + "depth": 12, + "diameter": 5.64, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 24, + "y": 60, + "z": 1.85 + }, + "B4": { + "depth": 12, + "diameter": 5.64, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 33, + "y": 60, + "z": 1.85 + }, + "B5": { + "depth": 12, + "diameter": 5.64, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 42, + "y": 60, + "z": 1.85 + }, + "B6": { + "depth": 12, + "diameter": 5.64, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 51, + "y": 60, + "z": 1.85 + }, + "B7": { + "depth": 12, + "diameter": 5.64, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 60, + "y": 60, + "z": 1.85 + }, + "B8": { + "depth": 12, + "diameter": 5.64, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 69, + "y": 60, + "z": 1.85 + }, + "B9": { + "depth": 12, + "diameter": 5.64, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 78, + "y": 60, + "z": 1.85 + }, + "C1": { + "depth": 12, + "diameter": 5.64, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 6, + "y": 51, + "z": 1.85 + }, + "C10": { + "depth": 12, + "diameter": 5.64, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 87, + "y": 51, + "z": 1.85 + }, + "C11": { + "depth": 12, + "diameter": 5.64, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 96, + "y": 51, + "z": 1.85 + }, + "C12": { + "depth": 12, + "diameter": 5.64, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 105, + "y": 51, + "z": 1.85 + }, + "C2": { + "depth": 12, + "diameter": 5.64, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 15, + "y": 51, + "z": 1.85 + }, + "C3": { + "depth": 12, + "diameter": 5.64, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 24, + "y": 51, + "z": 1.85 + }, + "C4": { + "depth": 12, + "diameter": 5.64, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 33, + "y": 51, + "z": 1.85 + }, + "C5": { + "depth": 12, + "diameter": 5.64, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 42, + "y": 51, + "z": 1.85 + }, + "C6": { + "depth": 12, + "diameter": 5.64, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 51, + "y": 51, + "z": 1.85 + }, + "C7": { + "depth": 12, + "diameter": 5.64, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 60, + "y": 51, + "z": 1.85 + }, + "C8": { + "depth": 12, + "diameter": 5.64, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 69, + "y": 51, + "z": 1.85 + }, + "C9": { + "depth": 12, + "diameter": 5.64, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 78, + "y": 51, + "z": 1.85 + }, + "D1": { + "depth": 12, + "diameter": 5.64, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 6, + "y": 42, + "z": 1.85 + }, + "D10": { + "depth": 12, + "diameter": 5.64, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 87, + "y": 42, + "z": 1.85 + }, + "D11": { + "depth": 12, + "diameter": 5.64, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 96, + "y": 42, + "z": 1.85 + }, + "D12": { + "depth": 12, + "diameter": 5.64, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 105, + "y": 42, + "z": 1.85 + }, + "D2": { + "depth": 12, + "diameter": 5.64, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 15, + "y": 42, + "z": 1.85 + }, + "D3": { + "depth": 12, + "diameter": 5.64, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 24, + "y": 42, + "z": 1.85 + }, + "D4": { + "depth": 12, + "diameter": 5.64, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 33, + "y": 42, + "z": 1.85 + }, + "D5": { + "depth": 12, + "diameter": 5.64, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 42, + "y": 42, + "z": 1.85 + }, + "D6": { + "depth": 12, + "diameter": 5.64, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 51, + "y": 42, + "z": 1.85 + }, + "D7": { + "depth": 12, + "diameter": 5.64, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 60, + "y": 42, + "z": 1.85 + }, + "D8": { + "depth": 12, + "diameter": 5.64, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 69, + "y": 42, + "z": 1.85 + }, + "D9": { + "depth": 12, + "diameter": 5.64, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 78, + "y": 42, + "z": 1.85 + }, + "E1": { + "depth": 12, + "diameter": 5.64, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 6, + "y": 33, + "z": 1.85 + }, + "E10": { + "depth": 12, + "diameter": 5.64, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 87, + "y": 33, + "z": 1.85 + }, + "E11": { + "depth": 12, + "diameter": 5.64, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 96, + "y": 33, + "z": 1.85 + }, + "E12": { + "depth": 12, + "diameter": 5.64, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 105, + "y": 33, + "z": 1.85 + }, + "E2": { + "depth": 12, + "diameter": 5.64, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 15, + "y": 33, + "z": 1.85 + }, + "E3": { + "depth": 12, + "diameter": 5.64, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 24, + "y": 33, + "z": 1.85 + }, + "E4": { + "depth": 12, + "diameter": 5.64, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 33, + "y": 33, + "z": 1.85 + }, + "E5": { + "depth": 12, + "diameter": 5.64, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 42, + "y": 33, + "z": 1.85 + }, + "E6": { + "depth": 12, + "diameter": 5.64, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 51, + "y": 33, + "z": 1.85 + }, + "E7": { + "depth": 12, + "diameter": 5.64, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 60, + "y": 33, + "z": 1.85 + }, + "E8": { + "depth": 12, + "diameter": 5.64, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 69, + "y": 33, + "z": 1.85 + }, + "E9": { + "depth": 12, + "diameter": 5.64, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 78, + "y": 33, + "z": 1.85 + }, + "F1": { + "depth": 12, + "diameter": 5.64, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 6, + "y": 24, + "z": 1.85 + }, + "F10": { + "depth": 12, + "diameter": 5.64, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 87, + "y": 24, + "z": 1.85 + }, + "F11": { + "depth": 12, + "diameter": 5.64, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 96, + "y": 24, + "z": 1.85 + }, + "F12": { + "depth": 12, + "diameter": 5.64, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 105, + "y": 24, + "z": 1.85 + }, + "F2": { + "depth": 12, + "diameter": 5.64, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 15, + "y": 24, + "z": 1.85 + }, + "F3": { + "depth": 12, + "diameter": 5.64, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 24, + "y": 24, + "z": 1.85 + }, + "F4": { + "depth": 12, + "diameter": 5.64, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 33, + "y": 24, + "z": 1.85 + }, + "F5": { + "depth": 12, + "diameter": 5.64, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 42, + "y": 24, + "z": 1.85 + }, + "F6": { + "depth": 12, + "diameter": 5.64, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 51, + "y": 24, + "z": 1.85 + }, + "F7": { + "depth": 12, + "diameter": 5.64, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 60, + "y": 24, + "z": 1.85 + }, + "F8": { + "depth": 12, + "diameter": 5.64, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 69, + "y": 24, + "z": 1.85 + }, + "F9": { + "depth": 12, + "diameter": 5.64, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 78, + "y": 24, + "z": 1.85 + }, + "G1": { + "depth": 12, + "diameter": 5.64, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 6, + "y": 15, + "z": 1.85 + }, + "G10": { + "depth": 12, + "diameter": 5.64, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 87, + "y": 15, + "z": 1.85 + }, + "G11": { + "depth": 12, + "diameter": 5.64, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 96, + "y": 15, + "z": 1.85 + }, + "G12": { + "depth": 12, + "diameter": 5.64, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 105, + "y": 15, + "z": 1.85 + }, + "G2": { + "depth": 12, + "diameter": 5.64, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 15, + "y": 15, + "z": 1.85 + }, + "G3": { + "depth": 12, + "diameter": 5.64, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 24, + "y": 15, + "z": 1.85 + }, + "G4": { + "depth": 12, + "diameter": 5.64, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 33, + "y": 15, + "z": 1.85 + }, + "G5": { + "depth": 12, + "diameter": 5.64, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 42, + "y": 15, + "z": 1.85 + }, + "G6": { + "depth": 12, + "diameter": 5.64, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 51, + "y": 15, + "z": 1.85 + }, + "G7": { + "depth": 12, + "diameter": 5.64, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 60, + "y": 15, + "z": 1.85 + }, + "G8": { + "depth": 12, + "diameter": 5.64, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 69, + "y": 15, + "z": 1.85 + }, + "G9": { + "depth": 12, + "diameter": 5.64, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 78, + "y": 15, + "z": 1.85 + }, + "H1": { + "depth": 12, + "diameter": 5.64, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 6, + "y": 6, + "z": 1.85 + }, + "H10": { + "depth": 12, + "diameter": 5.64, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 87, + "y": 6, + "z": 1.85 + }, + "H11": { + "depth": 12, + "diameter": 5.64, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 96, + "y": 6, + "z": 1.85 + }, + "H12": { + "depth": 12, + "diameter": 5.64, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 105, + "y": 6, + "z": 1.85 + }, + "H2": { + "depth": 12, + "diameter": 5.64, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 15, + "y": 6, + "z": 1.85 + }, + "H3": { + "depth": 12, + "diameter": 5.64, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 24, + "y": 6, + "z": 1.85 + }, + "H4": { + "depth": 12, + "diameter": 5.64, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 33, + "y": 6, + "z": 1.85 + }, + "H5": { + "depth": 12, + "diameter": 5.64, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 42, + "y": 6, + "z": 1.85 + }, + "H6": { + "depth": 12, + "diameter": 5.64, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 51, + "y": 6, + "z": 1.85 + }, + "H7": { + "depth": 12, + "diameter": 5.64, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 60, + "y": 6, + "z": 1.85 + }, + "H8": { + "depth": 12, + "diameter": 5.64, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 69, + "y": 6, + "z": 1.85 + }, + "H9": { + "depth": 12, + "diameter": 5.64, + "shape": "circular", + "totalLiquidVolume": 0, + "x": 78, + "y": 6, + "z": 1.85 + } + } + } + }, + "status": "succeeded" + }, + { + "commandType": "loadLabware", + "notes": [], + "params": { + "loadName": "nest_96_wellplate_100ul_pcr_full_skirt", + "location": {}, + "namespace": "opentrons", + "version": 2 + }, + "result": { + "definition": { + "allowedRoles": [], + "brand": { + "brand": "NEST", + "brandId": [ + "402501" + ], + "links": [ + "https://www.nest-biotech.com/pcr-plates/58773587.html" + ] + }, + "cornerOffsetFromSlot": { + "x": 0, + "y": 0, + "z": 0 + }, + "dimensions": { + "xDimension": 127.76, + "yDimension": 85.48, + "zDimension": 15.7 + }, + "gripForce": 15.0, + "gripHeightFromLabwareBottom": 10.65, + "gripperOffsets": {}, + "groups": [ + { + "metadata": { + "wellBottomShape": "v" + }, + "wells": [ + "A1", + "A10", + "A11", + "A12", + "A2", + "A3", + "A4", + "A5", + "A6", + "A7", + "A8", + "A9", + "B1", + "B10", + "B11", + "B12", + "B2", + "B3", + "B4", + "B5", + "B6", + "B7", + "B8", + "B9", + "C1", + "C10", + "C11", + "C12", + "C2", + "C3", + "C4", + "C5", + "C6", + "C7", + "C8", + "C9", + "D1", + "D10", + "D11", + "D12", + "D2", + "D3", + "D4", + "D5", + "D6", + "D7", + "D8", + "D9", + "E1", + "E10", + "E11", + "E12", + "E2", + "E3", + "E4", + "E5", + "E6", + "E7", + "E8", + "E9", + "F1", + "F10", + "F11", + "F12", + "F2", + "F3", + "F4", + "F5", + "F6", + "F7", + "F8", + "F9", + "G1", + "G10", + "G11", + "G12", + "G2", + "G3", + "G4", + "G5", + "G6", + "G7", + "G8", + "G9", + "H1", + "H10", + "H11", + "H12", + "H2", + "H3", + "H4", + "H5", + "H6", + "H7", + "H8", + "H9" + ] + } + ], + "metadata": { + "displayCategory": "wellPlate", + "displayName": "NEST 96 Well Plate 100 µL PCR Full Skirt", + "displayVolumeUnits": "µL", + "tags": [] + }, + "namespace": "opentrons", + "ordering": [ + [ + "A1", + "B1", + "C1", + "D1", + "E1", + "F1", + "G1", + "H1" + ], + [ + "A10", + "B10", + "C10", + "D10", + "E10", + "F10", + "G10", + "H10" + ], + [ + "A11", + "B11", + "C11", + "D11", + "E11", + "F11", + "G11", + "H11" + ], + [ + "A12", + "B12", + "C12", + "D12", + "E12", + "F12", + "G12", + "H12" + ], + [ + "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" + ] + ], + "parameters": { + "format": "96Standard", + "isMagneticModuleCompatible": true, + "isTiprack": false, + "loadName": "nest_96_wellplate_100ul_pcr_full_skirt", + "magneticModuleEngageHeight": 20 + }, + "schemaVersion": 2, + "stackingOffsetWithLabware": { + "opentrons_96_pcr_adapter": { + "x": 0, + "y": 0, + "z": 10.2 + }, + "opentrons_96_well_aluminum_block": { + "x": 0, + "y": 0, + "z": 12.66 + } + }, + "stackingOffsetWithModule": { + "thermocyclerModuleV2": { + "x": 0, + "y": 0, + "z": 10.8 + } + }, + "version": 2, + "wells": { + "A1": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 14.38, + "y": 74.24, + "z": 0.92 + }, + "A10": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 95.38, + "y": 74.24, + "z": 0.92 + }, + "A11": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 104.38, + "y": 74.24, + "z": 0.92 + }, + "A12": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 113.38, + "y": 74.24, + "z": 0.92 + }, + "A2": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 23.38, + "y": 74.24, + "z": 0.92 + }, + "A3": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 32.38, + "y": 74.24, + "z": 0.92 + }, + "A4": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 41.38, + "y": 74.24, + "z": 0.92 + }, + "A5": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 50.38, + "y": 74.24, + "z": 0.92 + }, + "A6": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 59.38, + "y": 74.24, + "z": 0.92 + }, + "A7": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 68.38, + "y": 74.24, + "z": 0.92 + }, + "A8": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 77.38, + "y": 74.24, + "z": 0.92 + }, + "A9": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 86.38, + "y": 74.24, + "z": 0.92 + }, + "B1": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 14.38, + "y": 65.24, + "z": 0.92 + }, + "B10": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 95.38, + "y": 65.24, + "z": 0.92 + }, + "B11": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 104.38, + "y": 65.24, + "z": 0.92 + }, + "B12": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 113.38, + "y": 65.24, + "z": 0.92 + }, + "B2": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 23.38, + "y": 65.24, + "z": 0.92 + }, + "B3": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 32.38, + "y": 65.24, + "z": 0.92 + }, + "B4": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 41.38, + "y": 65.24, + "z": 0.92 + }, + "B5": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 50.38, + "y": 65.24, + "z": 0.92 + }, + "B6": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 59.38, + "y": 65.24, + "z": 0.92 + }, + "B7": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 68.38, + "y": 65.24, + "z": 0.92 + }, + "B8": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 77.38, + "y": 65.24, + "z": 0.92 + }, + "B9": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 86.38, + "y": 65.24, + "z": 0.92 + }, + "C1": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 14.38, + "y": 56.24, + "z": 0.92 + }, + "C10": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 95.38, + "y": 56.24, + "z": 0.92 + }, + "C11": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 104.38, + "y": 56.24, + "z": 0.92 + }, + "C12": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 113.38, + "y": 56.24, + "z": 0.92 + }, + "C2": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 23.38, + "y": 56.24, + "z": 0.92 + }, + "C3": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 32.38, + "y": 56.24, + "z": 0.92 + }, + "C4": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 41.38, + "y": 56.24, + "z": 0.92 + }, + "C5": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 50.38, + "y": 56.24, + "z": 0.92 + }, + "C6": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 59.38, + "y": 56.24, + "z": 0.92 + }, + "C7": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 68.38, + "y": 56.24, + "z": 0.92 + }, + "C8": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 77.38, + "y": 56.24, + "z": 0.92 + }, + "C9": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 86.38, + "y": 56.24, + "z": 0.92 + }, + "D1": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 14.38, + "y": 47.24, + "z": 0.92 + }, + "D10": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 95.38, + "y": 47.24, + "z": 0.92 + }, + "D11": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 104.38, + "y": 47.24, + "z": 0.92 + }, + "D12": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 113.38, + "y": 47.24, + "z": 0.92 + }, + "D2": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 23.38, + "y": 47.24, + "z": 0.92 + }, + "D3": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 32.38, + "y": 47.24, + "z": 0.92 + }, + "D4": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 41.38, + "y": 47.24, + "z": 0.92 + }, + "D5": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 50.38, + "y": 47.24, + "z": 0.92 + }, + "D6": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 59.38, + "y": 47.24, + "z": 0.92 + }, + "D7": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 68.38, + "y": 47.24, + "z": 0.92 + }, + "D8": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 77.38, + "y": 47.24, + "z": 0.92 + }, + "D9": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 86.38, + "y": 47.24, + "z": 0.92 + }, + "E1": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 14.38, + "y": 38.24, + "z": 0.92 + }, + "E10": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 95.38, + "y": 38.24, + "z": 0.92 + }, + "E11": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 104.38, + "y": 38.24, + "z": 0.92 + }, + "E12": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 113.38, + "y": 38.24, + "z": 0.92 + }, + "E2": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 23.38, + "y": 38.24, + "z": 0.92 + }, + "E3": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 32.38, + "y": 38.24, + "z": 0.92 + }, + "E4": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 41.38, + "y": 38.24, + "z": 0.92 + }, + "E5": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 50.38, + "y": 38.24, + "z": 0.92 + }, + "E6": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 59.38, + "y": 38.24, + "z": 0.92 + }, + "E7": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 68.38, + "y": 38.24, + "z": 0.92 + }, + "E8": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 77.38, + "y": 38.24, + "z": 0.92 + }, + "E9": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 86.38, + "y": 38.24, + "z": 0.92 + }, + "F1": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 14.38, + "y": 29.24, + "z": 0.92 + }, + "F10": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 95.38, + "y": 29.24, + "z": 0.92 + }, + "F11": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 104.38, + "y": 29.24, + "z": 0.92 + }, + "F12": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 113.38, + "y": 29.24, + "z": 0.92 + }, + "F2": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 23.38, + "y": 29.24, + "z": 0.92 + }, + "F3": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 32.38, + "y": 29.24, + "z": 0.92 + }, + "F4": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 41.38, + "y": 29.24, + "z": 0.92 + }, + "F5": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 50.38, + "y": 29.24, + "z": 0.92 + }, + "F6": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 59.38, + "y": 29.24, + "z": 0.92 + }, + "F7": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 68.38, + "y": 29.24, + "z": 0.92 + }, + "F8": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 77.38, + "y": 29.24, + "z": 0.92 + }, + "F9": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 86.38, + "y": 29.24, + "z": 0.92 + }, + "G1": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 14.38, + "y": 20.24, + "z": 0.92 + }, + "G10": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 95.38, + "y": 20.24, + "z": 0.92 + }, + "G11": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 104.38, + "y": 20.24, + "z": 0.92 + }, + "G12": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 113.38, + "y": 20.24, + "z": 0.92 + }, + "G2": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 23.38, + "y": 20.24, + "z": 0.92 + }, + "G3": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 32.38, + "y": 20.24, + "z": 0.92 + }, + "G4": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 41.38, + "y": 20.24, + "z": 0.92 + }, + "G5": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 50.38, + "y": 20.24, + "z": 0.92 + }, + "G6": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 59.38, + "y": 20.24, + "z": 0.92 + }, + "G7": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 68.38, + "y": 20.24, + "z": 0.92 + }, + "G8": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 77.38, + "y": 20.24, + "z": 0.92 + }, + "G9": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 86.38, + "y": 20.24, + "z": 0.92 + }, + "H1": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 14.38, + "y": 11.24, + "z": 0.92 + }, + "H10": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 95.38, + "y": 11.24, + "z": 0.92 + }, + "H11": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 104.38, + "y": 11.24, + "z": 0.92 + }, + "H12": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 113.38, + "y": 11.24, + "z": 0.92 + }, + "H2": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 23.38, + "y": 11.24, + "z": 0.92 + }, + "H3": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 32.38, + "y": 11.24, + "z": 0.92 + }, + "H4": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 41.38, + "y": 11.24, + "z": 0.92 + }, + "H5": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 50.38, + "y": 11.24, + "z": 0.92 + }, + "H6": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 59.38, + "y": 11.24, + "z": 0.92 + }, + "H7": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 68.38, + "y": 11.24, + "z": 0.92 + }, + "H8": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 77.38, + "y": 11.24, + "z": 0.92 + }, + "H9": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 86.38, + "y": 11.24, + "z": 0.92 + } + } + } + }, + "status": "succeeded" + }, + { + "commandType": "loadLabware", + "notes": [], + "params": { + "loadName": "nest_96_wellplate_100ul_pcr_full_skirt", + "location": {}, + "namespace": "opentrons", + "version": 2 + }, + "result": { + "definition": { + "allowedRoles": [], + "brand": { + "brand": "NEST", + "brandId": [ + "402501" + ], + "links": [ + "https://www.nest-biotech.com/pcr-plates/58773587.html" + ] + }, + "cornerOffsetFromSlot": { + "x": 0, + "y": 0, + "z": 0 + }, + "dimensions": { + "xDimension": 127.76, + "yDimension": 85.48, + "zDimension": 15.7 + }, + "gripForce": 15.0, + "gripHeightFromLabwareBottom": 10.65, + "gripperOffsets": {}, + "groups": [ + { + "metadata": { + "wellBottomShape": "v" + }, + "wells": [ + "A1", + "A10", + "A11", + "A12", + "A2", + "A3", + "A4", + "A5", + "A6", + "A7", + "A8", + "A9", + "B1", + "B10", + "B11", + "B12", + "B2", + "B3", + "B4", + "B5", + "B6", + "B7", + "B8", + "B9", + "C1", + "C10", + "C11", + "C12", + "C2", + "C3", + "C4", + "C5", + "C6", + "C7", + "C8", + "C9", + "D1", + "D10", + "D11", + "D12", + "D2", + "D3", + "D4", + "D5", + "D6", + "D7", + "D8", + "D9", + "E1", + "E10", + "E11", + "E12", + "E2", + "E3", + "E4", + "E5", + "E6", + "E7", + "E8", + "E9", + "F1", + "F10", + "F11", + "F12", + "F2", + "F3", + "F4", + "F5", + "F6", + "F7", + "F8", + "F9", + "G1", + "G10", + "G11", + "G12", + "G2", + "G3", + "G4", + "G5", + "G6", + "G7", + "G8", + "G9", + "H1", + "H10", + "H11", + "H12", + "H2", + "H3", + "H4", + "H5", + "H6", + "H7", + "H8", + "H9" + ] + } + ], + "metadata": { + "displayCategory": "wellPlate", + "displayName": "NEST 96 Well Plate 100 µL PCR Full Skirt", + "displayVolumeUnits": "µL", + "tags": [] + }, + "namespace": "opentrons", + "ordering": [ + [ + "A1", + "B1", + "C1", + "D1", + "E1", + "F1", + "G1", + "H1" + ], + [ + "A10", + "B10", + "C10", + "D10", + "E10", + "F10", + "G10", + "H10" + ], + [ + "A11", + "B11", + "C11", + "D11", + "E11", + "F11", + "G11", + "H11" + ], + [ + "A12", + "B12", + "C12", + "D12", + "E12", + "F12", + "G12", + "H12" + ], + [ + "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" + ] + ], + "parameters": { + "format": "96Standard", + "isMagneticModuleCompatible": true, + "isTiprack": false, + "loadName": "nest_96_wellplate_100ul_pcr_full_skirt", + "magneticModuleEngageHeight": 20 + }, + "schemaVersion": 2, + "stackingOffsetWithLabware": { + "opentrons_96_pcr_adapter": { + "x": 0, + "y": 0, + "z": 10.2 + }, + "opentrons_96_well_aluminum_block": { + "x": 0, + "y": 0, + "z": 12.66 + } + }, + "stackingOffsetWithModule": { + "thermocyclerModuleV2": { + "x": 0, + "y": 0, + "z": 10.8 + } + }, + "version": 2, + "wells": { + "A1": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 14.38, + "y": 74.24, + "z": 0.92 + }, + "A10": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 95.38, + "y": 74.24, + "z": 0.92 + }, + "A11": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 104.38, + "y": 74.24, + "z": 0.92 + }, + "A12": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 113.38, + "y": 74.24, + "z": 0.92 + }, + "A2": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 23.38, + "y": 74.24, + "z": 0.92 + }, + "A3": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 32.38, + "y": 74.24, + "z": 0.92 + }, + "A4": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 41.38, + "y": 74.24, + "z": 0.92 + }, + "A5": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 50.38, + "y": 74.24, + "z": 0.92 + }, + "A6": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 59.38, + "y": 74.24, + "z": 0.92 + }, + "A7": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 68.38, + "y": 74.24, + "z": 0.92 + }, + "A8": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 77.38, + "y": 74.24, + "z": 0.92 + }, + "A9": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 86.38, + "y": 74.24, + "z": 0.92 + }, + "B1": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 14.38, + "y": 65.24, + "z": 0.92 + }, + "B10": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 95.38, + "y": 65.24, + "z": 0.92 + }, + "B11": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 104.38, + "y": 65.24, + "z": 0.92 + }, + "B12": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 113.38, + "y": 65.24, + "z": 0.92 + }, + "B2": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 23.38, + "y": 65.24, + "z": 0.92 + }, + "B3": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 32.38, + "y": 65.24, + "z": 0.92 + }, + "B4": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 41.38, + "y": 65.24, + "z": 0.92 + }, + "B5": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 50.38, + "y": 65.24, + "z": 0.92 + }, + "B6": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 59.38, + "y": 65.24, + "z": 0.92 + }, + "B7": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 68.38, + "y": 65.24, + "z": 0.92 + }, + "B8": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 77.38, + "y": 65.24, + "z": 0.92 + }, + "B9": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 86.38, + "y": 65.24, + "z": 0.92 + }, + "C1": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 14.38, + "y": 56.24, + "z": 0.92 + }, + "C10": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 95.38, + "y": 56.24, + "z": 0.92 + }, + "C11": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 104.38, + "y": 56.24, + "z": 0.92 + }, + "C12": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 113.38, + "y": 56.24, + "z": 0.92 + }, + "C2": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 23.38, + "y": 56.24, + "z": 0.92 + }, + "C3": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 32.38, + "y": 56.24, + "z": 0.92 + }, + "C4": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 41.38, + "y": 56.24, + "z": 0.92 + }, + "C5": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 50.38, + "y": 56.24, + "z": 0.92 + }, + "C6": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 59.38, + "y": 56.24, + "z": 0.92 + }, + "C7": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 68.38, + "y": 56.24, + "z": 0.92 + }, + "C8": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 77.38, + "y": 56.24, + "z": 0.92 + }, + "C9": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 86.38, + "y": 56.24, + "z": 0.92 + }, + "D1": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 14.38, + "y": 47.24, + "z": 0.92 + }, + "D10": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 95.38, + "y": 47.24, + "z": 0.92 + }, + "D11": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 104.38, + "y": 47.24, + "z": 0.92 + }, + "D12": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 113.38, + "y": 47.24, + "z": 0.92 + }, + "D2": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 23.38, + "y": 47.24, + "z": 0.92 + }, + "D3": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 32.38, + "y": 47.24, + "z": 0.92 + }, + "D4": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 41.38, + "y": 47.24, + "z": 0.92 + }, + "D5": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 50.38, + "y": 47.24, + "z": 0.92 + }, + "D6": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 59.38, + "y": 47.24, + "z": 0.92 + }, + "D7": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 68.38, + "y": 47.24, + "z": 0.92 + }, + "D8": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 77.38, + "y": 47.24, + "z": 0.92 + }, + "D9": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 86.38, + "y": 47.24, + "z": 0.92 + }, + "E1": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 14.38, + "y": 38.24, + "z": 0.92 + }, + "E10": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 95.38, + "y": 38.24, + "z": 0.92 + }, + "E11": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 104.38, + "y": 38.24, + "z": 0.92 + }, + "E12": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 113.38, + "y": 38.24, + "z": 0.92 + }, + "E2": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 23.38, + "y": 38.24, + "z": 0.92 + }, + "E3": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 32.38, + "y": 38.24, + "z": 0.92 + }, + "E4": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 41.38, + "y": 38.24, + "z": 0.92 + }, + "E5": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 50.38, + "y": 38.24, + "z": 0.92 + }, + "E6": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 59.38, + "y": 38.24, + "z": 0.92 + }, + "E7": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 68.38, + "y": 38.24, + "z": 0.92 + }, + "E8": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 77.38, + "y": 38.24, + "z": 0.92 + }, + "E9": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 86.38, + "y": 38.24, + "z": 0.92 + }, + "F1": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 14.38, + "y": 29.24, + "z": 0.92 + }, + "F10": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 95.38, + "y": 29.24, + "z": 0.92 + }, + "F11": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 104.38, + "y": 29.24, + "z": 0.92 + }, + "F12": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 113.38, + "y": 29.24, + "z": 0.92 + }, + "F2": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 23.38, + "y": 29.24, + "z": 0.92 + }, + "F3": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 32.38, + "y": 29.24, + "z": 0.92 + }, + "F4": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 41.38, + "y": 29.24, + "z": 0.92 + }, + "F5": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 50.38, + "y": 29.24, + "z": 0.92 + }, + "F6": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 59.38, + "y": 29.24, + "z": 0.92 + }, + "F7": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 68.38, + "y": 29.24, + "z": 0.92 + }, + "F8": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 77.38, + "y": 29.24, + "z": 0.92 + }, + "F9": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 86.38, + "y": 29.24, + "z": 0.92 + }, + "G1": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 14.38, + "y": 20.24, + "z": 0.92 + }, + "G10": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 95.38, + "y": 20.24, + "z": 0.92 + }, + "G11": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 104.38, + "y": 20.24, + "z": 0.92 + }, + "G12": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 113.38, + "y": 20.24, + "z": 0.92 + }, + "G2": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 23.38, + "y": 20.24, + "z": 0.92 + }, + "G3": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 32.38, + "y": 20.24, + "z": 0.92 + }, + "G4": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 41.38, + "y": 20.24, + "z": 0.92 + }, + "G5": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 50.38, + "y": 20.24, + "z": 0.92 + }, + "G6": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 59.38, + "y": 20.24, + "z": 0.92 + }, + "G7": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 68.38, + "y": 20.24, + "z": 0.92 + }, + "G8": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 77.38, + "y": 20.24, + "z": 0.92 + }, + "G9": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 86.38, + "y": 20.24, + "z": 0.92 + }, + "H1": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 14.38, + "y": 11.24, + "z": 0.92 + }, + "H10": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 95.38, + "y": 11.24, + "z": 0.92 + }, + "H11": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 104.38, + "y": 11.24, + "z": 0.92 + }, + "H12": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 113.38, + "y": 11.24, + "z": 0.92 + }, + "H2": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 23.38, + "y": 11.24, + "z": 0.92 + }, + "H3": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 32.38, + "y": 11.24, + "z": 0.92 + }, + "H4": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 41.38, + "y": 11.24, + "z": 0.92 + }, + "H5": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 50.38, + "y": 11.24, + "z": 0.92 + }, + "H6": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 59.38, + "y": 11.24, + "z": 0.92 + }, + "H7": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 68.38, + "y": 11.24, + "z": 0.92 + }, + "H8": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 77.38, + "y": 11.24, + "z": 0.92 + }, + "H9": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 86.38, + "y": 11.24, + "z": 0.92 + } + } + } + }, + "status": "succeeded" + }, + { + "commandType": "loadLabware", + "notes": [], + "params": { + "displayName": "4 custom tubes", + "loadName": "cpx_4_tuberack_100ul", + "location": { + "slotName": "6" + }, + "namespace": "custom_beta", + "version": 1 + }, + "result": { + "definition": { + "allowedRoles": [], + "brand": { + "brand": "cpx", + "brandId": [] + }, + "cornerOffsetFromSlot": { + "x": 0, + "y": 0, + "z": 0 + }, + "dimensions": { + "xDimension": 127, + "yDimension": 85, + "zDimension": 40 + }, + "gripperOffsets": {}, + "groups": [ + { + "brand": { + "brand": "cpx", + "brandId": [] + }, + "metadata": { + "displayCategory": "tubeRack", + "wellBottomShape": "u" + }, + "wells": [ + "A1", + "A2", + "B1", + "B2" + ] + } + ], + "metadata": { + "displayCategory": "tubeRack", + "displayName": "cpx 4 Tube Rack with cpx 0.1 mL", + "displayVolumeUnits": "µL", + "tags": [] + }, + "namespace": "custom_beta", + "ordering": [ + [ + "A1", + "B1" + ], + [ + "A2", + "B2" + ] + ], + "parameters": { + "format": "irregular", + "isMagneticModuleCompatible": false, + "isTiprack": false, + "loadName": "cpx_4_tuberack_100ul", + "quirks": [] + }, + "schemaVersion": 2, + "stackingOffsetWithLabware": {}, + "stackingOffsetWithModule": {}, + "version": 1, + "wells": { + "A1": { + "depth": 23, + "diameter": 20, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 20, + "y": 65, + "z": 17 + }, + "A2": { + "depth": 23, + "diameter": 20, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 50, + "y": 65, + "z": 17 + }, + "B1": { + "depth": 23, + "diameter": 20, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 20, + "y": 35, + "z": 17 + }, + "B2": { + "depth": 23, + "diameter": 20, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 50, + "y": 35, + "z": 17 + } + } + } + }, + "status": "succeeded" + }, + { + "commandType": "loadLabware", + "notes": [], + "params": { + "displayName": "logo destination", + "loadName": "nest_96_wellplate_100ul_pcr_full_skirt", + "location": { + "slotName": "2" + }, + "namespace": "opentrons", + "version": 2 + }, + "result": { + "definition": { + "allowedRoles": [], + "brand": { + "brand": "NEST", + "brandId": [ + "402501" + ], + "links": [ + "https://www.nest-biotech.com/pcr-plates/58773587.html" + ] + }, + "cornerOffsetFromSlot": { + "x": 0, + "y": 0, + "z": 0 + }, + "dimensions": { + "xDimension": 127.76, + "yDimension": 85.48, + "zDimension": 15.7 + }, + "gripForce": 15.0, + "gripHeightFromLabwareBottom": 10.65, + "gripperOffsets": {}, + "groups": [ + { + "metadata": { + "wellBottomShape": "v" + }, + "wells": [ + "A1", + "A10", + "A11", + "A12", + "A2", + "A3", + "A4", + "A5", + "A6", + "A7", + "A8", + "A9", + "B1", + "B10", + "B11", + "B12", + "B2", + "B3", + "B4", + "B5", + "B6", + "B7", + "B8", + "B9", + "C1", + "C10", + "C11", + "C12", + "C2", + "C3", + "C4", + "C5", + "C6", + "C7", + "C8", + "C9", + "D1", + "D10", + "D11", + "D12", + "D2", + "D3", + "D4", + "D5", + "D6", + "D7", + "D8", + "D9", + "E1", + "E10", + "E11", + "E12", + "E2", + "E3", + "E4", + "E5", + "E6", + "E7", + "E8", + "E9", + "F1", + "F10", + "F11", + "F12", + "F2", + "F3", + "F4", + "F5", + "F6", + "F7", + "F8", + "F9", + "G1", + "G10", + "G11", + "G12", + "G2", + "G3", + "G4", + "G5", + "G6", + "G7", + "G8", + "G9", + "H1", + "H10", + "H11", + "H12", + "H2", + "H3", + "H4", + "H5", + "H6", + "H7", + "H8", + "H9" + ] + } + ], + "metadata": { + "displayCategory": "wellPlate", + "displayName": "NEST 96 Well Plate 100 µL PCR Full Skirt", + "displayVolumeUnits": "µL", + "tags": [] + }, + "namespace": "opentrons", + "ordering": [ + [ + "A1", + "B1", + "C1", + "D1", + "E1", + "F1", + "G1", + "H1" + ], + [ + "A10", + "B10", + "C10", + "D10", + "E10", + "F10", + "G10", + "H10" + ], + [ + "A11", + "B11", + "C11", + "D11", + "E11", + "F11", + "G11", + "H11" + ], + [ + "A12", + "B12", + "C12", + "D12", + "E12", + "F12", + "G12", + "H12" + ], + [ + "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" + ] + ], + "parameters": { + "format": "96Standard", + "isMagneticModuleCompatible": true, + "isTiprack": false, + "loadName": "nest_96_wellplate_100ul_pcr_full_skirt", + "magneticModuleEngageHeight": 20 + }, + "schemaVersion": 2, + "stackingOffsetWithLabware": { + "opentrons_96_pcr_adapter": { + "x": 0, + "y": 0, + "z": 10.2 + }, + "opentrons_96_well_aluminum_block": { + "x": 0, + "y": 0, + "z": 12.66 + } + }, + "stackingOffsetWithModule": { + "thermocyclerModuleV2": { + "x": 0, + "y": 0, + "z": 10.8 + } + }, + "version": 2, + "wells": { + "A1": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 14.38, + "y": 74.24, + "z": 0.92 + }, + "A10": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 95.38, + "y": 74.24, + "z": 0.92 + }, + "A11": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 104.38, + "y": 74.24, + "z": 0.92 + }, + "A12": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 113.38, + "y": 74.24, + "z": 0.92 + }, + "A2": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 23.38, + "y": 74.24, + "z": 0.92 + }, + "A3": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 32.38, + "y": 74.24, + "z": 0.92 + }, + "A4": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 41.38, + "y": 74.24, + "z": 0.92 + }, + "A5": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 50.38, + "y": 74.24, + "z": 0.92 + }, + "A6": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 59.38, + "y": 74.24, + "z": 0.92 + }, + "A7": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 68.38, + "y": 74.24, + "z": 0.92 + }, + "A8": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 77.38, + "y": 74.24, + "z": 0.92 + }, + "A9": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 86.38, + "y": 74.24, + "z": 0.92 + }, + "B1": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 14.38, + "y": 65.24, + "z": 0.92 + }, + "B10": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 95.38, + "y": 65.24, + "z": 0.92 + }, + "B11": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 104.38, + "y": 65.24, + "z": 0.92 + }, + "B12": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 113.38, + "y": 65.24, + "z": 0.92 + }, + "B2": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 23.38, + "y": 65.24, + "z": 0.92 + }, + "B3": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 32.38, + "y": 65.24, + "z": 0.92 + }, + "B4": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 41.38, + "y": 65.24, + "z": 0.92 + }, + "B5": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 50.38, + "y": 65.24, + "z": 0.92 + }, + "B6": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 59.38, + "y": 65.24, + "z": 0.92 + }, + "B7": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 68.38, + "y": 65.24, + "z": 0.92 + }, + "B8": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 77.38, + "y": 65.24, + "z": 0.92 + }, + "B9": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 86.38, + "y": 65.24, + "z": 0.92 + }, + "C1": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 14.38, + "y": 56.24, + "z": 0.92 + }, + "C10": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 95.38, + "y": 56.24, + "z": 0.92 + }, + "C11": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 104.38, + "y": 56.24, + "z": 0.92 + }, + "C12": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 113.38, + "y": 56.24, + "z": 0.92 + }, + "C2": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 23.38, + "y": 56.24, + "z": 0.92 + }, + "C3": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 32.38, + "y": 56.24, + "z": 0.92 + }, + "C4": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 41.38, + "y": 56.24, + "z": 0.92 + }, + "C5": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 50.38, + "y": 56.24, + "z": 0.92 + }, + "C6": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 59.38, + "y": 56.24, + "z": 0.92 + }, + "C7": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 68.38, + "y": 56.24, + "z": 0.92 + }, + "C8": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 77.38, + "y": 56.24, + "z": 0.92 + }, + "C9": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 86.38, + "y": 56.24, + "z": 0.92 + }, + "D1": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 14.38, + "y": 47.24, + "z": 0.92 + }, + "D10": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 95.38, + "y": 47.24, + "z": 0.92 + }, + "D11": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 104.38, + "y": 47.24, + "z": 0.92 + }, + "D12": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 113.38, + "y": 47.24, + "z": 0.92 + }, + "D2": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 23.38, + "y": 47.24, + "z": 0.92 + }, + "D3": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 32.38, + "y": 47.24, + "z": 0.92 + }, + "D4": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 41.38, + "y": 47.24, + "z": 0.92 + }, + "D5": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 50.38, + "y": 47.24, + "z": 0.92 + }, + "D6": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 59.38, + "y": 47.24, + "z": 0.92 + }, + "D7": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 68.38, + "y": 47.24, + "z": 0.92 + }, + "D8": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 77.38, + "y": 47.24, + "z": 0.92 + }, + "D9": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 86.38, + "y": 47.24, + "z": 0.92 + }, + "E1": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 14.38, + "y": 38.24, + "z": 0.92 + }, + "E10": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 95.38, + "y": 38.24, + "z": 0.92 + }, + "E11": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 104.38, + "y": 38.24, + "z": 0.92 + }, + "E12": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 113.38, + "y": 38.24, + "z": 0.92 + }, + "E2": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 23.38, + "y": 38.24, + "z": 0.92 + }, + "E3": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 32.38, + "y": 38.24, + "z": 0.92 + }, + "E4": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 41.38, + "y": 38.24, + "z": 0.92 + }, + "E5": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 50.38, + "y": 38.24, + "z": 0.92 + }, + "E6": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 59.38, + "y": 38.24, + "z": 0.92 + }, + "E7": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 68.38, + "y": 38.24, + "z": 0.92 + }, + "E8": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 77.38, + "y": 38.24, + "z": 0.92 + }, + "E9": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 86.38, + "y": 38.24, + "z": 0.92 + }, + "F1": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 14.38, + "y": 29.24, + "z": 0.92 + }, + "F10": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 95.38, + "y": 29.24, + "z": 0.92 + }, + "F11": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 104.38, + "y": 29.24, + "z": 0.92 + }, + "F12": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 113.38, + "y": 29.24, + "z": 0.92 + }, + "F2": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 23.38, + "y": 29.24, + "z": 0.92 + }, + "F3": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 32.38, + "y": 29.24, + "z": 0.92 + }, + "F4": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 41.38, + "y": 29.24, + "z": 0.92 + }, + "F5": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 50.38, + "y": 29.24, + "z": 0.92 + }, + "F6": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 59.38, + "y": 29.24, + "z": 0.92 + }, + "F7": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 68.38, + "y": 29.24, + "z": 0.92 + }, + "F8": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 77.38, + "y": 29.24, + "z": 0.92 + }, + "F9": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 86.38, + "y": 29.24, + "z": 0.92 + }, + "G1": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 14.38, + "y": 20.24, + "z": 0.92 + }, + "G10": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 95.38, + "y": 20.24, + "z": 0.92 + }, + "G11": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 104.38, + "y": 20.24, + "z": 0.92 + }, + "G12": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 113.38, + "y": 20.24, + "z": 0.92 + }, + "G2": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 23.38, + "y": 20.24, + "z": 0.92 + }, + "G3": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 32.38, + "y": 20.24, + "z": 0.92 + }, + "G4": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 41.38, + "y": 20.24, + "z": 0.92 + }, + "G5": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 50.38, + "y": 20.24, + "z": 0.92 + }, + "G6": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 59.38, + "y": 20.24, + "z": 0.92 + }, + "G7": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 68.38, + "y": 20.24, + "z": 0.92 + }, + "G8": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 77.38, + "y": 20.24, + "z": 0.92 + }, + "G9": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 86.38, + "y": 20.24, + "z": 0.92 + }, + "H1": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 14.38, + "y": 11.24, + "z": 0.92 + }, + "H10": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 95.38, + "y": 11.24, + "z": 0.92 + }, + "H11": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 104.38, + "y": 11.24, + "z": 0.92 + }, + "H12": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 113.38, + "y": 11.24, + "z": 0.92 + }, + "H2": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 23.38, + "y": 11.24, + "z": 0.92 + }, + "H3": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 32.38, + "y": 11.24, + "z": 0.92 + }, + "H4": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 41.38, + "y": 11.24, + "z": 0.92 + }, + "H5": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 50.38, + "y": 11.24, + "z": 0.92 + }, + "H6": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 59.38, + "y": 11.24, + "z": 0.92 + }, + "H7": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 68.38, + "y": 11.24, + "z": 0.92 + }, + "H8": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 77.38, + "y": 11.24, + "z": 0.92 + }, + "H9": { + "depth": 14.78, + "diameter": 5.34, + "shape": "circular", + "totalLiquidVolume": 100, + "x": 86.38, + "y": 11.24, + "z": 0.92 + } + } + } + }, + "status": "succeeded" + }, + { + "commandType": "loadLabware", + "notes": [], + "params": { + "displayName": "dye container", + "loadName": "nest_12_reservoir_15ml", + "location": { + "slotName": "3" + }, + "namespace": "opentrons", + "version": 1 + }, + "result": { + "definition": { + "allowedRoles": [], + "brand": { + "brand": "NEST", + "brandId": [ + "360102" + ], + "links": [ + "https://www.nest-biotech.com/reagent-reserviors/59178414.html" + ] + }, + "cornerOffsetFromSlot": { + "x": 0, + "y": 0, + "z": 0 + }, + "dimensions": { + "xDimension": 127.76, + "yDimension": 85.48, + "zDimension": 31.4 + }, + "gripperOffsets": {}, + "groups": [ + { + "metadata": { + "wellBottomShape": "v" + }, + "wells": [ + "A1", + "A10", + "A11", + "A12", + "A2", + "A3", + "A4", + "A5", + "A6", + "A7", + "A8", + "A9" + ] + } + ], + "metadata": { + "displayCategory": "reservoir", + "displayName": "NEST 12 Well Reservoir 15 mL", + "displayVolumeUnits": "mL", + "tags": [] + }, + "namespace": "opentrons", + "ordering": [ + [ + "A1" + ], + [ + "A10" + ], + [ + "A11" + ], + [ + "A12" + ], + [ + "A2" + ], + [ + "A3" + ], + [ + "A4" + ], + [ + "A5" + ], + [ + "A6" + ], + [ + "A7" + ], + [ + "A8" + ], + [ + "A9" + ] + ], + "parameters": { + "format": "trough", + "isMagneticModuleCompatible": false, + "isTiprack": false, + "loadName": "nest_12_reservoir_15ml", + "quirks": [ + "centerMultichannelOnWells", + "touchTipDisabled" + ] + }, + "schemaVersion": 2, + "stackingOffsetWithLabware": {}, + "stackingOffsetWithModule": {}, + "version": 1, + "wells": { + "A1": { + "depth": 26.85, + "shape": "rectangular", + "totalLiquidVolume": 15000, + "x": 14.38, + "xDimension": 8.2, + "y": 42.78, + "yDimension": 71.2, + "z": 4.55 + }, + "A10": { + "depth": 26.85, + "shape": "rectangular", + "totalLiquidVolume": 15000, + "x": 95.38, + "xDimension": 8.2, + "y": 42.78, + "yDimension": 71.2, + "z": 4.55 + }, + "A11": { + "depth": 26.85, + "shape": "rectangular", + "totalLiquidVolume": 15000, + "x": 104.38, + "xDimension": 8.2, + "y": 42.78, + "yDimension": 71.2, + "z": 4.55 + }, + "A12": { + "depth": 26.85, + "shape": "rectangular", + "totalLiquidVolume": 15000, + "x": 113.38, + "xDimension": 8.2, + "y": 42.78, + "yDimension": 71.2, + "z": 4.55 + }, + "A2": { + "depth": 26.85, + "shape": "rectangular", + "totalLiquidVolume": 15000, + "x": 23.38, + "xDimension": 8.2, + "y": 42.78, + "yDimension": 71.2, + "z": 4.55 + }, + "A3": { + "depth": 26.85, + "shape": "rectangular", + "totalLiquidVolume": 15000, + "x": 32.38, + "xDimension": 8.2, + "y": 42.78, + "yDimension": 71.2, + "z": 4.55 + }, + "A4": { + "depth": 26.85, + "shape": "rectangular", + "totalLiquidVolume": 15000, + "x": 41.38, + "xDimension": 8.2, + "y": 42.78, + "yDimension": 71.2, + "z": 4.55 + }, + "A5": { + "depth": 26.85, + "shape": "rectangular", + "totalLiquidVolume": 15000, + "x": 50.38, + "xDimension": 8.2, + "y": 42.78, + "yDimension": 71.2, + "z": 4.55 + }, + "A6": { + "depth": 26.85, + "shape": "rectangular", + "totalLiquidVolume": 15000, + "x": 59.38, + "xDimension": 8.2, + "y": 42.78, + "yDimension": 71.2, + "z": 4.55 + }, + "A7": { + "depth": 26.85, + "shape": "rectangular", + "totalLiquidVolume": 15000, + "x": 68.38, + "xDimension": 8.2, + "y": 42.78, + "yDimension": 71.2, + "z": 4.55 + }, + "A8": { + "depth": 26.85, + "shape": "rectangular", + "totalLiquidVolume": 15000, + "x": 77.38, + "xDimension": 8.2, + "y": 42.78, + "yDimension": 71.2, + "z": 4.55 + }, + "A9": { + "depth": 26.85, + "shape": "rectangular", + "totalLiquidVolume": 15000, + "x": 86.38, + "xDimension": 8.2, + "y": 42.78, + "yDimension": 71.2, + "z": 4.55 + } + } + } + }, + "status": "succeeded" + }, + { + "commandType": "loadLiquid", + "notes": [], + "params": { + "volumeByWell": { + "A1": 4000.0 + } + }, + "result": {}, + "status": "succeeded" + }, + { + "commandType": "loadLiquid", + "notes": [], + "params": { + "volumeByWell": { + "A2": 2000.0 + } + }, + "result": {}, + "status": "succeeded" + }, + { + "commandType": "loadLiquid", + "notes": [], + "params": { + "volumeByWell": { + "A5": 555.55555 + } + }, + "result": {}, + "status": "succeeded" + }, + { + "commandType": "loadLiquid", + "notes": [], + "params": { + "volumeByWell": { + "A8": 900.0 + } + }, + "result": {}, + "status": "succeeded" + }, + { + "commandType": "loadLiquid", + "notes": [], + "params": { + "volumeByWell": { + "A8": 1001.11 + } + }, + "result": {}, + "status": "succeeded" + }, + { + "commandType": "heaterShaker/closeLabwareLatch", + "notes": [], + "params": {}, + "result": {}, + "status": "succeeded" + }, + { + "commandType": "pickUpTip", + "notes": [], + "params": { + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "origin": "top" + }, + "wellName": "A1" + }, + "result": { + "position": { + "x": 14.38, + "y": 164.74, + "z": 64.69 + }, + "tipDiameter": 3.27, + "tipLength": 30.950000000000003, + "tipVolume": 20.0 + }, + "status": "succeeded" + }, + { + "commandType": "moveLabware", + "notes": [], + "params": { + "newLocation": "offDeck", + "strategy": "manualMoveWithPause" + }, + "result": {}, + "status": "succeeded" + }, + { + "commandType": "moveLabware", + "notes": [], + "params": { + "newLocation": { + "slotName": "2" + }, + "strategy": "manualMoveWithPause" + }, + "result": {}, + "status": "succeeded" + }, + { + "commandType": "moveToWell", + "notes": [], + "params": { + "forceDirect": false, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "origin": "top" + }, + "wellName": "A1" + }, + "result": { + "position": { + "x": 146.88, + "y": 42.78, + "z": 31.400000000000002 + } + }, + "status": "succeeded" + }, + { + "commandType": "waitForResume", + "notes": [], + "params": { + "message": "Is the pipette tip in the middle of reservoir A1 in slot 2?" + }, + "result": {}, + "status": "succeeded" + }, + { + "commandType": "moveLabware", + "notes": [], + "params": { + "newLocation": { + "slotName": "3" + }, + "strategy": "manualMoveWithPause" + }, + "result": {}, + "status": "succeeded" + }, + { + "commandType": "moveToWell", + "notes": [], + "params": { + "forceDirect": false, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "origin": "top" + }, + "wellName": "A1" + }, + "result": { + "position": { + "x": 279.38, + "y": 42.78, + "z": 31.400000000000002 + } + }, + "status": "succeeded" + }, + { + "commandType": "waitForResume", + "notes": [], + "params": { + "message": "Is the pipette tip in the middle of reservoir A1 in slot 3?" + }, + "result": {}, + "status": "succeeded" + }, + { + "commandType": "moveLabware", + "notes": [], + "params": { + "newLocation": { + "slotName": "2" + }, + "strategy": "manualMoveWithPause" + }, + "result": {}, + "status": "succeeded" + }, + { + "commandType": "moveToWell", + "notes": [], + "params": { + "forceDirect": false, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "origin": "top" + }, + "wellName": "A1" + }, + "result": { + "position": { + "x": 152.5, + "y": 65.0, + "z": 40.0 + } + }, + "status": "succeeded" + }, + { + "commandType": "waitForResume", + "notes": [], + "params": { + "message": "Is the pipette tip in the middle of custom labware A1 in slot 2?" + }, + "result": {}, + "status": "succeeded" + }, + { + "commandType": "moveLabware", + "notes": [], + "params": { + "newLocation": { + "slotName": "6" + }, + "strategy": "manualMoveWithPause" + }, + "result": {}, + "status": "succeeded" + }, + { + "commandType": "moveToWell", + "notes": [], + "params": { + "forceDirect": false, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "origin": "top" + }, + "wellName": "A1" + }, + "result": { + "position": { + "x": 285.0, + "y": 155.5, + "z": 40.0 + } + }, + "status": "succeeded" + }, + { + "commandType": "waitForResume", + "notes": [], + "params": { + "message": "Is the pipette tip in the middle of custom labware A1 in slot 6?" + }, + "result": {}, + "status": "succeeded" + }, + { + "commandType": "moveLabware", + "notes": [], + "params": { + "newLocation": { + "slotName": "2" + }, + "strategy": "manualMoveWithPause" + }, + "result": {}, + "status": "succeeded" + }, + { + "commandType": "moveToWell", + "notes": [], + "params": { + "forceDirect": false, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "origin": "top" + }, + "wellName": "A1" + }, + "result": { + "position": { + "x": 146.88, + "y": 74.24, + "z": 15.7 + } + }, + "status": "succeeded" + }, + { + "commandType": "waitForResume", + "notes": [], + "params": { + "message": "Is the pipette tip in the middle of well A1 in slot 2?" + }, + "result": {}, + "status": "succeeded" + }, + { + "commandType": "prepareToAspirate", + "notes": [], + "params": {}, + "result": {}, + "status": "succeeded" + }, + { + "commandType": "moveToWell", + "notes": [], + "params": { + "forceDirect": false, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": -24.85 + }, + "origin": "top" + }, + "wellName": "A1" + }, + "result": { + "position": { + "x": 279.38, + "y": 42.78, + "z": 6.55 + } + }, + "status": "succeeded" + }, + { + "commandType": "waitForResume", + "notes": [], + "params": { + "message": "Testing prepare_to_aspirate - watch pipette until next pause.\n The pipette should only move up out of the well after it has aspirated." + }, + "result": {}, + "status": "succeeded" + }, + { + "commandType": "aspirate", + "notes": [], + "params": { + "flowRate": 7.56, + "volume": 10.0, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": -24.85 + }, + "origin": "top" + }, + "wellName": "A1" + }, + "result": { + "position": { + "x": 279.38, + "y": 42.78, + "z": 6.55 + }, + "volume": 10.0 + }, + "status": "succeeded" + }, + { + "commandType": "waitForResume", + "notes": [], + "params": { + "message": "Did the pipette move up out of the well, only once, after aspirating?" + }, + "result": {}, + "status": "succeeded" + }, + { + "commandType": "dispense", + "notes": [], + "params": { + "flowRate": 7.56, + "volume": 10.0, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": -24.85 + }, + "origin": "top" + }, + "wellName": "A1" + }, + "result": { + "position": { + "x": 279.38, + "y": 42.78, + "z": 6.55 + }, + "volume": 10.0 + }, + "status": "succeeded" + }, + { + "commandType": "moveToAddressableAreaForDropTip", + "notes": [], + "params": { + "addressableAreaName": "fixedTrash", + "alternateDropLocation": false, + "forceDirect": false, + "ignoreTipConfiguration": true, + "offset": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + }, + "result": { + "position": { + "x": 347.84000000000003, + "y": 351.5, + "z": 82.0 + } + }, + "status": "succeeded" + }, + { + "commandType": "waitForResume", + "notes": [], + "params": { + "message": "Is the pipette over the trash? Pipette will home after this pause." + }, + "result": {}, + "status": "succeeded" + }, + { + "commandType": "home", + "notes": [], + "params": {}, + "result": {}, + "status": "succeeded" + }, + { + "commandType": "moveToAddressableAreaForDropTip", + "notes": [], + "params": { + "addressableAreaName": "fixedTrash", + "alternateDropLocation": false, + "forceDirect": false, + "ignoreTipConfiguration": true, + "offset": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + }, + "result": { + "position": { + "x": 347.84000000000003, + "y": 351.5, + "z": 82.0 + } + }, + "status": "succeeded" + }, + { + "commandType": "waitForResume", + "notes": [], + "params": { + "message": "Is the pipette over the trash?" + }, + "result": {}, + "status": "succeeded" + }, + { + "commandType": "aspirate", + "notes": [], + "params": { + "flowRate": 7.56, + "volume": 19.0, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": -25.85 + }, + "origin": "top" + }, + "wellName": "A2" + }, + "result": { + "position": { + "x": 288.38, + "y": 42.78, + "z": 5.55 + }, + "volume": 19.0 + }, + "status": "succeeded" + }, + { + "commandType": "dispense", + "notes": [], + "params": { + "flowRate": 7.56, + "volume": 18.0, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": -13.78 + }, + "origin": "top" + }, + "wellName": "C7" + }, + "result": { + "position": { + "x": 200.88, + "y": 56.24, + "z": 1.92 + }, + "volume": 18.0 + }, + "status": "succeeded" + }, + { + "commandType": "moveToAddressableAreaForDropTip", + "notes": [], + "params": { + "addressableAreaName": "fixedTrash", + "alternateDropLocation": false, + "forceDirect": false, + "ignoreTipConfiguration": true, + "offset": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + }, + "result": { + "position": { + "x": 347.84000000000003, + "y": 351.5, + "z": 82.0 + } + }, + "status": "succeeded" + }, + { + "commandType": "blowOutInPlace", + "notes": [], + "params": { + "flowRate": 7.56 + }, + "result": {}, + "status": "succeeded" + }, + { + "commandType": "aspirate", + "notes": [], + "params": { + "flowRate": 7.56, + "volume": 19.0, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": -25.85 + }, + "origin": "top" + }, + "wellName": "A2" + }, + "result": { + "position": { + "x": 288.38, + "y": 42.78, + "z": 5.55 + }, + "volume": 19.0 + }, + "status": "succeeded" + }, + { + "commandType": "dispense", + "notes": [], + "params": { + "flowRate": 7.56, + "volume": 18.0, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": -13.78 + }, + "origin": "top" + }, + "wellName": "D6" + }, + "result": { + "position": { + "x": 191.88, + "y": 47.24, + "z": 1.92 + }, + "volume": 18.0 + }, + "status": "succeeded" + }, + { + "commandType": "moveToAddressableAreaForDropTip", + "notes": [], + "params": { + "addressableAreaName": "fixedTrash", + "alternateDropLocation": false, + "forceDirect": false, + "ignoreTipConfiguration": true, + "offset": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + }, + "result": { + "position": { + "x": 347.84000000000003, + "y": 351.5, + "z": 82.0 + } + }, + "status": "succeeded" + }, + { + "commandType": "blowOutInPlace", + "notes": [], + "params": { + "flowRate": 7.56 + }, + "result": {}, + "status": "succeeded" + }, + { + "commandType": "aspirate", + "notes": [], + "params": { + "flowRate": 7.56, + "volume": 19.0, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": -25.85 + }, + "origin": "top" + }, + "wellName": "A2" + }, + "result": { + "position": { + "x": 288.38, + "y": 42.78, + "z": 5.55 + }, + "volume": 19.0 + }, + "status": "succeeded" + }, + { + "commandType": "dispense", + "notes": [], + "params": { + "flowRate": 7.56, + "volume": 18.0, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": -13.78 + }, + "origin": "top" + }, + "wellName": "D7" + }, + "result": { + "position": { + "x": 200.88, + "y": 47.24, + "z": 1.92 + }, + "volume": 18.0 + }, + "status": "succeeded" + }, + { + "commandType": "moveToAddressableAreaForDropTip", + "notes": [], + "params": { + "addressableAreaName": "fixedTrash", + "alternateDropLocation": false, + "forceDirect": false, + "ignoreTipConfiguration": true, + "offset": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + }, + "result": { + "position": { + "x": 347.84000000000003, + "y": 351.5, + "z": 82.0 + } + }, + "status": "succeeded" + }, + { + "commandType": "blowOutInPlace", + "notes": [], + "params": { + "flowRate": 7.56 + }, + "result": {}, + "status": "succeeded" + }, + { + "commandType": "aspirate", + "notes": [], + "params": { + "flowRate": 7.56, + "volume": 19.0, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": -25.85 + }, + "origin": "top" + }, + "wellName": "A2" + }, + "result": { + "position": { + "x": 288.38, + "y": 42.78, + "z": 5.55 + }, + "volume": 19.0 + }, + "status": "succeeded" + }, + { + "commandType": "dispense", + "notes": [], + "params": { + "flowRate": 7.56, + "volume": 18.0, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": -13.78 + }, + "origin": "top" + }, + "wellName": "D8" + }, + "result": { + "position": { + "x": 209.88, + "y": 47.24, + "z": 1.92 + }, + "volume": 18.0 + }, + "status": "succeeded" + }, + { + "commandType": "moveToAddressableAreaForDropTip", + "notes": [], + "params": { + "addressableAreaName": "fixedTrash", + "alternateDropLocation": false, + "forceDirect": false, + "ignoreTipConfiguration": true, + "offset": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + }, + "result": { + "position": { + "x": 347.84000000000003, + "y": 351.5, + "z": 82.0 + } + }, + "status": "succeeded" + }, + { + "commandType": "blowOutInPlace", + "notes": [], + "params": { + "flowRate": 7.56 + }, + "result": {}, + "status": "succeeded" + }, + { + "commandType": "aspirate", + "notes": [], + "params": { + "flowRate": 7.56, + "volume": 19.0, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": -25.85 + }, + "origin": "top" + }, + "wellName": "A2" + }, + "result": { + "position": { + "x": 288.38, + "y": 42.78, + "z": 5.55 + }, + "volume": 19.0 + }, + "status": "succeeded" + }, + { + "commandType": "dispense", + "notes": [], + "params": { + "flowRate": 7.56, + "volume": 18.0, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": -13.78 + }, + "origin": "top" + }, + "wellName": "E5" + }, + "result": { + "position": { + "x": 182.88, + "y": 38.24, + "z": 1.92 + }, + "volume": 18.0 + }, + "status": "succeeded" + }, + { + "commandType": "moveToAddressableAreaForDropTip", + "notes": [], + "params": { + "addressableAreaName": "fixedTrash", + "alternateDropLocation": false, + "forceDirect": false, + "ignoreTipConfiguration": true, + "offset": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + }, + "result": { + "position": { + "x": 347.84000000000003, + "y": 351.5, + "z": 82.0 + } + }, + "status": "succeeded" + }, + { + "commandType": "blowOutInPlace", + "notes": [], + "params": { + "flowRate": 7.56 + }, + "result": {}, + "status": "succeeded" + }, + { + "commandType": "moveToAddressableAreaForDropTip", + "notes": [], + "params": { + "addressableAreaName": "fixedTrash", + "alternateDropLocation": true, + "forceDirect": false, + "ignoreTipConfiguration": true, + "offset": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + }, + "result": { + "position": { + "x": 363.89500000000004, + "y": 351.5, + "z": 82.0 + } + }, + "status": "succeeded" + }, + { + "commandType": "dropTipInPlace", + "notes": [], + "params": {}, + "result": {}, + "status": "succeeded" + }, + { + "commandType": "pickUpTip", + "notes": [], + "params": { + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "origin": "top" + }, + "wellName": "B1" + }, + "result": { + "position": { + "x": 14.38, + "y": 155.74, + "z": 64.69 + }, + "tipDiameter": 3.27, + "tipLength": 30.950000000000003, + "tipVolume": 20.0 + }, + "status": "succeeded" + }, + { + "commandType": "aspirate", + "notes": [], + "params": { + "flowRate": 7.56, + "volume": 20.0, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": -25.85 + }, + "origin": "top" + }, + "wellName": "A2" + }, + "result": { + "position": { + "x": 288.38, + "y": 42.78, + "z": 5.55 + }, + "volume": 20.0 + }, + "status": "succeeded" + }, + { + "commandType": "dispense", + "notes": [], + "params": { + "flowRate": 7.56, + "pushOut": 0.0, + "volume": 20.0, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": -25.85 + }, + "origin": "top" + }, + "wellName": "A2" + }, + "result": { + "position": { + "x": 288.38, + "y": 42.78, + "z": 5.55 + }, + "volume": 20.0 + }, + "status": "succeeded" + }, + { + "commandType": "aspirate", + "notes": [], + "params": { + "flowRate": 7.56, + "volume": 20.0, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": -25.85 + }, + "origin": "top" + }, + "wellName": "A2" + }, + "result": { + "position": { + "x": 288.38, + "y": 42.78, + "z": 5.55 + }, + "volume": 20.0 + }, + "status": "succeeded" + }, + { + "commandType": "dispense", + "notes": [], + "params": { + "flowRate": 7.56, + "pushOut": 0.0, + "volume": 20.0, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": -25.85 + }, + "origin": "top" + }, + "wellName": "A2" + }, + "result": { + "position": { + "x": 288.38, + "y": 42.78, + "z": 5.55 + }, + "volume": 20.0 + }, + "status": "succeeded" + }, + { + "commandType": "aspirate", + "notes": [], + "params": { + "flowRate": 7.56, + "volume": 20.0, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": -25.85 + }, + "origin": "top" + }, + "wellName": "A2" + }, + "result": { + "position": { + "x": 288.38, + "y": 42.78, + "z": 5.55 + }, + "volume": 20.0 + }, + "status": "succeeded" + }, + { + "commandType": "dispense", + "notes": [], + "params": { + "flowRate": 7.56, + "volume": 20.0, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": -25.85 + }, + "origin": "top" + }, + "wellName": "A2" + }, + "result": { + "position": { + "x": 288.38, + "y": 42.78, + "z": 5.55 + }, + "volume": 20.0 + }, + "status": "succeeded" + }, + { + "commandType": "aspirate", + "notes": [], + "params": { + "flowRate": 7.56, + "volume": 20.0, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": -25.85 + }, + "origin": "top" + }, + "wellName": "A2" + }, + "result": { + "position": { + "x": 288.38, + "y": 42.78, + "z": 5.55 + }, + "volume": 20.0 + }, + "status": "succeeded" + }, + { + "commandType": "dispense", + "notes": [], + "params": { + "flowRate": 7.56, + "volume": 20.0, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": -13.78 + }, + "origin": "top" + }, + "wellName": "A11" + }, + "result": { + "position": { + "x": 236.88, + "y": 74.24, + "z": 1.92 + }, + "volume": 20.0 + }, + "status": "succeeded" + }, + { + "commandType": "aspirate", + "notes": [], + "params": { + "flowRate": 7.56, + "volume": 20.0, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": -13.78 + }, + "origin": "top" + }, + "wellName": "A11" + }, + "result": { + "position": { + "x": 236.88, + "y": 74.24, + "z": 1.92 + }, + "volume": 20.0 + }, + "status": "succeeded" + }, + { + "commandType": "dispense", + "notes": [], + "params": { + "flowRate": 7.56, + "volume": 20.0, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": -13.78 + }, + "origin": "top" + }, + "wellName": "A11" + }, + "result": { + "position": { + "x": 236.88, + "y": 74.24, + "z": 1.92 + }, + "volume": 20.0 + }, + "status": "succeeded" + }, + { + "commandType": "touchTip", + "notes": [], + "params": { + "radius": 1.0, + "speed": 60.0, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": -1.0 + }, + "origin": "top" + }, + "wellName": "A11" + }, + "result": { + "position": { + "x": 236.88, + "y": 74.24, + "z": 14.7 + } + }, + "status": "succeeded" + }, + { + "commandType": "blowout", + "notes": [], + "params": { + "flowRate": 7.56, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "origin": "top" + }, + "wellName": "A11" + }, + "result": { + "position": { + "x": 236.88, + "y": 74.24, + "z": 15.7 + } + }, + "status": "succeeded" + }, + { + "commandType": "aspirate", + "notes": [], + "params": { + "flowRate": 7.56, + "volume": 20.0, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": -25.85 + }, + "origin": "top" + }, + "wellName": "A2" + }, + "result": { + "position": { + "x": 288.38, + "y": 42.78, + "z": 5.55 + }, + "volume": 20.0 + }, + "status": "succeeded" + }, + { + "commandType": "dispense", + "notes": [], + "params": { + "flowRate": 7.56, + "pushOut": 0.0, + "volume": 20.0, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": -25.85 + }, + "origin": "top" + }, + "wellName": "A2" + }, + "result": { + "position": { + "x": 288.38, + "y": 42.78, + "z": 5.55 + }, + "volume": 20.0 + }, + "status": "succeeded" + }, + { + "commandType": "aspirate", + "notes": [], + "params": { + "flowRate": 7.56, + "volume": 20.0, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": -25.85 + }, + "origin": "top" + }, + "wellName": "A2" + }, + "result": { + "position": { + "x": 288.38, + "y": 42.78, + "z": 5.55 + }, + "volume": 20.0 + }, + "status": "succeeded" + }, + { + "commandType": "dispense", + "notes": [], + "params": { + "flowRate": 7.56, + "pushOut": 0.0, + "volume": 20.0, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": -25.85 + }, + "origin": "top" + }, + "wellName": "A2" + }, + "result": { + "position": { + "x": 288.38, + "y": 42.78, + "z": 5.55 + }, + "volume": 20.0 + }, + "status": "succeeded" + }, + { + "commandType": "aspirate", + "notes": [], + "params": { + "flowRate": 7.56, + "volume": 20.0, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": -25.85 + }, + "origin": "top" + }, + "wellName": "A2" + }, + "result": { + "position": { + "x": 288.38, + "y": 42.78, + "z": 5.55 + }, + "volume": 20.0 + }, + "status": "succeeded" + }, + { + "commandType": "dispense", + "notes": [], + "params": { + "flowRate": 7.56, + "volume": 20.0, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": -25.85 + }, + "origin": "top" + }, + "wellName": "A2" + }, + "result": { + "position": { + "x": 288.38, + "y": 42.78, + "z": 5.55 + }, + "volume": 20.0 + }, + "status": "succeeded" + }, + { + "commandType": "aspirate", + "notes": [], + "params": { + "flowRate": 7.56, + "volume": 20.0, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": -25.85 + }, + "origin": "top" + }, + "wellName": "A2" + }, + "result": { + "position": { + "x": 288.38, + "y": 42.78, + "z": 5.55 + }, + "volume": 20.0 + }, + "status": "succeeded" + }, + { + "commandType": "dispense", + "notes": [], + "params": { + "flowRate": 7.56, + "volume": 20.0, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": -13.78 + }, + "origin": "top" + }, + "wellName": "A11" + }, + "result": { + "position": { + "x": 236.88, + "y": 74.24, + "z": 1.92 + }, + "volume": 20.0 + }, + "status": "succeeded" + }, + { + "commandType": "aspirate", + "notes": [], + "params": { + "flowRate": 7.56, + "volume": 20.0, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": -13.78 + }, + "origin": "top" + }, + "wellName": "A11" + }, + "result": { + "position": { + "x": 236.88, + "y": 74.24, + "z": 1.92 + }, + "volume": 20.0 + }, + "status": "succeeded" + }, + { + "commandType": "dispense", + "notes": [], + "params": { + "flowRate": 7.56, + "volume": 20.0, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": -13.78 + }, + "origin": "top" + }, + "wellName": "A11" + }, + "result": { + "position": { + "x": 236.88, + "y": 74.24, + "z": 1.92 + }, + "volume": 20.0 + }, + "status": "succeeded" + }, + { + "commandType": "touchTip", + "notes": [], + "params": { + "radius": 1.0, + "speed": 60.0, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": -1.0 + }, + "origin": "top" + }, + "wellName": "A11" + }, + "result": { + "position": { + "x": 236.88, + "y": 74.24, + "z": 14.7 + } + }, + "status": "succeeded" + }, + { + "commandType": "blowout", + "notes": [], + "params": { + "flowRate": 7.56, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "origin": "top" + }, + "wellName": "A11" + }, + "result": { + "position": { + "x": 236.88, + "y": 74.24, + "z": 15.7 + } + }, + "status": "succeeded" + }, + { + "commandType": "aspirate", + "notes": [], + "params": { + "flowRate": 7.56, + "volume": 20.0, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": -25.85 + }, + "origin": "top" + }, + "wellName": "A2" + }, + "result": { + "position": { + "x": 288.38, + "y": 42.78, + "z": 5.55 + }, + "volume": 20.0 + }, + "status": "succeeded" + }, + { + "commandType": "dispense", + "notes": [], + "params": { + "flowRate": 7.56, + "pushOut": 0.0, + "volume": 20.0, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": -25.85 + }, + "origin": "top" + }, + "wellName": "A2" + }, + "result": { + "position": { + "x": 288.38, + "y": 42.78, + "z": 5.55 + }, + "volume": 20.0 + }, + "status": "succeeded" + }, + { + "commandType": "aspirate", + "notes": [], + "params": { + "flowRate": 7.56, + "volume": 20.0, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": -25.85 + }, + "origin": "top" + }, + "wellName": "A2" + }, + "result": { + "position": { + "x": 288.38, + "y": 42.78, + "z": 5.55 + }, + "volume": 20.0 + }, + "status": "succeeded" + }, + { + "commandType": "dispense", + "notes": [], + "params": { + "flowRate": 7.56, + "pushOut": 0.0, + "volume": 20.0, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": -25.85 + }, + "origin": "top" + }, + "wellName": "A2" + }, + "result": { + "position": { + "x": 288.38, + "y": 42.78, + "z": 5.55 + }, + "volume": 20.0 + }, + "status": "succeeded" + }, + { + "commandType": "aspirate", + "notes": [], + "params": { + "flowRate": 7.56, + "volume": 20.0, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": -25.85 + }, + "origin": "top" + }, + "wellName": "A2" + }, + "result": { + "position": { + "x": 288.38, + "y": 42.78, + "z": 5.55 + }, + "volume": 20.0 + }, + "status": "succeeded" + }, + { + "commandType": "dispense", + "notes": [], + "params": { + "flowRate": 7.56, + "volume": 20.0, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": -25.85 + }, + "origin": "top" + }, + "wellName": "A2" + }, + "result": { + "position": { + "x": 288.38, + "y": 42.78, + "z": 5.55 + }, + "volume": 20.0 + }, + "status": "succeeded" + }, + { + "commandType": "aspirate", + "notes": [], + "params": { + "flowRate": 7.56, + "volume": 20.0, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": -25.85 + }, + "origin": "top" + }, + "wellName": "A2" + }, + "result": { + "position": { + "x": 288.38, + "y": 42.78, + "z": 5.55 + }, + "volume": 20.0 + }, + "status": "succeeded" + }, + { + "commandType": "dispense", + "notes": [], + "params": { + "flowRate": 7.56, + "volume": 20.0, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": -13.78 + }, + "origin": "top" + }, + "wellName": "A11" + }, + "result": { + "position": { + "x": 236.88, + "y": 74.24, + "z": 1.92 + }, + "volume": 20.0 + }, + "status": "succeeded" + }, + { + "commandType": "aspirate", + "notes": [], + "params": { + "flowRate": 7.56, + "volume": 20.0, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": -13.78 + }, + "origin": "top" + }, + "wellName": "A11" + }, + "result": { + "position": { + "x": 236.88, + "y": 74.24, + "z": 1.92 + }, + "volume": 20.0 + }, + "status": "succeeded" + }, + { + "commandType": "dispense", + "notes": [], + "params": { + "flowRate": 7.56, + "volume": 20.0, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": -13.78 + }, + "origin": "top" + }, + "wellName": "A11" + }, + "result": { + "position": { + "x": 236.88, + "y": 74.24, + "z": 1.92 + }, + "volume": 20.0 + }, + "status": "succeeded" + }, + { + "commandType": "touchTip", + "notes": [], + "params": { + "radius": 1.0, + "speed": 60.0, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": -1.0 + }, + "origin": "top" + }, + "wellName": "A11" + }, + "result": { + "position": { + "x": 236.88, + "y": 74.24, + "z": 14.7 + } + }, + "status": "succeeded" + }, + { + "commandType": "blowout", + "notes": [], + "params": { + "flowRate": 7.56, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "origin": "top" + }, + "wellName": "A11" + }, + "result": { + "position": { + "x": 236.88, + "y": 74.24, + "z": 15.7 + } + }, + "status": "succeeded" + }, + { + "commandType": "aspirate", + "notes": [], + "params": { + "flowRate": 7.56, + "volume": 20.0, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": -25.85 + }, + "origin": "top" + }, + "wellName": "A2" + }, + "result": { + "position": { + "x": 288.38, + "y": 42.78, + "z": 5.55 + }, + "volume": 20.0 + }, + "status": "succeeded" + }, + { + "commandType": "dispense", + "notes": [], + "params": { + "flowRate": 7.56, + "pushOut": 0.0, + "volume": 20.0, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": -25.85 + }, + "origin": "top" + }, + "wellName": "A2" + }, + "result": { + "position": { + "x": 288.38, + "y": 42.78, + "z": 5.55 + }, + "volume": 20.0 + }, + "status": "succeeded" + }, + { + "commandType": "aspirate", + "notes": [], + "params": { + "flowRate": 7.56, + "volume": 20.0, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": -25.85 + }, + "origin": "top" + }, + "wellName": "A2" + }, + "result": { + "position": { + "x": 288.38, + "y": 42.78, + "z": 5.55 + }, + "volume": 20.0 + }, + "status": "succeeded" + }, + { + "commandType": "dispense", + "notes": [], + "params": { + "flowRate": 7.56, + "pushOut": 0.0, + "volume": 20.0, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": -25.85 + }, + "origin": "top" + }, + "wellName": "A2" + }, + "result": { + "position": { + "x": 288.38, + "y": 42.78, + "z": 5.55 + }, + "volume": 20.0 + }, + "status": "succeeded" + }, + { + "commandType": "aspirate", + "notes": [], + "params": { + "flowRate": 7.56, + "volume": 20.0, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": -25.85 + }, + "origin": "top" + }, + "wellName": "A2" + }, + "result": { + "position": { + "x": 288.38, + "y": 42.78, + "z": 5.55 + }, + "volume": 20.0 + }, + "status": "succeeded" + }, + { + "commandType": "dispense", + "notes": [], + "params": { + "flowRate": 7.56, + "volume": 20.0, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": -25.85 + }, + "origin": "top" + }, + "wellName": "A2" + }, + "result": { + "position": { + "x": 288.38, + "y": 42.78, + "z": 5.55 + }, + "volume": 20.0 + }, + "status": "succeeded" + }, + { + "commandType": "aspirate", + "notes": [], + "params": { + "flowRate": 7.56, + "volume": 20.0, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": -25.85 + }, + "origin": "top" + }, + "wellName": "A2" + }, + "result": { + "position": { + "x": 288.38, + "y": 42.78, + "z": 5.55 + }, + "volume": 20.0 + }, + "status": "succeeded" + }, + { + "commandType": "dispense", + "notes": [], + "params": { + "flowRate": 7.56, + "volume": 20.0, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": -13.78 + }, + "origin": "top" + }, + "wellName": "B11" + }, + "result": { + "position": { + "x": 236.88, + "y": 65.24, + "z": 1.92 + }, + "volume": 20.0 + }, + "status": "succeeded" + }, + { + "commandType": "aspirate", + "notes": [], + "params": { + "flowRate": 7.56, + "volume": 20.0, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": -13.78 + }, + "origin": "top" + }, + "wellName": "B11" + }, + "result": { + "position": { + "x": 236.88, + "y": 65.24, + "z": 1.92 + }, + "volume": 20.0 + }, + "status": "succeeded" + }, + { + "commandType": "dispense", + "notes": [], + "params": { + "flowRate": 7.56, + "volume": 20.0, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": -13.78 + }, + "origin": "top" + }, + "wellName": "B11" + }, + "result": { + "position": { + "x": 236.88, + "y": 65.24, + "z": 1.92 + }, + "volume": 20.0 + }, + "status": "succeeded" + }, + { + "commandType": "touchTip", + "notes": [], + "params": { + "radius": 1.0, + "speed": 60.0, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": -1.0 + }, + "origin": "top" + }, + "wellName": "B11" + }, + "result": { + "position": { + "x": 236.88, + "y": 65.24, + "z": 14.7 + } + }, + "status": "succeeded" + }, + { + "commandType": "blowout", + "notes": [], + "params": { + "flowRate": 7.56, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "origin": "top" + }, + "wellName": "B11" + }, + "result": { + "position": { + "x": 236.88, + "y": 65.24, + "z": 15.7 + } + }, + "status": "succeeded" + }, + { + "commandType": "aspirate", + "notes": [], + "params": { + "flowRate": 7.56, + "volume": 20.0, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": -25.85 + }, + "origin": "top" + }, + "wellName": "A2" + }, + "result": { + "position": { + "x": 288.38, + "y": 42.78, + "z": 5.55 + }, + "volume": 20.0 + }, + "status": "succeeded" + }, + { + "commandType": "dispense", + "notes": [], + "params": { + "flowRate": 7.56, + "pushOut": 0.0, + "volume": 20.0, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": -25.85 + }, + "origin": "top" + }, + "wellName": "A2" + }, + "result": { + "position": { + "x": 288.38, + "y": 42.78, + "z": 5.55 + }, + "volume": 20.0 + }, + "status": "succeeded" + }, + { + "commandType": "aspirate", + "notes": [], + "params": { + "flowRate": 7.56, + "volume": 20.0, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": -25.85 + }, + "origin": "top" + }, + "wellName": "A2" + }, + "result": { + "position": { + "x": 288.38, + "y": 42.78, + "z": 5.55 + }, + "volume": 20.0 + }, + "status": "succeeded" + }, + { + "commandType": "dispense", + "notes": [], + "params": { + "flowRate": 7.56, + "pushOut": 0.0, + "volume": 20.0, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": -25.85 + }, + "origin": "top" + }, + "wellName": "A2" + }, + "result": { + "position": { + "x": 288.38, + "y": 42.78, + "z": 5.55 + }, + "volume": 20.0 + }, + "status": "succeeded" + }, + { + "commandType": "aspirate", + "notes": [], + "params": { + "flowRate": 7.56, + "volume": 20.0, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": -25.85 + }, + "origin": "top" + }, + "wellName": "A2" + }, + "result": { + "position": { + "x": 288.38, + "y": 42.78, + "z": 5.55 + }, + "volume": 20.0 + }, + "status": "succeeded" + }, + { + "commandType": "dispense", + "notes": [], + "params": { + "flowRate": 7.56, + "volume": 20.0, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": -25.85 + }, + "origin": "top" + }, + "wellName": "A2" + }, + "result": { + "position": { + "x": 288.38, + "y": 42.78, + "z": 5.55 + }, + "volume": 20.0 + }, + "status": "succeeded" + }, + { + "commandType": "aspirate", + "notes": [], + "params": { + "flowRate": 7.56, + "volume": 20.0, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": -25.85 + }, + "origin": "top" + }, + "wellName": "A2" + }, + "result": { + "position": { + "x": 288.38, + "y": 42.78, + "z": 5.55 + }, + "volume": 20.0 + }, + "status": "succeeded" + }, + { + "commandType": "dispense", + "notes": [], + "params": { + "flowRate": 7.56, + "volume": 20.0, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": -13.78 + }, + "origin": "top" + }, + "wellName": "B11" + }, + "result": { + "position": { + "x": 236.88, + "y": 65.24, + "z": 1.92 + }, + "volume": 20.0 + }, + "status": "succeeded" + }, + { + "commandType": "aspirate", + "notes": [], + "params": { + "flowRate": 7.56, + "volume": 20.0, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": -13.78 + }, + "origin": "top" + }, + "wellName": "B11" + }, + "result": { + "position": { + "x": 236.88, + "y": 65.24, + "z": 1.92 + }, + "volume": 20.0 + }, + "status": "succeeded" + }, + { + "commandType": "dispense", + "notes": [], + "params": { + "flowRate": 7.56, + "volume": 20.0, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": -13.78 + }, + "origin": "top" + }, + "wellName": "B11" + }, + "result": { + "position": { + "x": 236.88, + "y": 65.24, + "z": 1.92 + }, + "volume": 20.0 + }, + "status": "succeeded" + }, + { + "commandType": "touchTip", + "notes": [], + "params": { + "radius": 1.0, + "speed": 60.0, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": -1.0 + }, + "origin": "top" + }, + "wellName": "B11" + }, + "result": { + "position": { + "x": 236.88, + "y": 65.24, + "z": 14.7 + } + }, + "status": "succeeded" + }, + { + "commandType": "blowout", + "notes": [], + "params": { + "flowRate": 7.56, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "origin": "top" + }, + "wellName": "B11" + }, + "result": { + "position": { + "x": 236.88, + "y": 65.24, + "z": 15.7 + } + }, + "status": "succeeded" + }, + { + "commandType": "aspirate", + "notes": [], + "params": { + "flowRate": 7.56, + "volume": 20.0, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": -25.85 + }, + "origin": "top" + }, + "wellName": "A2" + }, + "result": { + "position": { + "x": 288.38, + "y": 42.78, + "z": 5.55 + }, + "volume": 20.0 + }, + "status": "succeeded" + }, + { + "commandType": "dispense", + "notes": [], + "params": { + "flowRate": 7.56, + "pushOut": 0.0, + "volume": 20.0, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": -25.85 + }, + "origin": "top" + }, + "wellName": "A2" + }, + "result": { + "position": { + "x": 288.38, + "y": 42.78, + "z": 5.55 + }, + "volume": 20.0 + }, + "status": "succeeded" + }, + { + "commandType": "aspirate", + "notes": [], + "params": { + "flowRate": 7.56, + "volume": 20.0, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": -25.85 + }, + "origin": "top" + }, + "wellName": "A2" + }, + "result": { + "position": { + "x": 288.38, + "y": 42.78, + "z": 5.55 + }, + "volume": 20.0 + }, + "status": "succeeded" + }, + { + "commandType": "dispense", + "notes": [], + "params": { + "flowRate": 7.56, + "pushOut": 0.0, + "volume": 20.0, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": -25.85 + }, + "origin": "top" + }, + "wellName": "A2" + }, + "result": { + "position": { + "x": 288.38, + "y": 42.78, + "z": 5.55 + }, + "volume": 20.0 + }, + "status": "succeeded" + }, + { + "commandType": "aspirate", + "notes": [], + "params": { + "flowRate": 7.56, + "volume": 20.0, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": -25.85 + }, + "origin": "top" + }, + "wellName": "A2" + }, + "result": { + "position": { + "x": 288.38, + "y": 42.78, + "z": 5.55 + }, + "volume": 20.0 + }, + "status": "succeeded" + }, + { + "commandType": "dispense", + "notes": [], + "params": { + "flowRate": 7.56, + "volume": 20.0, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": -25.85 + }, + "origin": "top" + }, + "wellName": "A2" + }, + "result": { + "position": { + "x": 288.38, + "y": 42.78, + "z": 5.55 + }, + "volume": 20.0 + }, + "status": "succeeded" + }, + { + "commandType": "aspirate", + "notes": [], + "params": { + "flowRate": 7.56, + "volume": 20.0, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": -25.85 + }, + "origin": "top" + }, + "wellName": "A2" + }, + "result": { + "position": { + "x": 288.38, + "y": 42.78, + "z": 5.55 + }, + "volume": 20.0 + }, + "status": "succeeded" + }, + { + "commandType": "dispense", + "notes": [], + "params": { + "flowRate": 7.56, + "volume": 20.0, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": -13.78 + }, + "origin": "top" + }, + "wellName": "B11" + }, + "result": { + "position": { + "x": 236.88, + "y": 65.24, + "z": 1.92 + }, + "volume": 20.0 + }, + "status": "succeeded" + }, + { + "commandType": "aspirate", + "notes": [], + "params": { + "flowRate": 7.56, + "volume": 20.0, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": -13.78 + }, + "origin": "top" + }, + "wellName": "B11" + }, + "result": { + "position": { + "x": 236.88, + "y": 65.24, + "z": 1.92 + }, + "volume": 20.0 + }, + "status": "succeeded" + }, + { + "commandType": "dispense", + "notes": [], + "params": { + "flowRate": 7.56, + "volume": 20.0, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": -13.78 + }, + "origin": "top" + }, + "wellName": "B11" + }, + "result": { + "position": { + "x": 236.88, + "y": 65.24, + "z": 1.92 + }, + "volume": 20.0 + }, + "status": "succeeded" + }, + { + "commandType": "touchTip", + "notes": [], + "params": { + "radius": 1.0, + "speed": 60.0, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": -1.0 + }, + "origin": "top" + }, + "wellName": "B11" + }, + "result": { + "position": { + "x": 236.88, + "y": 65.24, + "z": 14.7 + } + }, + "status": "succeeded" + }, + { + "commandType": "blowout", + "notes": [], + "params": { + "flowRate": 7.56, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "origin": "top" + }, + "wellName": "B11" + }, + "result": { + "position": { + "x": 236.88, + "y": 65.24, + "z": 15.7 + } + }, + "status": "succeeded" + }, + { + "commandType": "aspirate", + "notes": [], + "params": { + "flowRate": 7.56, + "volume": 20.0, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": -25.85 + }, + "origin": "top" + }, + "wellName": "A2" + }, + "result": { + "position": { + "x": 288.38, + "y": 42.78, + "z": 5.55 + }, + "volume": 20.0 + }, + "status": "succeeded" + }, + { + "commandType": "dispense", + "notes": [], + "params": { + "flowRate": 7.56, + "pushOut": 0.0, + "volume": 20.0, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": -25.85 + }, + "origin": "top" + }, + "wellName": "A2" + }, + "result": { + "position": { + "x": 288.38, + "y": 42.78, + "z": 5.55 + }, + "volume": 20.0 + }, + "status": "succeeded" + }, + { + "commandType": "aspirate", + "notes": [], + "params": { + "flowRate": 7.56, + "volume": 20.0, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": -25.85 + }, + "origin": "top" + }, + "wellName": "A2" + }, + "result": { + "position": { + "x": 288.38, + "y": 42.78, + "z": 5.55 + }, + "volume": 20.0 + }, + "status": "succeeded" + }, + { + "commandType": "dispense", + "notes": [], + "params": { + "flowRate": 7.56, + "pushOut": 0.0, + "volume": 20.0, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": -25.85 + }, + "origin": "top" + }, + "wellName": "A2" + }, + "result": { + "position": { + "x": 288.38, + "y": 42.78, + "z": 5.55 + }, + "volume": 20.0 + }, + "status": "succeeded" + }, + { + "commandType": "aspirate", + "notes": [], + "params": { + "flowRate": 7.56, + "volume": 20.0, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": -25.85 + }, + "origin": "top" + }, + "wellName": "A2" + }, + "result": { + "position": { + "x": 288.38, + "y": 42.78, + "z": 5.55 + }, + "volume": 20.0 + }, + "status": "succeeded" + }, + { + "commandType": "dispense", + "notes": [], + "params": { + "flowRate": 7.56, + "volume": 20.0, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": -25.85 + }, + "origin": "top" + }, + "wellName": "A2" + }, + "result": { + "position": { + "x": 288.38, + "y": 42.78, + "z": 5.55 + }, + "volume": 20.0 + }, + "status": "succeeded" + }, + { + "commandType": "aspirate", + "notes": [], + "params": { + "flowRate": 7.56, + "volume": 20.0, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": -25.85 + }, + "origin": "top" + }, + "wellName": "A2" + }, + "result": { + "position": { + "x": 288.38, + "y": 42.78, + "z": 5.55 + }, + "volume": 20.0 + }, + "status": "succeeded" + }, + { + "commandType": "dispense", + "notes": [], + "params": { + "flowRate": 7.56, + "volume": 20.0, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": -13.78 + }, + "origin": "top" + }, + "wellName": "C11" + }, + "result": { + "position": { + "x": 236.88, + "y": 56.24, + "z": 1.92 + }, + "volume": 20.0 + }, + "status": "succeeded" + }, + { + "commandType": "aspirate", + "notes": [], + "params": { + "flowRate": 7.56, + "volume": 20.0, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": -13.78 + }, + "origin": "top" + }, + "wellName": "C11" + }, + "result": { + "position": { + "x": 236.88, + "y": 56.24, + "z": 1.92 + }, + "volume": 20.0 + }, + "status": "succeeded" + }, + { + "commandType": "dispense", + "notes": [], + "params": { + "flowRate": 7.56, + "volume": 20.0, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": -13.78 + }, + "origin": "top" + }, + "wellName": "C11" + }, + "result": { + "position": { + "x": 236.88, + "y": 56.24, + "z": 1.92 + }, + "volume": 20.0 + }, + "status": "succeeded" + }, + { + "commandType": "touchTip", + "notes": [], + "params": { + "radius": 1.0, + "speed": 60.0, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": -1.0 + }, + "origin": "top" + }, + "wellName": "C11" + }, + "result": { + "position": { + "x": 236.88, + "y": 56.24, + "z": 14.7 + } + }, + "status": "succeeded" + }, + { + "commandType": "blowout", + "notes": [], + "params": { + "flowRate": 7.56, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "origin": "top" + }, + "wellName": "C11" + }, + "result": { + "position": { + "x": 236.88, + "y": 56.24, + "z": 15.7 + } + }, + "status": "succeeded" + }, + { + "commandType": "aspirate", + "notes": [], + "params": { + "flowRate": 7.56, + "volume": 20.0, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": -25.85 + }, + "origin": "top" + }, + "wellName": "A2" + }, + "result": { + "position": { + "x": 288.38, + "y": 42.78, + "z": 5.55 + }, + "volume": 20.0 + }, + "status": "succeeded" + }, + { + "commandType": "dispense", + "notes": [], + "params": { + "flowRate": 7.56, + "pushOut": 0.0, + "volume": 20.0, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": -25.85 + }, + "origin": "top" + }, + "wellName": "A2" + }, + "result": { + "position": { + "x": 288.38, + "y": 42.78, + "z": 5.55 + }, + "volume": 20.0 + }, + "status": "succeeded" + }, + { + "commandType": "aspirate", + "notes": [], + "params": { + "flowRate": 7.56, + "volume": 20.0, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": -25.85 + }, + "origin": "top" + }, + "wellName": "A2" + }, + "result": { + "position": { + "x": 288.38, + "y": 42.78, + "z": 5.55 + }, + "volume": 20.0 + }, + "status": "succeeded" + }, + { + "commandType": "dispense", + "notes": [], + "params": { + "flowRate": 7.56, + "pushOut": 0.0, + "volume": 20.0, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": -25.85 + }, + "origin": "top" + }, + "wellName": "A2" + }, + "result": { + "position": { + "x": 288.38, + "y": 42.78, + "z": 5.55 + }, + "volume": 20.0 + }, + "status": "succeeded" + }, + { + "commandType": "aspirate", + "notes": [], + "params": { + "flowRate": 7.56, + "volume": 20.0, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": -25.85 + }, + "origin": "top" + }, + "wellName": "A2" + }, + "result": { + "position": { + "x": 288.38, + "y": 42.78, + "z": 5.55 + }, + "volume": 20.0 + }, + "status": "succeeded" + }, + { + "commandType": "dispense", + "notes": [], + "params": { + "flowRate": 7.56, + "volume": 20.0, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": -25.85 + }, + "origin": "top" + }, + "wellName": "A2" + }, + "result": { + "position": { + "x": 288.38, + "y": 42.78, + "z": 5.55 + }, + "volume": 20.0 + }, + "status": "succeeded" + }, + { + "commandType": "aspirate", + "notes": [], + "params": { + "flowRate": 7.56, + "volume": 20.0, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": -25.85 + }, + "origin": "top" + }, + "wellName": "A2" + }, + "result": { + "position": { + "x": 288.38, + "y": 42.78, + "z": 5.55 + }, + "volume": 20.0 + }, + "status": "succeeded" + }, + { + "commandType": "dispense", + "notes": [], + "params": { + "flowRate": 7.56, + "volume": 20.0, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": -13.78 + }, + "origin": "top" + }, + "wellName": "C11" + }, + "result": { + "position": { + "x": 236.88, + "y": 56.24, + "z": 1.92 + }, + "volume": 20.0 + }, + "status": "succeeded" + }, + { + "commandType": "aspirate", + "notes": [], + "params": { + "flowRate": 7.56, + "volume": 20.0, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": -13.78 + }, + "origin": "top" + }, + "wellName": "C11" + }, + "result": { + "position": { + "x": 236.88, + "y": 56.24, + "z": 1.92 + }, + "volume": 20.0 + }, + "status": "succeeded" + }, + { + "commandType": "dispense", + "notes": [], + "params": { + "flowRate": 7.56, + "volume": 20.0, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": -13.78 + }, + "origin": "top" + }, + "wellName": "C11" + }, + "result": { + "position": { + "x": 236.88, + "y": 56.24, + "z": 1.92 + }, + "volume": 20.0 + }, + "status": "succeeded" + }, + { + "commandType": "touchTip", + "notes": [], + "params": { + "radius": 1.0, + "speed": 60.0, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": -1.0 + }, + "origin": "top" + }, + "wellName": "C11" + }, + "result": { + "position": { + "x": 236.88, + "y": 56.24, + "z": 14.7 + } + }, + "status": "succeeded" + }, + { + "commandType": "blowout", + "notes": [], + "params": { + "flowRate": 7.56, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "origin": "top" + }, + "wellName": "C11" + }, + "result": { + "position": { + "x": 236.88, + "y": 56.24, + "z": 15.7 + } + }, + "status": "succeeded" + }, + { + "commandType": "aspirate", + "notes": [], + "params": { + "flowRate": 7.56, + "volume": 20.0, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": -25.85 + }, + "origin": "top" + }, + "wellName": "A2" + }, + "result": { + "position": { + "x": 288.38, + "y": 42.78, + "z": 5.55 + }, + "volume": 20.0 + }, + "status": "succeeded" + }, + { + "commandType": "dispense", + "notes": [], + "params": { + "flowRate": 7.56, + "pushOut": 0.0, + "volume": 20.0, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": -25.85 + }, + "origin": "top" + }, + "wellName": "A2" + }, + "result": { + "position": { + "x": 288.38, + "y": 42.78, + "z": 5.55 + }, + "volume": 20.0 + }, + "status": "succeeded" + }, + { + "commandType": "aspirate", + "notes": [], + "params": { + "flowRate": 7.56, + "volume": 20.0, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": -25.85 + }, + "origin": "top" + }, + "wellName": "A2" + }, + "result": { + "position": { + "x": 288.38, + "y": 42.78, + "z": 5.55 + }, + "volume": 20.0 + }, + "status": "succeeded" + }, + { + "commandType": "dispense", + "notes": [], + "params": { + "flowRate": 7.56, + "pushOut": 0.0, + "volume": 20.0, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": -25.85 + }, + "origin": "top" + }, + "wellName": "A2" + }, + "result": { + "position": { + "x": 288.38, + "y": 42.78, + "z": 5.55 + }, + "volume": 20.0 + }, + "status": "succeeded" + }, + { + "commandType": "aspirate", + "notes": [], + "params": { + "flowRate": 7.56, + "volume": 20.0, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": -25.85 + }, + "origin": "top" + }, + "wellName": "A2" + }, + "result": { + "position": { + "x": 288.38, + "y": 42.78, + "z": 5.55 + }, + "volume": 20.0 + }, + "status": "succeeded" + }, + { + "commandType": "dispense", + "notes": [], + "params": { + "flowRate": 7.56, + "volume": 20.0, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": -25.85 + }, + "origin": "top" + }, + "wellName": "A2" + }, + "result": { + "position": { + "x": 288.38, + "y": 42.78, + "z": 5.55 + }, + "volume": 20.0 + }, + "status": "succeeded" + }, + { + "commandType": "aspirate", + "notes": [], + "params": { + "flowRate": 7.56, + "volume": 20.0, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": -25.85 + }, + "origin": "top" + }, + "wellName": "A2" + }, + "result": { + "position": { + "x": 288.38, + "y": 42.78, + "z": 5.55 + }, + "volume": 20.0 + }, + "status": "succeeded" + }, + { + "commandType": "dispense", + "notes": [], + "params": { + "flowRate": 7.56, + "volume": 20.0, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": -13.78 + }, + "origin": "top" + }, + "wellName": "C11" + }, + "result": { + "position": { + "x": 236.88, + "y": 56.24, + "z": 1.92 + }, + "volume": 20.0 + }, + "status": "succeeded" + }, + { + "commandType": "aspirate", + "notes": [], + "params": { + "flowRate": 7.56, + "volume": 20.0, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": -13.78 + }, + "origin": "top" + }, + "wellName": "C11" + }, + "result": { + "position": { + "x": 236.88, + "y": 56.24, + "z": 1.92 + }, + "volume": 20.0 + }, + "status": "succeeded" + }, + { + "commandType": "dispense", + "notes": [], + "params": { + "flowRate": 7.56, + "volume": 20.0, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": -13.78 + }, + "origin": "top" + }, + "wellName": "C11" + }, + "result": { + "position": { + "x": 236.88, + "y": 56.24, + "z": 1.92 + }, + "volume": 20.0 + }, + "status": "succeeded" + }, + { + "commandType": "touchTip", + "notes": [], + "params": { + "radius": 1.0, + "speed": 60.0, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": -1.0 + }, + "origin": "top" + }, + "wellName": "C11" + }, + "result": { + "position": { + "x": 236.88, + "y": 56.24, + "z": 14.7 + } + }, + "status": "succeeded" + }, + { + "commandType": "blowout", + "notes": [], + "params": { + "flowRate": 7.56, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "origin": "top" + }, + "wellName": "C11" + }, + "result": { + "position": { + "x": 236.88, + "y": 56.24, + "z": 15.7 + } + }, + "status": "succeeded" + }, + { + "commandType": "aspirate", + "notes": [], + "params": { + "flowRate": 7.56, + "volume": 20.0, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": -13.78 + }, + "origin": "top" + }, + "wellName": "A11" + }, + "result": { + "position": { + "x": 236.88, + "y": 74.24, + "z": 1.92 + }, + "volume": 20.0 + }, + "status": "succeeded" + }, + { + "commandType": "dispense", + "notes": [], + "params": { + "flowRate": 7.56, + "volume": 20.0, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": -25.85 + }, + "origin": "top" + }, + "wellName": "A5" + }, + "result": { + "position": { + "x": 315.38, + "y": 42.78, + "z": 5.55 + }, + "volume": 20.0 + }, + "status": "succeeded" + }, + { + "commandType": "blowout", + "notes": [], + "params": { + "flowRate": 7.56, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "origin": "top" + }, + "wellName": "A5" + }, + "result": { + "position": { + "x": 315.38, + "y": 42.78, + "z": 31.400000000000002 + } + }, + "status": "succeeded" + }, + { + "commandType": "aspirate", + "notes": [], + "params": { + "flowRate": 7.56, + "volume": 20.0, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": -13.78 + }, + "origin": "top" + }, + "wellName": "B11" + }, + "result": { + "position": { + "x": 236.88, + "y": 65.24, + "z": 1.92 + }, + "volume": 20.0 + }, + "status": "succeeded" + }, + { + "commandType": "dispense", + "notes": [], + "params": { + "flowRate": 7.56, + "volume": 20.0, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": -25.85 + }, + "origin": "top" + }, + "wellName": "A5" + }, + "result": { + "position": { + "x": 315.38, + "y": 42.78, + "z": 5.55 + }, + "volume": 20.0 + }, + "status": "succeeded" + }, + { + "commandType": "blowout", + "notes": [], + "params": { + "flowRate": 7.56, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "origin": "top" + }, + "wellName": "A5" + }, + "result": { + "position": { + "x": 315.38, + "y": 42.78, + "z": 31.400000000000002 + } + }, + "status": "succeeded" + }, + { + "commandType": "aspirate", + "notes": [], + "params": { + "flowRate": 7.56, + "volume": 20.0, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": -13.78 + }, + "origin": "top" + }, + "wellName": "C11" + }, + "result": { + "position": { + "x": 236.88, + "y": 56.24, + "z": 1.92 + }, + "volume": 20.0 + }, + "status": "succeeded" + }, + { + "commandType": "dispense", + "notes": [], + "params": { + "flowRate": 7.56, + "volume": 20.0, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": -25.85 + }, + "origin": "top" + }, + "wellName": "A5" + }, + "result": { + "position": { + "x": 315.38, + "y": 42.78, + "z": 5.55 + }, + "volume": 20.0 + }, + "status": "succeeded" + }, + { + "commandType": "blowout", + "notes": [], + "params": { + "flowRate": 7.56, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "origin": "top" + }, + "wellName": "A5" + }, + "result": { + "position": { + "x": 315.38, + "y": 42.78, + "z": 31.400000000000002 + } + }, + "status": "succeeded" + }, + { + "commandType": "dropTip", + "notes": [], + "params": { + "alternateDropLocation": false, + "wellLocation": { + "offset": { + "x": 0, + "y": 0, + "z": 0 + }, + "origin": "default" + }, + "wellName": "B1" + }, + "result": { + "position": { + "x": 14.38, + "y": 155.74, + "z": 45.09 + } + }, + "status": "succeeded" + }, + { + "commandType": "pickUpTip", + "notes": [], + "params": { + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "origin": "top" + }, + "wellName": "C1" + }, + "result": { + "position": { + "x": 14.38, + "y": 146.74, + "z": 64.69 + }, + "tipDiameter": 3.27, + "tipLength": 30.950000000000003, + "tipVolume": 20.0 + }, + "status": "succeeded" + }, + { + "commandType": "aspirate", + "notes": [], + "params": { + "flowRate": 7.56, + "volume": 5.0, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": -13.78 + }, + "origin": "top" + }, + "wellName": "A11" + }, + "result": { + "position": { + "x": 236.88, + "y": 74.24, + "z": 1.92 + }, + "volume": 5.0 + }, + "status": "succeeded" + }, + { + "commandType": "moveToWell", + "notes": [], + "params": { + "forceDirect": false, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": 5.0000000000000036 + }, + "origin": "top" + }, + "wellName": "A11" + }, + "result": { + "position": { + "x": 236.88, + "y": 74.24, + "z": 20.700000000000003 + } + }, + "status": "succeeded" + }, + { + "commandType": "aspirate", + "notes": [], + "params": { + "flowRate": 7.56, + "volume": 10.0, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": 5.0000000000000036 + }, + "origin": "top" + }, + "wellName": "A11" + }, + "result": { + "position": { + "x": 236.88, + "y": 74.24, + "z": 20.700000000000003 + }, + "volume": 10.0 + }, + "status": "succeeded" + }, + { + "commandType": "waitForDuration", + "notes": [], + "params": { + "seconds": 3.0 + }, + "result": {}, + "status": "succeeded" + }, + { + "commandType": "dispense", + "notes": [], + "params": { + "flowRate": 7.56, + "volume": 5.0, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": -13.78 + }, + "origin": "top" + }, + "wellName": "H11" + }, + "result": { + "position": { + "x": 236.88, + "y": 11.24, + "z": 1.92 + }, + "volume": 5.0 + }, + "status": "succeeded" + }, + { + "commandType": "moveToWell", + "notes": [], + "params": { + "forceDirect": false, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "origin": "top" + }, + "wellName": "E12" + }, + "result": { + "position": { + "x": 245.88, + "y": 38.24, + "z": 15.7 + } + }, + "status": "succeeded" + }, + { + "commandType": "moveToWell", + "notes": [], + "params": { + "forceDirect": false, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": -14.78 + }, + "origin": "top" + }, + "wellName": "E11" + }, + "result": { + "position": { + "x": 236.88, + "y": 38.24, + "z": 0.92 + } + }, + "status": "succeeded" + }, + { + "commandType": "blowout", + "notes": [], + "params": { + "flowRate": 7.56, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": -14.78 + }, + "origin": "top" + }, + "wellName": "E11" + }, + "result": { + "position": { + "x": 236.88, + "y": 38.24, + "z": 0.92 + } + }, + "status": "succeeded" + }, + { + "commandType": "touchTip", + "notes": [], + "params": { + "radius": 1.0, + "speed": 60.0, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": -1.0 + }, + "origin": "top" + }, + "wellName": "H1" + }, + "result": { + "position": { + "x": 146.88, + "y": 11.24, + "z": 14.7 + } + }, + "status": "succeeded" + }, + { + "commandType": "waitForResume", + "notes": [], + "params": { + "message": "Is the pipette tip in the middle of the well?" + }, + "result": {}, + "status": "succeeded" + }, + { + "commandType": "dropTip", + "notes": [], + "params": { + "alternateDropLocation": false, + "wellLocation": { + "offset": { + "x": 0, + "y": 0, + "z": 0 + }, + "origin": "default" + }, + "wellName": "C1" + }, + "result": { + "position": { + "x": 14.38, + "y": 146.74, + "z": 45.09 + } + }, + "status": "succeeded" + }, + { + "commandType": "temperatureModule/waitForTemperature", + "notes": [], + "params": { + "celsius": 25.0 + }, + "result": {}, + "status": "succeeded" + }, + { + "commandType": "heaterShaker/setAndWaitForShakeSpeed", + "notes": [], + "params": { + "rpm": 466.0 + }, + "result": { + "pipetteRetracted": true + }, + "status": "succeeded" + }, + { + "commandType": "waitForDuration", + "notes": [], + "params": { + "seconds": 5.0 + }, + "result": {}, + "status": "succeeded" + }, + { + "commandType": "heaterShaker/setTargetTemperature", + "notes": [], + "params": { + "celsius": 38.0 + }, + "result": {}, + "status": "succeeded" + }, + { + "commandType": "heaterShaker/waitForTemperature", + "notes": [], + "params": {}, + "result": {}, + "status": "succeeded" + }, + { + "commandType": "thermocycler/openLid", + "notes": [], + "params": {}, + "result": {}, + "status": "succeeded" + }, + { + "commandType": "thermocycler/closeLid", + "notes": [], + "params": {}, + "result": {}, + "status": "succeeded" + }, + { + "commandType": "thermocycler/setTargetLidTemperature", + "notes": [], + "params": { + "celsius": 38.0 + }, + "result": { + "targetLidTemperature": 38.0 + }, + "status": "succeeded" + }, + { + "commandType": "thermocycler/waitForLidTemperature", + "notes": [], + "params": {}, + "result": {}, + "status": "succeeded" + }, + { + "commandType": "thermocycler/setTargetBlockTemperature", + "notes": [], + "params": { + "celsius": 28.0, + "holdTimeSeconds": 5.0 + }, + "result": { + "targetBlockTemperature": 28.0 + }, + "status": "succeeded" + }, + { + "commandType": "thermocycler/waitForBlockTemperature", + "notes": [], + "params": {}, + "result": {}, + "status": "succeeded" + }, + { + "commandType": "thermocycler/deactivateBlock", + "notes": [], + "params": {}, + "result": {}, + "status": "succeeded" + }, + { + "commandType": "thermocycler/deactivateLid", + "notes": [], + "params": {}, + "result": {}, + "status": "succeeded" + }, + { + "commandType": "thermocycler/openLid", + "notes": [], + "params": {}, + "result": {}, + "status": "succeeded" + }, + { + "commandType": "heaterShaker/deactivateShaker", + "notes": [], + "params": {}, + "result": {}, + "status": "succeeded" + }, + { + "commandType": "pickUpTip", + "notes": [], + "params": { + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "origin": "top" + }, + "wellName": "D1" + }, + "result": { + "position": { + "x": 14.38, + "y": 137.74, + "z": 64.69 + }, + "tipDiameter": 3.27, + "tipLength": 30.950000000000003, + "tipVolume": 20.0 + }, + "status": "succeeded" + }, + { + "commandType": "aspirate", + "notes": [], + "params": { + "flowRate": 7.56, + "volume": 15.0, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": -25.85 + }, + "origin": "top" + }, + "wellName": "A2" + }, + "result": { + "position": { + "x": 288.38, + "y": 42.78, + "z": 5.55 + }, + "volume": 15.0 + }, + "status": "succeeded" + }, + { + "commandType": "dispense", + "notes": [], + "params": { + "flowRate": 7.56, + "volume": 15.0, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": -13.780000000000001 + }, + "origin": "top" + }, + "wellName": "A1" + }, + "result": { + "position": { + "x": 280.53, + "y": 255.08999999999997, + "z": 87.51 + }, + "volume": 15.0 + }, + "status": "succeeded" + }, + { + "commandType": "moveToAddressableAreaForDropTip", + "notes": [], + "params": { + "addressableAreaName": "fixedTrash", + "alternateDropLocation": true, + "forceDirect": false, + "ignoreTipConfiguration": true, + "offset": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + }, + "result": { + "position": { + "x": 331.785, + "y": 351.5, + "z": 82.0 + } + }, + "status": "succeeded" + }, + { + "commandType": "dropTipInPlace", + "notes": [], + "params": {}, + "result": {}, + "status": "succeeded" + }, + { + "commandType": "pickUpTip", + "notes": [], + "params": { + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "origin": "top" + }, + "wellName": "A1" + }, + "result": { + "position": { + "x": 146.88, + "y": 164.74, + "z": 64.69 + }, + "tipDiameter": 5.23, + "tipLength": 51.099999999999994, + "tipVolume": 300.0 + }, + "status": "succeeded" + }, + { + "commandType": "aspirate", + "notes": [], + "params": { + "flowRate": 94.0, + "volume": 50.0, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": -25.85 + }, + "origin": "top" + }, + "wellName": "A2" + }, + "result": { + "position": { + "x": 288.38, + "y": 42.78, + "z": 5.55 + }, + "volume": 50.0 + }, + "status": "succeeded" + }, + { + "commandType": "dispense", + "notes": [], + "params": { + "flowRate": 94.0, + "volume": 50.0, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": -13.780000000000001 + }, + "origin": "top" + }, + "wellName": "A1" + }, + "result": { + "position": { + "x": 14.255, + "y": 75.365, + "z": 73.84500000000001 + }, + "volume": 50.0 + }, + "status": "succeeded" + }, + { + "commandType": "heaterShaker/setAndWaitForShakeSpeed", + "notes": [], + "params": { + "rpm": 350.0 + }, + "result": { + "pipetteRetracted": true + }, + "status": "succeeded" + }, + { + "commandType": "waitForDuration", + "notes": [], + "params": { + "seconds": 5.0 + }, + "result": {}, + "status": "succeeded" + }, + { + "commandType": "heaterShaker/deactivateShaker", + "notes": [], + "params": {}, + "result": {}, + "status": "succeeded" + }, + { + "commandType": "pickUpTip", + "notes": [], + "params": { + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "origin": "top" + }, + "wellName": "E1" + }, + "result": { + "position": { + "x": 14.38, + "y": 128.74, + "z": 64.69 + }, + "tipDiameter": 3.27, + "tipLength": 30.950000000000003, + "tipVolume": 20.0 + }, + "status": "succeeded" + }, + { + "commandType": "aspirate", + "notes": [], + "params": { + "flowRate": 15.12, + "volume": 10.0, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": -25.85 + }, + "origin": "top" + }, + "wellName": "A2" + }, + "result": { + "position": { + "x": 288.38, + "y": 42.78, + "z": 5.55 + }, + "volume": 10.0 + }, + "status": "succeeded" + }, + { + "commandType": "dispense", + "notes": [], + "params": { + "flowRate": 11.34, + "volume": 10.0, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": -22.0 + }, + "origin": "top" + }, + "wellName": "B2" + }, + "result": { + "position": { + "x": 315.0, + "y": 125.5, + "z": 18.0 + }, + "volume": 10.0 + }, + "status": "succeeded" + }, + { + "commandType": "moveToAddressableAreaForDropTip", + "notes": [], + "params": { + "addressableAreaName": "fixedTrash", + "alternateDropLocation": true, + "forceDirect": false, + "ignoreTipConfiguration": true, + "offset": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + }, + "result": { + "position": { + "x": 363.89500000000004, + "y": 351.5, + "z": 82.0 + } + }, + "status": "succeeded" + }, + { + "commandType": "dropTipInPlace", + "notes": [], + "params": {}, + "result": {}, + "status": "succeeded" + }, + { + "commandType": "aspirate", + "notes": [], + "params": { + "flowRate": 94.0, + "volume": 75.0, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": -25.85 + }, + "origin": "top" + }, + "wellName": "A2" + }, + "result": { + "position": { + "x": 288.38, + "y": 42.78, + "z": 5.55 + }, + "volume": 75.0 + }, + "status": "succeeded" + }, + { + "commandType": "dispense", + "notes": [], + "params": { + "flowRate": 94.0, + "volume": 60.0, + "wellLocation": { + "offset": { + "x": 0.0, + "y": 0.0, + "z": -13.780000000000001 + }, + "origin": "top" + }, + "wellName": "A6" + }, + "result": { + "position": { + "x": 59.38, + "y": 324.04, + "z": 100.08 + }, + "volume": 60.0 + }, + "status": "succeeded" + }, + { + "commandType": "moveToAddressableAreaForDropTip", + "notes": [], + "params": { + "addressableAreaName": "fixedTrash", + "alternateDropLocation": true, + "forceDirect": false, + "ignoreTipConfiguration": true, + "offset": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + }, + "result": { + "position": { + "x": 331.785, + "y": 351.5, + "z": 82.0 + } + }, + "status": "succeeded" + }, + { + "commandType": "dropTipInPlace", + "notes": [], + "params": {}, + "result": {}, + "status": "succeeded" } ], "config": { "apiVersion": [ 2, - 16 + 17 ], "protocolType": "python" }, @@ -42,16 +15573,122 @@ "role": "labware" } ], - "labware": [], - "liquids": [], + "labware": [ + { + "definitionUri": "opentrons/opentrons_96_tiprack_300ul/1", + "displayName": "300ul tips", + "loadName": "opentrons_96_tiprack_300ul", + "location": { + "slotName": "5" + } + }, + { + "definitionUri": "opentrons/opentrons_96_tiprack_20ul/1", + "displayName": "20ul tips", + "loadName": "opentrons_96_tiprack_20ul", + "location": { + "slotName": "4" + } + }, + { + "definitionUri": "opentrons/opentrons_96_well_aluminum_block/1", + "loadName": "opentrons_96_well_aluminum_block", + "location": {} + }, + { + "definitionUri": "opentrons/nest_96_wellplate_100ul_pcr_full_skirt/2", + "displayName": "Temperature-Controlled plate", + "loadName": "nest_96_wellplate_100ul_pcr_full_skirt", + "location": {} + }, + { + "definitionUri": "opentrons/opentrons_96_pcr_adapter/1", + "loadName": "opentrons_96_pcr_adapter", + "location": {} + }, + { + "definitionUri": "opentrons/nest_96_wellplate_100ul_pcr_full_skirt/2", + "loadName": "nest_96_wellplate_100ul_pcr_full_skirt", + "location": {} + }, + { + "definitionUri": "opentrons/nest_96_wellplate_100ul_pcr_full_skirt/2", + "loadName": "nest_96_wellplate_100ul_pcr_full_skirt", + "location": {} + }, + { + "definitionUri": "custom_beta/cpx_4_tuberack_100ul/1", + "displayName": "4 custom tubes", + "loadName": "cpx_4_tuberack_100ul", + "location": { + "slotName": "6" + } + }, + { + "definitionUri": "opentrons/nest_96_wellplate_100ul_pcr_full_skirt/2", + "displayName": "logo destination", + "loadName": "nest_96_wellplate_100ul_pcr_full_skirt", + "location": { + "slotName": "2" + } + }, + { + "definitionUri": "opentrons/nest_12_reservoir_15ml/1", + "displayName": "dye container", + "loadName": "nest_12_reservoir_15ml", + "location": { + "slotName": "3" + } + } + ], + "liquids": [ + { + "description": "H₂O", + "displayColor": "#42AB2D", + "displayName": "water" + }, + { + "description": "C₃H₆O", + "displayColor": "#38588a", + "displayName": "acetone" + } + ], "metadata": { "author": "Opentrons Engineering ", - "description": "Placeholder - 2.17 Smoke Test is the same a 2.16 Smoke Test.", - "protocolName": "🛠️ 2.17 Smoke Test", + "description": "Description of the protocol that is longish \n has \n returns and \n emoji 😊 ⬆️ ", + "protocolName": "🛠️ 2.17 Smoke Test V3 🪄", "source": "Software Testing Team" }, - "modules": [], - "pipettes": [], + "modules": [ + { + "location": { + "slotName": "1" + }, + "model": "heaterShakerModuleV1" + }, + { + "location": { + "slotName": "9" + }, + "model": "temperatureModuleV2" + }, + { + "location": { + "slotName": "7" + }, + "model": "thermocyclerModuleV2" + } + ], + "pipettes": [ + { + "mount": "left", + "pipetteName": "p300_multi_gen2" + }, + { + "mount": "right", + "pipetteName": "p20_single_gen2" + } + ], "robotType": "OT-2 Standard", "runTimeParameters": [] } diff --git a/app/README.md b/app/README.md index f73f215a48a..93bf6182ed9 100644 --- a/app/README.md +++ b/app/README.md @@ -27,7 +27,7 @@ make -C app dev **Note:** If you would like to interact with a virtual robot server being served at `localhost`, you will need to manually add `localhost` to the discovery candidates list. This can be done through the app's GUI settings for "Connect to a robot via IP address / Add Manual IP Address" -At this point, the Electron app will be running with [HMR][] and various Chrome devtools enabled. The app and dev server look for the following environment variables (defaults set in Makefile): +At this point, the Electron app will be running with various Chrome devtools enabled. The app and dev server look for the following environment variables (defaults set in Makefile): | Variable | Default | Description | | -------------------- | ------------ | --------------------------------------------------- | @@ -46,7 +46,7 @@ The UI stack is built using: - [Redux][] - [CSS modules][css-modules] - [Babel][] -- [Webpack][] +- [Vite][] Some important directories: @@ -54,7 +54,6 @@ Some important directories: - API clients (see [`api/opentrons/server`][api-server-source]) - `api-client` - HTTP Robot API client - `react-api-client` - react utilities for Robot API client -- `app/webpack` - Webpack configuration helpers ## Copy management @@ -131,10 +130,9 @@ ANALYZER=1 make -C app [api-server-source]: ../api/opentrons/server [electron]: https://www.electronjs.org/ [electron-renderer]: https://electronjs.org/docs/tutorial/quick-start#renderer-process -[hmr]: https://webpack.js.org/concepts/hot-module-replacement/ [react]: https://react.dev/ [redux]: http://redux.js.org/ [css-modules]: https://github.com/css-modules/css-modules [babel]: https://babeljs.io/ -[webpack]: https://webpack.js.org/ +[vite]: https://vitejs.dev/ [bundle-analyzer]: https://github.com/webpack-contrib/webpack-bundle-analyzer diff --git a/app/package.json b/app/package.json index f72519e3f4a..30836e11b8e 100644 --- a/app/package.json +++ b/app/package.json @@ -49,16 +49,15 @@ "react-error-boundary": "^4.0.10", "react-i18next": "13.5.0", "react-intersection-observer": "^8.33.1", + "react-markdown": "9.0.1", "react-redux": "8.1.2", "react-router-dom": "5.3.4", "react-select": "5.4.0", - "react-simple-keyboard": "^3.4.187", + "react-simple-keyboard": "^3.7.0", "react-viewport-list": "6.3.0", "redux": "4.0.5", "redux-observable": "1.1.0", "redux-thunk": "2.3.0", - "remark": "9.0.0", - "remark-react": "4.0.3", "reselect": "4.0.0", "rxjs": "^6.5.1", "semver": "5.5.0", diff --git a/app/src/App/DesktopApp.tsx b/app/src/App/DesktopApp.tsx index f42ef7e0e80..ffa50727da1 100644 --- a/app/src/App/DesktopApp.tsx +++ b/app/src/App/DesktopApp.tsx @@ -1,6 +1,7 @@ import * as React from 'react' import { Redirect, Route, Switch, useRouteMatch } from 'react-router-dom' import { ErrorBoundary } from 'react-error-boundary' +import { I18nextProvider } from 'react-i18next' import { Box, @@ -11,6 +12,7 @@ import { import { ApiHostProvider } from '@opentrons/react-api-client' import NiceModal from '@ebay/nice-modal-react' +import { i18n } from '../i18n' import { Alerts } from '../organisms/Alerts' import { Breadcrumbs } from '../organisms/Breadcrumbs' import { ToasterOven } from '../organisms/ToasterOven' @@ -101,45 +103,47 @@ export const DesktopApp = (): JSX.Element => { return ( - - - - - - - - {desktopRoutes.map( - ({ Component, exact, path }: RouteProps) => { - return ( - - - - - - - - ) - } - )} - - - - - - - - + + + + + + + + + {desktopRoutes.map( + ({ Component, exact, path }: RouteProps) => { + return ( + + + + + + + + ) + } + )} + + + + + + + + + ) } diff --git a/app/src/App/Navbar.tsx b/app/src/App/Navbar.tsx index 8397927392f..f9e79ea65e9 100644 --- a/app/src/App/Navbar.tsx +++ b/app/src/App/Navbar.tsx @@ -28,6 +28,7 @@ import { NAV_BAR_WIDTH } from './constants' import type { RouteProps } from './types' const SALESFORCE_HELP_LINK = 'https://support.opentrons.com/s/' +const PROJECT: string = _OPENTRONS_PROJECT_ const NavbarLink = styled(NavLink)` color: ${COLORS.white}; @@ -128,7 +129,7 @@ export function Navbar({ routes }: { routes: RouteProps[] }): JSX.Element { alignSelf={ALIGN_STRETCH} > {navRoutes.map(({ name, navLinkTo }: RouteProps) => ( diff --git a/app/src/App/OnDeviceDisplayApp.tsx b/app/src/App/OnDeviceDisplayApp.tsx index c9923e1aea3..1459ff5071f 100644 --- a/app/src/App/OnDeviceDisplayApp.tsx +++ b/app/src/App/OnDeviceDisplayApp.tsx @@ -16,6 +16,7 @@ import { ApiHostProvider } from '@opentrons/react-api-client' import NiceModal from '@ebay/nice-modal-react' import { SleepScreen } from '../atoms/SleepScreen' +import { OnDeviceLocalizationProvider } from '../LocalizationProvider' import { ToasterOven } from '../organisms/ToasterOven' import { MaintenanceRunTakeover } from '../organisms/TakeoverModal' import { FirmwareUpdateTakeover } from '../organisms/FirmwareUpdateModal/FirmwareUpdateTakeover' @@ -31,6 +32,7 @@ import { RobotDashboard } from '../pages/RobotDashboard' import { RobotSettingsDashboard } from '../pages/RobotSettingsDashboard' import { ProtocolDashboard } from '../pages/ProtocolDashboard' import { ProtocolDetails } from '../pages/ProtocolDetails' +import { QuickTransferFlow } from '../organisms/QuickTransferFlow' import { RunningProtocol } from '../pages/RunningProtocol' import { RunSummary } from '../pages/RunSummary' import { UpdateRobot } from '../pages/UpdateRobot/UpdateRobot' @@ -66,13 +68,13 @@ export const ON_DEVICE_DISPLAY_PATHS = [ '/emergency-stop', '/instruments', '/instruments/:mount', - '/loading', '/network-setup', '/network-setup/ethernet', '/network-setup/usb', '/network-setup/wifi', '/protocols', '/protocols/:protocolId', + '/quick-transfer', '/robot-settings', '/robot-settings/rename-robot', '/robot-settings/update-robot', @@ -97,8 +99,6 @@ function getPathComponent( return case '/instruments/:mount': return - case '/loading': - return case '/network-setup': return case '/network-setup/ethernet': @@ -111,6 +111,8 @@ function getPathComponent( return case '/protocols/:protocolId': return + case `/quick-transfer`: + return case '/robot-settings': return case '/robot-settings/rename-robot': @@ -151,12 +153,75 @@ export const OnDeviceDisplayApp = (): JSX.Element => { } const dispatch = useDispatch() const isIdle = useIdle(sleepTime, options) + + React.useEffect(() => { + if (isIdle) { + dispatch(updateBrightness(TURN_OFF_BACKLIGHT)) + } else { + dispatch( + updateConfigValue( + 'onDeviceDisplaySettings.brightness', + userSetBrightness + ) + ) + } + }, [dispatch, isIdle, userSetBrightness]) + + // TODO (sb:6/12/23) Create a notification manager to set up preference and order of takeover modals + return ( + + + + + + {isIdle ? ( + + ) : ( + <> + + + + + + + + + + + + )} + + + + + + + ) +} + +const getTargetPath = (unfinishedUnboxingFlowRoute: string | null): string => { + if (unfinishedUnboxingFlowRoute != null) { + return unfinishedUnboxingFlowRoute + } + + return '/dashboard' +} + +// split to a separate function because scrollRef rerenders on every route change +// this avoids rerendering parent providers as well +export function OnDeviceDisplayAppRoutes(): JSX.Element { const [currentNode, setCurrentNode] = React.useState(null) const scrollRef = React.useCallback((node: HTMLElement | null) => { setCurrentNode(node) }, []) const isScrolling = useScrolling(currentNode) + const { unfinishedUnboxingFlowRoute } = useSelector( + getOnDeviceDisplaySettings + ) + + const targetPath = getTargetPath(unfinishedUnboxingFlowRoute) + const TOUCH_SCREEN_STYLE = css` position: ${POSITION_RELATIVE}; width: 100%; @@ -176,54 +241,18 @@ export const OnDeviceDisplayApp = (): JSX.Element => { } ` - React.useEffect(() => { - if (isIdle) { - dispatch(updateBrightness(TURN_OFF_BACKLIGHT)) - } else { - dispatch( - updateConfigValue( - 'onDeviceDisplaySettings.brightness', - userSetBrightness - ) - ) - } - }, [dispatch, isIdle, userSetBrightness]) - - // TODO (sb:6/12/23) Create a notification manager to set up preference and order of takeover modals return ( - - - - {isIdle ? ( - - ) : ( - <> - - - - - - - - {ON_DEVICE_DISPLAY_PATHS.map(path => ( - - - - {getPathComponent(path)} - - - ))} - - - - - - - )} - - - - + + {ON_DEVICE_DISPLAY_PATHS.map(path => ( + + + + {getPathComponent(path)} + + + ))} + {targetPath != null && } + ) } diff --git a/app/src/App/OnDeviceDisplayAppFallback.tsx b/app/src/App/OnDeviceDisplayAppFallback.tsx index 6a345c1735e..0e48a31e565 100644 --- a/app/src/App/OnDeviceDisplayAppFallback.tsx +++ b/app/src/App/OnDeviceDisplayAppFallback.tsx @@ -27,7 +27,7 @@ import type { ModalHeaderBaseProps } from '../molecules/Modal/types' export function OnDeviceDisplayAppFallback({ error, }: FallbackProps): JSX.Element { - const { t } = useTranslation('app_settings') + const { t } = useTranslation(['app_settings', 'branded']) const trackEvent = useTrackEvent() const dispatch = useDispatch() const localRobot = useSelector(getLocalRobot) @@ -59,7 +59,9 @@ export function OnDeviceDisplayAppFallback({ alignItems={ALIGN_CENTER} justifyContent={JUSTIFY_CENTER} > - {t('error_boundary_description')} + + {t('branded:error_boundary_description')} + { + const actual = await vi.importActual('@opentrons/react-api-client') + return { + ...actual, + useRobotSettingsQuery: () => + (({ + data: { settings: [] }, + } as unknown) as UseQueryResult), + } +}) +vi.mock('../../LocalizationProvider') vi.mock('../../pages/Welcome') vi.mock('../../pages/NetworkSetupMenu') vi.mock('../../pages/ConnectViaEthernet') @@ -45,7 +60,6 @@ vi.mock('../../pages/InstrumentsDashboard') vi.mock('../../pages/RunningProtocol') vi.mock('../../pages/RunSummary') vi.mock('../../pages/NameRobot') -vi.mock('../../pages/InitialLoadingScreen') vi.mock('../../pages/EmergencyStop') vi.mock('../../pages/DeckConfiguration') vi.mock('../../redux/config') @@ -73,7 +87,7 @@ const render = (path = '/') => { describe('OnDeviceDisplayApp', () => { beforeEach(() => { vi.mocked(getOnDeviceDisplaySettings).mockReturnValue(mockSettings as any) - vi.mocked(getIsShellReady).mockReturnValue(false) + vi.mocked(getIsShellReady).mockReturnValue(true) vi.mocked(useCurrentRunRoute).mockReturnValue(null) vi.mocked(getLocalRobot).mockReturnValue(mockConnectedRobot) vi.mocked(useNotifyCurrentMaintenanceRun).mockReturnValue({ @@ -83,6 +97,12 @@ describe('OnDeviceDisplayApp', () => { }, }, } as any) + // TODO(bh, 2024-03-27): implement testing of branded and anonymous i18n, but for now pass through + vi.mocked( + OnDeviceLocalizationProvider + ).mockImplementation((props: OnDeviceLocalizationProviderProps) => ( + <>{props.children} + )) }) afterEach(() => { vi.resetAllMocks() @@ -140,9 +160,16 @@ describe('OnDeviceDisplayApp', () => { render('/runs/my-run-id/summary') expect(vi.mocked(RunSummary)).toHaveBeenCalled() }) - it('renders the loading screen on mount', () => { - render('/loading') - expect(vi.mocked(InitialLoadingScreen)).toHaveBeenCalled() + it('renders the localization provider and not the loading screen when app-shell is ready', () => { + render('/') + expect(vi.mocked(OnDeviceLocalizationProvider)).toHaveBeenCalled() + expect(screen.queryByLabelText('loading indicator')).toBeNull() + }) + it('renders the loading screen when app-shell is not ready', () => { + vi.mocked(getIsShellReady).mockReturnValue(false) + render('/') + screen.getByLabelText('loading indicator') + expect(vi.mocked(OnDeviceLocalizationProvider)).not.toHaveBeenCalled() }) it('renders EmergencyStop component from /emergency-stop', () => { render('/emergency-stop') diff --git a/app/src/LocalizationProvider.tsx b/app/src/LocalizationProvider.tsx new file mode 100644 index 00000000000..4b9e10a1f8d --- /dev/null +++ b/app/src/LocalizationProvider.tsx @@ -0,0 +1,60 @@ +import * as React from 'react' +import { I18nextProvider } from 'react-i18next' +import reduce from 'lodash/reduce' + +import { useRobotSettingsQuery } from '@opentrons/react-api-client' + +import { resources } from './assets/localization' +import { i18n, i18nCb, i18nConfig } from './i18n' + +import type { RobotSettingsField } from '@opentrons/api-client' + +export interface OnDeviceLocalizationProviderProps { + children?: React.ReactNode +} + +const BRANDED_RESOURCE = 'branded' +const ANONYMOUS_RESOURCE = 'anonymous' + +// TODO(bh, 2024-03-26): anonymization limited to ODD for now, may change in future OEM phases +export function OnDeviceLocalizationProvider( + props: OnDeviceLocalizationProviderProps +): JSX.Element | null { + const { settings } = useRobotSettingsQuery().data ?? {} + const oemModeSetting = (settings ?? []).find( + (setting: RobotSettingsField) => setting?.id === 'enableOEMMode' + ) + const isOEMMode = oemModeSetting?.value ?? false + + // iterate through language resources, nested files, substitute anonymous file for branded file for OEM mode + const anonResources = reduce( + resources, + (acc, resource, language) => { + const anonFiles = reduce( + resource, + (acc, file, fileName) => { + if (fileName === BRANDED_RESOURCE && isOEMMode) { + return acc + } else if (fileName === ANONYMOUS_RESOURCE) { + return isOEMMode ? { ...acc, [BRANDED_RESOURCE]: file } : acc + } else { + return { ...acc, [fileName]: file } + } + }, + {} + ) + return { ...acc, [language]: anonFiles } + }, + {} + ) + + const anonI18n = i18n.createInstance( + { + ...i18nConfig, + resources: anonResources, + }, + i18nCb + ) + + return {props.children} +} diff --git a/app/src/assets/images/on-device-display/multiple_modules_modal.png b/app/src/assets/images/on-device-display/multiple_modules_modal.png deleted file mode 100644 index 721c7cb11f7..00000000000 Binary files a/app/src/assets/images/on-device-display/multiple_modules_modal.png and /dev/null differ diff --git a/app/src/assets/localization/en/anonymous.json b/app/src/assets/localization/en/anonymous.json new file mode 100644 index 00000000000..5dcfd9bf237 --- /dev/null +++ b/app/src/assets/localization/en/anonymous.json @@ -0,0 +1,73 @@ +{ + "a_robot_software_update_is_available": "A robot software update is required to run protocols with this version of the desktop app. Go to Robot", + "about_flex_gripper": "About Gripper", + "alternative_security_types_description": "The robot supports connecting to various enterprise access points. Connect via USB and finish setup in the desktop app.", + "calibration_block_description": "This block is a specially made tool that fits perfectly on your deck and helps with calibration.If you do not have a Calibration Block, please email support so we can send you one. In your message, be sure to include your name, company or institution name, and shipping address. While you wait for the block to arrive, you can use the flat surface on the trash bin of your robot instead.", + "calibration_on_opentrons_tips_is_important": "It’s extremely important to perform this calibration using the tips and tip racks specified above, as the robot determines accuracy based on the known measurements of these tips.", + "choose_what_data_to_share": "Choose what robot data to share.", + "computer_in_app_is_controlling_robot": "A network-connected computer is currently controlling this robot.", + "confirm_terminate": "This will immediately stop the activity begun on a computer. You, or another user, may lose progress or see an error on that computer.", + "connect_and_screw_in_gripper": "Connect and secure gripper", + "connect_via_usb_description_3": "3. Launch the robot app on the connected computer to continue.", + "connection_description_usb": "Connect directly to a computer.", + "connection_lost_description": "The app is unable to communicate with this robot right now. Double check the USB or Wi-Fi connection to the robot and then try to reconnect.", + "contact_information": "Contact support for assistance.", + "contact_support_for_connection_help": "If none of these work, contact support for help (via the question mark link in this app, or by emailing {{support_email}}.)", + "deck_fixture_setup_modal_bottom_description": "For details on installing different fixture types, contact support.", + "delete_protocol_from_app": "Delete the protocol, make changes to address the error, and resend the protocol to this robot from the desktop app.", + "error_boundary_description": "You need to restart the touchscreen. Contact support for assistance.", + "estop_pressed_description": "First, safely clear the deck of any labware or spills. Then, twist the E-stop button clockwise. Finally, have the robot move the gantry to its home position.", + "find_your_robot": "Find your robot in the Devices section of the app to install software updates.", + "firmware_update_download_logs": "Contact support for assistance.", + "general_error_message": "If you keep getting this message, try restarting your app and robot. If this does not resolve the issue, contact support.", + "gripper_still_attached": "Gripper still attached", + "gripper_successfully_attached_and_calibrated": "Gripper successfully attached and calibrated", + "gripper_successfully_calibrated": "Gripper successfully calibrated", + "gripper_successfully_detached": "Gripper successfully detached", + "gripper": "Gripper", + "ip_description_second": "Work with your network administrator to assign a static IP address to the robot.", + "learn_uninstalling": "Learn more about uninstalling the app", + "loosen_screws_and_detach": "Loosen screws and detach gripper", + "modal_instructions": "For step-by-step instructions on setting up your module, consult the Quickstart Guide that came in its box.", + "module_calibration_failed": "Module calibration was unsuccessful. Make sure the calibration adapter is fully seated on the module and try again. If you still have trouble, contact support.{{error}}", + "module_calibration_get_started": "To get started, remove labware from the deck and clean up the working area to make the calibration easier. Also gather the needed equipment shown to the right.The calibration adapter came with your module. The pipette probe came with your pipette.", + "module_error_contact_support": "Try powering the module off and on again. If the error persists, contact support.", + "network_setup_menu_description": "You’ll use this connection to run software updates and load protocols onto your robot.", + "oem_mode_description": "Enable OEM Mode to remove all instances of Opentrons from the Flex touchscreen.", + "opentrons_app_successfully_updated": "The app was successfully updated.", + "opentrons_app_update": "app update", + "opentrons_app_update_available": "App Update Available", + "opentrons_app_update_available_variation": "An app update is available.", + "opentrons_app_will_use_interpreter": "If specified, the app will use the Python interpreter at this path instead of the default bundled Python interpreter.", + "opentrons_cares_about_privacy": "We care about your privacy. We anonymize all data and only use it to improve our products.", + "opentrons_def": "Verified Definition", + "opentrons_labware_def": "Verified labware definition", + "opentrons_tip_racks_recommended": "Opentrons tip racks are highly recommended. Accuracy cannot be guaranteed with other tip racks.", + "opentrons_tip_rack_name": "opentrons", + "previous_releases": "View previous releases", + "receive_alert": "Receive an alert when a software update is available.", + "restore_description": "Reverting to previous software versions is not recommended, but you can access previous releases below. For best results, uninstall the existing app and remove its configuration files before installing the previous version.", + "robot_server_version_ot3_description": "The robot software includes the robot server and the touchscreen display interface.", + "robot_software_update_required": "A robot software update is required to run protocols with this version of the app.", + "run_failed_modal_description_desktop": "Contact support for assistance.", + "secure_labware_explanation_magnetic_module": "Ensure that your labware locks to the Magnetic Module by adjusting the black plate bracket on top of the module. There are two sizes of plate brackets supplied with your module: standard and deep well. These brackets can be removed and swapped by unscrewing the module's thumb screw (the silver knob on the front).", + "secure_labware_explanation_thermocycler": "Secure your labware to the Thermocycler Module by closing its latch. Doing so ensures level and accurate plate placement for optimal results.", + "send_a_protocol_to_store": "Send a protocol to the robot to get started.", + "setup_instructions_description": "For step-by-step instructions on setting up your module, consult the Quickstart Guide that came in its box.", + "share_app_analytics": "Share App Analytics", + "share_app_analytics_description": "Help improve this product by automatically sending anonymous diagnostics and usage data.", + "share_display_usage_description": "Data on how you interact with the robot's touchscreen.", + "share_logs_with_opentrons": "Share robot logs", + "share_logs_with_opentrons_description": "Help improve this product by automatically sending anonymous robot logs. These logs are used to troubleshoot robot issues and spot error trends.", + "show_labware_offset_snippets_description": "Only for users who need to apply labware offset data outside of the app. When enabled, code snippets for Jupyter Notebook and SSH are available during protocol setup.", + "something_seems_wrong": "There may be a problem with your pipette. Exit setup and contact support for assistance.", + "these_are_advanced_settings": "These are advanced settings. Please do not attempt to adjust without assistance from support. Changing these settings may affect the lifespan of your pipette.These settings do not override any pipette settings defined in protocols.", + "update_requires_restarting_app": "Updating requires restarting the app.", + "update_robot_software_description": "Bypass the auto-update process and update the robot software manually.", + "update_robot_software_link": "Launch software update page", + "use_older_protocol_analysis_method_description": "Use an older, slower method of analyzing uploaded protocols. This changes how the OT-2 validates your protocol during the upload step, but does not affect how your protocol actually runs. Support might ask you to change this setting if you encounter problems with the newer, faster protocol analysis method.", + "versions_sync": "Learn more about keeping the app and robot software in sync", + "want_to_help_out": "Want to help out?", + "welcome_title": "Welcome!", + "why_use_lpc": "Labware Position Check is intended to correct for minor variances. Don't use Labware Position Check to compensate for large positional adjustments. Needing to set large labware offsets could indicate a problem with robot calibration." +} diff --git a/app/src/assets/localization/en/app_settings.json b/app/src/assets/localization/en/app_settings.json index 4a00283f3de..389854a8b33 100644 --- a/app/src/assets/localization/en/app_settings.json +++ b/app/src/assets/localization/en/app_settings.json @@ -1,6 +1,5 @@ { "__dev_internal__protocolStats": "Protocol Stats", - "__dev_internal__enableRunTimeParameters": "Enable Run Time Parameters", "__dev_internal__enableRunNotes": "Display Notes During a Protocol Run", "__dev_internal__enableQuickTransfer": "Enable Quick Transfer", "add_folder_button": "Add labware source folder", @@ -12,16 +11,12 @@ "additional_folder_location": "Additional Source Folder", "additional_labware_folder_title": "Additional Custom Labware Source Folder", "advanced": "Advanced", - "allow_sending_all_protocols_to_ot3": "Allow Sending All Protocols to Opentrons Flex", - "allow_sending_all_protocols_to_ot3_description": "Enable the \"Send to Opentrons Flex\" menu item for each imported protocol, even if protocol analysis fails or does not recognize it as designed for the Opentrons Flex.", - "analytics_description": "Help Opentrons improve its products and services by automatically sending anonymous diagnostics and usage data.", "app_changes": "App Changes in ", "app_settings": "App Settings", "bug_fixes": "Bug Fixes", "cal_block": "Always use calibration block to calibrate", "change_folder_button": "Change labware source folder", "channel": "Channel", - "choose_what_data_to_share": "Choose what data to share with Opentrons.", "clear_confirm": "Clear unavailable robots", "clear_robots_button": "Clear unavailable robots list", "clear_robots_description": "Clear the list of unavailable robots on the Devices page. This action cannot be undone.", @@ -36,7 +31,6 @@ "download_update": "Downloading update...", "enable_dev_tools": "Developer Tools", "enable_dev_tools_description": "Enabling this setting opens Developer Tools on app launch, enables additional logging and gives access to feature flags.", - "error_boundary_description": "You need to restart the touchscreen. Then download the robot logs from the Opentrons App and send them to support@opentrons.com for assistance.", "error_boundary_desktop_app_description": "You need to reload the app. Contact support with the following error message:", "error_boundary_title": "An unknown error has occurred", "feature_flags": "Feature Flags", @@ -47,20 +41,12 @@ "installing_update": "Installing update...", "ip_available": "Available", "ip_description_first": "Enter an IP address or hostname to connect to a robot.", - "ip_description_second": "Opentrons recommends working with your network administrator to assign a static IP address to the robot.", - "learn_uninstalling": "Learn more about uninstalling the Opentrons App", "manage_versions": "It is very important for the robot and app software to be on the same version. Manage the robot software versions via Robot Settings > Advanced.", "new_features": "New Features", "no_folder": "No additional source folder specified", "no_specified_folder": "No path specified", "no_unavail_robots_to_clear": "No unavailable robots to clear", "not_found": "Not Found", - "opentrons_app_successfully_updated": "The Opentrons App was successfully updated.", - "opentrons_app_update": "Opentrons App update", - "opentrons_app_update_available": "Opentrons App Update Available", - "opentrons_app_update_available_variation": "An Opentrons App update is available.", - "opentrons_app_will_use_interpreter": "If specified, the Opentrons App will use the Python interpreter at this path instead of the default bundled Python interpreter.", - "opentrons_cares_about_privacy": "Opentrons cares about your privacy. We anonymize all data and only use it to improve our products.", "opt_in": "Opt in", "opt_in_description": "Automatically send us anonymous diagnostics and usage data. We only use this information to improve our products.", "opt_out": "Opt out", @@ -69,29 +55,22 @@ "override_path_to_python": "Override Path to Python", "prevent_robot_caching": "Prevent Robot Caching", "prevent_robot_caching_description": "The app will immediately clear unavailable robots and will not remember unavailable robots while this is enabled. On networks with many robots, preventing caching may improve network performance at the expense of slower and less reliable robot discovery on app launch.", - "previous_releases": "View previous Opentrons releases", "privacy": "Privacy", "problem_during_update": "This update is taking longer than usual.", "prompt": "Always show the prompt to choose calibration block or trash bin", - "receive_alert": "Receive an alert when an Opentrons software update is available.", "release_notes": "Release notes", "reload_app": "Reload app", "remind_later": "Remind me later", "reset_to_default": "Reset to default", "restart_touchscreen": "Restart touchscreen", "restarting_app": "Download complete, restarting the app...", - "restore_description": "Opentrons does not recommend reverting to previous software versions, but you can access previous releases below. For best results, uninstall the existing app and remove its configuration files before installing the previous version.", "restore_previous": "See how to restore a previous software version", "searching": "Searching for 30s", "setup_connection": "Set up connection", - "share_app_analytics": "Share App Analytics with Opentrons", - "share_app_analytics_description": "Help Opentrons improve its products and services by automatically sending anonymous diagnostics and usage data.", "share_display_usage": "Share display usage", - "share_display_usage_description": "Data on how you interact with the touchscreen on Flex.", "share_robot_logs": "Share robot logs", "share_robot_logs_description": "Data on actions the robot does, like running protocols.", "show_labware_offset_snippets": "Show Labware Offset data code snippets", - "show_labware_offset_snippets_description": "Only for users who need to apply Labware Offset data outside of the Opentrons App. When enabled, code snippets for Jupyter Notebook and SSH are available during protocol setup.", "software_update_available": "Software Update Available", "software_version": "App Software Version", "successfully_deleted_unavail_robots": "Successfully deleted unavailable robots", @@ -105,7 +84,6 @@ "update_available": "Update available", "update_channel": "Update Channel", "update_description": "Stable receives the latest stable releases. Beta allows you to try out new in-progress features before they launch in Stable channel, but they have not completed testing yet.", - "update_requires_restarting": "Updating requires restarting the Opentrons App.", "usb_to_ethernet_adapter_description": "Description", "usb_to_ethernet_adapter_driver_version": "Driver Version", "usb_to_ethernet_adapter_info": "USB-to-Ethernet Adapter Information", @@ -117,11 +95,6 @@ "usb_to_ethernet_not_connected": "No USB-to-Ethernet adapter connected", "usb_to_ethernet_unknown_manufacturer": "Unknown Manufacturer", "usb_to_ethernet_unknown_product": "Unknown Adapter", - "versions_sync": "Learn more about keeping the Opentrons App and robot software in sync", - "view_change_log": "View Opentrons technical change log", - "view_issue_tracker": "View Opentrons issue tracker", - "view_release_notes": "View full Opentrons release notes", "view_software_update": "View software update", - "view_update": "View Update", - "want_to_help_out": "Want to help out Opentrons?" + "view_update": "View Update" } diff --git a/app/src/assets/localization/en/branded.json b/app/src/assets/localization/en/branded.json new file mode 100644 index 00000000000..13b53967aff --- /dev/null +++ b/app/src/assets/localization/en/branded.json @@ -0,0 +1,73 @@ +{ + "a_robot_software_update_is_available": "A robot software update is required to run protocols with this version of the Opentrons App. Go to Robot", + "about_flex_gripper": "About Flex Gripper", + "alternative_security_types_description": "The Opentrons App supports connecting Flex to various enterprise access points. Connect via USB and finish setup in the app.", + "calibration_block_description": "This block is a specially made tool that fits perfectly on your deck and helps with calibration.If you do not have a Calibration Block, please email support@opentrons.com so we can send you one. In your message, be sure to include your name, company or institution name, and shipping address. While you wait for the block to arrive, you can use the flat surface on the trash bin of your robot instead.", + "calibration_on_opentrons_tips_is_important": "It’s extremely important to perform this calibration using the Opentrons tips and tip racks specified above, as the robot determines accuracy based on the known measurements of these tips.", + "choose_what_data_to_share": "Choose what data to share with Opentrons.", + "computer_in_app_is_controlling_robot": "A computer with the Opentrons App is currently controlling this robot.", + "confirm_terminate": "This will immediately stop the activity begun on a computer. You, or another user, may lose progress or see an error in the Opentrons App.", + "connect_and_screw_in_gripper": "Connect and secure Flex Gripper", + "connect_via_usb_description_3": "3. Launch the Opentrons App on the computer to continue.", + "connection_description_usb": "Connect directly to a computer (running the Opentrons App).", + "connection_lost_description": "The Opentrons App is unable to communicate with this robot right now. Double check the USB or Wi-Fi connection to the robot, then try to reconnect.", + "contact_information": "Download the robot logs from the Opentrons App and send it to support@opentrons.com for assistance.", + "contact_support_for_connection_help": "If none of these work, contact Opentrons Support for help (via the question mark link in this app, or by emailing {{support_email}}.)", + "deck_fixture_setup_modal_bottom_description": "For details on installing different fixture types, scan the QR code or search for “deck configuration” on support.opentrons.com", + "delete_protocol_from_app": "Delete the protocol, make changes to address the error, and resend the protocol to this robot from the Opentrons App.", + "error_boundary_description": "You need to restart the touchscreen. Then download the robot logs from the Opentrons App and send them to support@opentrons.com for assistance.", + "estop_pressed_description": "First, safely clear the deck of any labware or spills. Then, twist the E-stop button clockwise. Finally, have Flex move the gantry to its home position.", + "find_your_robot": "Find your robot in the Opentrons App to install software updates.", + "firmware_update_download_logs": "Download the robot logs from the Opentrons App and send them to support@opentrons.com for assistance.", + "general_error_message": "If you keep getting this message, try restarting your app and robot. If this does not resolve the issue, contact Opentrons Support.", + "gripper_still_attached": "Flex Gripper still attached", + "gripper_successfully_attached_and_calibrated": "Flex Gripper successfully attached and calibrated", + "gripper_successfully_calibrated": "Flex Gripper successfully calibrated", + "gripper_successfully_detached": "Flex Gripper successfully detached", + "gripper": "Flex Gripper", + "ip_description_second": "Opentrons recommends working with your network administrator to assign a static IP address to the robot.", + "learn_uninstalling": "Learn more about uninstalling the Opentrons App", + "loosen_screws_and_detach": "Loosen screws and detach Flex Gripper", + "modal_instructions": "For step-by-step instructions on setting up your module, consult the Quickstart Guide that came in its box. You can also click the link below or scan the QR code to visit the modules section of the Opentrons Help Center.", + "module_calibration_failed": "Module calibration was unsuccessful. Make sure the calibration adapter is fully seated on the module and try again. If you still have trouble, contact Opentrons Support.{{error}}", + "module_calibration_get_started": "To get started, remove labware from the deck and clean up the working area to make the calibration easier. Also gather the needed equipment shown to the right.The calibration adapter came with your module. The pipette probe came with your Flex pipette.", + "module_error_contact_support": "Try powering the module off and on again. If the error persists, contact Opentrons Support.", + "network_setup_menu_description": "You’ll use this connection to run software updates and load protocols onto your Opentrons Flex.", + "oem_mode_description": "Enable OEM Mode to remove all instances of Opentrons from the Flex touchscreen.", + "opentrons_app_successfully_updated": "The Opentrons App was successfully updated.", + "opentrons_app_update": "Opentrons App update", + "opentrons_app_update_available": "Opentrons App Update Available", + "opentrons_app_update_available_variation": "An Opentrons App update is available.", + "opentrons_app_will_use_interpreter": "If specified, the Opentrons App will use the Python interpreter at this path instead of the default bundled Python interpreter.", + "opentrons_cares_about_privacy": "Opentrons cares about your privacy. We anonymize all data and only use it to improve our products.", + "opentrons_def": "Opentrons Definition", + "opentrons_labware_def": "Opentrons labware definition", + "opentrons_tip_rack_name": "opentrons", + "opentrons_tip_racks_recommended": "Opentrons tip racks are highly recommended. Accuracy cannot be guaranteed with other tip racks.", + "previous_releases": "View previous Opentrons releases", + "receive_alert": "Receive an alert when an Opentrons software update is available.", + "restore_description": "Opentrons does not recommend reverting to previous software versions, but you can access previous releases below. For best results, uninstall the existing app and remove its configuration files before installing the previous version.", + "robot_server_version_ot3_description": "The Opentrons Flex software includes the robot server and the touchscreen display interface.", + "robot_software_update_required": "A robot software update is required to run protocols with this version of the Opentrons App.", + "run_failed_modal_description_desktop": "Download the run log and send it to support@opentrons.com for assistance.", + "secure_labware_explanation_magnetic_module": "Opentrons recommends ensuring your labware locks to the Magnetic Module by adjusting the black plate bracket on top of the module. There are two sizes of plate brackets supplied with your module: standard and deep well. These brackets can be removed and swapped by unscrewing the module's thumb screw (the silver knob on the front).", + "secure_labware_explanation_thermocycler": "Opentrons recommends securing your labware to the Thermocycler Module by closing its latch. Doing so ensures level and accurate plate placement for optimal results.", + "send_a_protocol_to_store": "Send a protocol from the Opentrons App to get started.", + "setup_instructions_description": "For step-by-step instructions on setting up your module, consult the Quickstart Guide that came in its box or scan the QR code to visit the modules section of the Opentrons Help Center.", + "share_app_analytics": "Share App Analytics with Opentrons", + "share_app_analytics_description": "Help Opentrons improve its products and services by automatically sending anonymous diagnostics and usage data.", + "share_display_usage_description": "Data on how you interact with the touchscreen on Flex.", + "share_logs_with_opentrons": "Share Robot logs with Opentrons", + "share_logs_with_opentrons_description": "Help Opentrons improve its products and services by automatically sending anonymous robot logs. Opentrons uses these logs to troubleshoot robot issues and spot error trends.", + "show_labware_offset_snippets_description": "Only for users who need to apply labware offset data outside of the Opentrons App. When enabled, code snippets for Jupyter Notebook and SSH are available during protocol setup.", + "something_seems_wrong": "There may be a problem with your pipette. Exit setup and contact Opentrons Support for assistance.", + "these_are_advanced_settings": "These are advanced settings. Please do not attempt to adjust without assistance from Opentrons Support. Changing these settings may affect the lifespan of your pipette.These settings do not override any pipette settings defined in protocols.", + "update_requires_restarting_app": "Updating requires restarting the Opentrons App.", + "update_robot_software_description": "Bypass the Opentrons App auto-update process and update the robot software manually.", + "update_robot_software_link": "Launch Opentrons software update page", + "use_older_protocol_analysis_method_description": "Use an older, slower method of analyzing uploaded protocols. This changes how the OT-2 validates your protocol during the upload step, but does not affect how your protocol actually runs. Opentrons Support might ask you to change this setting if you encounter problems with the newer, faster protocol analysis method.", + "versions_sync": "Learn more about keeping the Opentrons App and robot software in sync", + "want_to_help_out": "Want to help out Opentrons?", + "welcome_title": "Welcome to your Opentrons Flex!", + "why_use_lpc": "Labware Position Check is intended to correct for minor variances. Opentrons does not recommend using Labware Position Check to compensate for large positional adjustments. Needing to set large labware offsets could indicate a problem with robot calibration." +} diff --git a/app/src/assets/localization/en/device_details.json b/app/src/assets/localization/en/device_details.json index d217718af42..d3fdab0b04c 100644 --- a/app/src/assets/localization/en/device_details.json +++ b/app/src/assets/localization/en/device_details.json @@ -1,11 +1,10 @@ { - "about_flex_gripper": "About Flex Gripper", "about_gripper": "About gripper", "about_module": "About {{name}}", "about_pipette_name": "About {{name}} Pipette", "about_pipette": "About pipette", - "add_fixture_description": "Add this fixture to your deck configuration. It will be referenced during protocol analysis.", - "add_to_slot_description": "Choose a fixture below to add to your deck configuration. It will be referenced during protocol analysis.", + "add_fixture_description": "Add this item to your deck configuration. It will be referenced during protocol analysis.", + "add_to_slot_description": "Choose an item below to add to your deck configuration. It will be referenced during protocol analysis.", "add_to_slot": "Add to slot {{slotName}}", "add": "Add", "an_error_occurred_while_updating_module": "An error occurred while updating your {{moduleName}}. Please try again.", @@ -40,8 +39,8 @@ "deck_configuration": "deck configuration", "deck_fixture_setup_instructions": "Deck fixture setup instructions", "deck_fixture_setup_modal_bottom_description_desktop": "For detailed instructions for different types of fixtures, scan the QR code or go to the link below.", - "deck_fixture_setup_modal_bottom_description": "For details on installing different fixture types, scan the QR code or search for “deck configuration” on support.opentrons.com", "deck_fixture_setup_modal_top_description": "First, unscrew and remove the deck slot where you'll install a fixture. Then put the fixture in place and attach it as needed.", + "deck_hardware": "deck hardware", "deck_slot": "deck slot {{slot}}", "delete_run": "Delete protocol run record", "detach_gripper": "Detach gripper", @@ -59,7 +58,7 @@ "firmware_update_needed": "Instrument firmware update needed. Start the update on the robot's touchscreen.", "firmware_update_available": "Firmware update available.", "firmware_update_failed": "Failed to update module firmware", - "firmware_update_installation_successful": "Installation successful", + "firmware_updated_successfully": "Firmware updated successfully", "firmware_update_occurring": "Firmware update in progress...", "fixture": "Fixture", "have_not_run_description": "After you run some protocols, they will appear here.", @@ -94,7 +93,6 @@ "module_calibration_required_update_pipette_FW": "Update pipette firmware before proceeding with required module calibration.", "module_calibration_required": "Module calibration required.", "module_controls": "Module Controls", - "module_error_contact_support": "Try powering the module off and on again. If the error persists, contact Opentrons Support.", "module_error": "Module error", "module_name_error": "{{moduleName}} error", "module_status_range": "Between {{min}} - {{max}} {{unit}}", @@ -177,7 +175,6 @@ "tempdeck_slideout_body": "Pre heat or cool your {{model}}. Enter a whole number between 4 °C and 96 °C.", "tempdeck_slideout_title": "Set Temperature for {{name}}", "temperature": "Temperature", - "these_are_advanced_settings": "These are advanced settings. Please do not attempt to adjust without assistance from Opentrons Support. Changing these settings may affect the lifespan of your pipette.These settings do not override any pipette settings defined in protocols.", "this_robot_will_restart_with_update": "This robot has to restart to update its software. Restarting will immediately stop the current run or calibration.Do you want to update now anyway?", "tip_pickup_drop": "Tip Pickup / Drop", "to_run_protocol_go_to_protocols_page": "To run a protocol on this robot, import a protocol on the Protocols page", diff --git a/app/src/assets/localization/en/device_settings.json b/app/src/assets/localization/en/device_settings.json index f92a6e30d69..711ce0451d7 100644 --- a/app/src/assets/localization/en/device_settings.json +++ b/app/src/assets/localization/en/device_settings.json @@ -6,7 +6,6 @@ "advanced": "Advanced", "alpha_description": "Warning: alpha releases are feature-complete but may contain significant bugs.", "alternative_security_types": "Alternative security types", - "alternative_security_types_description": "The Opentrons App supports connecting Flex to various enterprise access points. Connect via USB and finish setup in the app.", "app_change_in": "App Changes in {{version}}", "apply_historic_offsets": "Apply Labware Offsets", "are_you_sure_you_want_to_disconnect": "Are you sure you want to disconnect from {{ssid}}?", @@ -30,6 +29,7 @@ "check_for_updates": "Check for updates", "checking_for_updates": "Checking for updates", "choose": "Choose...", + "choose_file": "Choose file", "choose_network_type": "Choose network type", "choose_reset_settings": "Choose reset settings", "clear_all_data": "Clear all data", @@ -51,6 +51,7 @@ "clear_option_runs_history_subtext": "Clears information about past runs of all protocols.", "clear_option_tip_length_calibrations": "Clear tip length calibrations", "cancel_software_update": "Cancel software update", + "complete_and_restart_robot": "Complete and restart robot", "confirm_device_reset_description": "This will permanently delete all protocol, calibration, and other data. You’ll have to redo initial setup before using the robot again.", "confirm_device_reset_heading": "Are you sure you want to reset your device?", "connect": "Connect", @@ -59,16 +60,13 @@ "connect_via": "Connect via {{type}}", "connect_via_usb_description_1": "1. Connect the USB A-to-B cable to the robot’s USB-B port.", "connect_via_usb_description_2": "2. Connect the cable to an open USB port on your computer.", - "connect_via_usb_description_3": "3. Launch the Opentrons App on the computer to continue.", "connected": "Connected", "connected_network": "Connected Network", "connected_to_ssid": "Connected to {{ssid}}", "connected_via": "Connected via {{networkInterface}}", "connecting_to": "Connecting to {{ssid}}...", "connection_description_ethernet": "Connect to your lab's wired network.", - "connection_description_usb": "Connect directly to a computer (running the Opentrons App).", "connection_description_wifi": "Find a network in your lab or enter your own.", - "connection_lost_description": "The Opentrons App is unable to communicate with this robot right now. Double check the USB or Wifi connection to the robot, then try to reconnect.", "connection_to_robot_lost": "Connection to robot lost", "deck_calibration_description": "Calibrating the deck is required for new robots or after you relocate your robot. Recalibrating the deck will require you to also recalibrate pipette offsets.", "deck_calibration_missing": "Deck calibration missing", @@ -111,6 +109,7 @@ "enable_status_light": "Enable status light", "enable_status_light_description": "Turn on or off the strip of color LEDs on the front of the robot.", "engaged": "Engaged", + "enter_factory_password": "Enter factory password", "enter_network_name": "Enter network name", "enter_password": "Enter password", "estop": "E-stop", @@ -119,17 +118,16 @@ "estop_missing": "E-stop missing", "estop_missing_description": "Your E-stop could be damaged or detached. {{robotName}} lost its connection to the E-stop, so it canceled the protocol. Connect a functioning E-stop to continue.", "estop_pressed": "E-stop pressed", - "estop_pressed_description": "First, safely clear the deck of any labware or spills. Then, twist the E-stop button clockwise. Finally, have Flex move the gantry to its home position.", "ethernet": "Ethernet", "ethernet_connection_description": "Connect an Ethernet cable to the back of the robot and a network switch or hub.", "exit": "exit", + "factory_mode": "Factory Mode", "factory_reset": "Factory Reset", "factory_reset_description": "Resets all settings. You’ll have to redo initial setup before using the robot again.", "factory_reset_modal_description": "This data cannot be retrieved later.", "factory_resets_cannot_be_undone": "Factory resets cannot be undone.", "failed_to_connect_to_ssid": "Failed to connect to {{ssid}}", "feature_flags": "Feature Flags", - "find_your_robot": "Find your robot in the Opentrons App to install software updates.", "finish_setup": "Finish setup", "firmware_version": "Firmware Version", "fully_calibrate_before_checking_health": "Fully calibrate your robot before checking calibration health", @@ -146,6 +144,7 @@ "install_e_stop": "Install the E-stop", "installing_software": "Installing software...", "installing_update": "Installing update...", + "invalid_password": "Invalid password", "ip_address": "IP Address", "join_other_network": "Join other network", "join_other_network_error_message": "Must be 2–32 characters long", @@ -157,6 +156,7 @@ "launch_jupyter_notebook": "Launch Jupyter Notebook", "legacy_settings": "Legacy Settings", "mac_address": "MAC Address", + "manage_oem_settings": "Manage OEM settings", "minutes": "{{minute}} minutes", "missing_calibration": "Missing calibration", "model_and_serial": "Pipette Model and Serial", @@ -174,7 +174,6 @@ "need_another_security_type": "Need another security type?", "network_name": "Network Name", "network_settings": "Network Settings", - "network_setup_menu_description": "You’ll use this connection to run software updates and load protocols onto your Opentrons Flex.", "networking": "Networking", "never": "Never", "new_features": "New Features", @@ -193,7 +192,10 @@ "not_connected_via_wifi": "Not connected via Wi-Fi", "not_connected_via_wired_usb": "Not connected via wired USB", "not_now": "Not now", + "oem_mode": "OEM Mode", + "off": "Off", "one_hour": "1 hour", + "on": "On", "other_networks": "Other Networks", "password": "Password", "password_error_message": "Must be at least 8 characters", @@ -240,10 +242,8 @@ "robot_operating_update_available": "Robot Operating System Update Available", "robot_serial_number": "Robot Serial Number", "robot_server_version": "Robot Server Version", - "robot_server_version_ot3_description": "The Opentrons Flex software includes the robot server and the touchscreen display interface.", "robot_settings": "Robot Settings", "robot_settings_advanced_unknown": "Unknown", - "robot_software_update_required": "A robot software update is required to run protocols with this version of the Opentrons App.", "robot_successfully_connected": "Robot successfully connected to {{networkName}}.", "robot_system_version": "Robot System Version", "robot_system_version_available": "Robot System Version {{releaseVersion}} available", @@ -261,10 +261,7 @@ "select_authentication_method": "Select authentication method for your selected network.", "sending_software": "Sending software...", "serial": "Serial", - "share_logs_with_opentrons": "Share Robot logs with Opentrons", - "share_logs_with_opentrons_description": "Help Opentrons improve its products and services by automatically sending anonymous robot logs. Opentrons uses these logs to troubleshoot robot issues and spot error trends.", - "share_logs_with_opentrons_description_short": "Share anonymous robot logs with Opentrons.", - "share_logs_with_opentrons_short": "Share Robot logs", + "setup_mode": "Setup mode", "short_trash_bin": "Short trash bin", "short_trash_bin_description": "For pre-2019 robots with trash bins that are 55mm tall (instead of 77mm default)", "show": "Show", @@ -278,7 +275,6 @@ "successfully_connected": "Successfully connected!", "successfully_connected_to_network": "Successfully connected to {{ssid}}!", "supported_protocol_api_versions": "Supported Protocol API Versions", - "switch_to_usb_description": "If your network uses a different authentication method, connect to the Opentrons App and finish Wi-Fi setup there.", "text_size": "Text Size", "text_size_description": "Text on all screens will adjust to the size you choose below.", "tip_length_calibrations_history": "See all Tip Length Calibration history", @@ -296,27 +292,23 @@ "update_found": "Update found!", "update_robot_now": "Update robot now", "update_robot_software": "Update robot software manually with a local file (.zip)", - "update_robot_software_description": "Bypass the Opentrons App auto-update process and update the robot software manually.", - "update_robot_software_link": "Launch Opentrons software update page", "updating": "Updating", - "update_requires_restarting": "Updating the robot software requires restarting the robot", + "update_requires_restarting_robot": "Updating the robot software requires restarting the robot", + "upload_custom_logo_description": "Upload a logo for the robot to display during boot up. If no file is uploaded, we will display an anonymous logo.", + "upload_custom_logo_dimensions": "The logo must fit within dimensions 1024 x 600 and be a PNG file (.png).", + "upload_custom_logo": "Upload custom logo", "usage_settings": "Usage Settings", "usb": "USB", "usb_to_ethernet_description": "Looking for USB-to-Ethernet Adapter info?", "use_older_aspirate": "Use older aspirate behavior", "use_older_aspirate_description": "Aspirate with the less accurate volumetric calibrations that were used before version 3.7.0. Use this if you need consistency with pre-v3.7.0 results. This only affects GEN1 P10S, P10M, P50M, and P300S pipettes.", "use_older_protocol_analysis_method": "Use older protocol analysis method", - "use_older_protocol_analysis_method_description": "Use an older, slower method of analyzing uploaded protocols. This changes how the OT-2 validates your protocol during the upload step, but does not affect how your protocol actually runs. Opentrons Support might ask you to change this setting if you encounter problems with the newer, faster protocol analysis method.", "validating_software": "Validating software...", "view_details": "View details", "view_latest_release_notes_at": "View latest release notes at {{url}}", "view_network_details": "View network details", - "view_opentrons_issue_tracker": "View Opentrons issue tracker", - "view_opentrons_release_notes": "View full Opentrons release notes", - "view_opentrons_technical_change_log": "View Opentrons technical change log", "view_update": "View update", "welcome_description": "Quickly run protocols and check on your robot's status right on your lab bench.", - "welcome_title": "Welcome to your Opentrons Flex!", "wifi": "Wi-Fi", "wired_ip": "Wired IP", "wired_mac_address": "Wired MAC Address", diff --git a/app/src/assets/localization/en/devices_landing.json b/app/src/assets/localization/en/devices_landing.json index 60a25974fec..b0a3307ace1 100644 --- a/app/src/assets/localization/en/devices_landing.json +++ b/app/src/assets/localization/en/devices_landing.json @@ -4,7 +4,6 @@ "check_same_network": "Check that the computer and robot are on the same network", "connect_to_network": "Connect to network", "connection_troubleshooting_intro": "If you’re having trouble with the robot’s connection, try these troubleshooting tasks. First, double check that the robot is powered on.", - "contact_support_for_connection_help": "If none of these work, contact Opentrons Support for help (via the question mark link in this app, or by emailing {{support_email}}.)", "deck_configuration": "Deck configuration", "devices": "Devices", "disconnect_from_network": "Disconnect from network", diff --git a/app/src/assets/localization/en/drop_tip_wizard.json b/app/src/assets/localization/en/drop_tip_wizard.json index 66924d00210..fc3bf25dfdf 100644 --- a/app/src/assets/localization/en/drop_tip_wizard.json +++ b/app/src/assets/localization/en/drop_tip_wizard.json @@ -3,10 +3,12 @@ "begin_removal": "Begin removal", "blowout_complete": "blowout complete", "blowout_liquid": "Blow out liquid", + "cant_safely_drop_tips": "Can't safely drop tips", "choose_blowout_location": "choose blowout location", "choose_drop_tip_location": "choose tip-drop location", "confirm_blowout_location": "Is the pipette positioned where the liquids should be blown out?", "confirm_drop_tip_location": "Is the pipette positioned where the tips should be dropped?", + "confirm_removal_and_home": "Confirm removal and home", "drop_tip_complete": "tip drop complete", "drop_tip_failed": "The drop tip could not be completed. Contact customer support for assistance.", "drop_tips": "drop tips", @@ -21,6 +23,7 @@ "position_the_pipette": "position the pipette", "remove_the_tips": "You may want to remove the tips from the {{mount}} Pipette before using it again in a protocol.", "remove_the_tips_from_pipette": "You may want to remove the tips from the pipette before using it again in a protocol.", + "remove_the_tips_manually": "Remove the tips manually. Then home the gantry. Homing with tips attached could pull liquid into the pipette and damage it.", "remove_tips": "Remove tips", "select_blowout_slot": "You can blow out liquid into a labware or dispose of it.Select the slot where you want to blow out the liquid on the deck map to the right. Once confirmed, the gantry will move to the chosen slot.", "select_blowout_slot_odd": "You can blow out liquid into a labware or dispose of it.
After the gantry moves to the chosen slot, use the jog controls to move the pipette to the exact position for blowing out.", diff --git a/app/src/assets/localization/en/error_recovery.json b/app/src/assets/localization/en/error_recovery.json new file mode 100644 index 00000000000..7531853df16 --- /dev/null +++ b/app/src/assets/localization/en/error_recovery.json @@ -0,0 +1,3 @@ +{ + "run_paused": "Run paused" +} diff --git a/app/src/assets/localization/en/firmware_update.json b/app/src/assets/localization/en/firmware_update.json index 8abe122d914..5d543032e12 100644 --- a/app/src/assets/localization/en/firmware_update.json +++ b/app/src/assets/localization/en/firmware_update.json @@ -1,10 +1,10 @@ { - "download_logs": "Download the robot logs from the Opentrons App and send them to support@opentrons.com for assistance.", "firmware_out_of_date": "The firmware for {{mount}} {{instrument}} is out of date. You need to update it before running protocols that use this instrument.", "gantry_x": "Gantry X", "gantry_y": "Gantry Y", "gripper": "Gripper", "head": "Head", + "hepa_uv": "HEPA/UV Module", "pipette_left": "pipette", "pipette_right": "pipette", "ready_to_use": "Your {{instrument}} is ready to use!", diff --git a/app/src/assets/localization/en/gripper_wizard_flows.json b/app/src/assets/localization/en/gripper_wizard_flows.json index a868cdb474e..ff98d8e07f0 100644 --- a/app/src/assets/localization/en/gripper_wizard_flows.json +++ b/app/src/assets/localization/en/gripper_wizard_flows.json @@ -8,7 +8,6 @@ "calibration_pin": "Calibration Pin", "calibration_pin_touching": "The calibration pin will touch the calibration square in slot {{slot}} to determine its exact position.", "complete_calibration": "Complete calibration", - "connect_and_screw_in_gripper": "Connect and secure Flex Gripper", "continue": "Continue", "continue_calibration": "Continue calibration", "detach_gripper": "Detach Gripper", @@ -17,17 +16,11 @@ "get_started": "Get started", "gripper_calibration": "Gripper Calibration", "gripper_recalibration": "Gripper Recalibration", - "gripper_still_attached": "Flex Gripper still attached", - "gripper_successfully_attached_and_calibrated": "Flex Gripper successfully attached and calibrated", "gripper_successfully_attached": "Gripper successfully attached", - "gripper_successfully_calibrated": "Flex Gripper successfully calibrated", - "gripper_successfully_detached": "Flex Gripper successfully detached", - "gripper": "Flex Gripper", "hex_screwdriver": "2.5 mm Hex Screwdriver", "hold_gripper_and_loosen_screws": "Hold the gripper in place and loosen the top gripper screw first. After that move onto the bottom screw. (The screws are captive and will not come apart from the gripper.) Then carefully remove the gripper.", "insert_pin_into_front_jaw": "Insert calibration pin in front jaw", "insert_pin_into_rear_jaw": "Insert calibration pin in rear jaw", - "loosen_screws_and_detach": "Loosen screws and detach Flex Gripper", "move_gantry_to_front": "Move gantry to front", "move_pin_from_front_to_rear_jaw": "Remove the calibration pin from the front jaw and attach it to the rear jaw.", "move_pin_from_rear_jaw_to_storage": "Take the calibration pin from the rear gripper jaw and return it to its storage location.", diff --git a/app/src/assets/localization/en/index.ts b/app/src/assets/localization/en/index.ts index c7256b1d415..51acf92db53 100644 --- a/app/src/assets/localization/en/index.ts +++ b/app/src/assets/localization/en/index.ts @@ -1,5 +1,7 @@ import shared from './shared.json' +import anonymous from './anonymous.json' import app_settings from './app_settings.json' +import branded from './branded.json' import change_pipette from './change_pipette.json' import protocol_command_text from './protocol_command_text.json' import device_details from './device_details.json' @@ -14,25 +16,23 @@ import labware_details from './labware_details.json' import labware_landing from './labware_landing.json' import labware_position_check from './labware_position_check.json' import module_wizard_flows from './module_wizard_flows.json' -import more_network_and_system from './more_network_and_system.json' -import more_panel from './more_panel.json' import pipette_wizard_flows from './pipette_wizard_flows.json' -import protocol_calibration from './protocol_calibration.json' import protocol_details from './protocol_details.json' import protocol_info from './protocol_info.json' import protocol_list from './protocol_list.json' import protocol_setup from './protocol_setup.json' -import robot_advanced_settings from './robot_advanced_settings.json' +import quick_transfer from './quick_transfer.json' import robot_calibration from './robot_calibration.json' -import robot_connection from './robot_connection.json' import robot_controls from './robot_controls.json' -import robot_info from './robot_info.json' import run_details from './run_details.json' import top_navigation from './top_navigation.json' +import error_recovery from './error_recovery.json' export const en = { shared, + anonymous, app_settings, + branded, change_pipette, protocol_command_text, device_details, @@ -47,19 +47,15 @@ export const en = { labware_landing, labware_position_check, module_wizard_flows, - more_network_and_system, - more_panel, pipette_wizard_flows, - protocol_calibration, protocol_details, protocol_info, protocol_list, protocol_setup, - robot_advanced_settings, + quick_transfer, robot_calibration, - robot_connection, robot_controls, - robot_info, run_details, top_navigation, + error_recovery, } diff --git a/app/src/assets/localization/en/labware_landing.json b/app/src/assets/localization/en/labware_landing.json index 63212567561..17eebda979d 100644 --- a/app/src/assets/localization/en/labware_landing.json +++ b/app/src/assets/localization/en/labware_landing.json @@ -22,8 +22,6 @@ "labware": "labware", "last_updated": "Last updated", "open_labware_creator": "Open Labware Creator", - "opentrons_def": "Opentrons Definition", - "opentrons_labware_def": "Opentrons labware definition", "show_in_folder": "Show in folder", "unable_to_upload": "Unable to upload file", "yes_delete_def": "Yes, delete definition" diff --git a/app/src/assets/localization/en/labware_position_check.json b/app/src/assets/localization/en/labware_position_check.json index f08c465f7fa..4072826650a 100644 --- a/app/src/assets/localization/en/labware_position_check.json +++ b/app/src/assets/localization/en/labware_position_check.json @@ -27,9 +27,6 @@ "detach_probe": "Remove calibration probe", "ensure_nozzle_position_odd": "Ensure that the {{tip_type}} is centered above and level with {{item_location}}. If it isn't, tap Move pipette and then jog the pipette until it is properly aligned.", "ensure_nozzle_position_desktop": "Ensure that the {{tip_type}} is centered above and level with {{item_location}}. If it isn't, use the controls below or your keyboard to jog the pipette until it is properly aligned.", - "error_modal_header": "Something went wrong", - "error_modal_problem_in_app": "There was an error performing Labware Position Check. Please restart the app. If the problem persists, please contact Opentrons Support", - "error_modal_problem_on_robot": "There was an error processing your request on the robot", "exit_screen_confirm_exit": "Exit and discard all labware offsets", "exit_screen_go_back": "Go back to labware position check", "exit_screen_subtitle": "If you exit now, all labware offsets will be discarded. This cannot be undone.", diff --git a/app/src/assets/localization/en/module_wizard_flows.json b/app/src/assets/localization/en/module_wizard_flows.json index 636bb368662..a502b0ef797 100644 --- a/app/src/assets/localization/en/module_wizard_flows.json +++ b/app/src/assets/localization/en/module_wizard_flows.json @@ -21,12 +21,10 @@ "exit": "Exit", "firmware_up_to_date": "{{module}} firmware up to date.", "firmware_updated": "{{module}} firmware updated!", - "get_started": "To get started, remove labware from the deck and clean up the working area to make the calibration easier. Also gather the needed equipment shown to the right.The calibration adapter came with your module. The pipette probe came with your Flex pipette.", "install_adapter": "Place calibration adapter in {{module}}", "install_calibration_adapter": "Install calibration adapter", "location_occupied": "A {{fixture}} is currently specified here on the deck configuration", "module_calibrating": "Stand back, {{moduleName}} is calibrating", - "module_calibration_failed": "Module calibration was unsuccessful. Make sure the calibration adapter is fully seated on the module and try again. If you still have trouble, contact Opentrons Support.{{error}}", "module_calibration": "Module calibration", "module_secured": "The module must be fully secured in its caddy and secured in the deck slot.", "module_too_hot": "Module is too hot to proceed to module calibration", diff --git a/app/src/assets/localization/en/more_network_and_system.json b/app/src/assets/localization/en/more_network_and_system.json deleted file mode 100644 index a6fd1593b38..00000000000 --- a/app/src/assets/localization/en/more_network_and_system.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "description": "Description", - "driver_version": "Driver Version", - "launch_realtek_adapter_drivers_site": "Launch Realtek Adapter Drivers Site", - "manufacturer": "Manufacturer", - "network_and_system_title": "Network & System", - "u2e_adapter_information": "USB-to-Ethernet Adapter Information", - "u2e_driver_update_alert": "Update available for Realtek USB-to-Ethernet adapter driver" -} diff --git a/app/src/assets/localization/en/more_panel.json b/app/src/assets/localization/en/more_panel.json deleted file mode 100644 index 63b594c1f37..00000000000 --- a/app/src/assets/localization/en/more_panel.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "app": "App", - "custom_labware": "Custom Labware", - "network_and_system": "Network & System", - "resources": "Resources" -} diff --git a/app/src/assets/localization/en/pipette_wizard_flows.json b/app/src/assets/localization/en/pipette_wizard_flows.json index 5d13142349c..53ae23d07e2 100644 --- a/app/src/assets/localization/en/pipette_wizard_flows.json +++ b/app/src/assets/localization/en/pipette_wizard_flows.json @@ -32,7 +32,7 @@ "detach_mount_attach_96": "Detach {{mount}} Pipette and Attach 96-Channel Pipette", "detach_mounting_plate_instructions": "Hold onto the plate so it does not fall. Then remove the pins on the plate from the slots on the gantry carriage.", "detach_next_pipette": "Detach next pipette", - "detach_pipette_to_attach_96": "Detach {{pipetteName}} and attach 96-Channel pipette", + "detach_pipette_to_attach_96": "Detach {{pipetteName}} and Attach 96-Channel pipette", "detach_pipette": "detach {{mount}} pipette", "detach_pipettes_attach_96": "Detach Pipettes and Attach 96-Channel Pipette", "detach_z_axis_screw_again": "detach the z-axis screw before attaching the 96-Channel Pipette.", @@ -77,7 +77,6 @@ "return_probe_error": "Return the calibration probe to its storage location before exiting. {{error}}", "single_mount_attached_error": "Single mount pipette is selected when this is the 96 channel flow", "single_or_8_channel": "{{single}} or {{eight}} pipette", - "something_seems_wrong": "There may be a problem with your pipette. Exit setup and contact Opentrons Support for assistance.", "stand_back": "Stand back, robot is in motion", "try_again": "try again", "unable_to_detect_probe": "Unable to detect calibration probe", diff --git a/app/src/assets/localization/en/protocol_calibration.json b/app/src/assets/localization/en/protocol_calibration.json deleted file mode 100644 index 1c65b64721f..00000000000 --- a/app/src/assets/localization/en/protocol_calibration.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "cal_data_existing_data": "Existing data", - "cal_data_legacy_definition": "Calibration Data N/A", - "cal_data_not_calibrated": "Not yet calibrated", - "cal_data_updated_data": "Updated data", - "cal_panel_title": "Placement and Calibration", - "labware_cal_labware_title": "labware", - "labware_cal_tipracks_title": "tip racks", - "labware_cal_title": "Labware Calibration", - "module_connect_description": "Power up and plug in the required module(s) via the OT-2 USB Ports. Place the modules as shown in the deck map.", - "module_connect_duplicate_description": "Plug the modules in to the USB ports as listed in the Placement and Calibration Panel. Check out our help docs for more information on using modules of the same type.", - "module_connect_instruction": "Plug the modules in to the USB ports as listed below, from left to right.", - "module_connect_missing_tooltip": "Connect module(s) to proceed to labware calibration", - "module_connect_proceed_button": "Continue to labware setup", - "modules_deck_slot_title": "slot", - "modules_module_title": "module", - "modules_title": "modules", - "modules_update_software_tooltip": "Update robot software to see USB port information", - "modules_usb_order_title": "USB order (L to R)", - "modules_usb_port_title": "USB port", - "tip_length_cal_title": "Tip Length Calibration" -} diff --git a/app/src/assets/localization/en/protocol_details.json b/app/src/assets/localization/en/protocol_details.json index c5cc80f2804..fafd2c98038 100644 --- a/app/src/assets/localization/en/protocol_details.json +++ b/app/src/assets/localization/en/protocol_details.json @@ -39,6 +39,7 @@ "not_connected": "not connected", "not_in_protocol": "no {{section}} is specified for this protocol", "num_choices": "{{num}} choices", + "num_options": "{{num}} options", "off": "Off", "on_off": "On, off", "on": "On", @@ -80,6 +81,7 @@ "unavailable_robot_not_listed_plural": "{{count}} unavailable robots are not listed.", "unavailable_robot_not_listed": "{{count}} unavailable robot is not listed.", "unsuccessfully_sent": "Unsuccessfully sent", + "value_out_of_range": "Value must be between {{min}}-{{max}}", "view_run_details": "View run details", "view_unavailable_robots": "View unavailable robots on the Devices page" } diff --git a/app/src/assets/localization/en/protocol_info.json b/app/src/assets/localization/en/protocol_info.json index bbaac1ce9c2..20d618db601 100644 --- a/app/src/assets/localization/en/protocol_info.json +++ b/app/src/assets/localization/en/protocol_info.json @@ -10,7 +10,6 @@ "creation_method": "Creation Method", "custom_labware_not_supported": "Robot doesn't support custom labware", "date_added": "Date Added", - "delete_protocol_from_app": "Delete the protocol, make changes to address the error, and resend the protocol to this robot from the Opentrons App.", "delete_protocol": "Delete protocol", "description": "Description", "drag_file_here": "Drag and drop protocol file here", @@ -65,19 +64,15 @@ "protocol_title": "Protocol - {{protocol_name}}", "protocol_upload_failed": "Protocol upload failed. Fix the error and try again", "protocols": "Protocols", + "quick_transfer": "Quick transfer", "required_cal_data_title": "Calibration Data", "required_quantity_title": "Quantity", "required_type_title": "Type", - "rerunning_protocol_modal_body": "Opentrons displays the connected robot’s last protocol run on on the Protocol Upload page. If you run again, Opentrons loads this protocol and applies Labware Offset data if any exists.Clicking “Run Again” will take you directly to the Run tab. If you’d like to review the deck setup or run Labware Position Check before running the protocol, navigate to the Protocol tab.If you recalibrate your robot, it will clear the last run from the upload page. A run can have the following statuses:Not started: when this protocol was loaded on to the robot, it was closed before the user ran itCanceled: when this protocol was loaded on to the robot, it was canceled before the run completedComplete: when this protocol was loaded on to the robot, it was closed after the protocol run completed", - "rerunning_protocol_modal_header": "How Rerunning A Protocol Works", - "rerunning_protocol_modal_link": "Learn more about Labware Offset Data", - "rerunning_protocol_modal_title": "See How Rerunning a Protocol Works", "robot_name_last_run": "{{robot_name}}’s last run", "robot_type_first": "{{robotType}} protocols first", "run_again": "Run again", "run_protocol": "Run protocol", "run_timestamp_title": "Run timestamp", - "send_a_protocol_to_store": "Send a protocol from the Opentrons App to get started.", "simulation_in_progress": "Simulation in Progress", "timestamp": "+{{index}}", "too_many_pins_body": "Remove a protocol in order to add more protocols to your pinned list.", diff --git a/app/src/assets/localization/en/protocol_list.json b/app/src/assets/localization/en/protocol_list.json index d014c27abae..76c1b068d94 100644 --- a/app/src/assets/localization/en/protocol_list.json +++ b/app/src/assets/localization/en/protocol_list.json @@ -1,5 +1,4 @@ { - "a_robot_software_update_is_available": "A robot software update is required to run protocols with this version of the Opentrons App. Go to Robot", "delete_protocol_message": " and its run history will be permanently deleted.", "last_updated_at": "Updated {{date}}", "left_mount": "left mount", diff --git a/app/src/assets/localization/en/protocol_setup.json b/app/src/assets/localization/en/protocol_setup.json index 084debdb5f0..74fbf93d3c2 100644 --- a/app/src/assets/localization/en/protocol_setup.json +++ b/app/src/assets/localization/en/protocol_setup.json @@ -38,6 +38,7 @@ "calibration_status": "calibration status", "calibration": "Calibration", "cancel_and_restart_to_edit": "Cancel the run and restart setup to edit", + "choose_enum": "Choose {{displayName}}", "closing": "Closing...", "complete_setup_before_proceeding": "complete setup before continuing run", "configure": "Configure", @@ -47,6 +48,7 @@ "confirm_values": "Confirm values", "connect_all_hardware": "Connect and calibrate all hardware first", "connect_all_mod": "Connect all modules first", + "connect_modules_for_controls": "Connect modules to see controls", "connection_info_not_available": "Connection info not available once run has started", "connection_status": "Connection Status", "currently_configured": "Currently configured", @@ -59,6 +61,7 @@ "deck_conflict_info_thermocycler": "Update the deck configuration by removing the fixtures in locations A1 and B1. Either remove the fixtures from the deck configuration or update the protocol.", "deck_conflict_info": "Update the deck configuration by removing the {{currentFixture}} in location {{cutout}}. Either remove the fixture from the deck configuration or update the protocol.", "deck_conflict": "Deck location conflict", + "deck_hardware": "Deck hardware", "deck_map": "Deck Map", "default_values": "Default values", "example": "Example", @@ -120,8 +123,6 @@ "lpc_disabled_modules_not_connected": "Make sure all modules are connected before running Labware Position Check", "lpc_disabled_no_tipracks_loaded": "Labware Position Check requires that the protocol loads a tip rack", "lpc_disabled_no_tipracks_used": "Labware Position Check requires that the protocol has at least one pipette that picks up a tip", - "magnetic_module_attention_warning": "Opentrons recommends securing labware with the module’s bracket. See how to secure labware to the Magnetic Module", - "magnetic_module_extra_attention": "Opentrons recommends securing labware with the module’s bracket", "map_view": "Map View", "missing_gripper": "Missing gripper", "missing_instruments": "Missing {{count}}", @@ -129,7 +130,6 @@ "missing_pipettes": "Missing {{count}} pipette", "missing": "Missing", "modal_instructions_title": "{{moduleName}} Setup Instructions", - "modal_instructions": "For step-by-step instructions on setting up your module, consult the Quickstart Guide that came in its box. You can also click the link below or scan the QR code to visit the modules section of the Opentrons Help Center.", "module_and_deck_setup": "Modules & deck", "module_connected": "Connected", "module_disconnected": "Disconnected", @@ -161,12 +161,14 @@ "must_have_labware_and_pip": "Protocol must load labware and a pipette", "n_a": "N/A", "name": "Name", + "no_custom_values": "No custom values specified", "no_data": "no data", "no_labware_offset_data": "no labware offset data yet", "no_modules_or_fixtures": "No modules or fixtures are specified for this protocol.", "no_modules_specified": "no modules are specified for this protocol.", "no_modules_used_in_this_protocol": "No hardware used in this protocol", "no_parameters_specified": "No parameters specified", + "no_parameters_specified_in_protocol": "No parameters specified in this protocol", "no_tiprack_loaded": "Protocol must load a tip rack", "no_tiprack_used": "Protocol must pick up a tip", "no_usb_connection_required": "No USB connection required", @@ -224,7 +226,8 @@ "resolve": "Resolve", "restart_setup_and_try": "Restart setup and try using different parameter values.", "restart_setup": "Restart setup", - "restore_default": "Restore default values", + "restore_defaults": "Restore default values", + "restore_default": "Restore default value", "robot_cal_description": "Robot calibration establishes how the robot knows where it is in relation to the deck. Accurate Robot calibration is essential to run protocols successfully. Robot calibration has 3 parts: Deck calibration, Tip Length calibration and Pipette Offset calibration.", "robot_cal_help_title": "How Robot Calibration Works", "robot_calibration_step_description_pipettes_only": "Review required instruments and calibrations for this protocol.", @@ -234,23 +237,18 @@ "run_disabled_modules_and_calibration_not_complete": "Make sure robot calibration is complete and all modules are connected before proceeding to run", "run_disabled_modules_not_connected": "Make sure all modules are connected before proceeding to run", "run_labware_position_check": "run labware position check", + "run_never_started": "Run was never started", "run": "Run", - "secure_labware_explanation_magnetic_module": "Opentrons recommends ensuring your labware locks to the Magnetic Module by adjusting the black plate bracket on top of the module. Please note there are two sizes of plate brackets supplied with your module: standard and deep well. These brackets can be removed and swapped by unscrewing the modules thumb screw (the silver knob on the front).", - "secure_labware_explanation_thermocycler": "Opentrons recommends securing your labware to the Thermocycler module by closing its latch. Doing so ensures level and accurate plate placement for optimal results.", "secure_labware_instructions": "Secure labware instructions", "secure_labware_modal": "Securing labware to the {{name}}", "secure": "Secure", "setup_for_run": "Setup for Run", - "setup_instructions_description": "For step-by-step instructions on setting up your module, consult the Quickstart Guide that came in its box or scan the QR code to visit the modules section of the Opentrons Help Center.", "setup_instructions": "setup instructions", "setup_is_view_only": "Setup is view-only once run has started", "slot_location": "Slot {{slotName}}", "slot_number": "Slot Number", "status": "Status", "step": "STEP {{index}}", - "thermocycler_attention_warning": " Labware must be secured with the module’s latch. See how to secure labware to the Thermocycler Module Thermocycler lid must be open when robot moves to the slots it occupies. Opentrons will automatically open the lid to move to these slots during Labware Position Check.", - "thermocycler_extra_attention_gen_1": "Labware must be secured with the module’s latch. Thermocycler lid must be open when robot moves to the slots it occupies. Opentrons will automatically open the lid to move to these slots during Labware Position Check.", - "thermocycler_extra_attention_gen_2": "The lid will automatically open when moving to these slots during Labware Position Check.", "tip_length_cal_description_bullet": "Perform Tip Length Calibration for each new tip type used on a pipette.", "tip_length_cal_description": "This measures the Z distance between the bottom of the tip and the pipette’s nozzle. If you redo the tip length calibration for the tip you used to calibrate a pipette, you will also have to redo that Pipette Offset Calibration.", "tip_length_cal_title": "Tip Length Calibration", @@ -260,13 +258,15 @@ "updated": "Updated", "usb_connected_no_port_info": "USB Port Connected", "usb_port_connected": "USB Port {{port}}", + "usb_port_number": "USB-{{port}}", "value": "Value", "values_are_view_only": "Values are view-only", + "value_out_of_range_generic": "Value must be in range", + "value_out_of_range": "Value must be between {{min}}-{{max}}", "view_current_offsets": "View current offsets", "view_moam": "View setup instructions for placing modules of the same type to the robot.", "view_setup_instructions": "View setup instructions", "volume": "Volume", "what_labware_offset_is": "A Labware Offset is a type of positional adjustment that accounts for small, real-world variances in the overall position of the labware on a robot’s deck. Labware Offset data is unique to a specific combination of labware definition, deck slot, and robot.", - "why_use_lpc": "Labware Position Check is intended to correct for minor variances. Opentrons does not recommend using Labware Position Check to compensate for large positional adjustments. Needing to set large labware offsets could indicate a problem with robot calibration.", "with_the_chosen_value": "With the chosen values, the following error occurred:" } diff --git a/app/src/assets/localization/en/quick_transfer.json b/app/src/assets/localization/en/quick_transfer.json new file mode 100644 index 00000000000..b0e9e294dc4 --- /dev/null +++ b/app/src/assets/localization/en/quick_transfer.json @@ -0,0 +1,21 @@ +{ + "create_new_transfer": "Create new quick transfer", + "left_mount": "Left Mount", + "both_mounts": "Left + Right Mount", + "right_mount": "Right Mount", + "select_attached_pipette": "Select attached pipette", + "select_dest_labware": "Select destination labware", + "select_dest_wells": "Select destination wells", + "select_source_labware": "Select source labware", + "select_source_wells": "Select source wells", + "select_tip_rack": "Select tip rack", + "set_aspirate_volume": "Set aspirate volume", + "set_dispense_volume": "Set dispense volume", + "set_transfer_volume": "Set transfer volume", + "use_deck_slots": "Quick transfers use deck slots B2-D2. These slots hold a tip rack, a source labware, and a destination labware.Make sure that your deck configuration is up to date to avoid collisions.", + "tip_rack": "Tip rack", + "labware": "Labware", + "pipette_currently_attached": "Quick transfer options depend on the pipettes currently attached to your robot.", + "well_selection": "Well selection", + "well_ratio": "Quick transfers with multiple source wells can either be one-to-one (select {{wells}} for this transfer) or consolidate (select 1 destination well)." +} diff --git a/app/src/assets/localization/en/robot_advanced_settings.json b/app/src/assets/localization/en/robot_advanced_settings.json deleted file mode 100644 index 8ce165ecaa0..00000000000 --- a/app/src/assets/localization/en/robot_advanced_settings.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "download_logs_button": "download", - "download_logs_description": "Access logs from this robot.", - "download_logs_label": "download logs", - "log_opt_out_explanation": "If your OT-2 is connected to the internet, Opentrons will collect logs from your robot to troubleshoot issues and identify error trends.", - "log_opt_out_heading": "Robot Logging", - "log_opt_out_instruction": "If you would like to disable log collection, please click "Opt out" below.", - "open_jupyter_description": "Open the Jupyter Notebook running on this OT-2 in your web browser. (Experimental feature! See documentation for more details.)", - "open_jupyter_label": "Jupyter Notebook", - "opt_in": "Sounds Good!", - "opt_out": "Opt Out", - "reset_button": "reset", - "reset_description": "Restore robot to factory configuration.", - "reset_label": "factory reset", - "title": "advanced settings", - "update_from_file_description": "If your app is unable to auto-download robot updates, you can download the robot update yourself and update your robot manually.", - "update_from_file_label": "Update robot software from file" -} diff --git a/app/src/assets/localization/en/robot_calibration.json b/app/src/assets/localization/en/robot_calibration.json index 9828ed706c4..1e96d8d1c24 100644 --- a/app/src/assets/localization/en/robot_calibration.json +++ b/app/src/assets/localization/en/robot_calibration.json @@ -11,13 +11,11 @@ "calibrate_z_axis_on_block": "Calibrate z-axis on block", "calibrate_z_axis_on_slot": "Calibrate z-axis in slot 5", "calibrate_z_axis_on_trash": "Calibrate z-axis on trash bin", - "calibration_block_description": "This block is a specially made tool that fits perfectly on your deck and helps with calibration.If you do not have a Calibration Block, please email support@opentrons.com so we can send you one. In your message, be sure to include your name, company or institution name, and shipping address. While you wait for the block to arrive, you can use the flat surface on the trash bin of your robot instead.", "calibration_complete": "Calibration complete", "calibration_dashboard": "Calibration Dashboard", "calibration_health_check": "Calibration Health Check", "calibration_health_check_intro_body": "Calibration Health Check diagnoses problems with Deck, Tip Length, and Pipette Offset Calibration.You will move the pipettes to various positions, which will be compared against your existing calibration data.If there is a large difference, you will be prompted to redo some or all of your calibrations.", "calibration_health_check_results": "Calibration Health Check Results", - "calibration_on_opentrons_tips_is_important": "It’s extremely important to perform this calibration using the Opentrons tips and tip racks specified above, as the robot determines accuracy based on the known measurements of these tips.", "calibration_recommended": "Calibration recommended", "calibration_status": "Calibration Status", "calibration_status_description": "For accurate and precise movement, calibrate the robot's deck, pipette offsets, and tip lengths.", @@ -84,8 +82,6 @@ "need_help": "Need help?", "no_pipette": "No pipette attached", "no_tip_length": "Calibrate your pipette to see saved tip length", - "opentrons": "opentrons", - "opentrons_tip_racks_recommended": "Opentrons tip racks are highly recommended. Accuracy cannot be guaranteed with other tip racks.", "pick_up_tip": "Pick up tip", "pipette_name_and_serial": "{{name}}, {{serial}}", "pipette_offset_calibration": "Pipette Offset Calibration", diff --git a/app/src/assets/localization/en/robot_connection.json b/app/src/assets/localization/en/robot_connection.json deleted file mode 100644 index 46d1874d51c..00000000000 --- a/app/src/assets/localization/en/robot_connection.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "connect": "connect", - "connected_description": "Your app is currently connected to your robot via {{type}} at IP address {{ip}}", - "connection_label": "this robot is currently", - "connection_status_default": "idle", - "connection_status_disconnected": "unknown - connect to view status", - "connection_status_not_connectable": "not connectable", - "connection_title": "status", - "disconnect": "disconnect", - "disconnected_description": "Your app is trying to connect to your robot via {{type}} at IP address {{ip}}", - "failed_connection_heading": "Could not connect to robot", - "health_status_not_ok": "not responding correctly to requests", - "health_status_ok": "responding to requests", - "health_status_unreachable": "unreachable", - "internet_status_full": "Internet: The robot is connected to a network and has full access to the Internet.", - "internet_status_limited": "Internet: The robot is connected to a network, but it has no access to the Internet.", - "internet_status_none": "Internet: The robot is not connected to any network.", - "internet_status_portal": "Internet: The robot is behind a captive portal and cannot reach the full Internet.", - "internet_status_unknown": "Internet: Unknown", - "internet_status": "Internet: Unknown", - "ip": "{{type}} IP: {{ip}}", - "last_resort": "If your robot remains unresponsive, please reach out to our Support team.", - "mac": "{{type}} MAC Address: {{mac}}", - "no_server_message": "This OT-2 has been seen recently, but it is currently {{status}} at IP address {{ip}}.We recommend power-cycling your robot.", - "server_message": "Your OT-2's API server is {{status}} at IP address {{ip}}.We recommend the following troubleshooting steps:
  1. Power-cycle your robot
  2. If power-cycling does not work, please update your robot's software
    (Note: your robot's update server is still responding and should accept an update.)
", - "subnet": "{{type}} Subnet Mask: {{subnet}}", - "success_banner": "{{robot}} successfully connected", - "title": "connectivity", - "unresponsive_title": "Unable to establish connection with robot", - "wired": "wired", - "wireless": "wireless" -} diff --git a/app/src/assets/localization/en/robot_info.json b/app/src/assets/localization/en/robot_info.json deleted file mode 100644 index f608623ac4c..00000000000 --- a/app/src/assets/localization/en/robot_info.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "api_version_min_max": "min: {{min}}, max: {{max}}", - "firmware_version": "firmware version", - "robot_name": "robot name", - "server_version": "server version", - "supported_api_versions": "supported protocol API versions", - "title": "information" -} diff --git a/app/src/assets/localization/en/run_details.json b/app/src/assets/localization/en/run_details.json index 90a6977806e..ed0fbbdc7e7 100644 --- a/app/src/assets/localization/en/run_details.json +++ b/app/src/assets/localization/en/run_details.json @@ -5,11 +5,12 @@ "anticipated": "Anticipated steps", "apply_stored_data": "Apply stored data", "apply_stored_labware_offset_data": "Apply stored Labware Offset data?", - "cancel_run_alert_info": "Doing so will terminate this run, drop any attached tips in the trash container and home your robot.", + "cancel_run_alert_info_flex": "Doing so will terminate this run and home your robot.", + "cancel_run_alert_info_ot2": "Doing so will terminate this run, drop any attached tips in the trash container, and home your robot.", "cancel_run_and_restart": "Cancel the run and restart setup to edit", "cancel_run_modal_back": "No, go back", "cancel_run_modal_confirm": "Yes, cancel run", - "cancel_run_modal_heading": "Are you sure you want to cancel this run?", + "cancel_run_modal_heading": "Are you sure you want to cancel?", "cancel_run_module_info": "Additionally, any hardware modules used within the protocol will remain active and maintain their current states until deactivated.", "cancel_run": "Cancel run", "canceling_run_dot": "canceling run...", @@ -22,7 +23,6 @@ "comment_step": "Comment", "comment": "Comment", "complete_protocol_to_download": "Complete the protocol to download the run log", - "contact_information": "Download the robot logs from the Opentrons App and send it to support@opentrons.com for assistance.", "current_step_pause_timer": "Timer", "current_step_pause": "Current Step - Paused by User", "current_step": "Current Step", @@ -101,8 +101,6 @@ "run_completed": "Run completed.", "run_cta_disabled": "Complete required steps on Protocol tab before starting the run", "run_failed_modal_body": "Error occurred when protocol was {{command}}", - "run_failed_modal_description_desktop": "Download the run log and send it to support@opentrons.com for assistance.", - "run_failed_modal_description": "Please contact support@opentrons.com with relevant information for assistance with troubleshooting.", "run_failed_modal_header": "{{errorName}}: {{errorCode}} at protocol step {{count}}", "run_failed_modal_title": "Run failed", "run_failed_splash": "Run failed", diff --git a/app/src/assets/localization/en/shared.json b/app/src/assets/localization/en/shared.json index 8c8bed0a5af..fe1a9bb21e6 100644 --- a/app/src/assets/localization/en/shared.json +++ b/app/src/assets/localization/en/shared.json @@ -6,14 +6,13 @@ "before_you_begin": "Before you begin", "browse": "browse", "cancel": "cancel", + "change_protocol": "Change protocol", "change_robot": "Change robot", "clear_data": "clear data", "close_robot_door": "Close the robot door before starting the run.", "close": "close", - "computer_in_app_is_controlling_robot": "A computer with the Opentrons App is currently controlling this robot.", "confirm_placement": "Confirm placement", "confirm_position": "Confirm position", - "confirm_terminate": "This will immediately stop the activity begun on a computer. You, or another user, may lose progress or see an error in the Opentrons App.", "confirm_values": "Confirm values", "confirm": "Confirm", "continue_activity": "Continue activity", @@ -34,7 +33,6 @@ "exit": "exit", "extension_mount": "extension mount", "flow_complete": "{{flowName}} complete!", - "general_error_message": "If you keep getting this message, try restarting your app and/or robot. If this does not resolve the issue please contact Opentrons Support.", "get_started": "Get started", "github": "GitHub", "go_back": "Go back", diff --git a/app/src/atoms/Banner/Banner.stories.tsx b/app/src/atoms/Banner/Banner.stories.tsx index deea5d236b4..0f3d6210075 100644 --- a/app/src/atoms/Banner/Banner.stories.tsx +++ b/app/src/atoms/Banner/Banner.stories.tsx @@ -1,36 +1,41 @@ import * as React from 'react' import { StyledText, TYPOGRAPHY } from '@opentrons/components' import { Banner } from './index' -import type { Story, Meta } from '@storybook/react' +import type { Meta, StoryObj } from '@storybook/react' -export default { +const meta: Meta = { title: 'App/Atoms/Banner', component: Banner, -} as Meta +} + +export default meta -const Template: Story> = args => ( - {'Banner component'} -) +type Story = StoryObj -export const Primary = Template.bind({}) -Primary.args = { - title: 'title', - type: 'success', +export const Primary: Story = { + args: { + children: 'Banner component', + type: 'success', + }, } -export const OverriddenIcon = Template.bind({}) -OverriddenIcon.args = { - type: 'warning', - title: 'Alert with overridden icon', - icon: { name: 'ot-hot-to-touch' }, + +export const OverriddenIcon: Story = { + args: { + type: 'warning', + children: 'Banner component', + icon: { name: 'ot-hot-to-touch' }, + }, } -export const OverriddenExitIcon = Template.bind({}) -OverriddenExitIcon.args = { - type: 'informing', - title: 'Alert with overriden exit icon', - onCloseClick: () => console.log('close'), - closeButton: ( - - {'Exit'} - - ), + +export const OverriddenExitIcon: Story = { + args: { + type: 'informing', + children: 'Banner component', + onCloseClick: () => console.log('close'), + closeButton: ( + + {'Exit'} + + ), + }, } diff --git a/app/src/atoms/Banner/__tests__/Banner.test.tsx b/app/src/atoms/Banner/__tests__/Banner.test.tsx index 126740f0c4b..f543ec98ec0 100644 --- a/app/src/atoms/Banner/__tests__/Banner.test.tsx +++ b/app/src/atoms/Banner/__tests__/Banner.test.tsx @@ -1,10 +1,9 @@ import * as React from 'react' import { describe, it, vi, expect, beforeEach } from 'vitest' -import '@testing-library/jest-dom/vitest' -import { fireEvent } from '@testing-library/react' +import { fireEvent, screen } from '@testing-library/react' +import { renderWithProviders } from '../../../__testing-utils__' import { i18n } from '../../../i18n' import { Banner } from '..' -import { renderWithProviders } from '../../../__testing-utils__' const render = (props: React.ComponentProps) => { return renderWithProviders(, { @@ -21,60 +20,67 @@ describe('Banner', () => { children: 'TITLE', } }) + it('renders success banner', () => { - const { getByText, getByLabelText } = render(props) - getByLabelText('icon_success') - getByText('TITLE') + render(props) + screen.getByLabelText('icon_success') + screen.getByText('TITLE') }) + it('renders success banner with exit button and when click dismisses banner', () => { props = { type: 'success', children: 'TITLE', onCloseClick: vi.fn(), } - const { getByText, getByLabelText } = render(props) - getByText('TITLE') - const btn = getByLabelText('close_icon') + render(props) + screen.getByText('TITLE') + const btn = screen.getByLabelText('close_icon') fireEvent.click(btn) expect(props.onCloseClick).toHaveBeenCalled() }) + it('renders warning banner', () => { props = { type: 'warning', children: 'TITLE', } - const { getByText, getByLabelText } = render(props) - getByLabelText('icon_warning') - getByText('TITLE') + render(props) + screen.getByLabelText('icon_warning') + screen.getByText('TITLE') }) + it('renders error banner', () => { props = { type: 'error', children: 'TITLE', } - const { getByText, getByLabelText } = render(props) - getByLabelText('icon_error') - getByText('TITLE') + render(props) + screen.getByLabelText('icon_error') + screen.getByText('TITLE') }) + it('renders updating banner', () => { props = { type: 'updating', children: 'TITLE', } - const { getByText, getByLabelText } = render(props) - getByLabelText('icon_updating') - getByText('TITLE') + render(props) + screen.getByLabelText('icon_updating') + screen.getByText('TITLE') }) + it('renders custom icon banner', () => { props = { type: 'warning', children: 'TITLE', icon: { name: 'ot-hot-to-touch' }, } - const { getByText, getByLabelText } = render(props) - getByLabelText('icon_warning') - getByText('TITLE') + render(props) + screen.getByLabelText('icon_warning') + screen.getByText('TITLE') }) + it('renders custom close', () => { props = { type: 'warning', @@ -82,8 +88,8 @@ describe('Banner', () => { closeButton: 'close button', onCloseClick: vi.fn(), } - const { getByText } = render(props) - const btn = getByText('close button') + render(props) + const btn = screen.getByText('close button') fireEvent.click(btn) expect(props.onCloseClick).toHaveBeenCalled() }) diff --git a/app/src/atoms/Banner/index.tsx b/app/src/atoms/Banner/index.tsx index a74fcf829ba..e7d2008521a 100644 --- a/app/src/atoms/Banner/index.tsx +++ b/app/src/atoms/Banner/index.tsx @@ -8,13 +8,12 @@ import { DIRECTION_ROW, Flex, Icon, - IconProps, JUSTIFY_SPACE_BETWEEN, RESPONSIVENESS, SPACING, TYPOGRAPHY, } from '@opentrons/components' -import type { StyleProps } from '@opentrons/components' +import type { IconProps, StyleProps } from '@opentrons/components' export type BannerType = | 'success' diff --git a/app/src/atoms/Chip/__tests__/Chip.test.tsx b/app/src/atoms/Chip/__tests__/Chip.test.tsx deleted file mode 100644 index 7f3b75f13c3..00000000000 --- a/app/src/atoms/Chip/__tests__/Chip.test.tsx +++ /dev/null @@ -1,224 +0,0 @@ -import * as React from 'react' -import { describe, it, expect } from 'vitest' -import { screen } from '@testing-library/react' -import { BORDERS, COLORS, SPACING } from '@opentrons/components' -import { renderWithProviders } from '../../../__testing-utils__' -import { Chip } from '..' - -const render = (props: React.ComponentProps) => { - return renderWithProviders() -} - -describe('Chip', () => { - let props: React.ComponentProps - - it('should render text, no icon with basic colors', () => { - props = { - text: 'mockBasic', - type: 'basic', - } - render(props) - const chip = screen.getByTestId('Chip_basic') - const chipText = screen.getByText('mockBasic') - expect(chip).toHaveStyle( - `background-color: ${COLORS.black90}${COLORS.opacity20HexCode}` - ) - expect(chipText).toHaveStyle(`color: ${COLORS.grey60}`) - expect(screen.queryByLabelText('icon_mockBasic')).not.toBeInTheDocument() - }) - - it('should render text, icon, bgcolor with success colors', () => { - props = { - text: 'mockSuccess', - type: 'success', - } - render(props) - const chip = screen.getByTestId('Chip_success') - const chipText = screen.getByText('mockSuccess') - expect(chip).toHaveStyle(`background-color: ${COLORS.green35}`) - expect(chip).toHaveStyle(`border-radius: ${BORDERS.borderRadius40}`) - expect(chipText).toHaveStyle(`color: ${COLORS.green60}`) - const icon = screen.getByLabelText('icon_mockSuccess') - expect(icon).toHaveStyle(`color: ${COLORS.green60}`) - expect(icon).toHaveStyle(`width: 1.5rem`) - }) - - it('should render text, icon, no bgcolor with success colors and bg false', () => { - props = { - background: false, - text: 'mockSuccess', - type: 'success', - } - render(props) - const chip = screen.getByTestId('Chip_success') - const chipText = screen.getByText('mockSuccess') - expect(chip).toHaveStyle(`background-color: ${COLORS.transparent}`) - expect(chip).toHaveStyle(`border-radius: ${BORDERS.borderRadius40}`) - expect(chipText).toHaveStyle(`color: ${COLORS.green60}`) - const icon = screen.getByLabelText('icon_mockSuccess') - expect(icon).toHaveStyle(`color: ${COLORS.green60}`) - }) - - it('should render text, icon, bgcolor with warning colors', () => { - props = { - text: 'mockWarning', - type: 'warning', - } - render(props) - const chip = screen.getByTestId('Chip_warning') - const chipText = screen.getByText('mockWarning') - expect(chip).toHaveStyle(`background-color: ${COLORS.yellow35}`) - expect(chip).toHaveStyle(`border-radius: ${BORDERS.borderRadius40}`) - expect(chipText).toHaveStyle(`color: ${COLORS.yellow60}`) - const icon = screen.getByLabelText('icon_mockWarning') - expect(icon).toHaveStyle(`color: ${COLORS.yellow60}`) - }) - - it('should render text, icon, no bgcolor with warning colors and bg false', () => { - props = { - background: false, - text: 'mockWarning', - type: 'warning', - } - render(props) - const chip = screen.getByTestId('Chip_warning') - const chipText = screen.getByText('mockWarning') - expect(chip).toHaveStyle(`background-color: ${COLORS.transparent}`) - expect(chip).toHaveStyle(`border-radius: ${BORDERS.borderRadius40}`) - expect(chipText).toHaveStyle(`color: ${COLORS.yellow60}`) - const icon = screen.getByLabelText('icon_mockWarning') - expect(icon).toHaveStyle(`color: ${COLORS.yellow60}`) - }) - - it('should render text, icon, bgcolor with neutral colors', () => { - props = { - text: 'mockNeutral', - type: 'neutral', - } - render(props) - const chip = screen.getByTestId('Chip_neutral') - const chipText = screen.getByText('mockNeutral') - expect(chip).toHaveStyle( - `background-color: ${COLORS.black90}${COLORS.opacity20HexCode}` - ) - expect(chip).toHaveStyle(`border-radius: ${BORDERS.borderRadius40}`) - expect(chipText).toHaveStyle(`color: ${COLORS.grey60}`) - const icon = screen.getByLabelText('icon_mockNeutral') - expect(icon).toHaveStyle(`color: ${COLORS.grey60}`) - }) - - it('should render text, icon, no bgcolor with neutral colors and bg false', () => { - props = { - background: false, - text: 'mockNeutral', - type: 'neutral', - } - render(props) - const chip = screen.getByTestId('Chip_neutral') - const chipText = screen.getByText('mockNeutral') - expect(chip).toHaveStyle(`background-color: ${COLORS.transparent}`) - expect(chip).toHaveStyle(`border-radius: ${BORDERS.borderRadius40}`) - expect(chipText).toHaveStyle(`color: ${COLORS.grey60}`) - const icon = screen.getByLabelText('icon_mockNeutral') - expect(icon).toHaveStyle(`color: ${COLORS.grey60}`) - }) - - it('should render text, icon, bgcolor with error colors', () => { - props = { - text: 'mockError', - type: 'error', - } - render(props) - const chip = screen.getByTestId('Chip_error') - const chipText = screen.getByText('mockError') - expect(chip).toHaveStyle(`background-color: ${COLORS.red35}`) - expect(chip).toHaveStyle(`border-radius: ${BORDERS.borderRadius40}`) - expect(chipText).toHaveStyle(`color: ${COLORS.red60}`) - const icon = screen.getByLabelText('icon_mockError') - expect(icon).toHaveStyle(`color: ${COLORS.red60}`) - }) - - it('should render text, icon, no bgcolor with error colors and bg false', () => { - props = { - background: false, - text: 'mockError', - type: 'error', - } - render(props) - const chip = screen.getByTestId('Chip_error') - const chipText = screen.getByText('mockError') - expect(chip).toHaveStyle(`background-color: ${COLORS.transparent}`) - expect(chip).toHaveStyle(`border-radius: ${BORDERS.borderRadius40}`) - expect(chipText).toHaveStyle(`color: ${COLORS.red60}`) - const icon = screen.getByLabelText('icon_mockError') - expect(icon).toHaveStyle(`color: ${COLORS.red60}`) - }) - - it('should render text, icon, bgcolor with info colors', () => { - props = { - text: 'mockInfo', - type: 'info', - } - render(props) - const chip = screen.getByTestId('Chip_info') - const chipText = screen.getByText('mockInfo') - expect(chip).toHaveStyle(`background-color: ${COLORS.blue35}`) - expect(chip).toHaveStyle(`border-radius: ${BORDERS.borderRadius40}`) - expect(chipText).toHaveStyle(`color: ${COLORS.blue60}`) - const icon = screen.getByLabelText('icon_mockInfo') - expect(icon).toHaveStyle(`color: ${COLORS.blue60}`) - }) - - it('should render text, icon, no bgcolor with info colors and bg false', () => { - props = { - background: false, - text: 'mockInfo', - type: 'info', - } - render(props) - const chip = screen.getByTestId('Chip_info') - const chipText = screen.getByText('mockInfo') - expect(chip).toHaveStyle(`background-color: ${COLORS.transparent}`) - expect(chip).toHaveStyle(`border-radius: ${BORDERS.borderRadius40}`) - expect(chipText).toHaveStyle(`color: ${COLORS.blue60}`) - const icon = screen.getByLabelText('icon_mockInfo') - expect(icon).toHaveStyle(`color: ${COLORS.blue60}`) - }) - it('renders no icon when hasIcon is false', () => { - props = { - text: 'mockInfo', - hasIcon: false, - type: 'info', - } - render(props) - expect(screen.queryByText('icon_mockInfo')).not.toBeInTheDocument() - }) - - it('render text with smaller padding and smaller icon when chip size is small and background is false', () => { - props = { - background: false, - text: 'mockInfo', - type: 'info', - chipSize: 'small', - } - render(props) - const chip = screen.getByTestId('Chip_info') - expect(chip).toHaveStyle(`padding: ${SPACING.spacing4} 0`) - const icon = screen.getByLabelText('icon_mockInfo') - expect(icon).toHaveStyle(`width: 1.25rem`) - }) - - it('render text with smaller padding and smaller icon when chip size is small and background is true', () => { - props = { - background: true, - text: 'mockInfo', - type: 'info', - chipSize: 'small', - } - render(props) - const chip = screen.getByTestId('Chip_info') - expect(chip).toHaveStyle(`padding: ${SPACING.spacing4} ${SPACING.spacing8}`) - const icon = screen.getByLabelText('icon_mockInfo') - expect(icon).toHaveStyle(`width: 1.25rem`) - }) -}) diff --git a/app/src/atoms/InlineNotification/InlineNotification.stories.tsx b/app/src/atoms/InlineNotification/InlineNotification.stories.tsx index 313d278c0fa..ec3af22be3e 100644 --- a/app/src/atoms/InlineNotification/InlineNotification.stories.tsx +++ b/app/src/atoms/InlineNotification/InlineNotification.stories.tsx @@ -1,5 +1,5 @@ import * as React from 'react' -import { touchScreenViewport } from '../../DesignTokens/constants' +import { VIEWPORT } from '@opentrons/components' import { InlineNotification } from '.' import type { Story, Meta } from '@storybook/react' @@ -26,7 +26,7 @@ export default { defaultValue: true, }, }, - parameters: touchScreenViewport, + parameters: VIEWPORT.touchScreenViewport, } as Meta const Template: Story< diff --git a/app/src/atoms/InputField/index.tsx b/app/src/atoms/InputField/index.tsx index c1ff5fbeddd..9be59bf1903 100644 --- a/app/src/atoms/InputField/index.tsx +++ b/app/src/atoms/InputField/index.tsx @@ -101,15 +101,16 @@ function Input(props: InputFieldProps): JSX.Element { tooltipText, ...inputProps } = props - const error = props.error != null + const hasError = props.error != null const value = props.isIndeterminate ?? false ? '' : props.value ?? '' const placeHolder = props.isIndeterminate ?? false ? '-' : props.placeholder const [targetProps, tooltipProps] = useHoverTooltip() const OUTER_CSS = css` @media ${RESPONSIVENESS.touchscreenMediaQuerySpecs} { + grid-gap: ${SPACING.spacing8}; &:focus-within { - filter: ${error + filter: ${hasError ? 'none' : `drop-shadow(0px 0px 10px ${COLORS.blue50})`}; } @@ -121,7 +122,7 @@ function Input(props: InputFieldProps): JSX.Element { background-color: ${COLORS.white}; border-radius: ${BORDERS.borderRadius4}; padding: ${SPACING.spacing8}; - border: 1px ${BORDERS.styleSolid} ${error ? COLORS.red50 : COLORS.grey50}; + border: 1px ${BORDERS.styleSolid} ${hasError ? COLORS.red50 : COLORS.grey50}; font-size: ${TYPOGRAPHY.fontSizeP}; width: 100%; height: 2rem; @@ -144,17 +145,20 @@ function Input(props: InputFieldProps): JSX.Element { } &:hover { - border: 1px ${BORDERS.styleSolid} ${error ? COLORS.red50 : COLORS.grey60}; + border: 1px ${BORDERS.styleSolid} + ${hasError ? COLORS.red50 : COLORS.grey60}; } &:focus-visible { - border: 1px ${BORDERS.styleSolid} ${error ? COLORS.red50 : COLORS.grey60}; + border: 1px ${BORDERS.styleSolid} + ${hasError ? COLORS.red50 : COLORS.grey60}; outline: 2px ${BORDERS.styleSolid} ${COLORS.blue50}; outline-offset: 3px; } &:focus-within { - border: 1px ${BORDERS.styleSolid} ${error ? COLORS.red50 : COLORS.blue50}; + border: 1px ${BORDERS.styleSolid} + ${hasError ? COLORS.red50 : COLORS.blue50}; } &:disabled { @@ -168,15 +172,16 @@ function Input(props: InputFieldProps): JSX.Element { @media ${RESPONSIVENESS.touchscreenMediaQuerySpecs} { height: ${size === 'small' ? '4.25rem' : '5rem'}; - box-shadow: ${error ? BORDERS.shadowBig : 'none'}; + box-shadow: ${hasError ? BORDERS.shadowBig : 'none'}; font-size: ${TYPOGRAPHY.fontSize28}; padding: ${SPACING.spacing16} ${SPACING.spacing24}; - border: 2px ${BORDERS.styleSolid} ${error ? COLORS.red50 : COLORS.grey50}; + border: 2px ${BORDERS.styleSolid} + ${hasError ? COLORS.red50 : COLORS.grey50}; &:focus-within { box-shadow: none; - border: ${error ? '2px' : '3px'} ${BORDERS.styleSolid} - ${error ? COLORS.red50 : COLORS.blue50}; + border: ${hasError ? '2px' : '3px'} ${BORDERS.styleSolid} + ${hasError ? COLORS.red50 : COLORS.blue50}; } & input { @@ -191,19 +196,17 @@ function Input(props: InputFieldProps): JSX.Element { ` const FORM_BOTTOM_SPACE_STYLE = css` - padding: ${SPACING.spacing4} 0rem; + padding-top: ${SPACING.spacing4}; @media ${RESPONSIVENESS.touchscreenMediaQuerySpecs} { + padding: ${SPACING.spacing8} 0rem; padding-bottom: 0; } ` const TITLE_STYLE = css` - color: ${error ? COLORS.red50 : COLORS.black90}; + color: ${hasError ? COLORS.red50 : COLORS.black90}; padding-bottom: ${SPACING.spacing8}; - font-size: ${TYPOGRAPHY.fontSizeLabel}; - font-weight: ${TYPOGRAPHY.fontWeightSemiBold}; - line-height: ${TYPOGRAPHY.lineHeight12}; - align-text: ${textAlign}; + text-align: ${textAlign}; @media ${RESPONSIVENESS.touchscreenMediaQuerySpecs} { font-size: ${TYPOGRAPHY.fontSize22}; font-weight: ${TYPOGRAPHY.fontWeightRegular}; @@ -214,9 +217,11 @@ function Input(props: InputFieldProps): JSX.Element { const ERROR_TEXT_STYLE = css` color: ${COLORS.red50}; + padding-top: ${SPACING.spacing4}; @media ${RESPONSIVENESS.touchscreenMediaQuerySpecs} { font-size: ${TYPOGRAPHY.fontSize22}; color: ${COLORS.red50}; + padding-top: ${SPACING.spacing8}; } ` @@ -239,9 +244,14 @@ function Input(props: InputFieldProps): JSX.Element { {title != null ? ( - + {title} - + {tooltipText != null ? ( <> @@ -277,16 +287,6 @@ function Input(props: InputFieldProps): JSX.Element { {props.units} ) : null} - {props.error != null ? ( - - {props.error} - - ) : null} {props.caption != null ? ( ) : null} + {hasError ? ( + + {props.error} + + ) : null} ) } diff --git a/app/src/atoms/Link/ExternalLink.stories.tsx b/app/src/atoms/Link/ExternalLink.stories.tsx index c243304ee59..8f664d257f5 100644 --- a/app/src/atoms/Link/ExternalLink.stories.tsx +++ b/app/src/atoms/Link/ExternalLink.stories.tsx @@ -1,22 +1,26 @@ import * as React from 'react' -import { Flex, COLORS } from '@opentrons/components' -import { ExternalLink } from './ExternalLink' +import { COLORS, Flex, SPACING } from '@opentrons/components' +import { ExternalLink as ExternalLinkComponent } from './ExternalLink' -import type { Story, Meta } from '@storybook/react' +import type { Meta, StoryObj } from '@storybook/react' -export default { +const meta: Meta = { title: 'App/Atoms/ExternalLink', - component: ExternalLink, -} as Meta - -const Template: Story> = args => ( - - - -) + component: ExternalLinkComponent, + decorators: [ + Story => ( + + + + ), + ], +} +export default meta +type Story = StoryObj -export const Primary = Template.bind({}) -Primary.args = { - href: 'https://www.opentrons.com', - children: 'Open the link', +export const ExternalLink: Story = { + args: { + href: 'https://www.opentrons.com', + children: 'Open the link', + }, } diff --git a/app/src/atoms/Link/ExternalLink.tsx b/app/src/atoms/Link/ExternalLink.tsx index 4baa78afa44..e35e3515277 100644 --- a/app/src/atoms/Link/ExternalLink.tsx +++ b/app/src/atoms/Link/ExternalLink.tsx @@ -1,12 +1,7 @@ import * as React from 'react' -import { - Link, - LinkProps, - Icon, - TYPOGRAPHY, - SPACING, -} from '@opentrons/components' +import { Link, Icon, TYPOGRAPHY, SPACING } from '@opentrons/components' +import type { LinkProps } from '@opentrons/components' export interface ExternalLinkProps extends LinkProps { href: string diff --git a/app/src/atoms/ListItem/ListItem.stories.tsx b/app/src/atoms/ListItem/ListItem.stories.tsx index 0380c5ddb13..1e7704af9d4 100644 --- a/app/src/atoms/ListItem/ListItem.stories.tsx +++ b/app/src/atoms/ListItem/ListItem.stories.tsx @@ -3,9 +3,9 @@ import { DIRECTION_COLUMN, Flex, SPACING, + VIEWPORT, StyledText, } from '@opentrons/components' -import { touchScreenViewport } from '../../DesignTokens/constants' import { ListItem } from '.' import type { Story, Meta } from '@storybook/react' @@ -19,7 +19,7 @@ export default { }, }, }, - parameters: touchScreenViewport, + parameters: VIEWPORT.touchScreenViewport, } as Meta const ListItemTemplate: Story> = args => ( diff --git a/app/src/atoms/MenuList/DropdownMenu.tsx b/app/src/atoms/MenuList/DropdownMenu.tsx index 7eafba80ecb..ec383bf0ead 100644 --- a/app/src/atoms/MenuList/DropdownMenu.tsx +++ b/app/src/atoms/MenuList/DropdownMenu.tsx @@ -15,7 +15,9 @@ import { TYPOGRAPHY, useOnClickOutside, POSITION_RELATIVE, + useHoverTooltip, } from '@opentrons/components' +import { Tooltip } from '../Tooltip' import { MenuItem } from './MenuItem' export interface DropdownOption { @@ -33,6 +35,7 @@ export interface DropdownMenuProps { dropdownType?: DropdownBorder title?: string caption?: string | null + tooltipText?: string } // TODO: (smb: 4/15/22) refactor this to use html select for accessibility @@ -46,7 +49,9 @@ export function DropdownMenu(props: DropdownMenuProps): JSX.Element { dropdownType = 'rounded', title, caption, + tooltipText, } = props + const [targetProps, tooltipProps] = useHoverTooltip() const [showDropdownMenu, setShowDropdownMenu] = React.useState(false) const toggleSetShowDropdownMenu = (): void => { setShowDropdownMenu(!showDropdownMenu) @@ -96,13 +101,27 @@ export function DropdownMenu(props: DropdownMenuProps): JSX.Element { return ( {title !== null ? ( - - {title} - + + + {title} + + {tooltipText != null ? ( + <> + + + + {tooltipText} + + ) : null} + ) : null} void - currentStep: number - maxSteps: number - // isExpanded is for collapse and expand animation - isExpanded?: boolean - footer?: React.ReactNode -} +import type { MultiSlideoutSpecs, SlideoutProps } from './index' + +type MultiSlideoutProps = SlideoutProps & MultiSlideoutSpecs export const MultiSlideout = (props: MultiSlideoutProps): JSX.Element => { const { diff --git a/app/src/atoms/Snackbar/Snackbar.stories.tsx b/app/src/atoms/Snackbar/Snackbar.stories.tsx index 1d42d193d64..db73e22d947 100644 --- a/app/src/atoms/Snackbar/Snackbar.stories.tsx +++ b/app/src/atoms/Snackbar/Snackbar.stories.tsx @@ -8,8 +8,8 @@ import { PrimaryButton, SPACING, StyledText, + VIEWPORT, } from '@opentrons/components' -import { touchScreenViewport } from '../../DesignTokens/constants' import { Snackbar } from './index' import type { Story, Meta } from '@storybook/react' @@ -17,7 +17,7 @@ import type { Story, Meta } from '@storybook/react' export default { title: 'ODD/Atoms/Snackbar', component: Snackbar, - parameters: touchScreenViewport, + parameters: VIEWPORT.touchScreenViewport, } as Meta const DefaultTemplate: Story> = args => { diff --git a/app/src/atoms/SoftwareKeyboard/CustomKeyboard/CustomKeyboard.stories.tsx b/app/src/atoms/SoftwareKeyboard/AlphanumericKeyboard/AlphanumericKeyboard.stories.tsx similarity index 52% rename from app/src/atoms/SoftwareKeyboard/CustomKeyboard/CustomKeyboard.stories.tsx rename to app/src/atoms/SoftwareKeyboard/AlphanumericKeyboard/AlphanumericKeyboard.stories.tsx index e298911ee0f..a610d352caf 100644 --- a/app/src/atoms/SoftwareKeyboard/CustomKeyboard/CustomKeyboard.stories.tsx +++ b/app/src/atoms/SoftwareKeyboard/AlphanumericKeyboard/AlphanumericKeyboard.stories.tsx @@ -1,25 +1,27 @@ import * as React from 'react' import { - Flex, DIRECTION_COLUMN, + Flex, POSITION_ABSOLUTE, SPACING, + VIEWPORT, } from '@opentrons/components' -import { touchScreenViewport } from '../../../DesignTokens/constants' import { InputField } from '../../InputField' -import { CustomKeyboard } from './' -import '../index.css' -import './index.css' +import { AlphanumericKeyboard } from '.' + +import type { Meta, StoryObj } from '@storybook/react' -import type { Story, Meta } from '@storybook/react' +const meta: Meta = { + title: 'ODD/Atoms/SoftwareKeyboard/AlphanumericKeyboard', + component: AlphanumericKeyboard, + parameters: VIEWPORT.touchScreenViewport, +} + +export default meta -export default { - title: 'ODD/Atoms/SoftwareKeyboard/CustomKeyboard', - component: CustomKeyboard, - parameters: touchScreenViewport, -} as Meta +type Story = StoryObj -const Template: Story> = args => { +const Keyboard = (): JSX.Element => { const [showKeyboard, setShowKeyboard] = React.useState(false) const [value, setValue] = React.useState('') const keyboardRef = React.useRef(null) @@ -30,12 +32,14 @@ const Template: Story> = args => { value={value} type="text" placeholder="When focusing, the keyboard shows up" + // eslint-disable-next-line @typescript-eslint/no-confusing-void-expression onFocus={() => setShowKeyboard(true)} /> - + {showKeyboard && ( - e != null && setValue(String(e))} keyboardRef={keyboardRef} /> @@ -45,4 +49,6 @@ const Template: Story> = args => { ) } -export const CustomSoftwareKeyboard = Template.bind({}) +export const AlphanumericSoftwareKeyboard: Story = { + render: () => , +} diff --git a/app/src/atoms/SoftwareKeyboard/CustomKeyboard/__tests__/CustomKeyboard.test.tsx b/app/src/atoms/SoftwareKeyboard/AlphanumericKeyboard/__tests__/CustomKeyboard.test.tsx similarity index 59% rename from app/src/atoms/SoftwareKeyboard/CustomKeyboard/__tests__/CustomKeyboard.test.tsx rename to app/src/atoms/SoftwareKeyboard/AlphanumericKeyboard/__tests__/CustomKeyboard.test.tsx index c4c38fad53b..336e0c86026 100644 --- a/app/src/atoms/SoftwareKeyboard/CustomKeyboard/__tests__/CustomKeyboard.test.tsx +++ b/app/src/atoms/SoftwareKeyboard/AlphanumericKeyboard/__tests__/CustomKeyboard.test.tsx @@ -3,14 +3,14 @@ import { describe, it, expect, vi } from 'vitest' import '@testing-library/jest-dom/vitest' import { fireEvent, renderHook, screen } from '@testing-library/react' import { renderWithProviders } from '../../../../__testing-utils__' -import { CustomKeyboard } from '..' +import { AlphanumericKeyboard } from '..' -const render = (props: React.ComponentProps) => { - return renderWithProviders()[0] +const render = (props: React.ComponentProps) => { + return renderWithProviders()[0] } -describe('CustomKeyboard', () => { - it('should render the custom keyboards lower case', () => { +describe('AlphanumericKeyboard', () => { + it('should render alphanumeric keyboard - lower case', () => { const { result } = renderHook(() => React.useRef(null)) const props = { onChange: vi.fn(), @@ -29,6 +29,7 @@ describe('CustomKeyboard', () => { 'i', 'o', 'p', + '123', 'a', 's', 'd', @@ -38,7 +39,7 @@ describe('CustomKeyboard', () => { 'j', 'k', 'l', - 'SHIFT', + 'ABC', 'z', 'x', 'c', @@ -47,21 +48,20 @@ describe('CustomKeyboard', () => { 'n', 'm', 'del', - '123', ] buttons.forEach((button, index) => { const expectedName = expectedButtonNames[index] expect(button).toHaveTextContent(expectedName) }) }) - it('should render the custom keyboards upper case, when clicking shift key', () => { + it('should render alphanumeric keyboard - upper case, when clicking ABC key', () => { const { result } = renderHook(() => React.useRef(null)) const props = { onChange: vi.fn(), keyboardRef: result.current, } render(props) - const shiftKey = screen.getByRole('button', { name: 'SHIFT' }) + const shiftKey = screen.getByRole('button', { name: 'ABC' }) fireEvent.click(shiftKey) const buttons = screen.getAllByRole('button') @@ -76,6 +76,7 @@ describe('CustomKeyboard', () => { 'I', 'O', 'P', + '123', 'A', 'S', 'D', @@ -94,7 +95,6 @@ describe('CustomKeyboard', () => { 'N', 'M', 'del', - '123', ] buttons.forEach((button, index) => { const expectedName = expectedButtonNames[index] @@ -102,7 +102,7 @@ describe('CustomKeyboard', () => { }) }) - it('should render the custom keyboards numbers, when clicking number key', () => { + it('should render alphanumeric keyboard - numbers, when clicking number key', () => { const { result } = renderHook(() => React.useRef(null)) const props = { onChange: vi.fn(), @@ -132,7 +132,7 @@ describe('CustomKeyboard', () => { }) }) - it('should render the custom keyboards lower case, when clicking number key then abc key', () => { + it('should render alphanumeric keyboard - lower case when layout is numbers and clicking abc ', () => { const { result } = renderHook(() => React.useRef(null)) const props = { onChange: vi.fn(), @@ -140,9 +140,63 @@ describe('CustomKeyboard', () => { } render(props) const numberKey = screen.getByRole('button', { name: '123' }) - screen.getByRole('button', { name: 'a' }) + fireEvent.click(numberKey) + const abcKey = screen.getByRole('button', { name: 'abc' }) + fireEvent.click(abcKey) + const buttons = screen.getAllByRole('button') + const expectedButtonNames = [ + 'q', + 'w', + 'e', + 'r', + 't', + 'y', + 'u', + 'i', + 'o', + 'p', + '123', + 'a', + 's', + 'd', + 'f', + 'g', + 'h', + 'j', + 'k', + 'l', + 'ABC', + 'z', + 'x', + 'c', + 'v', + 'b', + 'n', + 'm', + 'del', + ] + buttons.forEach((button, index) => { + const expectedName = expectedButtonNames[index] + expect(button).toHaveTextContent(expectedName) + }) + }) + + it('should switch each alphanumeric keyboard properly', () => { + const { result } = renderHook(() => React.useRef(null)) + const props = { + onChange: vi.fn(), + keyboardRef: result.current, + } + render(props) + // lower case keyboard -> upper case keyboard + const ABCKey = screen.getByRole('button', { name: 'ABC' }) + fireEvent.click(ABCKey) + screen.getByRole('button', { name: 'A' }) + // upper case keyboard -> number keyboard + const numberKey = screen.getByRole('button', { name: '123' }) fireEvent.click(numberKey) screen.getByRole('button', { name: '1' }) + // number keyboard -> lower case keyboard const abcKey = screen.getByRole('button', { name: 'abc' }) fireEvent.click(abcKey) screen.getByRole('button', { name: 'a' }) diff --git a/app/src/atoms/SoftwareKeyboard/AlphanumericKeyboard/index.css b/app/src/atoms/SoftwareKeyboard/AlphanumericKeyboard/index.css new file mode 100644 index 00000000000..1fa59e2230a --- /dev/null +++ b/app/src/atoms/SoftwareKeyboard/AlphanumericKeyboard/index.css @@ -0,0 +1,70 @@ +/* stylelint-disable */ + +/* Alphanumeric Keyboard has 3 layouts + 1. lower letter keys: hg-layout-default + 2. upper letter keys: hg-layout-shift + 3. number keys: hg-layout-numbers + 1, 2 are using the same style but 3 has own style. + */ + +.simple-keyboard.oddTheme1.hg-theme-default .hg-layout-default { + width: 100%; + height: 100%; + background-color: #cbcccc; /* grey35 */ + font-family: 'Public Sans', sans-serif; + padding: 8px; +} + +.hg-layout-default .hg-row .hg-button, +.hg-layout-shift .hg-row .hg-button, +.hg-layout-numbers .hg-row .hg-button { + color: #16212d; + font-size: 20px; + font-style: normal; + font-weight: 600; + line-height: 24px; + background-color: #ffffff; + padding: 10px 22px; +} + +.simple-keyboard .hg-button:active { + color: #16212d; + background-color: #dedede; /* grey30 */ +} + +.alphanumericKeyboard .hg-button { + height: 62.3px; +} + +/* first row and second row */ +.hg-layout-default .hg-row:not(:last-child), +.hg-layout-shift .hg-row:not(:last-child) { + grid-column: 8px; +} +.hg-row:not(:last-child) .hg-button { + width: 94px; +} + +/* third row first button and last button are the same size +the rest is the same */ +.hg-layout-default .hg-row:last-child, +.hg-layout-shift .hg-row:last-child, +.hg-layout-numbers .hg-row:last-child { + /* adding 3px because package's css add margin-right:5px */ + grid-gap: 3px; +} +.hg-layout-default .hg-row:last-child .hg-button, +.hg-layout-shift .hg-row:last-child .hg-button { + width: 97px; +} +.hg-layout-default .hg-row:last-child .hg-button:first-child, +.hg-layout-default .hg-row:last-child .hg-button:last-child, +.hg-layout-shift .hg-row:last-child .hg-button:first-child, +.hg-layout-shift .hg-row:last-child .hg-button:last-child { + width: 132px; +} + +.hg-layout-numbers .hg-row .hg-button { + height: 44.75px; + width: 330px !important; +} diff --git a/app/src/atoms/SoftwareKeyboard/AlphanumericKeyboard/index.tsx b/app/src/atoms/SoftwareKeyboard/AlphanumericKeyboard/index.tsx new file mode 100644 index 00000000000..dccad085c08 --- /dev/null +++ b/app/src/atoms/SoftwareKeyboard/AlphanumericKeyboard/index.tsx @@ -0,0 +1,57 @@ +import * as React from 'react' +import Keyboard from 'react-simple-keyboard' +import { alphanumericKeyboardLayout, customDisplay } from '../constants' +import type { KeyboardReactInterface } from 'react-simple-keyboard' + +import '../index.css' +import './index.css' + +// TODO (kk:04/05/2024) add debug to make debugging easy +interface AlphanumericKeyboardProps { + onChange: (input: string) => void + keyboardRef: React.MutableRefObject + debug?: boolean +} + +export function AlphanumericKeyboard({ + onChange, + keyboardRef, + debug = false, // If true, will input a \n +}: AlphanumericKeyboardProps): JSX.Element { + const [layoutName, setLayoutName] = React.useState('default') + const onKeyPress = (button: string): void => { + if (button === '{ABC}') handleShift() + if (button === '{numbers}') handleNumber() + if (button === '{abc}') handleUnShift() + } + + const handleShift = (): void => { + setLayoutName(layoutName === 'default' ? 'shift' : 'default') + } + + const handleNumber = (): void => { + setLayoutName( + layoutName === 'default' || layoutName === 'shift' ? 'numbers' : 'default' + ) + } + + const handleUnShift = (): void => { + setLayoutName('default') + } + + return ( + (keyboardRef.current = r)} + theme={'hg-theme-default oddTheme1 alphanumericKeyboard'} + onChange={onChange} + onKeyPress={onKeyPress} + layoutName={layoutName} + layout={alphanumericKeyboardLayout} + display={customDisplay} + mergeDisplay={true} + useButtonTag={true} + width="100%" + debug={debug} // If true, will input a \n + /> + ) +} diff --git a/app/src/atoms/SoftwareKeyboard/CustomKeyboard/index.css b/app/src/atoms/SoftwareKeyboard/CustomKeyboard/index.css deleted file mode 100644 index f3e0b6cdd54..00000000000 --- a/app/src/atoms/SoftwareKeyboard/CustomKeyboard/index.css +++ /dev/null @@ -1,33 +0,0 @@ -/* stylelint-disable */ - -.simple-keyboard.oddTheme1.hg-theme-default { - width: 100%; - height: 100%; - background-color: #cbcccc; /* grey35 */ - font-family: 'Public Sans', sans-serif; - padding: 8px; - font-size: 28px; -} - -.simple-keyboard.oddTheme1 - .hg-row:not(:last-child) - .hg-button:not(:last-child) { - margin-right: 8px; - margin-bottom: 3px; -} - -.simple-keyboard.simple-keyboard.oddTheme1 .hg-button { - height: 48px; -} - -.simple-keyboard .hg-button:active { - color: #16212d; - background-color: #e3e3e3; -} - -/* Numeric keyboard in custom keyboard */ -.hg-layout-numbers button.hg-button.hg-button-backspace, -.hg-layout-numbers button.hg-button.hg-button-abc, -.hg-layout-numbers button.hg-button.hg-standardBtn { - flex: 1; -} diff --git a/app/src/atoms/SoftwareKeyboard/CustomKeyboard/index.tsx b/app/src/atoms/SoftwareKeyboard/CustomKeyboard/index.tsx deleted file mode 100644 index ddf9215a874..00000000000 --- a/app/src/atoms/SoftwareKeyboard/CustomKeyboard/index.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import * as React from 'react' -import Keyboard from 'react-simple-keyboard' -import { customDisplay } from '../constants' - -interface CustomKeyboardProps { - onChange: (input: string) => void - keyboardRef: React.MutableRefObject -} - -const customLayout = { - default: [ - 'q w e r t y u i o p', - 'a s d f g h j k l', - '{shift} z x c v b n m {backspace}', - '{numbers}', - ], - shift: [ - 'Q W E R T Y U I O P', - 'A S D F G H J K L', - '{abc} Z X C V B N M {backspace}', - '{numbers}', - ], - numbers: ['1 2 3', '4 5 6', '7 8 9', '{abc} 0 {backspace}'], -} - -export function CustomKeyboard({ - onChange, - keyboardRef, -}: CustomKeyboardProps): JSX.Element { - const [layoutName, setLayoutName] = React.useState('default') - const onKeyPress = (button: string): void => { - if (button === '{shift}' || button === '{lock}') handleShift() - if (button === '{numbers}' || button === '{abc}') handleNumber() - } - - const handleShift = (): void => { - setLayoutName(layoutName === 'default' ? 'shift' : 'default') - } - - const handleNumber = (): void => { - setLayoutName(layoutName === 'default' ? 'numbers' : 'default') - } - - return ( - (keyboardRef.current = r)} - theme={'hg-theme-default oddTheme1'} - onChange={onChange} - onKeyPress={onKeyPress} - layoutName={layoutName} - layout={customLayout} - display={customDisplay} - mergeDisplay={true} - autoUseTouchEvents={true} - useButtonTag={true} - /> - ) -} diff --git a/app/src/atoms/SoftwareKeyboard/NormalKeyboard/NormalKeyboard.stories.tsx b/app/src/atoms/SoftwareKeyboard/FullKeyboard/FullKeyboard.stories.tsx similarity index 53% rename from app/src/atoms/SoftwareKeyboard/NormalKeyboard/NormalKeyboard.stories.tsx rename to app/src/atoms/SoftwareKeyboard/FullKeyboard/FullKeyboard.stories.tsx index c245ca23be9..417c922876d 100644 --- a/app/src/atoms/SoftwareKeyboard/NormalKeyboard/NormalKeyboard.stories.tsx +++ b/app/src/atoms/SoftwareKeyboard/FullKeyboard/FullKeyboard.stories.tsx @@ -1,26 +1,26 @@ import * as React from 'react' import { - Flex, DIRECTION_COLUMN, + Flex, POSITION_ABSOLUTE, SPACING, + VIEWPORT, } from '@opentrons/components' -import { touchScreenViewport } from '../../../DesignTokens/constants' import { InputField } from '../../InputField' -import { NormalKeyboard } from '.' +import { FullKeyboard } from '.' -import '../index.css' -import './index.css' +import type { Meta, StoryObj } from '@storybook/react' -import type { Story, Meta } from '@storybook/react' +const meta: Meta = { + title: 'ODD/Atoms/SoftwareKeyboard/FullKeyboard', + component: FullKeyboard, + parameters: VIEWPORT.touchScreenViewport, +} +export default meta -export default { - title: 'ODD/Atoms/SoftwareKeyboard/NormalKeyboard', - component: NormalKeyboard, - parameters: touchScreenViewport, -} as Meta +type Story = StoryObj -const Template: Story> = args => { +const Keyboard = (): JSX.Element => { const [showKeyboard, setShowKeyboard] = React.useState(false) const [value, setValue] = React.useState('') const keyboardRef = React.useRef(null) @@ -31,12 +31,14 @@ const Template: Story> = args => { value={value} type="text" placeholder="When focusing, the keyboard shows up" + // eslint-disable-next-line @typescript-eslint/no-confusing-void-expression onFocus={() => setShowKeyboard(true)} /> - + {showKeyboard && ( - e != null && setValue(String(e))} keyboardRef={keyboardRef} /> @@ -46,4 +48,6 @@ const Template: Story> = args => { ) } -export const NormalSoftwareKeyboard = Template.bind({}) +export const FullSoftwareKeyboard: Story = { + render: () => , +} diff --git a/app/src/atoms/SoftwareKeyboard/NormalKeyboard/__tests__/NormalKeyboard.test.tsx b/app/src/atoms/SoftwareKeyboard/FullKeyboard/__tests__/FullKeyboard.test.tsx similarity index 87% rename from app/src/atoms/SoftwareKeyboard/NormalKeyboard/__tests__/NormalKeyboard.test.tsx rename to app/src/atoms/SoftwareKeyboard/FullKeyboard/__tests__/FullKeyboard.test.tsx index cc53e3ff827..c84a33a2796 100644 --- a/app/src/atoms/SoftwareKeyboard/NormalKeyboard/__tests__/NormalKeyboard.test.tsx +++ b/app/src/atoms/SoftwareKeyboard/FullKeyboard/__tests__/FullKeyboard.test.tsx @@ -3,14 +3,14 @@ import { describe, it, expect, vi } from 'vitest' import '@testing-library/jest-dom/vitest' import { fireEvent, renderHook, screen } from '@testing-library/react' import { renderWithProviders } from '../../../../__testing-utils__' -import { NormalKeyboard } from '..' +import { FullKeyboard } from '..' -const render = (props: React.ComponentProps) => { - return renderWithProviders()[0] +const render = (props: React.ComponentProps) => { + return renderWithProviders()[0] } -describe('SoftwareKeyboard', () => { - it('should render the software keyboards', () => { +describe('FullKeyboard', () => { + it('should render FullKeyboard keyboard', () => { const { result } = renderHook(() => React.useRef(null)) const props = { onChange: vi.fn(), @@ -40,7 +40,7 @@ describe('SoftwareKeyboard', () => { 'j', 'k', 'l', - 'SHIFT', + 'ABC', 'z', 'x', 'c', @@ -58,14 +58,14 @@ describe('SoftwareKeyboard', () => { }) }) - it('should render the software keyboards when hitting shift key', () => { + it('should render full keyboard when hitting ABC key', () => { const { result } = renderHook(() => React.useRef(null)) const props = { onChange: vi.fn(), keyboardRef: result.current, } render(props) - const shiftKey = screen.getByRole('button', { name: 'SHIFT' }) + const shiftKey = screen.getByRole('button', { name: 'ABC' }) fireEvent.click(shiftKey) const buttons = screen.getAllByRole('button') const expectedButtonNames = [ @@ -107,7 +107,7 @@ describe('SoftwareKeyboard', () => { }) }) - it('should render the software keyboards when hitting 123 key', () => { + it('should render full keyboard when hitting 123 key', () => { const { result } = renderHook(() => React.useRef(null)) const props = { onChange: vi.fn(), @@ -128,6 +128,7 @@ describe('SoftwareKeyboard', () => { '8', '9', '0', + 'abc', '-', '/', ':', @@ -138,13 +139,14 @@ describe('SoftwareKeyboard', () => { '&', '@', '"', - 'abc', '#+=', '.', ',', '?', '!', "'", + '*', + '~', 'del', 'space', ] @@ -172,29 +174,25 @@ describe('SoftwareKeyboard', () => { ']', '{', '}', - '#', '%', '^', - '*', '+', - '=', + 'abc', '_', '\\', '|', - '~', '<', '>', - '€', - '£', - '¥', - '·', - 'abc', + '#', + '=', '123', '.', ',', '?', '!', "'", + '*', + '~', 'del', 'space', ] diff --git a/app/src/atoms/SoftwareKeyboard/FullKeyboard/index.css b/app/src/atoms/SoftwareKeyboard/FullKeyboard/index.css new file mode 100644 index 00000000000..b3ff8968da4 --- /dev/null +++ b/app/src/atoms/SoftwareKeyboard/FullKeyboard/index.css @@ -0,0 +1,105 @@ +/* stylelint-disable */ + +/* Full Keyboard has 4 layouts + 1. lower letter keys: hg-layout-default + 2. upper letter keys: hg-layout-shift + 3. number keys: hg-layout-numbers + 4. symbol keys: hg-layout-symbols + 1, 2 are using the same style but 3 & 4 have their own styles. + */ + +.simple-keyboard.oddTheme1.hg-theme-default { + width: 100%; + height: 100%; + background-color: #cbcccc; /* grey35 */ + font-family: 'Public Sans', sans-serif; + padding: 8px; +} + +.hg-layout-default .hg-row, +.hg-layout-shift .hg-row, +.hg-layout-symbols .hg-row, +.hg-layout-numbers .hg-row { + /* adding 3px because package's css add margin-right:5px */ + grid-gap: 3px; +} + +.simple-keyboard.simple-keyboard.oddTheme1 .hg-button:not(:last-child) { + margin-bottom: 3px; +} + +.simple-keyboard .hg-button:active { + color: #16212d; + background-color: #dedede; +} + +.hg-layout-default .hg-row .hg-button, +.hg-layout-shift .hg-row .hg-button, +.hg-layout-symbols .hg-row .hg-button, +.hg-layout-numbers .hg-row .hg-button { + color: #16212d; + font-size: 20px; + font-style: normal; + font-weight: 600; + line-height: 24px; + background-color: #ffffff; + padding: 10px 22px; +} + +.hg-layout-default .hg-row:nth-child(1) .hg-button, +.hg-layout-default .hg-row:nth-child(2) .hg-button, +.hg-layout-shift .hg-row:nth-child(1) .hg-button, +.hg-layout-shift .hg-row:nth-child(2) .hg-button, +.hg-layout-numbers .hg-row:nth-child(1) .hg-button { + width: 93.6px; +} + +.hg-layout-numbers .hg-row:nth-child(2) .hg-button { + width: 83.4px; +} + +.hg-layout-symbols .hg-row:nth-child(2) .hg-button { + width: 122.5px; +} + +.hg-layout-numbers .hg-row:nth-child(2) .hg-button:nth-child(10) { + /* This is needed to override the package style */ + max-width: 83.4px !important; +} + +.hg-layout-numbers .hg-row:nth-child(2) .hg-button:first-child, +.hg-layout-symbols .hg-row:nth-child(2) .hg-button:first-child { + width: 94px; +} + +.hg-layout-default .hg-row:nth-child(3) .hg-button, +.hg-layout-shift .hg-row:nth-child(3) .hg-button, +.hg-layout-numbers .hg-row:nth-child(3) .hg-button, +.hg-layout-symbols .hg-row:nth-child(3) .hg-button { + width: 97px; +} + +/* .hg-layout-default .hg-row:nth-child(3) .hg-button, +.hg-layout-shift .hg-row:nth-child(3) .hg-button { + width: 97px; +} */ + +.hg-layout-default .hg-row:nth-child(3) .hg-button:first-child, +.hg-layout-default .hg-row:nth-child(3) .hg-button:last-child, +.hg-layout-shift .hg-row:nth-child(3) .hg-button:first-child, +.hg-layout-shift .hg-row:nth-child(3) .hg-button:last-child, +.hg-layout-numbers .hg-row:nth-child(3) .hg-button:first-child, +.hg-layout-numbers .hg-row:nth-child(3) .hg-button:last-child, +.hg-layout-symbols .hg-row:nth-child(3) .hg-button:first-child, +.hg-layout-symbols .hg-row:nth-child(3) .hg-button:last-child { + width: 132px; +} + +.hg-layout-symbols .hg-row:nth-child(1) .hg-button { + width: 137.1px; +} + +.simple-keyboard .hg-button:active { + color: #16212d; + background-color: #e3e3e3; /* grey30 */ +} diff --git a/app/src/atoms/SoftwareKeyboard/NormalKeyboard/index.tsx b/app/src/atoms/SoftwareKeyboard/FullKeyboard/index.tsx similarity index 53% rename from app/src/atoms/SoftwareKeyboard/NormalKeyboard/index.tsx rename to app/src/atoms/SoftwareKeyboard/FullKeyboard/index.tsx index dcb02503f00..663efdd9c24 100644 --- a/app/src/atoms/SoftwareKeyboard/NormalKeyboard/index.tsx +++ b/app/src/atoms/SoftwareKeyboard/FullKeyboard/index.tsx @@ -1,46 +1,23 @@ import * as React from 'react' -import Keyboard from 'react-simple-keyboard' -import { customDisplay } from '../constants' +import { KeyboardReact as Keyboard } from 'react-simple-keyboard' +import { customDisplay, fullKeyboardLayout } from '../constants' +import type { KeyboardReactInterface } from 'react-simple-keyboard' -interface NormalKeyboardProps { - onChange: (input: string) => void - keyboardRef: React.MutableRefObject -} +import '../index.css' +import './index.css' -// Note the design team request is the following -// Input type: characters, numbers and special characters - -const customLayout = { - default: [ - 'q w e r t y u i o p', - '{numbers} a s d f g h j k l', - '{shift} z x c v b n m {backspace}', - '{space}', - ], - shift: [ - 'Q W E R T Y U I O P', - '{numbers} A S D F G H J K L', - '{abc} Z X C V B N M {backspace}', - '{space}', - ], - symbols: [ - '[ ] { } # % ^ * + =', - '_ \\ | ~ < > € £ ¥ ·', - "{abc} {numbers} . , ? ! ' {backspace}", - '{space}', - ], - numbers: [ - '1 2 3 4 5 6 7 8 9 0', - '- / : ; ( ) $ & @ "', - "{abc} {symbols} . , ? ! ' {backspace}", - '{space}', - ], +// TODO (kk:04/05/2024) add debug to make debugging easy +interface FullKeyboardProps { + onChange: (input: string) => void + keyboardRef: React.MutableRefObject + debug?: boolean } -export function NormalKeyboard({ +export function FullKeyboard({ onChange, keyboardRef, -}: NormalKeyboardProps): JSX.Element { + debug = false, +}: FullKeyboardProps): JSX.Element { const [layoutName, setLayoutName] = React.useState('default') const handleShift = (button: string): void => { switch (button) { @@ -78,11 +55,12 @@ export function NormalKeyboard({ onChange={onChange} onKeyPress={onKeyPress} layoutName={layoutName} - layout={customLayout} + layout={fullKeyboardLayout} display={customDisplay} mergeDisplay={true} - autoUseTouchEvents={true} useButtonTag={true} + debug={debug} // If true, will input a \n + baseClass="fullKeyboard" /> ) } diff --git a/app/src/atoms/SoftwareKeyboard/Numpad/Numpad.stories.tsx b/app/src/atoms/SoftwareKeyboard/IndividualKey/IndividualKey.stories.tsx similarity index 52% rename from app/src/atoms/SoftwareKeyboard/Numpad/Numpad.stories.tsx rename to app/src/atoms/SoftwareKeyboard/IndividualKey/IndividualKey.stories.tsx index f87ca54481b..3f91df121f6 100644 --- a/app/src/atoms/SoftwareKeyboard/Numpad/Numpad.stories.tsx +++ b/app/src/atoms/SoftwareKeyboard/IndividualKey/IndividualKey.stories.tsx @@ -1,25 +1,27 @@ import * as React from 'react' import { - Flex, DIRECTION_COLUMN, + Flex, POSITION_ABSOLUTE, SPACING, + VIEWPORT, } from '@opentrons/components' -import { touchScreenViewport } from '../../../DesignTokens/constants' import { InputField } from '../../InputField' -import { Numpad } from './' -import '../index.css' -import './index.css' +import { IndividualKey } from '.' + +import type { Meta, StoryObj } from '@storybook/react' -import type { Story, Meta } from '@storybook/react' +const meta: Meta = { + title: 'ODD/Atoms/SoftwareKeyboard/IndividualKey', + component: IndividualKey, + parameters: VIEWPORT.touchScreenViewport, +} + +export default meta -export default { - title: 'ODD/Atoms/SoftwareKeyboard/Numpad', - component: Numpad, - parameters: touchScreenViewport, -} as Meta +type Story = StoryObj -const Template: Story> = args => { +const Keyboard = ({ ...args }): JSX.Element => { const [showKeyboard, setShowKeyboard] = React.useState(false) const [value, setValue] = React.useState('') const keyboardRef = React.useRef(null) @@ -30,14 +32,18 @@ const Template: Story> = args => { value={value} type="text" placeholder="When focusing, the numpad shows up" - onFocus={() => setShowKeyboard(true)} + onFocus={() => { + setShowKeyboard(true) + }} /> {showKeyboard && ( - e != null && setValue(String(e))} keyboardRef={keyboardRef} + keyText={args.keyText} /> )} @@ -45,4 +51,9 @@ const Template: Story> = args => { ) } -export const NormalSoftwareKeyboard = Template.bind({}) +export const IndividualKeySoftwareKeyboard: Story = args => ( + +) +IndividualKeySoftwareKeyboard.args = { + keyText: 'hello', +} diff --git a/app/src/atoms/SoftwareKeyboard/IndividualKey/__tests__/IndividualKey.test.tsx b/app/src/atoms/SoftwareKeyboard/IndividualKey/__tests__/IndividualKey.test.tsx new file mode 100644 index 00000000000..f08c7e4566f --- /dev/null +++ b/app/src/atoms/SoftwareKeyboard/IndividualKey/__tests__/IndividualKey.test.tsx @@ -0,0 +1,36 @@ +import * as React from 'react' +import { describe, it, vi, expect } from 'vitest' +import { fireEvent, renderHook, screen } from '@testing-library/react' + +import { renderWithProviders } from '../../../../__testing-utils__' +import { IndividualKey } from '..' + +const render = (props: React.ComponentProps) => { + return renderWithProviders()[0] +} + +describe('IndividualKey', () => { + it('should render the text key', () => { + const { result } = renderHook(() => React.useRef(null)) + const props = { + onChange: vi.fn(), + keyboardRef: result.current, + keyText: 'mockKey', + } + render(props) + screen.getByRole('button', { name: 'mockKey' }) + }) + + it('should call mock function when clicking text key', () => { + const { result } = renderHook(() => React.useRef(null)) + const props = { + onChange: vi.fn(), + keyboardRef: result.current, + keyText: 'mockKey', + } + render(props) + const textKey = screen.getByRole('button', { name: 'mockKey' }) + fireEvent.click(textKey) + expect(props.onChange).toHaveBeenCalled() + }) +}) diff --git a/app/src/atoms/SoftwareKeyboard/IndividualKey/index.css b/app/src/atoms/SoftwareKeyboard/IndividualKey/index.css new file mode 100644 index 00000000000..cfd00f3a2af --- /dev/null +++ b/app/src/atoms/SoftwareKeyboard/IndividualKey/index.css @@ -0,0 +1,12 @@ +/* stylelint-disable */ + +.simple-keyboard .hg-button { + text-align: center; + font-size: 20px; + font-weight: 600; + line-height: 24px; +} +.simple-keyboard .hg-button:active { + color: #16212d; + background-color: #dedede; /* grey30 */ +} diff --git a/app/src/atoms/SoftwareKeyboard/IndividualKey/index.tsx b/app/src/atoms/SoftwareKeyboard/IndividualKey/index.tsx new file mode 100644 index 00000000000..310008cddc8 --- /dev/null +++ b/app/src/atoms/SoftwareKeyboard/IndividualKey/index.tsx @@ -0,0 +1,47 @@ +import * as React from 'react' +import { KeyboardReact as Keyboard } from 'react-simple-keyboard' +import type { KeyboardReactInterface } from 'react-simple-keyboard' +import '../index.css' +import './index.css' + +const customDisplay = { + '{backspace}': 'del', +} + +// TODO (kk:04/05/2024) add debug to make debugging easy +interface IndividualKeyProps { + onChange: (input: string) => void + keyboardRef: React.MutableRefObject + keyText: string + debug?: boolean +} + +export function IndividualKey({ + onChange, + keyboardRef, + keyText, + debug = false, +}: IndividualKeyProps): JSX.Element { + const numericalKeyboard = { + layout: { + default: [`${keyText}`], + }, + } + return ( + /* + * autoUseTouchEvents: for Flex on-device app + * useButtonTag: this is for testing purpose that each key renders as a button + */ + (keyboardRef.current = r)} + theme={'hg-theme-default oddTheme1 individual-key'} + onChange={onChange} + layoutName="default" + display={customDisplay} + useButtonTag={true} + {...numericalKeyboard} + width="100%" + debug={debug} // If true, will input a \n + /> + ) +} diff --git a/app/src/atoms/SoftwareKeyboard/NormalKeyboard/index.css b/app/src/atoms/SoftwareKeyboard/NormalKeyboard/index.css deleted file mode 100644 index 5e1b269ca82..00000000000 --- a/app/src/atoms/SoftwareKeyboard/NormalKeyboard/index.css +++ /dev/null @@ -1,26 +0,0 @@ -/* stylelint-disable */ - -.simple-keyboard.oddTheme1.hg-theme-default { - width: 100%; - height: 100%; - background-color: #cbcccc; /* grey35 */ - font-family: 'Public Sans', sans-serif; - padding: 8px; - font-size: 28px; -} - -.simple-keyboard.oddTheme1 - .hg-row:not(:last-child) - .hg-button:not(:last-child) { - margin-right: 8px; - margin-bottom: 3px; -} - -.simple-keyboard.simple-keyboard.oddTheme1 .hg-button { - height: 48px; -} - -.simple-keyboard .hg-button:active { - color: #16212d; - background-color: #e3e3e3; -} diff --git a/app/src/atoms/SoftwareKeyboard/NumericalKeyboard/NumericalKeyboard.stories.tsx b/app/src/atoms/SoftwareKeyboard/NumericalKeyboard/NumericalKeyboard.stories.tsx new file mode 100644 index 00000000000..53b3d714c4c --- /dev/null +++ b/app/src/atoms/SoftwareKeyboard/NumericalKeyboard/NumericalKeyboard.stories.tsx @@ -0,0 +1,79 @@ +import * as React from 'react' +import { + DIRECTION_COLUMN, + Flex, + POSITION_ABSOLUTE, + SPACING, + VIEWPORT, +} from '@opentrons/components' +import { InputField } from '../../InputField' +import { NumericalKeyboard } from '.' + +import type { Meta, StoryObj } from '@storybook/react' + +const meta: Meta = { + title: 'ODD/Atoms/SoftwareKeyboard/NumericalKeyboard', + component: NumericalKeyboard, + parameters: VIEWPORT.touchScreenViewport, + argTypes: { + isDecimal: { + control: { + type: 'boolean', + options: [true, false], + }, + }, + hasHyphen: { + control: { + type: 'boolean', + options: [true, false], + }, + }, + }, +} + +export default meta + +type Story = StoryObj + +const Keyboard = (args): JSX.Element => { + const { isDecimal, hasHyphen } = args + const [showKeyboard, setShowKeyboard] = React.useState(false) + const [value, setValue] = React.useState('') + const keyboardRef = React.useRef(null) + return ( + +
+ { + setShowKeyboard(true) + }} + /> + + + {showKeyboard && ( + e != null && setValue(String(e))} + keyboardRef={keyboardRef} + isDecimal={isDecimal} + hasHyphen={hasHyphen} + /> + )} + +
+ ) +} + +export const NumericalSoftwareKeyboard: Story = args => +NumericalSoftwareKeyboard.args = { + isDecimal: false, + hasHyphen: false, +} diff --git a/app/src/atoms/SoftwareKeyboard/NumericalKeyboard/__tests__/NumericalKeyboard.test.tsx b/app/src/atoms/SoftwareKeyboard/NumericalKeyboard/__tests__/NumericalKeyboard.test.tsx new file mode 100644 index 00000000000..0b3143554fa --- /dev/null +++ b/app/src/atoms/SoftwareKeyboard/NumericalKeyboard/__tests__/NumericalKeyboard.test.tsx @@ -0,0 +1,178 @@ +import * as React from 'react' +import { describe, it, expect, vi } from 'vitest' +import '@testing-library/jest-dom/vitest' +import { fireEvent, renderHook, screen } from '@testing-library/react' +import { renderWithProviders } from '../../../../__testing-utils__' +import { NumericalKeyboard } from '..' + +const render = (props: React.ComponentProps) => { + return renderWithProviders()[0] +} + +describe('NumericalKeyboard', () => { + it('should render numerical keyboard isDecimal: false and hasHyphen: false', () => { + const { result } = renderHook(() => React.useRef(null)) + const props = { + onChange: vi.fn(), + keyboardRef: result.current, + isDecimal: false, + hasHyphen: false, + } + render(props) + const buttons = screen.getAllByRole('button') + const expectedButtonNames = [ + '1', + '2', + '3', + '4', + '5', + '6', + '7', + '8', + '9', + '0', + 'del', + ] + + buttons.forEach((button, index) => { + const expectedName = expectedButtonNames[index] + expect(button).toHaveTextContent(expectedName) + }) + }) + + it('should render numerical keyboard isDecimal: false and hasHyphen: true', () => { + const { result } = renderHook(() => React.useRef(null)) + const props = { + onChange: vi.fn(), + keyboardRef: result.current, + isDecimal: false, + hasHyphen: true, + } + render(props) + const buttons = screen.getAllByRole('button') + const expectedButtonNames = [ + '1', + '2', + '3', + '4', + '5', + '6', + '7', + '8', + '9', + '0', + '-', + 'del', + ] + + buttons.forEach((button, index) => { + const expectedName = expectedButtonNames[index] + expect(button).toHaveTextContent(expectedName) + }) + }) + + it('should render numerical keyboard isDecimal: true and hasHyphen: false', () => { + const { result } = renderHook(() => React.useRef(null)) + const props = { + onChange: vi.fn(), + keyboardRef: result.current, + isDecimal: true, + hasHyphen: false, + } + render(props) + const buttons = screen.getAllByRole('button') + const expectedButtonNames = [ + '1', + '2', + '3', + '4', + '5', + '6', + '7', + '8', + '9', + '0', + '.', + 'del', + ] + + buttons.forEach((button, index) => { + const expectedName = expectedButtonNames[index] + expect(button).toHaveTextContent(expectedName) + }) + }) + + it('should render numerical keyboard isDecimal: true and hasHyphen: true', () => { + const { result } = renderHook(() => React.useRef(null)) + const props = { + onChange: vi.fn(), + keyboardRef: result.current, + isDecimal: true, + hasHyphen: true, + } + render(props) + const buttons = screen.getAllByRole('button') + const expectedButtonNames = [ + '1', + '2', + '3', + '4', + '5', + '6', + '7', + '8', + '9', + '0', + '.', + '-', + 'del', + ] + + buttons.forEach((button, index) => { + const expectedName = expectedButtonNames[index] + expect(button).toHaveTextContent(expectedName) + }) + }) + + it('should call mock function when clicking num key', () => { + const { result } = renderHook(() => React.useRef(null)) + const props = { + onChange: vi.fn(), + keyboardRef: result.current, + isDecimal: false, + hasHyphen: false, + } + render(props) + const numKey = screen.getByRole('button', { name: '1' }) + fireEvent.click(numKey) + expect(props.onChange).toHaveBeenCalled() + }) + + it('should call mock function when clicking decimal point key', () => { + const { result } = renderHook(() => React.useRef(null)) + const props = { + onChange: vi.fn(), + keyboardRef: result.current, + isDecimal: true, + hasHyphen: false, + } + render(props) + const numKey = screen.getByRole('button', { name: '.' }) + fireEvent.click(numKey) + expect(props.onChange).toHaveBeenCalled() + }) + + it('should call mock function when clicking hyphen key', () => { + const { result } = renderHook(() => React.useRef(null)) + const props = { + onChange: vi.fn(), + keyboardRef: result.current, + isDecimal: true, + hasHyphen: true, + } + render(props) + const numKey = screen.getByRole('button', { name: '-' }) + fireEvent.click(numKey) + expect(props.onChange).toHaveBeenCalled() + }) +}) diff --git a/app/src/atoms/SoftwareKeyboard/NumericalKeyboard/index.css b/app/src/atoms/SoftwareKeyboard/NumericalKeyboard/index.css new file mode 100644 index 00000000000..28fe3159979 --- /dev/null +++ b/app/src/atoms/SoftwareKeyboard/NumericalKeyboard/index.css @@ -0,0 +1,54 @@ +/* stylelint-disable */ + +/* Numerical Keyboard has 4 layouts + 1. int not allowed negative: intKeyboard + 2. int allowed negative: intNegKeyboard + 3. float not allowed negative: floatKeyboard + 4. float not allowed negative: floatNegKeyboard + */ + +.simple-keyboard.oddTheme1.hg-theme-default { + width: 100%; + height: 100%; + background-color: #cbcccc; /* grey35 */ + font-family: 'Public Sans', sans-serif; + padding: 8px; +} + +.hg-layout-intKeyboard .hg-row, +.hg-layout-intNegKeyboard .hg-row, +.hg-layout-floatKeyboard .hg-row, +.hg-layout-floatNegKeyboard .hg-row { + grid-gap: 3px; +} + +/* ToDo (kk:04/04/2024) this important will be removed when I refactor the entire css */ +.numerical-keyboard .hg-row .hg-button { + text-align: center; + font-size: 20px; + font-weight: 600; + line-height: 24px; + height: 75px !important; + padding: 10px 22px; +} + +.hg-layout-intKeyboard .hg-row:nth-child(-n + 3) .hg-button, +.hg-layout-intNegKeyboard .hg-row:nth-child(-n + 4) .hg-button, +.hg-layout-floatKeyboard .hg-row:nth-child(-n + 4) .hg-button, +.hg-layout-floatNegKeyboard .hg-row:nth-child(-n + 3) .hg-button { + width: 109.3px; + margin-bottom: 3px; +} + +.hg-layout-intKeyboard .hg-row:nth-child(4) .hg-button { + width: 168px; +} + +.hg-layout-floatNegKeyboard .hg-row:nth-child(4) .hg-button { + width: 80px; +} + +.simple-keyboard .hg-button:active { + color: #16212d; + background-color: #dedede; /* grey30 */ +} diff --git a/app/src/atoms/SoftwareKeyboard/NumericalKeyboard/index.tsx b/app/src/atoms/SoftwareKeyboard/NumericalKeyboard/index.tsx new file mode 100644 index 00000000000..8c41120d536 --- /dev/null +++ b/app/src/atoms/SoftwareKeyboard/NumericalKeyboard/index.tsx @@ -0,0 +1,46 @@ +import * as React from 'react' +import { KeyboardReact as Keyboard } from 'react-simple-keyboard' +import { numericalKeyboardLayout, numericalCustom } from '../constants' + +import type { KeyboardReactInterface } from 'react-simple-keyboard' +import '../index.css' +import './index.css' + +// Note (kk:04/05/2024) add debug to make debugging easy +interface NumericalKeyboardProps { + onChange: (input: string) => void + keyboardRef: React.MutableRefObject + isDecimal?: boolean + hasHyphen?: boolean + debug?: boolean +} + +// the default keyboard layout intKeyboard that doesn't have decimal point and hyphen. +export function NumericalKeyboard({ + onChange, + keyboardRef, + isDecimal = false, + hasHyphen = false, + debug = false, +}: NumericalKeyboardProps): JSX.Element { + const layoutName = `${isDecimal ? 'float' : 'int'}${ + hasHyphen ? 'NegKeyboard' : 'Keyboard' + }` + + return ( + /* + * autoUseTouchEvents: for Flex on-device app + * useButtonTag: this is for testing purpose that each key renders as a button + */ + (keyboardRef.current = r)} + theme={'hg-theme-default oddTheme1 numerical-keyboard'} + onChange={onChange} + display={numericalCustom} + useButtonTag={true} + layoutName={layoutName} + layout={numericalKeyboardLayout} + debug={debug} // If true, will input a \n + /> + ) +} diff --git a/app/src/atoms/SoftwareKeyboard/Numpad/__tests__/Numpad.test.tsx b/app/src/atoms/SoftwareKeyboard/Numpad/__tests__/Numpad.test.tsx deleted file mode 100644 index f9c90938eba..00000000000 --- a/app/src/atoms/SoftwareKeyboard/Numpad/__tests__/Numpad.test.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import * as React from 'react' -import { describe, it, expect, vi } from 'vitest' -import '@testing-library/jest-dom/vitest' -import { fireEvent, renderHook, screen } from '@testing-library/react' -import { renderWithProviders } from '../../../../__testing-utils__' -import { Numpad } from '..' - -const render = (props: React.ComponentProps) => { - return renderWithProviders()[0] -} - -describe('Numpad', () => { - it('should render the numpad keys', () => { - const { result } = renderHook(() => React.useRef(null)) - const props = { - onChange: vi.fn(), - keyboardRef: result.current, - } - render(props) - const buttons = screen.getAllByRole('button') - const expectedButtonNames = [ - '7', - '8', - '9', - '4', - '5', - '6', - '1', - '2', - '3', - '0', - '.', - 'del', - ] - - buttons.forEach((button, index) => { - const expectedName = expectedButtonNames[index] - expect(button).toHaveTextContent(expectedName) - }) - }) - - it('should call mock function when clicking num key', () => { - const { result } = renderHook(() => React.useRef(null)) - const props = { - onChange: vi.fn(), - keyboardRef: result.current, - } - render(props) - const numKey = screen.getByRole('button', { name: '1' }) - fireEvent.click(numKey) - expect(props.onChange).toHaveBeenCalled() - }) -}) diff --git a/app/src/atoms/SoftwareKeyboard/Numpad/index.css b/app/src/atoms/SoftwareKeyboard/Numpad/index.css deleted file mode 100644 index 7d832afeb2f..00000000000 --- a/app/src/atoms/SoftwareKeyboard/Numpad/index.css +++ /dev/null @@ -1,7 +0,0 @@ -/* stylelint-disable */ - -.numpad button.hg-button.hg-button-backspace, -.numpad button.hg-button.hg-button-abc, -.numpad button.hg-button.hg-standardBtn { - flex: 1; -} diff --git a/app/src/atoms/SoftwareKeyboard/Numpad/index.tsx b/app/src/atoms/SoftwareKeyboard/Numpad/index.tsx deleted file mode 100644 index b16b950fada..00000000000 --- a/app/src/atoms/SoftwareKeyboard/Numpad/index.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import * as React from 'react' -import Keyboard from 'react-simple-keyboard' - -const customDisplay = { - '{backspace}': 'del', -} -interface NumpadProps { - onChange: (input: string) => void - keyboardRef: React.MutableRefObject -} - -export function Numpad({ onChange, keyboardRef }: NumpadProps): JSX.Element { - const keyboardNumpad = { - layout: { - default: ['7 8 9', '4 5 6', '1 2 3', '0 . {backspace}'], - }, - } - return ( - /* - * autoUseTouchEvents: for Flex on-device app - * useButtonTag: this is for testing purpose that each key renders as a button - */ - (keyboardRef.current = r)} - theme={'hg-theme-default oddTheme1 numpad'} - onChange={onChange} - layoutName="default" - display={customDisplay} - autoUseTouchEvents={true} - useButtonTag={true} - {...keyboardNumpad} - /> - ) -} diff --git a/app/src/atoms/SoftwareKeyboard/constants.ts b/app/src/atoms/SoftwareKeyboard/constants.ts index 11fe6f11272..1808f4bd2f3 100644 --- a/app/src/atoms/SoftwareKeyboard/constants.ts +++ b/app/src/atoms/SoftwareKeyboard/constants.ts @@ -1,8 +1,71 @@ export const customDisplay = { '{numbers}': '123', - '{shift}': 'SHIFT', + '{shift}': 'ABC', '{space}': 'space', '{backspace}': 'del', '{abc}': 'abc', + '{ABC}': 'ABC', '{symbols}': '#+=', } + +// keyboard layout for Alphanumeric Keyboard +export const alphanumericKeyboardLayout = { + default: [ + 'q w e r t y u i o p', + '{numbers} a s d f g h j k l', + '{ABC} z x c v b n m {backspace}', + ], + shift: [ + 'Q W E R T Y U I O P', + '{numbers} A S D F G H J K L', + '{abc} Z X C V B N M {backspace}', + ], + numbers: ['1 2 3', '4 5 6', '7 8 9', '{abc} 0 {backspace}'], +} + +// keyboard layout for Full Keyboard +export const fullKeyboardLayout = { + default: [ + 'q w e r t y u i o p', + '{numbers} a s d f g h j k l', + '{shift} z x c v b n m {backspace}', + '{space}', + ], + shift: [ + 'Q W E R T Y U I O P', + '{numbers} A S D F G H J K L', + '{abc} Z X C V B N M {backspace}', + '{space}', + ], + symbols: [ + '[ ] { } % ^ +', + '{abc} _ \\ | < > # =', + "{numbers} . , ? ! ' * ~ {backspace}", + '{space}', + ], + numbers: [ + '1 2 3 4 5 6 7 8 9 0', + '{abc} - / : ; ( ) $ & @ "', + "{symbols} . , ? ! ' * ~ {backspace}", + '{space}', + ], +} + +// Numerical keyboard layout +export const numericalKeyboardLayout = { + // int without negative value + intKeyboard: ['1 2 3', '4 5 6', '7 8 9', '0 {backspace}'], + + // int with negative value + intNegKeyboard: ['1 2 3', '4 5 6', '7 8 9', '0 - {backspace}'], + + // float without negative value, + floatKeyboard: ['1 2 3', '4 5 6', '7 8 9', '0 . {backspace}'], + + // float with negative value + floatNegKeyboard: ['1 2 3', '4 5 6', '7 8 9', '0 . - {backspace}'], +} + +export const numericalCustom = { + '{backspace}': 'del', +} diff --git a/app/src/atoms/SoftwareKeyboard/index.css b/app/src/atoms/SoftwareKeyboard/index.css index 16fb1f9d25f..f19179f4366 100644 --- a/app/src/atoms/SoftwareKeyboard/index.css +++ b/app/src/atoms/SoftwareKeyboard/index.css @@ -4,7 +4,8 @@ background-color: #ececec; border-radius: 5px; box-sizing: border-box; - font-family: HelveticaNeue-Light, Helvetica Neue Light, Helvetica Neue, Helvetica, Arial, Lucida Grande, sans-serif; + font-family: HelveticaNeue-Light, Helvetica Neue Light, Helvetica Neue, + Helvetica, Arial, Lucida Grande, sans-serif; overflow: hidden; padding: 5px; touch-action: manipulation; @@ -59,7 +60,6 @@ box-sizing: border-box; cursor: pointer; display: flex; - height: 40px; justify-content: center; padding: 5px; -webkit-tap-highlight-color: rgba(0, 0, 0, 0); @@ -81,15 +81,6 @@ width: 33.3%; } -.hg-theme-default .hg-button.hg-button-numpadadd, -.hg-theme-default .hg-button.hg-button-numpadenter { - height: 85px; -} - -.hg-theme-default .hg-button.hg-button-numpad0 { - width: 105px; -} - .hg-theme-default .hg-button.hg-button-com { max-width: 85px; } @@ -103,11 +94,11 @@ color: #fff; } -.hg-theme-default .hg-button.hg-standardBtn[data-skbtn=".com"] { +.hg-theme-default .hg-button.hg-standardBtn[data-skbtn='.com'] { max-width: 82px; } -.hg-theme-default .hg-button.hg-standardBtn[data-skbtn="@"] { +.hg-theme-default .hg-button.hg-standardBtn[data-skbtn='@'] { max-width: 60px; } @@ -151,11 +142,11 @@ li.hg-candidate-box-list-item:active { } .hg-candidate-box-prev:before { - content: "◄"; + content: '◄'; } .hg-candidate-box-next:before { - content: "►"; + content: '►'; } .hg-candidate-box-next, @@ -179,4 +170,4 @@ li.hg-candidate-box-list-item:active { .hg-candidate-box-btn-active { color: #444; -} \ No newline at end of file +} diff --git a/app/src/atoms/SoftwareKeyboard/index.ts b/app/src/atoms/SoftwareKeyboard/index.ts index 93ae28749ac..81dc2e2b4fb 100644 --- a/app/src/atoms/SoftwareKeyboard/index.ts +++ b/app/src/atoms/SoftwareKeyboard/index.ts @@ -1,3 +1,4 @@ -export { CustomKeyboard } from './CustomKeyboard' -export { NormalKeyboard } from './NormalKeyboard' -export { Numpad } from './Numpad' +export { AlphanumericKeyboard } from './AlphanumericKeyboard' +export { IndividualKey } from './IndividualKey' +export { FullKeyboard } from './FullKeyboard' +export { NumericalKeyboard } from './NumericalKeyboard' diff --git a/app/src/atoms/Toast/ODDToast.stories.tsx b/app/src/atoms/Toast/ODDToast.stories.tsx index e70500bc960..9a0fe8db4e9 100644 --- a/app/src/atoms/Toast/ODDToast.stories.tsx +++ b/app/src/atoms/Toast/ODDToast.stories.tsx @@ -8,15 +8,15 @@ import { PrimaryButton, SPACING, StyledText, + VIEWPORT, } from '@opentrons/components' -import { touchScreenViewport } from '../../DesignTokens/constants' import { Toast } from '.' import type { Story, Meta } from '@storybook/react' export default { title: 'ODD/Atoms/Toast', component: Toast, - parameters: touchScreenViewport, + parameters: VIEWPORT.touchScreenViewport, } as Meta const Template: Story> = args => { diff --git a/app/src/atoms/buttons/FloatingActionButton.stories.tsx b/app/src/atoms/buttons/FloatingActionButton.stories.tsx index 820f1ec9618..a7526805a20 100644 --- a/app/src/atoms/buttons/FloatingActionButton.stories.tsx +++ b/app/src/atoms/buttons/FloatingActionButton.stories.tsx @@ -1,6 +1,5 @@ import * as React from 'react' -import { ICON_DATA_BY_NAME } from '@opentrons/components' -import { touchScreenViewport } from '../../DesignTokens/constants' +import { ICON_DATA_BY_NAME, VIEWPORT } from '@opentrons/components' import { FloatingActionButton } from './' import type { Story, Meta } from '@storybook/react' @@ -17,7 +16,7 @@ export default { }, onClick: { action: 'clicked' }, }, - parameters: touchScreenViewport, + parameters: VIEWPORT.touchScreenViewport, } as Meta const FloatingActionButtonTemplate: Story< diff --git a/app/src/atoms/buttons/LargeButton.stories.tsx b/app/src/atoms/buttons/LargeButton.stories.tsx index 737dada7656..d60e89d81f3 100644 --- a/app/src/atoms/buttons/LargeButton.stories.tsx +++ b/app/src/atoms/buttons/LargeButton.stories.tsx @@ -1,35 +1,60 @@ -import * as React from 'react' -import { touchScreenViewport } from '../../DesignTokens/constants' +import { ICON_DATA_BY_NAME, VIEWPORT } from '@opentrons/components' import { LargeButton } from './' -import type { Story, Meta } from '@storybook/react' -export default { +import type { Meta, StoryObj } from '@storybook/react' + +const meta: Meta = { title: 'ODD/Atoms/Buttons/LargeButton', - argTypes: { onClick: { action: 'clicked' } }, - parameters: touchScreenViewport, -} as Meta + component: LargeButton, + argTypes: { + onClick: { action: 'clicked' }, + iconName: { + control: { + type: 'select', + }, + options: Object.keys(ICON_DATA_BY_NAME), + }, + }, + parameters: VIEWPORT.touchScreenViewport, +} + +export default meta -const LargeButtonTemplate: Story< - React.ComponentProps -> = args => +type Story = StoryObj -export const PrimaryLargeButton = LargeButtonTemplate.bind({}) -PrimaryLargeButton.args = { - buttonText: 'Button text', - disabled: false, - iconName: 'play-round-corners', +export const Primary: Story = { + args: { + buttonText: 'Button text', + disabled: false, + iconName: 'play-round-corners', + }, +} +export const Secondary: Story = { + args: { + buttonText: 'Button text', + buttonType: 'secondary', + disabled: false, + iconName: 'build', + }, +} +export const Alert: Story = { + args: { + buttonText: 'Button text', + buttonType: 'alert', + disabled: false, + iconName: 'reset', + }, } -export const SecondaryLargeButton = LargeButtonTemplate.bind({}) -SecondaryLargeButton.args = { - buttonText: 'Button text', - buttonType: 'secondary', - disabled: false, - iconName: 'build', +export const PrimaryNoIcon: Story = { + args: { + buttonText: 'Button text', + disabled: false, + }, } -export const AlertLargeButton = LargeButtonTemplate.bind({}) -AlertLargeButton.args = { - buttonText: 'Button text', - buttonType: 'alert', - disabled: false, - iconName: 'reset', +export const PrimaryWithSubtext: Story = { + args: { + buttonText: 'Button text', + disabled: false, + subtext: 'Button subtext', + }, } diff --git a/app/src/atoms/buttons/LargeButton.tsx b/app/src/atoms/buttons/LargeButton.tsx index 6bfcf857d84..c5e45d3b731 100644 --- a/app/src/atoms/buttons/LargeButton.tsx +++ b/app/src/atoms/buttons/LargeButton.tsx @@ -7,6 +7,7 @@ import { DIRECTION_COLUMN, DISPLAY_FLEX, Icon, + Flex, JUSTIFY_SPACE_BETWEEN, SPACING, StyledText, @@ -20,7 +21,8 @@ interface LargeButtonProps extends StyleProps { onClick: () => void buttonType?: LargeButtonTypes buttonText: React.ReactNode - iconName: IconName + iconName?: IconName + subtext?: string disabled?: boolean } @@ -29,6 +31,7 @@ export function LargeButton(props: LargeButtonProps): JSX.Element { buttonType = 'primary', buttonText, iconName, + subtext, disabled = false, ...buttonProps } = props @@ -110,23 +113,28 @@ export function LargeButton(props: LargeButtonProps): JSX.Element { disabled={disabled} {...buttonProps} > - - {buttonText} - - + + + {buttonText} + + {subtext ? ( + + {subtext} + + ) : null} + + {iconName ? ( + + ) : null} ) } diff --git a/app/src/atoms/buttons/MediumButton.stories.tsx b/app/src/atoms/buttons/MediumButton.stories.tsx index 17d67f76093..6c7fbd2fe5b 100644 --- a/app/src/atoms/buttons/MediumButton.stories.tsx +++ b/app/src/atoms/buttons/MediumButton.stories.tsx @@ -1,74 +1,74 @@ -import * as React from 'react' -import { ICON_DATA_BY_NAME } from '@opentrons/components' -import { touchScreenViewport } from '../../DesignTokens/constants' +import { ICON_DATA_BY_NAME, VIEWPORT } from '@opentrons/components' import { MediumButton } from './' -import type { Story, Meta } from '@storybook/react' +import type { Meta, StoryObj } from '@storybook/react' -export default { +const meta: Meta = { title: 'ODD/Atoms/Buttons/MediumButton', + component: MediumButton, argTypes: { iconName: { control: { type: 'select', - options: Object.keys(ICON_DATA_BY_NAME), }, - defaultValue: undefined, + options: Object.keys(ICON_DATA_BY_NAME), }, buttonCategory: { control: { type: 'select', - options: ['default', 'rounded'], }, - defaultValue: undefined, + options: ['default', 'rounded'], }, onClick: { action: 'clicked' }, - width: { - control: { - type: 'text', - }, - defaultValue: undefined, - }, }, - parameters: touchScreenViewport, -} as Meta + parameters: VIEWPORT.touchScreenViewport, +} -const MediumButtonTemplate: Story< - React.ComponentProps -> = args => +export default meta +type Story = StoryObj -export const PrimaryMediumButton = MediumButtonTemplate.bind({}) -PrimaryMediumButton.args = { - buttonText: 'Button text', - buttonType: 'primary', - disabled: false, +export const PrimaryMediumButton: Story = { + args: { + buttonText: 'Button text', + buttonType: 'primary', + disabled: false, + }, } -export const SecondaryMediumButton = MediumButtonTemplate.bind({}) -SecondaryMediumButton.args = { - buttonText: 'Button text', - buttonType: 'secondary', - disabled: false, + +export const SecondaryMediumButton: Story = { + args: { + buttonText: 'Button text', + buttonType: 'secondary', + disabled: false, + }, } -export const AlertMediumButton = MediumButtonTemplate.bind({}) -AlertMediumButton.args = { - buttonText: 'Button text', - buttonType: 'alert', - disabled: false, + +export const AlertMediumButton: Story = { + args: { + buttonText: 'Button text', + buttonType: 'alert', + disabled: false, + }, } -export const AlertSecondaryMediumButton = MediumButtonTemplate.bind({}) -AlertSecondaryMediumButton.args = { - buttonText: 'Button text', - buttonType: 'alertSecondary', - disabled: false, +export const AlertSecondaryMediumButton: Story = { + args: { + buttonText: 'Button text', + buttonType: 'alertSecondary', + disabled: false, + }, } -export const TertiaryHighMediumButton = MediumButtonTemplate.bind({}) -TertiaryHighMediumButton.args = { - buttonText: 'Button text', - buttonType: 'tertiaryHigh', - disabled: false, + +export const TertiaryHighMediumButton: Story = { + args: { + buttonText: 'Button text', + buttonType: 'tertiaryHigh', + disabled: false, + }, } -export const TertiaryLowLightMediumButton = MediumButtonTemplate.bind({}) -TertiaryLowLightMediumButton.args = { - buttonText: 'Button text', - buttonType: 'tertiaryLowLight', - disabled: false, + +export const TertiaryLowLightMediumButton: Story = { + args: { + buttonText: 'Button text', + buttonType: 'tertiaryLowLight', + disabled: false, + }, } diff --git a/app/src/atoms/buttons/RadioButton.stories.tsx b/app/src/atoms/buttons/RadioButton.stories.tsx index 7bb570ffae9..3869cb70cc7 100644 --- a/app/src/atoms/buttons/RadioButton.stories.tsx +++ b/app/src/atoms/buttons/RadioButton.stories.tsx @@ -1,5 +1,5 @@ import * as React from 'react' -import { touchScreenViewport } from '../../DesignTokens/constants' +import { VIEWPORT } from '@opentrons/components' import { RadioButton } from './' import type { Story, Meta } from '@storybook/react' @@ -16,7 +16,7 @@ export default { }, onClick: { action: 'clicked' }, }, - parameters: touchScreenViewport, + parameters: VIEWPORT.touchScreenViewport, } as Meta const RadioButtonTemplate: Story< diff --git a/app/src/atoms/buttons/SmallButton.stories.tsx b/app/src/atoms/buttons/SmallButton.stories.tsx index cb1263f8a6c..6566847a62c 100644 --- a/app/src/atoms/buttons/SmallButton.stories.tsx +++ b/app/src/atoms/buttons/SmallButton.stories.tsx @@ -1,68 +1,75 @@ -import * as React from 'react' -import { touchScreenViewport } from '../../DesignTokens/constants' +import { VIEWPORT } from '@opentrons/components' import { SmallButton } from './' -import type { Story, Meta } from '@storybook/react' +import type { Meta, StoryObj } from '@storybook/react' -export default { +const meta: Meta = { title: 'ODD/Atoms/Buttons/SmallButton', argTypes: { onClick: { action: 'clicked' } }, component: SmallButton, - parameters: touchScreenViewport, -} as Meta + parameters: VIEWPORT.touchScreenViewport, +} + +export default meta -const Template: Story> = args => ( - -) +type Story = StoryObj -export const Primary = Template.bind({}) -Primary.args = { - buttonText: 'Button text', +export const Primary: Story = { + args: { + buttonText: 'Button text', + }, } -export const Alert = Template.bind({}) -Alert.args = { - buttonType: 'alert', - buttonText: 'Button text', +export const Alert: Story = { + args: { + buttonType: 'alert', + buttonText: 'Button text', + }, } -export const Secondary = Template.bind({}) -Secondary.args = { - buttonType: 'secondary', - buttonText: 'Button text', +export const Secondary: Story = { + args: { + buttonType: 'secondary', + buttonText: 'Button text', + }, } -export const TertiaryLowLight = Template.bind({}) -TertiaryLowLight.args = { - buttonType: 'tertiaryLowLight', - buttonText: 'Button text', +export const TertiaryLowLight: Story = { + args: { + buttonType: 'tertiaryLowLight', + buttonText: 'Button text', + }, } -export const TertiaryHighLight = Template.bind({}) -TertiaryHighLight.args = { - buttonType: 'tertiaryHighLight', - buttonText: 'Button text', +export const TertiaryHighLight: Story = { + args: { + buttonType: 'tertiaryHighLight', + buttonText: 'Button text', + }, } -export const StartIconPrimary = Template.bind({}) -StartIconPrimary.args = { - buttonType: 'primary', - buttonText: 'Button text', - iconPlacement: 'startIcon', - iconName: 'reset', +export const StartIconPrimary: Story = { + args: { + buttonType: 'primary', + buttonText: 'Button text', + iconPlacement: 'startIcon', + iconName: 'reset', + }, } -export const EndIconAlert = Template.bind({}) -EndIconAlert.args = { - buttonType: 'alert', - buttonText: 'Button text', - iconPlacement: 'endIcon', - iconName: 'play-round-corners', +export const EndIconAlert: Story = { + args: { + buttonType: 'alert', + buttonText: 'Button text', + iconPlacement: 'endIcon', + iconName: 'play-round-corners', + }, } -export const SecondaryRounded = Template.bind({}) -SecondaryRounded.args = { - buttonType: 'secondary', - buttonText: 'Button text', - buttonCategory: 'rounded', +export const SecondaryRounded: Story = { + args: { + buttonType: 'secondary', + buttonText: 'Button text', + buttonCategory: 'rounded', + }, } diff --git a/app/src/atoms/buttons/TabbedButton.stories.tsx b/app/src/atoms/buttons/TabbedButton.stories.tsx index 27efbc36a87..60c5131da3b 100644 --- a/app/src/atoms/buttons/TabbedButton.stories.tsx +++ b/app/src/atoms/buttons/TabbedButton.stories.tsx @@ -1,12 +1,12 @@ import * as React from 'react' -import { touchScreenViewport } from '../../DesignTokens/constants' +import { VIEWPORT } from '@opentrons/components' import { TabbedButton } from './' import type { Story, Meta } from '@storybook/react' export default { title: 'ODD/Atoms/Buttons/TabbedButton', argTypes: { onClick: { action: 'clicked' } }, - parameters: touchScreenViewport, + parameters: VIEWPORT.touchScreenViewport, } as Meta const TabbedButtonTemplate: Story< diff --git a/app/src/atoms/structure/Divider.stories.tsx b/app/src/atoms/structure/Divider.stories.tsx index 301e40debf9..021eb562020 100644 --- a/app/src/atoms/structure/Divider.stories.tsx +++ b/app/src/atoms/structure/Divider.stories.tsx @@ -8,49 +8,52 @@ import { StyledText, TYPOGRAPHY, } from '@opentrons/components' -import { Divider } from './index' -import type { Story, Meta } from '@storybook/react' +import { Divider as DividerComponent } from './index' +import type { Meta, StoryObj } from '@storybook/react' -export default { +const meta: Meta = { title: 'App/Atoms/Divider', - component: Divider, -} as Meta + component: DividerComponent, + decorators: [ + Story => ( + <> + + + + + {'About Calibration'} + -const Template: Story> = args => ( - <> - - - - - - {'About Calibration'} - - - - {'This section is about calibration.'} - + + {'This section is about calibration.'} + + + - - - - - - - - - {'Deck Calibration'} - - - - {'This section is for deck calibration.'} - + + + + + + {'Deck Calibration'} + + + {'This section is for deck calibration.'} + + + - - - -) - -export const Primary = Template.bind({}) -Primary.args = { - marginY: SPACING.spacing16, + + ), + ], } +export default meta +type Story = StoryObj +export const Divider: Story = {} diff --git a/app/src/atoms/structure/Line.stories.tsx b/app/src/atoms/structure/Line.stories.tsx index ed017ed95e1..46a90756c71 100644 --- a/app/src/atoms/structure/Line.stories.tsx +++ b/app/src/atoms/structure/Line.stories.tsx @@ -9,49 +9,57 @@ import { TYPOGRAPHY, } from '@opentrons/components' -import { Line } from './index' -import type { Story, Meta } from '@storybook/react' +import { Line as LineComponent } from './index' +import type { Meta, StoryObj } from '@storybook/react' -export default { +const meta: Meta = { title: 'App/Atoms/Line', - component: Line, -} as Meta - -const Template: Story> = args => ( - <> - - - - - - {'About Calibration'} - - - - {'This section is about calibration.'} - + component: LineComponent, + decorators: [ + Story => ( + <> + + + + + + {'About Calibration'} + + + + {'This section is about calibration.'} + + + - - - - - - - - - {'Deck Calibration'} - - - - {'This section is for deck calibration.'} - + + + + + + + {'Deck Calibration'} + + + + {'This section is for deck calibration.'} + + + - - - -) - -export const Primary = Template.bind({}) -Primary.args = { - marginY: SPACING.spacing8, + + ), + ], } + +export default meta + +type Story = StoryObj + +export const Line: Story = {} diff --git a/app/src/i18n.ts b/app/src/i18n.ts index 9e03af972c0..38bb6803b45 100644 --- a/app/src/i18n.ts +++ b/app/src/i18n.ts @@ -5,49 +5,43 @@ import { initReactI18next } from 'react-i18next' import { resources } from './assets/localization' import { titleCase } from '@opentrons/shared-data' -i18n.use(initReactI18next).init( - { - resources, - lng: 'en', - fallbackLng: 'en', - debug: process.env.NODE_ENV === 'development', - ns: [ - 'shared', - 'robot_advanced_settings', - 'robot_calibration', - 'robot_connection', - 'robot_controls', - 'robot_info', - 'top_navigation', - ], - defaultNS: 'shared', - interpolation: { - escapeValue: false, // not needed for react as it escapes by default - format: function (value, format, lng) { - if (format === 'upperCase') return value.toUpperCase() - if (format === 'lowerCase') return value.toLowerCase() - if (format === 'capitalize') return capitalize(value) - if (format === 'sentenceCase') return startCase(value) - if (format === 'titleCase') return titleCase(value) - return value - }, - }, - keySeparator: false, // use namespaces and context instead - saveMissing: true, - missingKeyHandler: (lng, ns, key) => { - process.env.NODE_ENV === 'test' - ? console.error(`Missing ${lng} Translation: key={${key}} ns={${ns}}`) - : console.warn(`Missing ${lng} Translation: key={${key}} ns={${ns}}`) +import type { InitOptions } from 'i18next' + +const i18nConfig: InitOptions = { + resources, + lng: 'en', + fallbackLng: 'en', + debug: process.env.NODE_ENV === 'development', + defaultNS: 'shared', + interpolation: { + escapeValue: false, // not needed for react as it escapes by default + format: function (value, format, lng) { + if (format === 'upperCase') return value.toUpperCase() + if (format === 'lowerCase') return value.toLowerCase() + if (format === 'capitalize') return capitalize(value) + if (format === 'sentenceCase') return startCase(value) + if (format === 'titleCase') return titleCase(value) + return value }, }, - err => { - if (err) { - console.error( - 'Internationalization was not initialized properly. error: ', - err - ) - } + keySeparator: false, // use namespaces and context instead + saveMissing: true, + missingKeyHandler: (lng, ns, key) => { + process.env.NODE_ENV === 'test' + ? console.error(`Missing ${lng} Translation: key={${key}} ns={${ns}}`) + : console.warn(`Missing ${lng} Translation: key={${key}} ns={${ns}}`) + }, +} + +const i18nCb = (err?: Error): void => { + if (err != null) { + console.error( + 'Internationalization was not initialized properly. error: ', + err + ) } -) +} + +void i18n.use(initReactI18next).init(i18nConfig, i18nCb) -export { i18n } +export { i18n, i18nCb, i18nConfig } diff --git a/app/src/index.tsx b/app/src/index.tsx index 123cfcc26fd..e37435c9aba 100644 --- a/app/src/index.tsx +++ b/app/src/index.tsx @@ -5,20 +5,18 @@ import { Provider } from 'react-redux' import { ConnectedRouter } from 'connected-react-router' -import { I18nextProvider } from 'react-i18next' import { ApiClientProvider } from '@opentrons/react-api-client' -import { i18n } from './i18n' import { createLogger } from './logger' import { uiInitialized } from './redux/shell' import { history } from './redux/reducer' import { store } from './redux/store' -import '../src/atoms/SoftwareKeyboard/index.css' -import '../src/atoms/SoftwareKeyboard/CustomKeyboard/index.css' -import '../src/atoms/SoftwareKeyboard/NormalKeyboard/index.css' -import '../src/atoms/SoftwareKeyboard/Numpad/index.css' +import '../src/atoms/SoftwareKeyboard/AlphanumericKeyboard' +import '../src/atoms/SoftwareKeyboard/FullKeyboard/index.css' +import '../src/atoms/SoftwareKeyboard/IndividualKey/index.css' +import '../src/atoms/SoftwareKeyboard/NumericalKeyboard/index.css' // component tree import { App } from './App' @@ -38,9 +36,7 @@ root.render( - - - + diff --git a/app/src/molecules/BackgroundOverlay/BackgroundOverlay.stories.tsx b/app/src/molecules/BackgroundOverlay/BackgroundOverlay.stories.tsx index 38c9e62baf1..b915e6be59b 100644 --- a/app/src/molecules/BackgroundOverlay/BackgroundOverlay.stories.tsx +++ b/app/src/molecules/BackgroundOverlay/BackgroundOverlay.stories.tsx @@ -1,12 +1,16 @@ import * as React from 'react' -import { Flex, PrimaryButton, StyledText } from '@opentrons/components' -import { touchScreenViewport } from '../../DesignTokens/constants' +import { + Flex, + PrimaryButton, + StyledText, + VIEWPORT, +} from '@opentrons/components' import { BackgroundOverlay } from './index' import type { Story, Meta } from '@storybook/react' export default { title: 'ODD/Molecules/BackgroundOverlay', - parameters: touchScreenViewport, + parameters: VIEWPORT.touchScreenViewport, } as Meta const Template: Story< diff --git a/app/src/molecules/CardButton/CardButton.stories.tsx b/app/src/molecules/CardButton/CardButton.stories.tsx index 38ce4a0f609..3ac71a8e3bf 100644 --- a/app/src/molecules/CardButton/CardButton.stories.tsx +++ b/app/src/molecules/CardButton/CardButton.stories.tsx @@ -1,7 +1,6 @@ import * as React from 'react' import { MemoryRouter } from 'react-router-dom' -import { Flex, SPACING } from '@opentrons/components' -import { touchScreenViewport } from '../../DesignTokens/constants' +import { Flex, SPACING, VIEWPORT } from '@opentrons/components' import { GlobalStyle } from '../../atoms/GlobalStyle' import { CardButton } from '.' @@ -10,7 +9,7 @@ import type { Story, Meta } from '@storybook/react' export default { title: 'ODD/Molecules/CardButton', component: CardButton, - parameters: touchScreenViewport, + parameters: VIEWPORT.touchScreenViewport, decorators: [ Story => ( <> diff --git a/app/src/molecules/FileUpload/index.tsx b/app/src/molecules/FileUpload/index.tsx new file mode 100644 index 00000000000..5e0fa7b0017 --- /dev/null +++ b/app/src/molecules/FileUpload/index.tsx @@ -0,0 +1,60 @@ +import * as React from 'react' +import { css } from 'styled-components' + +import { + ALIGN_CENTER, + BORDERS, + Btn, + COLORS, + DIRECTION_COLUMN, + Flex, + Icon, + JUSTIFY_SPACE_BETWEEN, + SPACING, + StyledText, +} from '@opentrons/components' + +const FILE_UPLOAD_STYLE = css` +&:hover > svg { + background: ${COLORS.black90}${COLORS.opacity20HexCode}; +} +&:active > svg { + background: ${COLORS.black90}${COLORS.opacity20HexCode}}; +} +` + +interface FileUploadProps { + file: File + fileError: string | null + handleClick: () => unknown +} + +export function FileUpload({ + file, + fileError, + handleClick, +}: FileUploadProps): JSX.Element { + return ( + + + + {file.name} + + + + {fileError != null ? ( + + {fileError} + + ) : null} + + ) +} diff --git a/app/src/molecules/InstrumentCard/index.tsx b/app/src/molecules/InstrumentCard/index.tsx index b0f722b8c5a..365c0a3eea5 100644 --- a/app/src/molecules/InstrumentCard/index.tsx +++ b/app/src/molecules/InstrumentCard/index.tsx @@ -111,9 +111,7 @@ export function InstrumentCard(props: InstrumentCardProps): JSX.Element { > {label} - - {description} - + {description}
{menuOverlayItems != null && ( > = args => ( diff --git a/app/src/molecules/Modal/ModalHeader.stories.tsx b/app/src/molecules/Modal/ModalHeader.stories.tsx index 0beabe6ba1b..92e9c83f9b4 100644 --- a/app/src/molecules/Modal/ModalHeader.stories.tsx +++ b/app/src/molecules/Modal/ModalHeader.stories.tsx @@ -1,6 +1,5 @@ import * as React from 'react' -import { COLORS } from '@opentrons/components' -import { touchScreenViewport } from '../../DesignTokens/constants' +import { COLORS, VIEWPORT } from '@opentrons/components' import { ModalHeader } from './ModalHeader' import type { Story, Meta } from '@storybook/react' @@ -24,7 +23,7 @@ export default { }, onClick: { action: 'clicked' }, }, - parameters: touchScreenViewport, + parameters: VIEWPORT.touchScreenViewport, } as Meta const Template: Story> = args => ( diff --git a/app/src/molecules/Modal/SmallModalChildren.stories.tsx b/app/src/molecules/Modal/SmallModalChildren.stories.tsx index cdea430b18f..c1889ca718e 100644 --- a/app/src/molecules/Modal/SmallModalChildren.stories.tsx +++ b/app/src/molecules/Modal/SmallModalChildren.stories.tsx @@ -1,12 +1,12 @@ import * as React from 'react' -import { touchScreenViewport } from '../../DesignTokens/constants' +import { VIEWPORT } from '@opentrons/components' import { SmallModalChildren } from './SmallModalChildren' import type { Story, Meta } from '@storybook/react' export default { title: 'ODD/Molecules/Modals/SmallModalChildren', argTypes: { onClick: { action: 'clicked' } }, - parameters: touchScreenViewport, + parameters: VIEWPORT.touchScreenViewport, } as Meta const Template: Story< diff --git a/app/src/molecules/ODDBackButton/ODDBackButton.stories.tsx b/app/src/molecules/ODDBackButton/ODDBackButton.stories.tsx index 14a0d050ba5..6fad4d7ae4a 100644 --- a/app/src/molecules/ODDBackButton/ODDBackButton.stories.tsx +++ b/app/src/molecules/ODDBackButton/ODDBackButton.stories.tsx @@ -1,5 +1,5 @@ import * as React from 'react' -import { touchScreenViewport } from '../../DesignTokens/constants' +import { VIEWPORT } from '@opentrons/components' import { ODDBackButton } from '.' import type { Story, Meta } from '@storybook/react' @@ -8,7 +8,7 @@ export default { argTypes: { onClick: { action: 'clicked' }, }, - parameters: touchScreenViewport, + parameters: VIEWPORT.touchScreenViewport, } as Meta const ODDBackButtonTemplate: Story< diff --git a/app/src/molecules/ReleaseNotes/index.tsx b/app/src/molecules/ReleaseNotes/index.tsx index 57eb22b04c6..38d88616143 100644 --- a/app/src/molecules/ReleaseNotes/index.tsx +++ b/app/src/molecules/ReleaseNotes/index.tsx @@ -1,26 +1,14 @@ import * as React from 'react' -import remark from 'remark' -import reactRenderer from 'remark-react' +import Markdown from 'react-markdown' + import { StyledText } from '@opentrons/components' + import styles from './styles.module.css' + export interface ReleaseNotesProps { source?: string | null } -// ToDo (kk:09/22/2023) This component should be updated in the future -// since the package we use hasn't been updated more than 2 years. -// Also the creator recommends users to replace remark-react with rehype-react. -const renderer = remark().use(reactRenderer, { - remarkReactComponents: { - div: React.Fragment, - h2: HeaderText, - ul: React.Fragment, - li: ParagraphText, - p: ParagraphText, - a: ExternalLink, - }, -}) - const DEFAULT_RELEASE_NOTES = 'We recommend upgrading to the latest version.' export function ReleaseNotes(props: ReleaseNotesProps): JSX.Element { @@ -29,7 +17,18 @@ export function ReleaseNotes(props: ReleaseNotesProps): JSX.Element { return (
{source != null ? ( - renderer.processSync(source).contents + + {source} + ) : (

{DEFAULT_RELEASE_NOTES}

)} diff --git a/app/src/molecules/UploadInput/index.tsx b/app/src/molecules/UploadInput/index.tsx index ea98b4735f3..45982e20ff2 100644 --- a/app/src/molecules/UploadInput/index.tsx +++ b/app/src/molecules/UploadInput/index.tsx @@ -45,11 +45,19 @@ const StyledInput = styled.input` export interface UploadInputProps { onUpload: (file: File) => unknown onClick?: () => void + uploadButtonText?: string uploadText?: string | JSX.Element dragAndDropText?: string | JSX.Element } export function UploadInput(props: UploadInputProps): JSX.Element | null { + const { + dragAndDropText, + onClick, + onUpload, + uploadButtonText, + uploadText, + } = props const { t } = useTranslation('protocol_info') const fileInput = React.useRef(null) @@ -60,7 +68,7 @@ export function UploadInput(props: UploadInputProps): JSX.Element | null { const handleDrop: React.DragEventHandler = e => { e.preventDefault() e.stopPropagation() - Array.from(e.dataTransfer.files).forEach(f => props.onUpload(f)) + Array.from(e.dataTransfer.files).forEach(f => onUpload(f)) setIsFileOverDropZone(false) } const handleDragEnter: React.DragEventHandler = e => { @@ -81,11 +89,11 @@ export function UploadInput(props: UploadInputProps): JSX.Element | null { } const handleClick: React.MouseEventHandler = _event => { - props.onClick != null ? props.onClick() : fileInput.current?.click() + onClick != null ? onClick() : fileInput.current?.click() } const onChange: React.ChangeEventHandler = event => { - ;[...(event.target.files ?? [])].forEach(f => props.onUpload(f)) + ;[...(event.target.files ?? [])].forEach(f => onUpload(f)) if ('value' in event.currentTarget) event.currentTarget.value = '' } @@ -97,18 +105,20 @@ export function UploadInput(props: UploadInputProps): JSX.Element | null { alignItems={ALIGN_CENTER} gridGap={SPACING.spacing24} > - - {props.uploadText} - + {uploadText != null ? ( + + {uploadText} + + ) : null} - {t('upload')} + {uploadButtonText ?? t('upload')} - {props.dragAndDropText} + {dragAndDropText} () const trackEvent = useTrackEvent() @@ -54,7 +54,7 @@ export function OverridePathToPython(): JSX.Element { {t('override_path_to_python')} - {t('opentrons_app_will_use_interpreter')} + {t('branded:opentrons_app_will_use_interpreter')} () const isLabwareOffsetCodeSnippetsOn = useSelector( getIsLabwareOffsetCodeSnippetsOn @@ -47,7 +47,7 @@ export function ShowLabwareOffsetSnippets(): JSX.Element { {t('show_labware_offset_snippets')} - {t('show_labware_offset_snippets_description')} + {t('branded:show_labware_offset_snippets_description')} { render() screen.getByText('Show Labware Offset data code snippets') screen.getByText( - 'Only for users who need to apply Labware Offset data outside of the Opentrons App. When enabled, code snippets for Jupyter Notebook and SSH are available during protocol setup.' + 'Only for users who need to apply labware offset data outside of the Opentrons App. When enabled, code snippets for Jupyter Notebook and SSH are available during protocol setup.' ) screen.getByRole('switch', { name: 'show_link_to_get_labware_offset_data' }) }) diff --git a/app/src/organisms/Alerts/AlertsModal.tsx b/app/src/organisms/Alerts/AlertsModal.tsx index 928d5762a0e..34b6f4c66c4 100644 --- a/app/src/organisms/Alerts/AlertsModal.tsx +++ b/app/src/organisms/Alerts/AlertsModal.tsx @@ -24,7 +24,7 @@ interface AlertsModalProps { export function AlertsModal({ toastIdRef }: AlertsModalProps): JSX.Element { const dispatch = useDispatch() const [showUpdateModal, setShowUpdateModal] = React.useState(false) - const { t } = useTranslation('app_settings') + const { t } = useTranslation(['app_settings', 'branded']) const { makeToast } = useToaster() const { removeActiveAppUpdateToast } = useRemoveActiveAppUpdateToast() @@ -54,10 +54,14 @@ export function AlertsModal({ toastIdRef }: AlertsModalProps): JSX.Element { // Only run this hook on app startup React.useEffect(() => { if (hasJustUpdated) { - makeToast(t('opentrons_app_successfully_updated'), SUCCESS_TOAST, { - closeButton: true, - disableTimeout: true, - }) + makeToast( + t('branded:opentrons_app_successfully_updated'), + SUCCESS_TOAST, + { + closeButton: true, + disableTimeout: true, + } + ) dispatch(toggleConfigValue('update.hasJustUpdated')) } }, []) @@ -65,7 +69,7 @@ export function AlertsModal({ toastIdRef }: AlertsModalProps): JSX.Element { React.useEffect(() => { if (createAppUpdateAvailableToast) { toastIdRef.current = makeToast( - t('opentrons_app_update_available_variation'), + t('branded:opentrons_app_update_available_variation'), WARNING_TOAST, { closeButton: true, diff --git a/app/src/organisms/AppSettings/ConnectRobotSlideout.tsx b/app/src/organisms/AppSettings/ConnectRobotSlideout.tsx index eaef9184985..1935cd33d78 100644 --- a/app/src/organisms/AppSettings/ConnectRobotSlideout.tsx +++ b/app/src/organisms/AppSettings/ConnectRobotSlideout.tsx @@ -43,7 +43,7 @@ export function ConnectRobotSlideout({ const [mostRecentDiscovered, setMostRecentDiscovered] = React.useState< boolean | null >(null) - const { t } = useTranslation(['app_settings', 'shared']) + const { t } = useTranslation(['app_settings', 'shared', 'branded']) const dispatch = useDispatch() const refreshDiscovery = (): unknown => dispatch(startDiscovery()) const isScanning = useSelector(getScanning) @@ -81,7 +81,7 @@ export function ConnectRobotSlideout({ {t('ip_description_first')} - {t('ip_description_second')} + {t('branded:ip_description_second')} - {t('restore_description')} + {t('branded:restore_description')} - {t('learn_uninstalling')} + {t('branded:learn_uninstalling')} - {t('previous_releases')} + {t('branded:previous_releases')} diff --git a/app/src/organisms/CalibrateTipLength/AskForCalibrationBlockModal.tsx b/app/src/organisms/CalibrateTipLength/AskForCalibrationBlockModal.tsx index 41a21ff0cba..c767cb4ee39 100644 --- a/app/src/organisms/CalibrateTipLength/AskForCalibrationBlockModal.tsx +++ b/app/src/organisms/CalibrateTipLength/AskForCalibrationBlockModal.tsx @@ -40,7 +40,7 @@ interface Props { } export function AskForCalibrationBlockModal(props: Props): JSX.Element { - const { t } = useTranslation(['robot_calibration', 'shared']) + const { t } = useTranslation(['robot_calibration', 'shared', 'branded']) const [rememberPreference, setRememberPreference] = React.useState( true ) @@ -77,7 +77,7 @@ export function AskForCalibrationBlockModal(props: Props): JSX.Element { , supportLink: ( diff --git a/app/src/organisms/CalibrationPanels/ChooseTipRack.tsx b/app/src/organisms/CalibrationPanels/ChooseTipRack.tsx index fed5ab02911..08e0b22e51b 100644 --- a/app/src/organisms/CalibrationPanels/ChooseTipRack.tsx +++ b/app/src/organisms/CalibrationPanels/ChooseTipRack.tsx @@ -75,7 +75,7 @@ export function ChooseTipRack(props: ChooseTipRackProps): JSX.Element { robotName, defaultTipracks, } = props - const { t } = useTranslation(['robot_calibration', 'shared']) + const { t } = useTranslation(['robot_calibration', 'shared', 'branded']) const pipSerial = usePipettesQuery( {}, { @@ -143,7 +143,7 @@ export function ChooseTipRack(props: ChooseTipRackProps): JSX.Element { customTipRacks.length > 0 ? [ { - label: t('opentrons'), + label: t('branded:opentrons_tip_rack_name'), options: opentronsTipRacksOptions, }, { @@ -233,14 +233,14 @@ export function ChooseTipRack(props: ChooseTipRackProps): JSX.Element { - {t('opentrons_tip_racks_recommended')} + {t('branded:opentrons_tip_racks_recommended')} - {t('calibration_on_opentrons_tips_is_important')} + {t('branded:calibration_on_opentrons_tips_is_important')} diff --git a/app/src/organisms/ChildNavigation/ChildNavigation.stories.tsx b/app/src/organisms/ChildNavigation/ChildNavigation.stories.tsx index c39b4b20dc1..cddbb2cd7a3 100644 --- a/app/src/organisms/ChildNavigation/ChildNavigation.stories.tsx +++ b/app/src/organisms/ChildNavigation/ChildNavigation.stories.tsx @@ -1,12 +1,12 @@ import * as React from 'react' -import { touchScreenViewport } from '../../DesignTokens/constants' +import { VIEWPORT } from '@opentrons/components' import { SmallButton } from '../../atoms/buttons' import { ChildNavigation } from '.' import type { Story, Meta } from '@storybook/react' export default { title: 'ODD/Organisms/ChildNavigation', - parameters: touchScreenViewport, + parameters: VIEWPORT.touchScreenViewport, } as Meta const Template: Story> = args => ( @@ -15,6 +15,13 @@ const Template: Story> = args => ( export const Default = Template.bind({}) Default.args = { header: 'Header', + onClickBack: () => {}, +} + +export const TitleNoBackButton = Template.bind({}) +TitleNoBackButton.args = { + header: 'Header', + onClickBack: undefined, } export const TitleWithNormalSmallButton = Template.bind({}) @@ -22,6 +29,16 @@ TitleWithNormalSmallButton.args = { header: 'Header', buttonText: 'ButtonText', onClickButton: () => {}, + onClickBack: () => {}, +} + +export const TitleWithNormalSmallButtonDisabled = Template.bind({}) +TitleWithNormalSmallButtonDisabled.args = { + header: 'Header', + buttonText: 'ButtonText', + onClickButton: () => {}, + onClickBack: () => {}, + buttonIsDisabled: true, } export const TitleWithLinkButton = Template.bind({}) @@ -32,6 +49,7 @@ TitleWithLinkButton.args = { iconName: 'information', iconPlacement: 'startIcon', onClickButton: () => {}, + onClickBack: () => {}, } export const TitleWithTwoButtons = Template.bind({}) @@ -47,4 +65,5 @@ TitleWithTwoButtons.args = { buttonText: 'ButtonText', onClickButton: () => {}, secondaryButtonProps, + onClickBack: () => {}, } diff --git a/app/src/organisms/ChildNavigation/__tests__/ChildNavigation.test.tsx b/app/src/organisms/ChildNavigation/__tests__/ChildNavigation.test.tsx index 8f53b640187..8e2a1c7ec0e 100644 --- a/app/src/organisms/ChildNavigation/__tests__/ChildNavigation.test.tsx +++ b/app/src/organisms/ChildNavigation/__tests__/ChildNavigation.test.tsx @@ -72,4 +72,26 @@ describe('ChildNavigation', () => { fireEvent.click(secondaryButton) expect(mockOnClickSecondaryButton).toHaveBeenCalled() }) + it.fails( + 'should not render back button if onClickBack does not exist', + () => { + props = { + ...props, + onClickBack: undefined, + } + render(props) + screen.getByTestId('ChildNavigation_Back_Button') + } + ) + it('should render button as disabled', () => { + props = { + ...props, + buttonText: 'mock button', + onClickButton: mockOnClickButton, + buttonIsDisabled: true, + } + render(props) + const button = screen.getByTestId('ChildNavigation_Primary_Button') + expect(button).toBeDisabled() + }) }) diff --git a/app/src/organisms/ChildNavigation/index.tsx b/app/src/organisms/ChildNavigation/index.tsx index afe3c1f7508..e076f7191af 100644 --- a/app/src/organisms/ChildNavigation/index.tsx +++ b/app/src/organisms/ChildNavigation/index.tsx @@ -20,20 +20,21 @@ import { ODD_FOCUS_VISIBLE } from '../../atoms/buttons/constants' import { SmallButton } from '../../atoms/buttons' import { InlineNotification } from '../../atoms/InlineNotification' -import type { IconName } from '@opentrons/components' +import type { IconName, StyleProps } from '@opentrons/components' import type { InlineNotificationProps } from '../../atoms/InlineNotification' import type { IconPlacement, SmallButtonTypes, } from '../../atoms/buttons/SmallButton' -interface ChildNavigationProps { +interface ChildNavigationProps extends StyleProps { header: string - onClickBack: React.MouseEventHandler + onClickBack?: React.MouseEventHandler buttonText?: React.ReactNode inlineNotification?: InlineNotificationProps onClickButton?: React.MouseEventHandler buttonType?: SmallButtonTypes + buttonIsDisabled?: boolean iconName?: IconName iconPlacement?: IconPlacement secondaryButtonProps?: React.ComponentProps @@ -49,6 +50,8 @@ export function ChildNavigation({ iconName, iconPlacement, secondaryButtonProps, + buttonIsDisabled, + ...styleProps }: ChildNavigationProps): JSX.Element { return ( - - - + {onClickBack != null ? ( + + + + ) : null} {header} @@ -87,6 +93,8 @@ export function ChildNavigation({ onClick={onClickButton} iconName={iconName} iconPlacement={iconPlacement} + disabled={buttonIsDisabled} + data-testid="ChildNavigation_Primary_Button" /> ) : null} diff --git a/app/src/organisms/ChooseProtocolSlideout/__tests__/ChooseProtocolSlideout.test.tsx b/app/src/organisms/ChooseProtocolSlideout/__tests__/ChooseProtocolSlideout.test.tsx index d5b910381bd..7973023d184 100644 --- a/app/src/organisms/ChooseProtocolSlideout/__tests__/ChooseProtocolSlideout.test.tsx +++ b/app/src/organisms/ChooseProtocolSlideout/__tests__/ChooseProtocolSlideout.test.tsx @@ -3,15 +3,21 @@ import { vi, it, describe, expect, beforeEach } from 'vitest' import { StaticRouter } from 'react-router-dom' import { fireEvent, screen } from '@testing-library/react' +import { simpleAnalysisFileFixture } from '@opentrons/api-client' +import { OT2_ROBOT_TYPE } from '@opentrons/shared-data' import { renderWithProviders } from '../../../__testing-utils__' import { i18n } from '../../../i18n' import { getStoredProtocols } from '../../../redux/protocol-storage' import { mockConnectableRobot } from '../../../redux/discovery/__fixtures__' -import { storedProtocolData as storedProtocolDataFixture } from '../../../redux/protocol-storage/__fixtures__' +import { + storedProtocolData as storedProtocolDataFixture, + storedProtocolDataWithoutRunTimeParameters, +} from '../../../redux/protocol-storage/__fixtures__' import { useTrackCreateProtocolRunEvent } from '../../../organisms/Devices/hooks' import { useCreateRunFromProtocol } from '../../ChooseRobotToRunProtocolSlideout/useCreateRunFromProtocol' import { ChooseProtocolSlideout } from '../' import { useNotifyService } from '../../../resources/useNotifyService' +import type { ProtocolAnalysisOutput } from '@opentrons/shared-data' vi.mock('../../ChooseRobotToRunProtocolSlideout/useCreateRunFromProtocol') vi.mock('../../../redux/protocol-storage') @@ -30,6 +36,20 @@ const render = (props: React.ComponentProps) => { ) } +const modifiedSimpleAnalysisFileFixture = { + ...simpleAnalysisFileFixture, + robotType: OT2_ROBOT_TYPE, +} +const mockStoredProtocolDataFixture = [ + { + ...storedProtocolDataFixture, + mostRecentAnalysis: ({ + ...modifiedSimpleAnalysisFileFixture, + runTimeParameters: [], + } as any) as ProtocolAnalysisOutput, + }, +] + describe('ChooseProtocolSlideout', () => { let mockCreateRunFromProtocol = vi.fn() let mockTrackCreateProtocolRunEvent = vi.fn() @@ -38,7 +58,7 @@ describe('ChooseProtocolSlideout', () => { mockTrackCreateProtocolRunEvent = vi.fn( () => new Promise(resolve => resolve({})) ) - vi.mocked(getStoredProtocols).mockReturnValue([storedProtocolDataFixture]) + vi.mocked(getStoredProtocols).mockReturnValue(mockStoredProtocolDataFixture) vi.mocked(useCreateRunFromProtocol).mockReturnValue({ createRunFromProtocolSource: mockCreateRunFromProtocol, reset: vi.fn(), @@ -58,6 +78,7 @@ describe('ChooseProtocolSlideout', () => { screen.getByText(/choose protocol to run/i) screen.getByText(/opentrons-robot-name/i) }) + it('renders an available protocol option for every stored protocol if any', () => { render({ robot: mockConnectableRobot, @@ -70,6 +91,7 @@ describe('ChooseProtocolSlideout', () => { screen.queryByRole('heading', { name: 'No protocols found' }) ).toBeNull() }) + it('renders an empty state if no protocol options', () => { vi.mocked(getStoredProtocols).mockReturnValue([]) render({ @@ -83,7 +105,14 @@ describe('ChooseProtocolSlideout', () => { screen.getByRole('heading', { name: 'No protocols found' }) ).toBeInTheDocument() }) + it('calls createRunFromProtocolSource if CTA clicked', () => { + const protocolDataWithoutRunTimeParameter = { + ...storedProtocolDataWithoutRunTimeParameters, + } + vi.mocked(getStoredProtocols).mockReturnValue([ + protocolDataWithoutRunTimeParameter, + ]) render({ robot: mockConnectableRobot, onCloseClick: vi.fn(), @@ -99,6 +128,30 @@ describe('ChooseProtocolSlideout', () => { }) expect(mockTrackCreateProtocolRunEvent).toHaveBeenCalled() }) + + it('move to the second slideout if CTA clicked', () => { + const protocolDataWithoutRunTimeParameter = { + ...storedProtocolDataFixture, + } + vi.mocked(getStoredProtocols).mockReturnValue([ + protocolDataWithoutRunTimeParameter, + ]) + render({ + robot: mockConnectableRobot, + onCloseClick: vi.fn(), + showSlideout: true, + }) + const proceedButton = screen.getByRole('button', { + name: 'Continue to parameters', + }) + fireEvent.click(proceedButton) + screen.getByText('Step 2 / 2') + screen.getByText('number of samples') + screen.getByText('Restore default values') + }) + + // ToDo (kk:04/18/2024) I will update test for RTP + /* it('renders error state when there is a run creation error', () => { vi.mocked(useCreateRunFromProtocol).mockReturnValue({ runCreationError: 'run creation error', @@ -153,4 +206,5 @@ describe('ChooseProtocolSlideout', () => { fireEvent.click(link) expect(link.getAttribute('href')).toEqual('/devices/opentrons-robot-name') }) + */ }) diff --git a/app/src/organisms/ChooseProtocolSlideout/index.tsx b/app/src/organisms/ChooseProtocolSlideout/index.tsx index b6d1d2805ff..dfd7b7c12a2 100644 --- a/app/src/organisms/ChooseProtocolSlideout/index.tsx +++ b/app/src/organisms/ChooseProtocolSlideout/index.tsx @@ -2,7 +2,6 @@ import * as React from 'react' import first from 'lodash/first' import { Trans, useTranslation } from 'react-i18next' import { Link, NavLink, useHistory } from 'react-router-dom' -import { ApiHostProvider } from '@opentrons/react-api-client' import { useSelector } from 'react-redux' import { css } from 'styled-components' @@ -12,30 +11,44 @@ import { Box, COLORS, DIRECTION_COLUMN, + DIRECTION_ROW, DISPLAY_BLOCK, Flex, Icon, + Link as LinkComponent, JUSTIFY_CENTER, + JUSTIFY_END, + JUSTIFY_FLEX_START, OVERFLOW_WRAP_ANYWHERE, PrimaryButton, ProtocolDeck, - SIZE_1, SPACING, + SecondaryButton, StyledText, TYPOGRAPHY, + useHoverTooltip, } from '@opentrons/components' +import { ApiHostProvider } from '@opentrons/react-api-client' import { useLogger } from '../../logger' import { OPENTRONS_USB } from '../../redux/discovery' import { getStoredProtocols } from '../../redux/protocol-storage' import { appShellRequestor } from '../../redux/shell/remote' -import { Slideout } from '../../atoms/Slideout' +import { MultiSlideout } from '../../atoms/Slideout/MultiSlideout' +import { Tooltip } from '../../atoms/Tooltip' +import { ToggleButton } from '../../atoms/buttons' +import { InputField } from '../../atoms/InputField' +import { DropdownMenu } from '../../atoms/MenuList/DropdownMenu' import { MiniCard } from '../../molecules/MiniCard' import { useTrackCreateProtocolRunEvent } from '../Devices/hooks' import { useCreateRunFromProtocol } from '../ChooseRobotToRunProtocolSlideout/useCreateRunFromProtocol' import { ApplyHistoricOffsets } from '../ApplyHistoricOffsets' import { useOffsetCandidatesForAnalysis } from '../ApplyHistoricOffsets/hooks/useOffsetCandidatesForAnalysis' import { getAnalysisStatus } from '../ProtocolsLanding/utils' + +import type { RunTimeParameterCreateData } from '@opentrons/api-client' +import type { RunTimeParameter } from '@opentrons/shared-data' +import type { DropdownOption } from '@opentrons/components' import type { Robot } from '../../redux/discovery/types' import type { StoredProtocolData } from '../../redux/protocol-storage' import type { State } from '../../redux/types' @@ -65,6 +78,8 @@ export function ChooseProtocolSlideoutComponent( const { t } = useTranslation(['device_details', 'shared']) const history = useHistory() const logger = useLogger(new URL('', import.meta.url).pathname) + const [targetProps, tooltipProps] = useHoverTooltip() + const { robot, showSlideout, onCloseClick } = props const { name } = robot @@ -72,6 +87,27 @@ export function ChooseProtocolSlideoutComponent( selectedProtocol, setSelectedProtocol, ] = React.useState(null) + const [ + runTimeParametersOverrides, + setRunTimeParametersOverrides, + ] = React.useState([]) + const [currentPage, setCurrentPage] = React.useState(1) + const [hasParamError, setHasParamError] = React.useState(false) + + React.useEffect(() => { + setRunTimeParametersOverrides( + selectedProtocol?.mostRecentAnalysis?.runTimeParameters ?? [] + ) + }, [selectedProtocol]) + React.useEffect(() => { + setHasParamError(errors.length > 0) + }, [runTimeParametersOverrides]) + + const runTimeParametersFromAnalysis = + selectedProtocol?.mostRecentAnalysis?.runTimeParameters ?? [] + + const hasRunTimeParameters = runTimeParametersFromAnalysis.length > 0 + const analysisStatus = getAnalysisStatus( false, selectedProtocol?.mostRecentAnalysis @@ -128,7 +164,14 @@ export function ChooseProtocolSlideoutComponent( location, definitionUri, })) - : [] + : [], + runTimeParametersOverrides.reduce( + (acc, param) => + param.value !== param.default + ? { ...acc, [param.variableName]: param.value } + : acc, + {} + ) ) const handleProceed: React.MouseEventHandler = () => { if (selectedProtocol != null) { @@ -141,10 +184,240 @@ export function ChooseProtocolSlideoutComponent( logger.warn('failed to create protocol, no protocol selected') } } + + const isRestoreDefaultsLinkEnabled = + runTimeParametersOverrides?.some( + parameter => parameter.value !== parameter.default + ) ?? false + + const errors: string[] = [] + const runTimeParametersInputs = + runTimeParametersOverrides?.map((runtimeParam, index) => { + if ('choices' in runtimeParam) { + const dropdownOptions = runtimeParam.choices.map(choice => { + return { name: choice.displayName, value: choice.value } + }) as DropdownOption[] + return ( + { + return choice.value === runtimeParam.value + }) ?? dropdownOptions[0] + } + onClick={choice => { + const clone = runTimeParametersOverrides.map((parameter, i) => { + if (i === index) { + return { + ...parameter, + value: + dropdownOptions.find(option => option.value === choice) + ?.value ?? parameter.default, + } + } + return parameter + }) + setRunTimeParametersOverrides(clone) + }} + title={runtimeParam.displayName} + width="100%" + dropdownType="neutral" + /> + ) + } else if (runtimeParam.type === 'int' || runtimeParam.type === 'float') { + const value = runtimeParam.value as number + const id = `InputField_${runtimeParam.variableName}_${index.toString()}` + const error = + Number.isNaN(value) || + value < runtimeParam.min || + value > runtimeParam.max + ? t(`protocol_details:value_out_of_range`, { + min: + runtimeParam.type === 'int' + ? runtimeParam.min + : runtimeParam.min.toFixed(1), + max: + runtimeParam.type === 'int' + ? runtimeParam.max + : runtimeParam.max.toFixed(1), + }) + : null + if (error != null) { + errors.push(error) + } + return ( + { + const clone = runTimeParametersOverrides.map((parameter, i) => { + if (i === index) { + return { + ...parameter, + value: + runtimeParam.type === 'int' + ? Math.round(e.target.valueAsNumber) + : e.target.valueAsNumber, + } + } + return parameter + }) + setRunTimeParametersOverrides(clone) + }} + /> + ) + } else if (runtimeParam.type === 'bool') { + return ( + + + {runtimeParam.displayName} + + + { + const clone = runTimeParametersOverrides.map( + (parameter, i) => { + if (i === index) { + return { + ...parameter, + value: !parameter.value, + } + } + return parameter + } + ) + setRunTimeParametersOverrides(clone) + }} + height="0.813rem" + label={ + Boolean(runtimeParam.value) + ? t('protocol_details:on') + : t('protocol_details:off') + } + paddingTop={SPACING.spacing2} // manual alignment of SVG with value label + /> + + {Boolean(runtimeParam.value) + ? t('protocol_details:on') + : t('protocol_details:off')} + + + + {runtimeParam.description} + + + ) + } + }) ?? null + + const resetRunTimeParameters = (): void => { + setRunTimeParametersOverrides( + runTimeParametersOverrides?.map(parameter => ({ + ...parameter, + value: parameter.default, + })) + ) + } + + const pageTwoBody = ( + + + + {t('protocol_details:restore_defaults')} + + {!isRestoreDefaultsLinkEnabled && ( + + {t('protocol_details:no_custom_values')} + + )} + + + {runTimeParametersInputs} + + + ) + + const singlePageFooter = ( + + {isCreatingRun ? ( + + ) : ( + t('shared:proceed_to_setup') + )} + + ) + + const multiPageFooter = + currentPage === 1 ? ( + setCurrentPage(2)} + width="100%" + disabled={isCreatingRun || selectedProtocol == null} + > + {t('shared:continue_to_param')} + + ) : ( + + setCurrentPage(1)} width="51%"> + {t('shared:change_protocol')} + + + {isCreatingRun ? ( + + ) : ( + t('shared:confirm_values') + )} + + + ) + return ( - { + onCloseClick() + setCurrentPage(1) + resetRunTimeParameters() + }} + currentStep={currentPage} + maxSteps={hasRunTimeParameters ? 2 : 1} title={t('choose_protocol_to_run', { name })} footer={ - - - {isCreatingRun ? ( - - ) : ( - t('shared:proceed_to_setup') - )} - + {currentPage === 1 ? ( + + ) : null} + {hasRunTimeParameters ? multiPageFooter : singlePageFooter} } > {showSlideout ? ( - { - if (!isCreatingRun) { - resetCreateRun() - setSelectedProtocol(storedProtocol) - } - }} - robotName={robot.name} - {...{ selectedProtocol, runCreationError, runCreationErrorCode }} - /> + currentPage === 1 ? ( + { + if (!isCreatingRun) { + resetCreateRun() + setSelectedProtocol(storedProtocol) + } + }} + robot={robot} + {...{ selectedProtocol, runCreationError, runCreationErrorCode }} + /> + ) : ( + pageTwoBody + ) ) : null} - + ) } @@ -214,7 +483,7 @@ interface StoredProtocolListProps { handleSelectProtocol: (storedProtocol: StoredProtocolData | null) => void runCreationError: string | null runCreationErrorCode: number | null - robotName: string + robot: Robot } function StoredProtocolList(props: StoredProtocolListProps): JSX.Element { @@ -223,11 +492,13 @@ function StoredProtocolList(props: StoredProtocolListProps): JSX.Element { handleSelectProtocol, runCreationError, runCreationErrorCode, - robotName, + robot, } = props - const { t } = useTranslation(['device_details', 'shared']) + const { t } = useTranslation(['device_details', 'protocol_details', 'shared']) const storedProtocols = useSelector((state: State) => getStoredProtocols(state) + ).filter( + protocol => protocol.mostRecentAnalysis?.robotType === robot.robotModel ) React.useEffect(() => { handleSelectProtocol(first(storedProtocols) ?? null) @@ -316,7 +587,7 @@ function StoredProtocolList(props: StoredProtocolListProps): JSX.Element { color: ${COLORS.red60}; text-decoration: ${TYPOGRAPHY.textDecorationUnderline}; `} - to={`/devices/${robotName}`} + to={`/devices/${robot.name}`} /> ), }} @@ -401,3 +672,18 @@ function StoredProtocolList(props: StoredProtocolListProps): JSX.Element { ) } + +const ENABLED_LINK_CSS = css` + ${TYPOGRAPHY.linkPSemiBold} + cursor: pointer; +` + +const DISABLED_LINK_CSS = css` + ${TYPOGRAPHY.linkPSemiBold} + color: ${COLORS.grey40}; + cursor: default; + + &:hover { + color: ${COLORS.grey40}; + } +` diff --git a/app/src/organisms/ChooseRobotSlideout/AvailableRobotOption.tsx b/app/src/organisms/ChooseRobotSlideout/AvailableRobotOption.tsx index c65e69ce163..148b9e30e35 100644 --- a/app/src/organisms/ChooseRobotSlideout/AvailableRobotOption.tsx +++ b/app/src/organisms/ChooseRobotSlideout/AvailableRobotOption.tsx @@ -53,7 +53,7 @@ export function AvailableRobotOption( registerRobotBusyStatus, } = props const { ip, local, name: robotName } = robot ?? {} - const { t } = useTranslation('protocol_list') + const { t } = useTranslation(['protocol_list', 'branded']) const dispatch = useDispatch() const robotModel = useSelector((state: State) => getRobotModelByName(state, robotName) @@ -160,7 +160,7 @@ export function AvailableRobotOption( > , }} diff --git a/app/src/organisms/ChooseRobotSlideout/__tests__/ChooseRobotSlideout.test.tsx b/app/src/organisms/ChooseRobotSlideout/__tests__/ChooseRobotSlideout.test.tsx index 586bc6fe3b9..19500166410 100644 --- a/app/src/organisms/ChooseRobotSlideout/__tests__/ChooseRobotSlideout.test.tsx +++ b/app/src/organisms/ChooseRobotSlideout/__tests__/ChooseRobotSlideout.test.tsx @@ -3,6 +3,7 @@ import { vi, it, describe, expect, beforeEach } from 'vitest' import { StaticRouter } from 'react-router-dom' import { fireEvent, screen } from '@testing-library/react' +import { OT2_ROBOT_TYPE } from '@opentrons/shared-data' import { renderWithProviders } from '../../../__testing-utils__' import { i18n } from '../../../i18n' @@ -22,7 +23,7 @@ import { useFeatureFlag } from '../../../redux/config' import { getNetworkInterfaces } from '../../../redux/networking' import { ChooseRobotSlideout } from '..' import { useNotifyService } from '../../../resources/useNotifyService' -import { RunTimeParameter } from '@opentrons/shared-data' +import type { RunTimeParameter } from '@opentrons/shared-data' vi.mock('../../../redux/discovery') vi.mock('../../../redux/robot-update') @@ -48,7 +49,7 @@ const mockRunTimeParameters: RunTimeParameter[] = [ value: false, variableName: 'DRYRUN', description: 'Is this a dry or wet run? Wet is true, dry is false', - type: 'boolean', + type: 'bool', default: false, }, { @@ -121,7 +122,7 @@ describe('ChooseRobotSlideout', () => { selectedRobot: null, setSelectedRobot: vi.fn(), title: 'choose robot slideout title', - robotType: 'OT-2 Standard', + robotType: OT2_ROBOT_TYPE, }) screen.getByText('choose robot slideout title') }) @@ -134,7 +135,7 @@ describe('ChooseRobotSlideout', () => { setSelectedRobot: vi.fn(), title: 'choose robot slideout title', isAnalysisError: true, - robotType: 'OT-2 Standard', + robotType: OT2_ROBOT_TYPE, }) screen.getByText( 'This protocol failed in-app analysis. It may be unusable on robots without custom software configurations.' @@ -148,7 +149,7 @@ describe('ChooseRobotSlideout', () => { selectedRobot: null, setSelectedRobot: vi.fn(), title: 'choose robot slideout title', - robotType: 'OT-2 Standard', + robotType: OT2_ROBOT_TYPE, }) screen.getByText('opentrons-robot-name') screen.getByText('2 unavailable robots are not listed.') @@ -162,7 +163,7 @@ describe('ChooseRobotSlideout', () => { selectedRobot: null, setSelectedRobot: vi.fn(), title: 'choose robot slideout title', - robotType: 'OT-2 Standard', + robotType: OT2_ROBOT_TYPE, }) screen.getByText('opentrons-robot-name') expect( @@ -177,7 +178,7 @@ describe('ChooseRobotSlideout', () => { selectedRobot: null, setSelectedRobot: mockSetSelectedRobot, title: 'choose robot slideout title', - robotType: 'OT-2 Standard', + robotType: OT2_ROBOT_TYPE, })[1] const refreshButton = screen.getByRole('button', { name: 'refresh' }) fireEvent.click(refreshButton) @@ -192,7 +193,7 @@ describe('ChooseRobotSlideout', () => { selectedRobot: null, setSelectedRobot: mockSetSelectedRobot, title: 'choose robot slideout title', - robotType: 'OT-2 Standard', + robotType: OT2_ROBOT_TYPE, multiSlideout: { currentPage: 1 }, }) screen.getByText('Step 1 / 2') @@ -205,7 +206,7 @@ describe('ChooseRobotSlideout', () => { selectedRobot: null, setSelectedRobot: mockSetSelectedRobot, title: 'choose robot slideout title', - robotType: 'OT-2 Standard', + robotType: OT2_ROBOT_TYPE, multiSlideout: { currentPage: 2 }, }) screen.getByText('Step 2 / 2') @@ -220,20 +221,51 @@ describe('ChooseRobotSlideout', () => { selectedRobot: null, setSelectedRobot: mockSetSelectedRobot, title: 'choose robot slideout title', - robotType: 'OT-2 Standard', + robotType: OT2_ROBOT_TYPE, multiSlideout: { currentPage: 2 }, runTimeParametersOverrides: [param], }) screen.getByText(param.displayName) - if (param.type === 'boolean' || 'choices' in param) { + if (param.type === 'bool') { screen.getByText(param.description) - } else { + } + if (param.type === 'int') { screen.getByText(`${param.min}-${param.max}`) } + if (param.type === 'float') { + screen.getByText(`${param.min.toFixed(1)}-${param.max.toFixed(1)}`) + } }) }) + it('renders error message for runtime parameter out of range', () => { + render({ + onCloseClick: vi.fn(), + isExpanded: true, + isSelectedRobotOnDifferentSoftwareVersion: false, + selectedRobot: null, + setSelectedRobot: mockSetSelectedRobot, + title: 'choose robot slideout title', + robotType: OT2_ROBOT_TYPE, + multiSlideout: { currentPage: 2 }, + runTimeParametersOverrides: [ + { + value: 1000, + displayName: 'EtoH Volume', + variableName: 'ETOH_VOLUME', + description: '70% ethanol volume', + type: 'float', + suffix: 'mL', + min: 1.5, + max: 10.0, + default: 6.5, + }, + ], + }) + screen.getByText('Value must be between 1.5-10.0') + }) + it('defaults to first available robot and allows an available robot to be selected', () => { vi.mocked(getConnectableRobots).mockReturnValue([ { ...mockConnectableRobot, name: 'otherRobot', ip: 'otherIp' }, @@ -246,7 +278,7 @@ describe('ChooseRobotSlideout', () => { selectedRobot: null, setSelectedRobot: mockSetSelectedRobot, title: 'choose robot slideout title', - robotType: 'OT-2 Standard', + robotType: OT2_ROBOT_TYPE, }) expect(mockSetSelectedRobot).toBeCalledWith({ ...mockConnectableRobot, @@ -264,4 +296,18 @@ describe('ChooseRobotSlideout', () => { ip: 'otherIp', }) }) + + it('sets selected robot to null if no available robots', () => { + vi.mocked(getConnectableRobots).mockReturnValue([]) + render({ + onCloseClick: vi.fn(), + isExpanded: true, + isSelectedRobotOnDifferentSoftwareVersion: false, + selectedRobot: null, + setSelectedRobot: mockSetSelectedRobot, + title: 'choose robot slideout title', + robotType: OT2_ROBOT_TYPE, + }) + expect(mockSetSelectedRobot).toBeCalledWith(null) + }) }) diff --git a/app/src/organisms/ChooseRobotSlideout/index.tsx b/app/src/organisms/ChooseRobotSlideout/index.tsx index ef5bb8c9368..fd77056db76 100644 --- a/app/src/organisms/ChooseRobotSlideout/index.tsx +++ b/app/src/organisms/ChooseRobotSlideout/index.tsx @@ -51,7 +51,6 @@ import type { SlideoutProps } from '../../atoms/Slideout' import type { UseCreateRun } from '../../organisms/ChooseRobotToRunProtocolSlideout/useCreateRunFromProtocol' import type { State, Dispatch } from '../../redux/types' import type { Robot } from '../../redux/discovery/types' -import { useFeatureFlag } from '../../redux/config' import type { DropdownOption } from '../../atoms/MenuList/DropdownMenu' export const CARD_OUTLINE_BORDER_STYLE = css` @@ -112,7 +111,9 @@ interface ChooseRobotSlideoutProps isAnalysisError?: boolean isAnalysisStale?: boolean showIdleOnly?: boolean - multiSlideout?: { currentPage: number } + multiSlideout?: { currentPage: number } | null + setHasParamError?: (isError: boolean) => void + resetRunTimeParameters?: () => void } export function ChooseRobotSlideout( @@ -135,12 +136,13 @@ export function ChooseRobotSlideout( setSelectedRobot, robotType, showIdleOnly = false, - multiSlideout, + multiSlideout = null, runTimeParametersOverrides, setRunTimeParametersOverrides, + setHasParamError, + resetRunTimeParameters, } = props - const enableRunTimeParametersFF = useFeatureFlag('enableRunTimeParameters') const dispatch = useDispatch() const isScanning = useSelector((state: State) => getScanning(state)) const [targetProps, tooltipProps] = useHoverTooltip() @@ -184,18 +186,27 @@ export function ChooseRobotSlideout( {} ) + const reducerAvailableRobots = healthyReachableRobots.filter(robot => + showIdleOnly ? !robotBusyStatusByName[robot.name] : robot + ) const reducerBusyCount = healthyReachableRobots.filter( robot => robotBusyStatusByName[robot.name] ).length // this useEffect sets the default selection to the first robot in the list. state is managed by the caller React.useEffect(() => { - if (selectedRobot == null && healthyReachableRobots.length > 0) { - setSelectedRobot(healthyReachableRobots[0]) - } else if (healthyReachableRobots.length === 0) { + if ( + (selectedRobot == null || + !reducerAvailableRobots.some( + robot => robot.name === selectedRobot.name + )) && + reducerAvailableRobots.length > 0 + ) { + setSelectedRobot(reducerAvailableRobots[0]) + } else if (reducerAvailableRobots.length === 0) { setSelectedRobot(null) } - }, [healthyReachableRobots, selectedRobot, setSelectedRobot]) + }, [reducerAvailableRobots, selectedRobot, setSelectedRobot]) const unavailableCount = unhealthyReachableRobots.length + unreachableRobots.length @@ -330,6 +341,7 @@ export function ChooseRobotSlideout( ) + const errors: string[] = [] const runTimeParameters = runTimeParametersOverrides?.map((runtimeParam, index) => { if ('choices' in runtimeParam) { @@ -362,25 +374,50 @@ export function ChooseRobotSlideout( } }} title={runtimeParam.displayName} - caption={runtimeParam.description} width="100%" dropdownType="neutral" + tooltipText={runtimeParam.description} /> ) } else if (runtimeParam.type === 'int' || runtimeParam.type === 'float') { const value = runtimeParam.value as number const id = `InputField_${runtimeParam.variableName}_${index.toString()}` + const error = + Number.isNaN(value) || + value < runtimeParam.min || + value > runtimeParam.max + ? t(`value_out_of_range`, { + min: + runtimeParam.type === 'int' + ? runtimeParam.min + : runtimeParam.min.toFixed(1), + max: + runtimeParam.type === 'int' + ? runtimeParam.max + : runtimeParam.max.toFixed(1), + }) + : null + if (error != null) { + errors.push(error) + } return ( { const clone = runTimeParametersOverrides.map((parameter, i) => { if (i === index) { @@ -400,7 +437,7 @@ export function ChooseRobotSlideout( }} /> ) - } else if (runtimeParam.type === 'boolean') { + } else if (runtimeParam.type === 'bool') { return ( 0) + } + const isRestoreDefaultsLinkEnabled = runTimeParametersOverrides?.some( parameter => parameter.value !== parameter.default @@ -468,15 +509,7 @@ export function ChooseRobotSlideout( ? ENABLED_LINK_CSS : DISABLED_LINK_CSS } - onClick={() => { - const clone = runTimeParametersOverrides.map(parameter => ({ - ...parameter, - value: parameter.default, - })) - if (setRunTimeParametersOverrides != null) { - setRunTimeParametersOverrides(clone) - } - }} + onClick={() => resetRunTimeParameters?.()} paddingBottom={SPACING.spacing10} {...targetProps} > @@ -494,7 +527,7 @@ export function ChooseRobotSlideout( ) : null - return multiSlideout != null && enableRunTimeParametersFF ? ( + return multiSlideout != null ? ( { .calledWith( expect.any(Object), { hostname: expect.any(String) }, - expect.any(Array) + expect.any(Array), + expect.any(Object) ) .thenReturn({ createRunFromProtocolSource: mockCreateRunFromProtocolSource, reset: mockResetCreateRun, } as any) when(vi.mocked(useCreateRunFromProtocol)) - .calledWith(expect.any(Object), null, expect.any(Array)) + .calledWith( + expect.any(Object), + null, + expect.any(Array), + expect.any(Object) + ) .thenReturn({ createRunFromProtocolSource: mockCreateRunFromProtocolSource, reset: mockResetCreateRun, @@ -315,7 +321,8 @@ describe('ChooseRobotToRunProtocolSlideout', () => { location: mockOffsetCandidate.location, definitionUri: mockOffsetCandidate.definitionUri, }, - ] + ], + {} ) expect(screen.getByRole('checkbox')).toBeChecked() const proceedButton = screen.getByRole('button', { @@ -373,13 +380,41 @@ describe('ChooseRobotToRunProtocolSlideout', () => { location: mockOffsetCandidate.location, definitionUri: mockOffsetCandidate.definitionUri, }, - ] + ], + {} ) - expect(vi.mocked(useCreateRunFromProtocol)).nthCalledWith( - 3, + expect(vi.mocked(useCreateRunFromProtocol)).toHaveBeenLastCalledWith( expect.any(Object), { hostname: 'otherIp' }, - [] + [], + {} + ) + }) + + it('disables proceed button if no available robots', () => { + vi.mocked(getConnectableRobots).mockReturnValue([]) + render({ + storedProtocolData: storedProtocolDataFixture, + onCloseClick: vi.fn(), + showSlideout: true, + }) + const proceedButton = screen.getByRole('button', { + name: 'Continue to parameters', + }) + expect(proceedButton).toBeDisabled() + }) + + it('renders labware offset data selection and learn more button launches help modal', () => { + render({ + storedProtocolData: storedProtocolDataFixture, + onCloseClick: vi.fn(), + showSlideout: true, + }) + screen.getByText('No offset data available') + const learnMoreLink = screen.getByText('Learn more') + fireEvent.click(learnMoreLink) + screen.getByText( + 'Labware offset data references previous protocol run labware locations to save you time. If all the labware in this protocol have been checked in previous runs, that data will be applied to this run.' ) }) }) diff --git a/app/src/organisms/ChooseRobotToRunProtocolSlideout/index.tsx b/app/src/organisms/ChooseRobotToRunProtocolSlideout/index.tsx index 56c1d9dd06e..5dd3278bdfe 100644 --- a/app/src/organisms/ChooseRobotToRunProtocolSlideout/index.tsx +++ b/app/src/organisms/ChooseRobotToRunProtocolSlideout/index.tsx @@ -17,7 +17,6 @@ import { import { getRobotUpdateDisplayInfo } from '../../redux/robot-update' import { OPENTRONS_USB } from '../../redux/discovery' import { appShellRequestor } from '../../redux/shell/remote' -import { useFeatureFlag } from '../../redux/config' import { useTrackCreateProtocolRunEvent } from '../Devices/hooks' import { ApplyHistoricOffsets } from '../ApplyHistoricOffsets' import { useOffsetCandidatesForAnalysis } from '../ApplyHistoricOffsets/hooks/useOffsetCandidatesForAnalysis' @@ -36,7 +35,6 @@ interface ChooseRobotToRunProtocolSlideoutProps extends StyleProps { storedProtocolData: StoredProtocolData onCloseClick: () => void showSlideout: boolean - runTimeParameters?: RunTimeParameter[] } export function ChooseRobotToRunProtocolSlideoutComponent( @@ -54,73 +52,19 @@ export function ChooseRobotToRunProtocolSlideoutComponent( srcFiles, mostRecentAnalysis, } = storedProtocolData - const enableRunTimeParametersFF = useFeatureFlag('enableRunTimeParameters') const [currentPage, setCurrentPage] = React.useState(1) const [selectedRobot, setSelectedRobot] = React.useState(null) const { trackCreateProtocolRunEvent } = useTrackCreateProtocolRunEvent( storedProtocolData, selectedRobot?.name ?? '' ) - - // TODO: (nd: 3/20/24) remove stubs and pull parameters from analysis - const mockRunTimeParameters: RunTimeParameter[] = [ - { - displayName: 'Dry Run', - value: false, - variableName: 'DRYRUN', - description: 'Is this a dry or wet run? Wet is true, dry is false', - type: 'boolean', - default: false, - }, - { - value: 4, - displayName: 'Columns of Samples', - variableName: 'COLUMNS', - description: 'How many columns do you want?', - type: 'int', - min: 1, - max: 14, - default: 4, - }, - { - value: 6.5, - displayName: 'EtoH Volume', - variableName: 'ETOH_VOLUME', - description: '70% ethanol volume', - type: 'float', - suffix: 'mL', - min: 1.5, - max: 10.0, - default: 6.5, - }, - { - value: 'none', - displayName: 'Default Module Offsets', - variableName: 'DEFAULT_OFFSETS', - description: 'default module offsets for temp, H-S, and none', - type: 'str', - choices: [ - { - displayName: 'No offsets', - value: 'none', - }, - { - displayName: 'temp offset', - value: '1', - }, - { - displayName: 'heater-shaker offset', - value: '2', - }, - ], - default: 'none', - }, - ] - const runTimeParameters: RunTimeParameter[] = mockRunTimeParameters + const runTimeParameters = + storedProtocolData.mostRecentAnalysis?.runTimeParameters ?? [] const [ runTimeParametersOverrides, setRunTimeParametersOverrides, ] = React.useState(runTimeParameters) + const [hasParamError, setHasParamError] = React.useState(false) const offsetCandidates = useOffsetCandidatesForAnalysis( mostRecentAnalysis, @@ -165,7 +109,14 @@ export function ChooseRobotToRunProtocolSlideoutComponent( location, definitionUri, })) - : [] + : [], + runTimeParametersOverrides.reduce( + (acc, param) => + param.value !== param.default + ? { ...acc, [param.variableName]: param.value } + : acc, + {} + ) ) const handleProceed: React.MouseEventHandler = () => { trackCreateProtocolRunEvent({ name: 'createProtocolRecordRequest' }) @@ -181,6 +132,8 @@ export function ChooseRobotToRunProtocolSlideoutComponent( 'downgrade', ].includes(autoUpdateAction) + const hasRunTimeParameters = runTimeParameters.length > 0 + if ( protocolKey == null || srcFileNames == null || @@ -205,7 +158,7 @@ export function ChooseRobotToRunProtocolSlideoutComponent( ? mostRecentAnalysis?.robotType ?? null : null - const SinglePageButtonWithoutFF = ( + const singlePageButton = ( ) + const offsetsComponent = ( + + ) + + const resetRunTimeParameters = (): void => { + setRunTimeParametersOverrides( + runTimeParametersOverrides?.map(parameter => ({ + ...parameter, + value: parameter.default, + })) + ) + } + return ( { + onCloseClick() + resetRunTimeParameters() + setCurrentPage(1) + setSelectedRobot(null) + }} title={ - enableRunTimeParametersFF && - runTimeParameters.length > 0 && - currentPage === 2 + hasRunTimeParameters && currentPage === 2 ? t('select_parameters_for_robot', { robot_name: selectedRobot?.name, }) @@ -246,17 +222,10 @@ export function ChooseRobotToRunProtocolSlideoutComponent( setRunTimeParametersOverrides={setRunTimeParametersOverrides} footer={ - {enableRunTimeParametersFF && runTimeParameters.length > 0 ? ( + {hasRunTimeParameters ? ( currentPage === 1 ? ( <> - + {offsetsComponent} setCurrentPage(2)} width="100%" @@ -274,7 +243,11 @@ export function ChooseRobotToRunProtocolSlideoutComponent( setCurrentPage(1)} width="50%"> {t('shared:change_robot')} - + {isCreatingRun ? ( ) : ( @@ -284,7 +257,10 @@ export function ChooseRobotToRunProtocolSlideoutComponent( ) ) : ( - SinglePageButtonWithoutFF + <> + {offsetsComponent} + {singlePageButton} + )} } @@ -295,7 +271,9 @@ export function ChooseRobotToRunProtocolSlideoutComponent( reset={resetCreateRun} runCreationError={runCreationError} runCreationErrorCode={runCreationErrorCode} - showIdleOnly={true} + showIdleOnly + setHasParamError={setHasParamError} + resetRunTimeParameters={resetRunTimeParameters} /> ) } diff --git a/app/src/organisms/ChooseRobotToRunProtocolSlideout/useCreateRunFromProtocol.ts b/app/src/organisms/ChooseRobotToRunProtocolSlideout/useCreateRunFromProtocol.ts index f44f92cb8c6..c649d2eb885 100644 --- a/app/src/organisms/ChooseRobotToRunProtocolSlideout/useCreateRunFromProtocol.ts +++ b/app/src/organisms/ChooseRobotToRunProtocolSlideout/useCreateRunFromProtocol.ts @@ -14,6 +14,7 @@ import type { HostConfig, LabwareOffsetCreateData, Protocol, + RunTimeParameterCreateData, } from '@opentrons/api-client' import type { UseCreateRunMutationOptions } from '@opentrons/react-api-client/src/runs/useCreateRunMutation' import type { CreateProtocolVariables } from '@opentrons/react-api-client/src/protocols/useCreateProtocolMutation' @@ -35,7 +36,8 @@ export interface UseCreateRun { export function useCreateRunFromProtocol( options: UseCreateRunMutationOptions, hostOverride?: HostConfig | null, - labwareOffsets?: LabwareOffsetCreateData[] + labwareOffsets?: LabwareOffsetCreateData[], + runTimeParameterValues?: RunTimeParameterCreateData ): UseCreateRun { const contextHost = useHost() const host = @@ -74,10 +76,15 @@ export function useCreateRunFromProtocol( } = useCreateProtocolMutation( { onSuccess: data => { - createRun({ protocolId: data.data.id, labwareOffsets }) + createRun({ + protocolId: data.data.id, + labwareOffsets, + runTimeParameterValues, + }) }, }, - host + host, + runTimeParameterValues ) let error = @@ -101,7 +108,11 @@ export function useCreateRunFromProtocol( ) => { resetRunMutation() createProtocolRun( - { files: [...srcFiles, ...customLabwareFiles], protocolKey }, + { + files: [...srcFiles, ...customLabwareFiles], + protocolKey, + runTimeParameterValues, + }, ...args ) }, diff --git a/app/src/organisms/CommandText/index.tsx b/app/src/organisms/CommandText/index.tsx index 06eae754759..47c54140149 100644 --- a/app/src/organisms/CommandText/index.tsx +++ b/app/src/organisms/CommandText/index.tsx @@ -190,11 +190,15 @@ export function CommandText(props: Props): JSX.Element | null { robotType ) : '' - return t('move_to_well', { - well_name: wellName, - labware: getLabwareName(robotSideAnalysis, labwareId), - labware_location: displayLocation, - }) + return ( + + {t('move_to_well', { + well_name: wellName, + labware: getLabwareName(robotSideAnalysis, labwareId), + labware_location: displayLocation, + })} + + ) } case 'moveLabware': { return ( diff --git a/app/src/organisms/ConfigurePipette/ConfigFormResetButton.tsx b/app/src/organisms/ConfigurePipette/ConfigFormResetButton.tsx index d97524f1e59..32ac241955d 100644 --- a/app/src/organisms/ConfigurePipette/ConfigFormResetButton.tsx +++ b/app/src/organisms/ConfigurePipette/ConfigFormResetButton.tsx @@ -17,13 +17,13 @@ export interface ButtonProps { export function ConfigFormResetButton(props: ButtonProps): JSX.Element { const { onClick, disabled } = props - const { t } = useTranslation(['shared', 'device_details']) + const { t } = useTranslation(['shared', 'branded']) return ( interface AddFixtureModalProps { cutoutId: CutoutId @@ -52,6 +68,12 @@ interface AddFixtureModalProps { providedFixtureOptions?: CutoutFixtureId[] isOnDevice?: boolean } +type OptionStage = + | 'modulesOrFixtures' + | 'fixtureOptions' + | 'moduleOptions' + | 'wasteChuteOptions' + | 'providedOptions' export function AddFixtureModal({ cutoutId, @@ -62,9 +84,26 @@ export function AddFixtureModal({ }: AddFixtureModalProps): JSX.Element { const { t } = useTranslation(['device_details', 'shared']) const { updateDeckConfiguration } = useUpdateDeckConfigurationMutation() + const { data: modulesData } = useModulesQuery() const deckConfig = useDeckConfigurationQuery()?.data ?? [] - const [showWasteChuteOptions, setShowWasteChuteOptions] = React.useState( - false + const unconfiguredMods = + modulesData?.data.filter( + attachedMod => + !deckConfig.some( + ({ opentronsModuleSerialNumber }) => + attachedMod.serialNumber === opentronsModuleSerialNumber + ) + ) ?? [] + + let initialStage: OptionStage = SINGLE_CENTER_CUTOUTS.includes(cutoutId) // only mag block (a module) can be configured in column 2 + ? 'moduleOptions' + : 'modulesOrFixtures' + if (providedFixtureOptions != null) { + // only show provided options if given as props + initialStage = 'providedOptions' + } + const [optionStage, setOptionStage] = React.useState( + initialStage ) const modalHeader: ModalHeaderBaseProps = { @@ -72,75 +111,232 @@ export function AddFixtureModal({ slotName: getCutoutDisplayName(cutoutId), }), hasExitIcon: providedFixtureOptions == null, - onClick: () => setShowAddFixtureModal(false), + onClick: () => { + setShowAddFixtureModal(false) + }, } const modalProps: LegacyModalProps = { title: t('add_to_slot', { slotName: getCutoutDisplayName(cutoutId), }), - onClose: () => setShowAddFixtureModal(false), + onClose: () => { + setShowAddFixtureModal(false) + }, closeOnOutsideClick: true, childrenPadding: SPACING.spacing24, width: '26.75rem', } - const availableFixtures: CutoutFixtureId[] = [TRASH_BIN_ADAPTER_FIXTURE] - if (STAGING_AREA_CUTOUTS.includes(cutoutId)) { - availableFixtures.push(STAGING_AREA_RIGHT_SLOT_FIXTURE) + let availableOptions: CutoutConfig[][] = [] + + if (providedFixtureOptions != null) { + availableOptions = providedFixtureOptions?.map(o => [ + { + cutoutId, + cutoutFixtureId: o, + opentronsModuleSerialNumber: undefined, + }, + ]) + } else if (optionStage === 'fixtureOptions') { + if ( + SINGLE_RIGHT_CUTOUTS.includes(cutoutId) || + SINGLE_LEFT_CUTOUTS.includes(cutoutId) + ) { + availableOptions = [ + ...availableOptions, + [ + { + cutoutId, + cutoutFixtureId: TRASH_BIN_ADAPTER_FIXTURE, + }, + ], + ] + } + if (STAGING_AREA_CUTOUTS.includes(cutoutId)) { + availableOptions = [ + ...availableOptions, + [ + { + cutoutId, + cutoutFixtureId: STAGING_AREA_RIGHT_SLOT_FIXTURE, + }, + ], + ] + } + } else if (optionStage === 'moduleOptions') { + availableOptions = [ + ...availableOptions, + [ + { + cutoutId, + cutoutFixtureId: MAGNETIC_BLOCK_V1_FIXTURE, + }, + ], + ] + if (SINGLE_RIGHT_CUTOUTS.includes(cutoutId)) { + availableOptions = [ + ...availableOptions, + [ + { + cutoutId, + cutoutFixtureId: STAGING_AREA_SLOT_WITH_MAGNETIC_BLOCK_V1_FIXTURE, + }, + ], + ] + } + if (unconfiguredMods.length > 0) { + if (THERMOCYCLER_MODULE_CUTOUTS.includes(cutoutId)) { + const unconfiguredTCs = unconfiguredMods + .filter(mod => mod.moduleModel === THERMOCYCLER_MODULE_V2) + .map(mod => [ + { + cutoutId: THERMOCYCLER_MODULE_CUTOUTS[0], + cutoutFixtureId: THERMOCYCLER_V2_REAR_FIXTURE, + opentronsModuleSerialNumber: mod.serialNumber, + }, + { + cutoutId: THERMOCYCLER_MODULE_CUTOUTS[1], + cutoutFixtureId: THERMOCYCLER_V2_FRONT_FIXTURE, + opentronsModuleSerialNumber: mod.serialNumber, + }, + ]) + availableOptions = [...availableOptions, ...unconfiguredTCs] + } + if ( + HEATER_SHAKER_CUTOUTS.includes(cutoutId) && + unconfiguredMods.some(m => m.moduleModel === HEATERSHAKER_MODULE_V1) + ) { + const unconfiguredHeaterShakers = unconfiguredMods + .filter(mod => mod.moduleModel === HEATERSHAKER_MODULE_V1) + .map(mod => [ + { + cutoutId, + cutoutFixtureId: HEATERSHAKER_MODULE_V1_FIXTURE, + opentronsModuleSerialNumber: mod.serialNumber, + }, + ]) + availableOptions = [...availableOptions, ...unconfiguredHeaterShakers] + } + if ( + TEMPERATURE_MODULE_CUTOUTS.includes(cutoutId) && + unconfiguredMods.some(m => m.moduleModel === TEMPERATURE_MODULE_V2) + ) { + const unconfiguredTemperatureModules = unconfiguredMods + .filter(mod => mod.moduleModel === TEMPERATURE_MODULE_V2) + .map(mod => [ + { + cutoutId, + cutoutFixtureId: TEMPERATURE_MODULE_V2_FIXTURE, + opentronsModuleSerialNumber: mod.serialNumber, + }, + ]) + availableOptions = [ + ...availableOptions, + ...unconfiguredTemperatureModules, + ] + } + } + } else if (optionStage === 'wasteChuteOptions') { + availableOptions = WASTE_CHUTE_FIXTURES.map(fixture => [ + { + cutoutId, + cutoutFixtureId: fixture, + }, + ]) + } + + let nextStageOptions = null + if (optionStage === 'modulesOrFixtures') { + nextStageOptions = ( + <> + {SINGLE_CENTER_CUTOUTS.includes(cutoutId) ? null : ( + { + setOptionStage('fixtureOptions') + }} + isOnDevice={isOnDevice} + /> + )} + { + setOptionStage('moduleOptions') + }} + isOnDevice={isOnDevice} + /> + + ) + } else if ( + optionStage === 'fixtureOptions' && + cutoutId === WASTE_CHUTE_CUTOUT + ) { + nextStageOptions = ( + <> + { + setOptionStage('wasteChuteOptions') + }} + isOnDevice={isOnDevice} + /> + + ) } - const handleAddODD = (requiredFixtureId: CutoutFixtureId): void => { + const handleAddODD = (addedCutoutConfigs: CutoutConfig[]): void => { if (setCurrentDeckConfig != null) setCurrentDeckConfig( (prevDeckConfig: DeckConfiguration): DeckConfiguration => - prevDeckConfig.map((fixture: CutoutConfig) => - fixture.cutoutId === cutoutId - ? { ...fixture, cutoutFixtureId: requiredFixtureId } - : fixture - ) + prevDeckConfig.map((fixture: CutoutConfig) => { + const replacementCutoutConfig = addedCutoutConfigs.find( + c => c.cutoutId === fixture.cutoutId + ) + return replacementCutoutConfig ?? fixture + }) ) setShowAddFixtureModal(false) } - const fixtureOptions = providedFixtureOptions ?? availableFixtures - const fixtureOptionsWithDisplayNames: Array< - [CutoutFixtureId | 'WASTE_CHUTE', string] - > = fixtureOptions.map(fixture => [fixture, getFixtureDisplayName(fixture)]) - - const showSelectWasteChuteOptions = - cutoutId === WASTE_CHUTE_CUTOUT && providedFixtureOptions == null - - const fixtureOptionsWithDisplayNamesAndGenericWasteChute = fixtureOptionsWithDisplayNames.concat( - showSelectWasteChuteOptions - ? [[GENERIC_WASTE_CHUTE_OPTION, t('waste_chute')]] - : [] - ) - - fixtureOptionsWithDisplayNamesAndGenericWasteChute.sort((a, b) => - a[1].localeCompare(b[1]) - ) - - const wasteChuteOptionsWithDisplayNames = WASTE_CHUTE_FIXTURES.map( - fixture => [fixture, getFixtureDisplayName(fixture)] - ).sort((a, b) => a[1].localeCompare(b[1])) as Array<[CutoutFixtureId, string]> - - const displayedFixtureOptions = showWasteChuteOptions - ? wasteChuteOptionsWithDisplayNames - : fixtureOptionsWithDisplayNamesAndGenericWasteChute - - const handleAddDesktop = (requiredFixtureId: CutoutFixtureId): void => { - const newDeckConfig = deckConfig.map(fixture => - fixture.cutoutId === cutoutId - ? { ...fixture, cutoutFixtureId: requiredFixtureId } - : fixture - ) + const handleAddDesktop = (addedCutoutConfigs: CutoutConfig[]): void => { + const newDeckConfig = deckConfig.map(fixture => { + const replacementCutoutConfig = addedCutoutConfigs.find( + c => c.cutoutId === fixture.cutoutId + ) + return replacementCutoutConfig ?? fixture + }) updateDeckConfiguration(newDeckConfig) setShowAddFixtureModal(false) } + const fixtureOptions = availableOptions.map(cutoutConfigs => ( + m.serialNumber === cutoutConfigs[0].opentronsModuleSerialNumber + )?.usbPort.port + )} + buttonText={t('add')} + onClickHandler={() => { + isOnDevice + ? handleAddODD(cutoutConfigs) + : handleAddDesktop(cutoutConfigs) + }} + isOnDevice={isOnDevice} + /> + )) + return ( <> {isOnDevice ? ( @@ -155,40 +351,8 @@ export function AddFixtureModal({ {t('add_to_slot_description')} - {displayedFixtureOptions.map( - ([cutoutFixtureOption, fixtureDisplayName]) => { - const onClickHandler = - cutoutFixtureOption === GENERIC_WASTE_CHUTE_OPTION - ? () => setShowWasteChuteOptions(true) - : () => handleAddODD(cutoutFixtureOption) - const buttonText = - cutoutFixtureOption === GENERIC_WASTE_CHUTE_OPTION - ? t('select_options') - : t('add') - - return ( - - - - {fixtureDisplayName} - - {buttonText} - - - ) - } - )} + {fixtureOptions} + {nextStageOptions} @@ -197,43 +361,15 @@ export function AddFixtureModal({ {t('add_fixture_description')} - {displayedFixtureOptions.map( - ([cutoutFixtureOption, fixtureDisplayName]) => { - const onClickHandler = - cutoutFixtureOption === GENERIC_WASTE_CHUTE_OPTION - ? () => setShowWasteChuteOptions(true) - : () => handleAddDesktop(cutoutFixtureOption) - const buttonText = - cutoutFixtureOption === GENERIC_WASTE_CHUTE_OPTION - ? t('select_options') - : t('add') - - return ( - - - - {fixtureDisplayName} - - - {buttonText} - - - - ) - } - )} + {fixtureOptions} + {nextStageOptions} - {showWasteChuteOptions ? ( + {optionStage === 'wasteChuteOptions' ? ( setShowWasteChuteOptions(false)} + onClick={() => { + setOptionStage('fixtureOptions') + }} aria-label="back" paddingX={SPACING.spacing16} marginTop={'1.44rem'} @@ -289,3 +425,41 @@ const GO_BACK_BUTTON_STYLE = css` opacity: 70%; } ` + +interface FixtureOptionProps { + onClickHandler: React.MouseEventHandler + optionName: string + buttonText: string + isOnDevice: boolean +} +export function FixtureOption(props: FixtureOptionProps): JSX.Element { + const { onClickHandler, optionName, buttonText, isOnDevice } = props + return isOnDevice ? ( + + + {props.optionName} + + {props.buttonText} + + ) : ( + + {optionName} + {buttonText} + + ) +} diff --git a/app/src/organisms/DeviceDetailsDeckConfiguration/DeckConfigurationDiscardChangesModal.stories.tsx b/app/src/organisms/DeviceDetailsDeckConfiguration/DeckConfigurationDiscardChangesModal.stories.tsx index d6b26521619..0fdee52a94e 100644 --- a/app/src/organisms/DeviceDetailsDeckConfiguration/DeckConfigurationDiscardChangesModal.stories.tsx +++ b/app/src/organisms/DeviceDetailsDeckConfiguration/DeckConfigurationDiscardChangesModal.stories.tsx @@ -1,5 +1,5 @@ import * as React from 'react' -import { touchScreenViewport } from '../../DesignTokens/constants' +import { VIEWPORT } from '@opentrons/components' import { DeckConfigurationDiscardChangesModal } from './DeckConfigurationDiscardChangesModal' import type { Story, Meta } from '@storybook/react' @@ -12,7 +12,7 @@ export default { }, onOutsideClick: { action: 'clicked' }, }, - parameters: touchScreenViewport, + parameters: VIEWPORT.touchScreenViewport, } as Meta const Template: Story< diff --git a/app/src/organisms/DeviceDetailsDeckConfiguration/DeckFixtureSetupInstructionsModal.tsx b/app/src/organisms/DeviceDetailsDeckConfiguration/DeckFixtureSetupInstructionsModal.tsx index 12d0f1d3967..9d622a4d4d5 100644 --- a/app/src/organisms/DeviceDetailsDeckConfiguration/DeckFixtureSetupInstructionsModal.tsx +++ b/app/src/organisms/DeviceDetailsDeckConfiguration/DeckFixtureSetupInstructionsModal.tsx @@ -32,7 +32,7 @@ export function DeckFixtureSetupInstructionsModal({ setShowSetupInstructionsModal, isOnDevice = false, }: DeckFixtureSetupInstructionsModalProps): JSX.Element { - const { i18n, t } = useTranslation(['device_details', 'shared']) + const { i18n, t } = useTranslation(['device_details', 'shared', 'branded']) const modalHeader: ModalHeaderBaseProps = { title: t('deck_fixture_setup_instructions'), iconName: 'information', @@ -62,7 +62,7 @@ export function DeckFixtureSetupInstructionsModal({ {t('deck_fixture_setup_modal_top_description')} - {t('deck_fixture_setup_modal_bottom_description')} + {t('branded:deck_fixture_setup_modal_bottom_description')} diff --git a/app/src/organisms/DeviceDetailsDeckConfiguration/TouchScreenDeckFixtureSetupInstructionModal.stories.tsx b/app/src/organisms/DeviceDetailsDeckConfiguration/TouchScreenDeckFixtureSetupInstructionModal.stories.tsx index 5fcc8d339a9..ec078d74eea 100644 --- a/app/src/organisms/DeviceDetailsDeckConfiguration/TouchScreenDeckFixtureSetupInstructionModal.stories.tsx +++ b/app/src/organisms/DeviceDetailsDeckConfiguration/TouchScreenDeckFixtureSetupInstructionModal.stories.tsx @@ -1,5 +1,5 @@ import * as React from 'react' -import { touchScreenViewport } from '../../DesignTokens/constants' +import { VIEWPORT } from '@opentrons/components' import { DeckFixtureSetupInstructionsModal } from './DeckFixtureSetupInstructionsModal' import type { Story, Meta } from '@storybook/react' @@ -12,7 +12,7 @@ export default { }, onOutsideClick: { action: 'clicked' }, }, - parameters: touchScreenViewport, + parameters: VIEWPORT.touchScreenViewport, } as Meta const Template: Story< diff --git a/app/src/organisms/DeviceDetailsDeckConfiguration/__tests__/AddFixtureModal.test.tsx b/app/src/organisms/DeviceDetailsDeckConfiguration/__tests__/AddFixtureModal.test.tsx index 846c060dc27..74d150d92dc 100644 --- a/app/src/organisms/DeviceDetailsDeckConfiguration/__tests__/AddFixtureModal.test.tsx +++ b/app/src/organisms/DeviceDetailsDeckConfiguration/__tests__/AddFixtureModal.test.tsx @@ -4,6 +4,7 @@ import { describe, it, beforeEach, vi, expect, afterEach } from 'vitest' import { useDeckConfigurationQuery, + useModulesQuery, useUpdateDeckConfigurationMutation, } from '@opentrons/react-api-client' import { @@ -17,6 +18,7 @@ import { AddFixtureModal } from '../AddFixtureModal' import type { UseQueryResult } from 'react-query' import type { DeckConfiguration } from '@opentrons/shared-data' +import type { Modules } from '@opentrons/api-client' vi.mock('@opentrons/react-api-client') const mockSetShowAddFixtureModal = vi.fn() @@ -45,23 +47,25 @@ describe('Touchscreen AddFixtureModal', () => { vi.mocked(useDeckConfigurationQuery).mockReturnValue(({ data: [], } as unknown) as UseQueryResult) + vi.mocked(useModulesQuery).mockReturnValue(({ + data: { data: [] }, + } as unknown) as UseQueryResult) }) it('should render text and buttons', () => { render(props) screen.getByText('Add to slot D3') screen.getByText( - 'Choose a fixture below to add to your deck configuration. It will be referenced during protocol analysis.' + 'Choose an item below to add to your deck configuration. It will be referenced during protocol analysis.' ) - screen.getByText('Staging area slot') - screen.getByText('Trash bin') - screen.getByText('Waste chute') - expect(screen.getAllByText('Add').length).toBe(2) - expect(screen.getAllByText('Select options').length).toBe(1) + screen.getByText('Fixtures') + screen.getByText('Modules') + expect(screen.getAllByText('Select options').length).toBe(2) }) - it('should a mock function when tapping app button', () => { + it('should set deck config when tapping add button', () => { render(props) + fireEvent.click(screen.getAllByText('Select options')[1]) fireEvent.click(screen.getAllByText('Add')[0]) expect(mockSetCurrentDeckConfig).toHaveBeenCalled() }) @@ -74,7 +78,7 @@ describe('Touchscreen AddFixtureModal', () => { render(props) screen.getByText('Add to slot D3') screen.getByText( - 'Choose a fixture below to add to your deck configuration. It will be referenced during protocol analysis.' + 'Choose an item below to add to your deck configuration. It will be referenced during protocol analysis.' ) expect(screen.queryByText('Staging area slot')).toBeNull() screen.getByText('Trash bin') @@ -105,8 +109,12 @@ describe('Desktop AddFixtureModal', () => { render(props) screen.getByText('Add to slot D3') screen.getByText( - 'Add this fixture to your deck configuration. It will be referenced during protocol analysis.' + 'Add this item to your deck configuration. It will be referenced during protocol analysis.' ) + + screen.getByText('Fixtures') + screen.getByText('Modules') + fireEvent.click(screen.getAllByText('Select options')[0]) screen.getByText('Staging area slot') screen.getByText('Trash bin') screen.getByText('Waste chute') @@ -121,8 +129,11 @@ describe('Desktop AddFixtureModal', () => { render(props) screen.getByText('Add to slot A1') screen.getByText( - 'Add this fixture to your deck configuration. It will be referenced during protocol analysis.' + 'Add this item to your deck configuration. It will be referenced during protocol analysis.' ) + screen.getByText('Fixtures') + screen.getByText('Modules') + fireEvent.click(screen.getAllByText('Select options')[0]) screen.getByText('Trash bin') screen.getByRole('button', { name: 'Add' }) }) @@ -132,23 +143,39 @@ describe('Desktop AddFixtureModal', () => { render(props) screen.getByText('Add to slot B3') screen.getByText( - 'Add this fixture to your deck configuration. It will be referenced during protocol analysis.' + 'Add this item to your deck configuration. It will be referenced during protocol analysis.' ) + screen.getByText('Fixtures') + screen.getByText('Modules') + fireEvent.click(screen.getAllByText('Select options')[0]) screen.getByText('Staging area slot') screen.getByText('Trash bin') expect(screen.getAllByRole('button', { name: 'Add' }).length).toBe(2) }) - it('should call a mock function when clicking add button', () => { + it('should only render module options in column 2', () => { + props = { ...props, cutoutId: 'cutoutB2' } + render(props) + screen.getByText('Add to slot B2') + screen.getByText( + 'Add this item to your deck configuration. It will be referenced during protocol analysis.' + ) + screen.getByText('Magnetic Block GEN1') + expect(screen.getByRole('button', { name: 'Add' })).toBeInTheDocument() + }) + + it('should call update deck config when add button is clicked', () => { props = { ...props, cutoutId: 'cutoutA1' } render(props) - fireEvent.click(screen.getByRole('button', { name: 'Add' })) + fireEvent.click(screen.getAllByText('Select options')[1]) + fireEvent.click(screen.getByText('Add')) expect(mockUpdateDeckConfiguration).toHaveBeenCalled() }) it('should display appropriate Waste Chute options when the generic Waste Chute button is clicked', () => { render(props) - fireEvent.click(screen.getByRole('button', { name: 'Select options' })) + fireEvent.click(screen.getAllByText('Select options')[0]) // click fixtures + fireEvent.click(screen.getByRole('button', { name: 'Select options' })) // click waste chute options expect(screen.getAllByRole('button', { name: 'Add' }).length).toBe( WASTE_CHUTE_FIXTURES.length ) @@ -161,6 +188,7 @@ describe('Desktop AddFixtureModal', () => { it('should allow a user to exit the Waste Chute submenu by clicking "go back"', () => { render(props) + fireEvent.click(screen.getAllByText('Select options')[0]) // click fixtures fireEvent.click(screen.getByRole('button', { name: 'Select options' })) fireEvent.click(screen.getByText('Go back')) diff --git a/app/src/organisms/DeviceDetailsDeckConfiguration/__tests__/DeviceDetailsDeckConfiguration.test.tsx b/app/src/organisms/DeviceDetailsDeckConfiguration/__tests__/DeviceDetailsDeckConfiguration.test.tsx index f3b008320af..5c8d3974dc8 100644 --- a/app/src/organisms/DeviceDetailsDeckConfiguration/__tests__/DeviceDetailsDeckConfiguration.test.tsx +++ b/app/src/organisms/DeviceDetailsDeckConfiguration/__tests__/DeviceDetailsDeckConfiguration.test.tsx @@ -6,6 +6,7 @@ import { describe, it, beforeEach, vi, afterEach } from 'vitest' import { DeckConfigurator } from '@opentrons/components' import { useDeckConfigurationQuery, + useModulesQuery, useUpdateDeckConfigurationMutation, } from '@opentrons/react-api-client' @@ -60,6 +61,7 @@ describe('DeviceDetailsDeckConfiguration', () => { props = { robotName: ROBOT_NAME, } + vi.mocked(useModulesQuery).mockReturnValue({ data: { data: [] } } as any) vi.mocked(useDeckConfigurationQuery).mockReturnValue({ data: [] } as any) vi.mocked(useUpdateDeckConfigurationMutation).mockReturnValue({ updateDeckConfiguration: mockUpdateDeckConfiguration, @@ -89,7 +91,7 @@ describe('DeviceDetailsDeckConfiguration', () => { screen.getByText('otie deck configuration') screen.getByRole('button', { name: 'Setup Instructions' }) screen.getByText('Location') - screen.getByText('Fixture') + screen.getByText('Deck hardware') screen.getByText('mock DeckConfigurator') }) diff --git a/app/src/organisms/DeviceDetailsDeckConfiguration/index.tsx b/app/src/organisms/DeviceDetailsDeckConfiguration/index.tsx index 9c1d852253a..0103fe25051 100644 --- a/app/src/organisms/DeviceDetailsDeckConfiguration/index.tsx +++ b/app/src/organisms/DeviceDetailsDeckConfiguration/index.tsx @@ -21,6 +21,7 @@ import { } from '@opentrons/components' import { useDeckConfigurationQuery, + useModulesQuery, useUpdateDeckConfigurationMutation, } from '@opentrons/react-api-client' import { @@ -30,6 +31,10 @@ import { SINGLE_SLOT_FIXTURES, SINGLE_LEFT_SLOT_FIXTURE, SINGLE_RIGHT_SLOT_FIXTURE, + SINGLE_CENTER_SLOT_FIXTURE, + SINGLE_LEFT_CUTOUTS, + getDeckDefFromRobotType, + FLEX_ROBOT_TYPE, } from '@opentrons/shared-data' import { useNotifyCurrentMaintenanceRun } from '../../resources/maintenance_runs' @@ -39,7 +44,7 @@ import { AddFixtureModal } from './AddFixtureModal' import { useIsRobotViewable, useRunStatuses } from '../Devices/hooks' import { useIsEstopNotDisengaged } from '../../resources/devices/hooks/useIsEstopNotDisengaged' -import type { CutoutId } from '@opentrons/shared-data' +import type { CutoutFixtureId, CutoutId } from '@opentrons/shared-data' const DECK_CONFIG_REFETCH_INTERVAL = 5000 const RUN_REFETCH_INTERVAL = 5000 @@ -48,10 +53,14 @@ interface DeviceDetailsDeckConfigurationProps { robotName: string } +function getDisplayLocationForCutoutIds(cutouts: CutoutId[]): string { + return cutouts.map(cutoutId => getCutoutDisplayName(cutoutId)).join(' + ') +} + export function DeviceDetailsDeckConfiguration({ robotName, }: DeviceDetailsDeckConfigurationProps): JSX.Element | null { - const { t } = useTranslation('device_details') + const { t, i18n } = useTranslation('device_details') const [ showSetupInstructionsModal, setShowSetupInstructionsModal, @@ -63,9 +72,11 @@ export function DeviceDetailsDeckConfiguration({ null ) + const { data: modulesData } = useModulesQuery() const deckConfig = useDeckConfigurationQuery({ refetchInterval: DECK_CONFIG_REFETCH_INTERVAL }) .data ?? [] + const deckDef = getDeckDefFromRobotType(FLEX_ROBOT_TYPE) const { updateDeckConfiguration } = useUpdateDeckConfigurationMutation() const { isRunRunning } = useRunStatuses() const { data: maintenanceRunData } = useNotifyCurrentMaintenanceRun({ @@ -80,26 +91,109 @@ export function DeviceDetailsDeckConfiguration({ setShowAddFixtureModal(true) } - const handleClickRemove = (cutoutId: CutoutId): void => { - const isRightCutout = SINGLE_RIGHT_CUTOUTS.includes(cutoutId) - const singleSlotFixture = isRightCutout - ? SINGLE_RIGHT_SLOT_FIXTURE - : SINGLE_LEFT_SLOT_FIXTURE + const handleClickRemove = ( + cutoutId: CutoutId, + cutoutFixtureId: CutoutFixtureId + ): void => { + let replacementFixtureId: CutoutFixtureId = SINGLE_CENTER_SLOT_FIXTURE + if (SINGLE_RIGHT_CUTOUTS.includes(cutoutId)) { + replacementFixtureId = SINGLE_RIGHT_SLOT_FIXTURE + } else if (SINGLE_LEFT_CUTOUTS.includes(cutoutId)) { + replacementFixtureId = SINGLE_LEFT_SLOT_FIXTURE + } - const newDeckConfig = deckConfig.map(fixture => - fixture.cutoutId === cutoutId - ? { ...fixture, cutoutFixtureId: singleSlotFixture } - : fixture - ) + const fixtureGroup = + deckDef.cutoutFixtures.find(cf => cf.id === cutoutFixtureId) + ?.fixtureGroup ?? {} + let newDeckConfig = deckConfig + if (cutoutId in fixtureGroup) { + const groupMap = + fixtureGroup[cutoutId]?.find(group => + Object.entries(group).every(([cId, cfId]) => + deckConfig.find( + config => + config.cutoutId === cId && config.cutoutFixtureId === cfId + ) + ) + ) ?? {} + newDeckConfig = deckConfig.map(cutoutConfig => + cutoutConfig.cutoutId in groupMap + ? { + ...cutoutConfig, + cutoutFixtureId: replacementFixtureId, + opentronsModuleSerialNumber: undefined, + } + : cutoutConfig + ) + } else { + newDeckConfig = deckConfig.map(cutoutConfig => + cutoutConfig.cutoutId === cutoutId + ? { + ...cutoutConfig, + cutoutFixtureId: replacementFixtureId, + opentronsModuleSerialNumber: undefined, + } + : cutoutConfig + ) + } updateDeckConfiguration(newDeckConfig) } // do not show standard slot in fixture display list - const fixtureDisplayList = deckConfig.filter( - fixture => - fixture.cutoutFixtureId != null && - !SINGLE_SLOT_FIXTURES.includes(fixture.cutoutFixtureId) + const { displayList: fixtureDisplayList } = deckConfig.reduce<{ + displayList: Array<{ displayLocation: string; displayName: string }> + groupedCutoutIds: CutoutId[] + }>( + (acc, { cutoutId, cutoutFixtureId, opentronsModuleSerialNumber }) => { + if ( + cutoutFixtureId == null || + SINGLE_SLOT_FIXTURES.includes(cutoutFixtureId) + ) { + return acc + } + const displayName = getFixtureDisplayName( + cutoutFixtureId, + modulesData?.data.find( + m => m.serialNumber === opentronsModuleSerialNumber + )?.usbPort.port + ) + const fixtureGroup = + deckDef.cutoutFixtures.find(cf => cf.id === cutoutFixtureId) + ?.fixtureGroup ?? {} + if (cutoutId in fixtureGroup) { + const groupMap = + fixtureGroup[cutoutId]?.find(group => + Object.entries(group).every(([cId, cfId]) => + deckConfig.find( + config => + config.cutoutId === cId && config.cutoutFixtureId === cfId + ) + ) + ) ?? {} + const groupedCutoutIds = Object.keys(groupMap) as CutoutId[] + const displayLocation = getDisplayLocationForCutoutIds(groupedCutoutIds) + if (acc.groupedCutoutIds.includes(cutoutId)) { + return acc // only list grouped fixtures once + } else { + return { + displayList: [...acc.displayList, { displayLocation, displayName }], + groupedCutoutIds: [...acc.groupedCutoutIds, ...groupedCutoutIds], + } + } + } + return { + ...acc, + displayList: [ + ...acc.displayList, + { + displayLocation: getDisplayLocationForCutoutIds([cutoutId]), + displayName, + }, + ], + } + }, + { displayList: [], groupedCutoutIds: [] } ) return ( @@ -132,11 +226,7 @@ export function DeviceDetailsDeckConfiguration({ width="100%" borderBottom={BORDERS.lineBorder} > - + {`${robotName} ${t('deck_configuration')}`} cutoutId) } deckConfig={deckConfig} handleClickAdd={handleClickAdd} @@ -197,30 +287,28 @@ export function DeviceDetailsDeckConfiguration({ width="32rem" > - {t('location')} - {t('fixture')} + {t('location')} + + {i18n.format(t('deck_hardware'), 'capitalize')} + {fixtureDisplayList.length > 0 ? ( - fixtureDisplayList.map(fixture => ( + fixtureDisplayList.map(({ displayLocation, displayName }) => ( - - {getCutoutDisplayName(fixture.cutoutId)} - - - {getFixtureDisplayName(fixture.cutoutFixtureId)} - + {displayLocation} + {displayName} )) ) : ( @@ -248,11 +336,7 @@ export function DeviceDetailsDeckConfiguration({ paddingBottom={SPACING.spacing24} width="100%" > - + {t('offline_deck_configuration')} diff --git a/app/src/organisms/Devices/ConnectionTroubleshootingModal.tsx b/app/src/organisms/Devices/ConnectionTroubleshootingModal.tsx index 560eedb235b..03cbde6898a 100644 --- a/app/src/organisms/Devices/ConnectionTroubleshootingModal.tsx +++ b/app/src/organisms/Devices/ConnectionTroubleshootingModal.tsx @@ -48,7 +48,7 @@ export function ConnectionTroubleshootingModal(props: Props): JSX.Element { steps={[t('restart_the_robot'), t('restart_the_app')]} /> - {t('contact_support_for_connection_help', { + {t('branded:contact_support_for_connection_help', { support_email: SUPPORT_EMAIL, })} diff --git a/app/src/organisms/Devices/HistoricalProtocolRunOverflowMenu.tsx b/app/src/organisms/Devices/HistoricalProtocolRunOverflowMenu.tsx index bf06e0db263..7f4bc54b6e1 100644 --- a/app/src/organisms/Devices/HistoricalProtocolRunOverflowMenu.tsx +++ b/app/src/organisms/Devices/HistoricalProtocolRunOverflowMenu.tsx @@ -32,7 +32,7 @@ import { ANALYTICS_PROTOCOL_RUN_AGAIN, } from '../../redux/analytics' import { getRobotUpdateDisplayInfo } from '../../redux/robot-update' -import { useDownloadRunLog, useTrackProtocolRunEvent } from './hooks' +import { useDownloadRunLog, useTrackProtocolRunEvent, useRobot } from './hooks' import { useIsEstopNotDisengaged } from '../../resources/devices/hooks/useIsEstopNotDisengaged' import type { Run } from '@opentrons/api-client' @@ -132,6 +132,9 @@ function MenuDropdown(props: MenuDropdownProps): JSX.Element { const { trackProtocolRunEvent } = useTrackProtocolRunEvent(runId, robotName) const { reset } = useRunControls(runId, onResetSuccess) const { deleteRun } = useDeleteRunMutation() + const robot = useRobot(robotName) + const robotSerialNumber = + robot?.health?.robot_serial ?? robot?.serverHealth?.serialNumber ?? null const handleResetClick: React.MouseEventHandler = ( e @@ -142,7 +145,10 @@ function MenuDropdown(props: MenuDropdownProps): JSX.Element { reset() trackEvent({ name: ANALYTICS_PROTOCOL_PROCEED_TO_RUN, - properties: { sourceLocation: 'HistoricalProtocolRun' }, + properties: { + sourceLocation: 'HistoricalProtocolRun', + robotSerialNumber, + }, }) trackProtocolRunEvent({ name: ANALYTICS_PROTOCOL_RUN_AGAIN }) } diff --git a/app/src/organisms/Devices/InstrumentsAndModules.tsx b/app/src/organisms/Devices/InstrumentsAndModules.tsx index 07b78af63cb..04068e8e21c 100644 --- a/app/src/organisms/Devices/InstrumentsAndModules.tsx +++ b/app/src/organisms/Devices/InstrumentsAndModules.tsx @@ -33,6 +33,7 @@ import { PipetteCard } from './PipetteCard' import { FlexPipetteCard } from './PipetteCard/FlexPipetteCard' import { GripperCard } from '../GripperCard' import { useIsEstopNotDisengaged } from '../../resources/devices/hooks/useIsEstopNotDisengaged' +import { useModuleApiRequests } from '../ModuleCard/utils' import type { BadGripper, @@ -62,6 +63,7 @@ export function InstrumentsAndModules({ const currentRunId = useCurrentRunId() const { isRunTerminal, isRunRunning } = useRunStatuses() const isEstopNotDisengaged = useIsEstopNotDisengaged(robotName) + const [getLatestRequestId, handleModuleApiRequests] = useModuleApiRequests() const { data: attachedInstruments } = useInstrumentsQuery({ refetchInterval: EQUIPMENT_POLL_MS, @@ -218,6 +220,8 @@ export function InstrumentsAndModules({ attachPipetteRequired={attachPipetteRequired} calibratePipetteRequired={calibratePipetteRequired} updatePipetteFWRequired={updatePipetteFWRequired} + latestRequestId={getLatestRequestId(module.serialNumber)} + handleModuleApiRequests={handleModuleApiRequests} /> ))} @@ -267,6 +271,8 @@ export function InstrumentsAndModules({ attachPipetteRequired={attachPipetteRequired} calibratePipetteRequired={calibratePipetteRequired} updatePipetteFWRequired={updatePipetteFWRequired} + latestRequestId={getLatestRequestId(module.serialNumber)} + handleModuleApiRequests={handleModuleApiRequests} /> ))} diff --git a/app/src/organisms/Devices/PipetteCard/FlexPipetteCard.tsx b/app/src/organisms/Devices/PipetteCard/FlexPipetteCard.tsx index 7dca3abc640..698a9fc2e86 100644 --- a/app/src/organisms/Devices/PipetteCard/FlexPipetteCard.tsx +++ b/app/src/organisms/Devices/PipetteCard/FlexPipetteCard.tsx @@ -183,7 +183,9 @@ export function FlexPipetteCard({ subsystemUpdateData == null ? ( | null @@ -174,7 +170,6 @@ export function ProtocolRunHeader({ const [pipettesWithTip, setPipettesWithTip] = React.useState< PipettesWithTip[] >([]) - const [closeTerminalBanner, setCloseTerminalBanner] = React.useState(false) const isResetRunLoadingRef = React.useRef(false) const { data: runRecord } = useNotifyRunQuery(runId, { staleTime: Infinity }) const highestPriorityError = @@ -200,7 +195,7 @@ export function ProtocolRunHeader({ const { data: doorStatus } = useDoorQuery({ refetchInterval: EQUIPMENT_POLL_MS, }) - let isDoorOpen = false + let isDoorOpen: boolean if (isFlex) { isDoorOpen = doorStatus?.data.status === 'open' } else if (!isFlex && Boolean(doorSafetySetting?.value)) { @@ -215,7 +210,11 @@ export function ProtocolRunHeader({ if (runStatus === RUN_STATUS_IDLE) { setShowDropTipBanner(true) setPipettesWithTip([]) - } else if (runStatus != null && RUN_OVER_STATUSES.includes(runStatus)) { + } else if ( + runStatus != null && + // @ts-expect-error runStatus expected to possibly not be terminal + RUN_STATUSES_TERMINAL.includes(runStatus) + ) { getPipettesWithTipAttached({ host, runId, @@ -248,7 +247,9 @@ export function ProtocolRunHeader({ } }, [protocolData, isRobotViewable, history]) + // Side effects dependent on the current run state. React.useEffect(() => { + // After a user-initiated stopped run, close the run current run automatically. if (runStatus === RUN_STATUS_STOPPED && isRunCurrent && runId != null) { trackProtocolRunEvent({ name: ANALYTICS_PROTOCOL_RUN_FINISH, @@ -260,12 +261,6 @@ export function ProtocolRunHeader({ } }, [runStatus, isRunCurrent, runId, closeCurrentRun]) - React.useEffect(() => { - if (runStatus === RUN_STATUS_IDLE) { - setCloseTerminalBanner(false) - } - }, [runStatus]) - const startedAtTimestamp = startedAt != null ? formatTimestamp(startedAt) : EMPTY_TIMESTAMP @@ -310,7 +305,6 @@ export function ProtocolRunHeader({ properties: robotAnalyticsData ?? undefined, }) closeCurrentRun() - setCloseTerminalBanner(true) } return ( @@ -375,7 +369,7 @@ export function ProtocolRunHeader({ CANCELLABLE_STATUSES.includes(runStatus) ? ( {t('shared:close_robot_door')} ) : null} - {mostRecentRunId === runId && !closeTerminalBanner ? ( + {mostRecentRunId === runId ? ( ) : null} {mostRecentRunId === runId && @@ -479,7 +474,9 @@ export function ProtocolRunHeader({ setShowDropTipWizard(false) setPipettesWithTip(prevPipettesWithTip => { const pipettesWithTip = prevPipettesWithTip.slice(1) ?? [] - if (pipettesWithTip.length === 0) closeCurrentRun() + if (pipettesWithTip.length === 0) { + closeCurrentRun() + } return pipettesWithTip }) }} @@ -570,6 +567,7 @@ interface ActionButtonProps { isResetRunLoadingRef: React.MutableRefObject } +// TODO(jh, 04-22-2024): Refactor switch cases into separate factories to increase readability and testability. function ActionButton(props: ActionButtonProps): JSX.Element { const { runId, @@ -613,9 +611,7 @@ function ActionButton(props: ActionButtonProps): JSX.Element { robotName, runId ) - const [showIsShakingModal, setShowIsShakingModal] = React.useState( - false - ) + const [showIsShakingModal, setShowIsShakingModal] = React.useState(false) const isSetupComplete = isCalibrationComplete && isModuleCalibrationComplete && @@ -804,12 +800,14 @@ function ActionButton(props: ActionButtonProps): JSX.Element { ) } +// TODO(jh 04-24-2024): Split TerminalRunBanner into a RunSuccessBanner and RunFailedBanner. interface TerminalRunProps { runStatus: RunStatus | null handleClearClick: () => void isClosingCurrentRun: boolean setShowRunFailedModal: (showRunFailedModal: boolean) => void isResetRunLoading: boolean + isRunCurrent: boolean highestPriorityError?: RunError | null } function TerminalRunBanner(props: TerminalRunProps): JSX.Element | null { @@ -820,51 +818,64 @@ function TerminalRunBanner(props: TerminalRunProps): JSX.Element | null { setShowRunFailedModal, highestPriorityError, isResetRunLoading, + isRunCurrent, } = props const { t } = useTranslation('run_details') - const handleClick = (): void => { + const handleRunSuccessClick = (): void => { + handleClearClick() + } + + const handleFailedRunClick = (): void => { handleClearClick() setShowRunFailedModal(true) } - if ( - isResetRunLoading === false && - (runStatus === RUN_STATUS_FAILED || runStatus === RUN_STATUS_SUCCEEDED) - ) { + const buildSuccessBanner = (): JSX.Element => { return ( - <> - {runStatus === RUN_STATUS_SUCCEEDED ? ( - - - {t('run_completed')} - - - ) : ( - - - - {t('error_info', { - errorType: highestPriorityError?.errorType, - errorCode: highestPriorityError?.errorCode, - })} - + + + {t('run_completed')} + + + ) + } - - {t('view_error')} - - - - )} - + const buildErrorBanner = (): JSX.Element => { + return ( + + + + {t('error_info', { + errorType: highestPriorityError?.errorType, + errorCode: highestPriorityError?.errorCode, + })} + + + + {t('view_error')} + + + ) } - return null + + if ( + runStatus === RUN_STATUS_SUCCEEDED && + isRunCurrent && + !isResetRunLoading + ) { + return buildSuccessBanner() + } else if (runStatus === RUN_STATUS_FAILED && !isResetRunLoading) { + return buildErrorBanner() + } else { + return null + } } diff --git a/app/src/organisms/Devices/ProtocolRun/ProtocolRunModuleControls.tsx b/app/src/organisms/Devices/ProtocolRun/ProtocolRunModuleControls.tsx index 690ae1b43d0..4930efee2d3 100644 --- a/app/src/organisms/Devices/ProtocolRun/ProtocolRunModuleControls.tsx +++ b/app/src/organisms/Devices/ProtocolRun/ProtocolRunModuleControls.tsx @@ -1,16 +1,17 @@ import * as React from 'react' -import { useTranslation } from 'react-i18next' import { useInstrumentsQuery } from '@opentrons/react-api-client' import { COLORS, DIRECTION_COLUMN, Flex, JUSTIFY_CENTER, + InfoScreen, SPACING, - StyledText, } from '@opentrons/components' import { ModuleCard } from '../../ModuleCard' import { useModuleRenderInfoForProtocolById } from '../hooks' +import { useModuleApiRequests } from '../../ModuleCard/utils' + import type { BadPipette, PipetteData } from '@opentrons/api-client' interface PipetteStatus { @@ -73,13 +74,12 @@ export const ProtocolRunModuleControls = ({ robotName, runId, }: ProtocolRunModuleControlsProps): JSX.Element => { - const { t } = useTranslation('protocol_details') - const { attachPipetteRequired, calibratePipetteRequired, updatePipetteFWRequired, } = usePipetteIsReady() + const [getLatestRequestId, handleModuleApiRequests] = useModuleApiRequests() const moduleRenderInfoForProtocolById = useModuleRenderInfoForProtocolById( runId, @@ -97,18 +97,15 @@ export const ProtocolRunModuleControls = ({ const rightColumnModules = attachedModules?.slice(halfAttachedModulesSize) return attachedModules.length === 0 ? ( - - - {t('connect_modules_to_see_controls')} - - - ) : ( + + + ) : ( + ) : null )} @@ -147,6 +148,10 @@ export const ProtocolRunModuleControls = ({ attachPipetteRequired={attachPipetteRequired} calibratePipetteRequired={calibratePipetteRequired} updatePipetteFWRequired={updatePipetteFWRequired} + latestRequestId={getLatestRequestId( + module.attachedModuleMatch.serialNumber + )} + handleModuleApiRequests={handleModuleApiRequests} /> ) : null )} diff --git a/app/src/organisms/Devices/ProtocolRun/ProtocolRunRunTimeParameters.tsx b/app/src/organisms/Devices/ProtocolRun/ProtocolRunRunTimeParameters.tsx index 0b3ccb5c141..b7a253fdeca 100644 --- a/app/src/organisms/Devices/ProtocolRun/ProtocolRunRunTimeParameters.tsx +++ b/app/src/organisms/Devices/ProtocolRun/ProtocolRunRunTimeParameters.tsx @@ -1,170 +1,38 @@ import * as React from 'react' import { useTranslation } from 'react-i18next' -import styled from 'styled-components' - +import styled, { css } from 'styled-components' +import { + RUN_ACTION_TYPE_PLAY, + RUN_STATUS_STOPPED, + RUN_STATUSES_TERMINAL, +} from '@opentrons/api-client' import { formatRunTimeParameterValue } from '@opentrons/shared-data' import { ALIGN_CENTER, BORDERS, + Chip, COLORS, DIRECTION_COLUMN, DIRECTION_ROW, + DISPLAY_INLINE, Flex, + Icon, + InfoScreen, SPACING, StyledText, TYPOGRAPHY, - NoParameters, + useHoverTooltip, } from '@opentrons/components' import { Banner } from '../../../atoms/Banner' import { Divider } from '../../../atoms/structure' -// import { Chip } from '../../../atoms/Chip' +import { Tooltip } from '../../../atoms/Tooltip' import { useMostRecentCompletedAnalysis } from '../../LabwarePositionCheck/useMostRecentCompletedAnalysis' +import { useRunStatus } from '../../RunTimeControl/hooks' +import { useNotifyRunQuery } from '../../../resources/runs' import type { RunTimeParameter } from '@opentrons/shared-data' - -const mockData: RunTimeParameter[] = [ - { - value: false, - displayName: 'Dry Run', - variableName: 'DRYRUN', - description: 'Is this a dry or wet run? Wet is true, dry is false', - type: 'boolean', - default: false, - }, - { - value: true, - displayName: 'Use Gripper', - variableName: 'USE_GRIPPER', - description: 'For using the gripper.', - type: 'boolean', - default: true, - }, - { - value: true, - displayName: 'Trash Tips', - variableName: 'TIP_TRASH', - description: - 'to throw tip into the trash or to not throw tip into the trash', - type: 'boolean', - default: true, - }, - { - value: true, - displayName: 'Deactivate Temperatures', - variableName: 'DEACTIVATE_TEMP', - description: 'deactivate temperature on the module', - type: 'boolean', - default: true, - }, - { - value: 4, - displayName: 'Columns of Samples', - variableName: 'COLUMNS', - description: 'How many columns do you want?', - type: 'int', - min: 1, - max: 14, - default: 4, - }, - { - value: 6, - displayName: 'PCR Cycles', - variableName: 'PCR_CYCLES', - description: 'number of PCR cycles on a thermocycler', - type: 'int', - min: 1, - max: 10, - default: 6, - }, - { - value: 6.5, - displayName: 'EtoH Volume', - variableName: 'ETOH_VOLUME', - description: '70% ethanol volume', - type: 'float', - suffix: 'mL', - min: 1.5, - max: 10.0, - default: 6.5, - }, - { - value: 'none', - displayName: 'Default Module Offsets', - variableName: 'DEFAULT_OFFSETS', - description: 'default module offsets for temp, H-S, and none', - type: 'str', - choices: [ - { - displayName: 'No offsets', - value: 'none', - }, - { - displayName: 'temp offset', - value: '1', - }, - { - displayName: 'heater-shaker offset', - value: '2', - }, - ], - default: 'none', - }, - { - value: 'left', - displayName: 'pipette mount', - variableName: 'mont', - description: 'pipette mount', - type: 'str', - choices: [ - { - displayName: 'Left', - value: 'left', - }, - { - displayName: 'Right', - value: 'right', - }, - ], - default: 'left', - }, - { - value: 'flex', - displayName: 'short test case', - variableName: 'short 2 options', - description: 'this play 2 short options', - type: 'str', - choices: [ - { - displayName: 'OT-2', - value: 'ot2', - }, - { - displayName: 'Flex', - value: 'flex', - }, - ], - default: 'flex', - }, - { - value: 'flex', - displayName: 'long test case', - variableName: 'long 2 options', - description: 'this play 2 long options', - type: 'str', - choices: [ - { - displayName: 'I am kind of long text version', - value: 'ot2', - }, - { - displayName: 'I am kind of long text version. Today is 3/15', - value: 'flex', - }, - ], - default: 'flex', - }, -] +import type { RunStatus } from '@opentrons/api-client' interface ProtocolRunRuntimeParametersProps { runId: string @@ -174,15 +42,31 @@ export function ProtocolRunRuntimeParameters({ }: ProtocolRunRuntimeParametersProps): JSX.Element { const { t } = useTranslation('protocol_setup') const mostRecentAnalysis = useMostRecentCompletedAnalysis(runId) - // ToDo (kk:03/18/2024) mockData will be replaced with [] - const runTimeParameters = mostRecentAnalysis?.runTimeParameters ?? mockData - const hasParameter = runTimeParameters.length > 0 + const runStatus = useRunStatus(runId) + const isRunTerminal = + runStatus == null + ? false + : (RUN_STATUSES_TERMINAL as RunStatus[]).includes(runStatus) + // 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 runTimeParameters = + (isRunTerminal + ? run?.data?.runTimeParameters + : mostRecentAnalysis?.runTimeParameters) ?? [] + const hasRunTimeParameters = runTimeParameters.length > 0 + const hasCustomRunTimeParameterValues = runTimeParameters.some( + parameter => parameter.value !== parameter.default + ) - // ToDo (kk:03/19/2024) this will be replaced with the boolean from values check result - const dummyBoolean = true + const runActions = run?.data.actions + const hasRunStarted = runActions?.some( + action => action.actionType === RUN_ACTION_TYPE_PLAY + ) + const isRunCancelledWithoutStarting = + !hasRunStarted && runStatus === RUN_STATUS_STOPPED - // ToDO (kk:03/18/2024) Need to add Chip to updated runTime parameter value - // This part will be implemented in a following PR since need to runTime parameter slideout return ( <> {t('parameters')} - {hasParameter ? ( + {hasRunTimeParameters ? ( - {dummyBoolean ? t('custom_values') : t('default_values')} + {hasCustomRunTimeParameterValues + ? t('custom_values') + : t('default_values')} ) : null} - {hasParameter ? ( + {hasRunTimeParameters ? ( ) : null} - {!hasParameter ? ( + {!hasRunTimeParameters ? ( - + ) : ( <> - + {t('name')} {t('value')} - + {runTimeParameters.map( - (parameter: RunTimeParameter, index: number) => { - return ( - - - - {parameter.displayName} - - - - - - {formatRunTimeParameterValue(parameter, t)} - - {/* ToDo (kk:03/19/2024) chip will be here with conditional render */} - {/* {index % 2 === 0 ? ( - - ) : null} */} - - - - ) - } + (parameter: RunTimeParameter, index: number) => ( + + ) )} @@ -276,16 +144,84 @@ export function ProtocolRunRuntimeParameters({ ) } +interface StyledTableRowComponentProps { + parameter: RunTimeParameter + index: number + isLast: boolean + t: any +} + +const StyledTableRowComponent = ( + props: StyledTableRowComponentProps +): JSX.Element => { + const { parameter, index, isLast, t } = props + const [targetProps, tooltipProps] = useHoverTooltip() + return ( + + + + {parameter.displayName} + + {parameter.description != null ? ( + <> + + + + + {parameter.description} + + + ) : null} + + + + + {formatRunTimeParameterValue(parameter, t)} + + {parameter.value !== parameter.default ? ( + + ) : null} + + + + ) +} + const StyledTable = styled.table` width: 100%; border-collapse: collapse; text-align: left; ` +const StyledTableHeaderContainer = styled.thead` + display: grid; + grid-template-columns: 0.35fr 0.35fr; + grid-gap: ${SPACING.spacing48}; + border-bottom: ${BORDERS.lineBorder}; +` const StyledTableHeader = styled.th` ${TYPOGRAPHY.labelSemiBold} - padding: ${SPACING.spacing8}; - border-bottom: ${BORDERS.lineBorder}; + padding-bottom: ${SPACING.spacing8}; ` interface StyledTableRowProps { @@ -293,16 +229,21 @@ interface StyledTableRowProps { } const StyledTableRow = styled.tr` - padding: ${SPACING.spacing8}; + display: grid; + grid-template-columns: 0.35fr 0.35fr; + grid-gap: ${SPACING.spacing48}; border-bottom: ${props => (props.isLast ? 'none' : BORDERS.lineBorder)}; ` interface StyledTableCellProps { - isLast: boolean + paddingRight?: string + display?: string } const StyledTableCell = styled.td` - padding-left: ${SPACING.spacing8}; - padding-top: ${SPACING.spacing12}; - padding-bottom: ${props => (props.isLast ? 0 : SPACING.spacing12)}; + align-items: ${ALIGN_CENTER}; + display: ${props => (props.display != null ? props.display : 'table-cell')}; + padding: ${SPACING.spacing8} 0; + padding-right: ${props => + props.paddingRight != null ? props.paddingRight : SPACING.spacing16}; ` diff --git a/app/src/organisms/Devices/ProtocolRun/RunFailedModal.tsx b/app/src/organisms/Devices/ProtocolRun/RunFailedModal.tsx index dbaeff488b8..e03287f5959 100644 --- a/app/src/organisms/Devices/ProtocolRun/RunFailedModal.tsx +++ b/app/src/organisms/Devices/ProtocolRun/RunFailedModal.tsx @@ -51,7 +51,7 @@ export function RunFailedModal({ setShowRunFailedModal, highestPriorityError, }: RunFailedModalProps): JSX.Element | null { - const { i18n, t } = useTranslation(['run_details', 'shared']) + const { i18n, t } = useTranslation(['run_details', 'shared', 'branded']) const modalProps: LegacyModalProps = { type: 'error', title: t('run_failed_modal_title'), @@ -89,7 +89,7 @@ export function RunFailedModal({ - {t('run_failed_modal_description_desktop')} + {t('branded:run_failed_modal_description_desktop')} { - const { t } = useTranslation(['protocol_setup', 'shared']) + const { t } = useTranslation(['protocol_setup', 'shared', 'branded']) const moduleName = getModuleName(props.type) return createPortal( - {t(`secure_labware_explanation_${snakeCase(moduleName)}`)} + {t(`branded:secure_labware_explanation_${snakeCase(moduleName)}`)} { 'Opentrons recommends ensuring your labware locks to the Magnetic Module by adjusting the black plate bracket on top of the module.' ) screen.getByText( - 'Please note there are two sizes of plate brackets supplied with your module: standard and deep well. These brackets can be removed and swapped by unscrewing the modules thumb screw (the silver knob on the front).' + "There are two sizes of plate brackets supplied with your module: standard and deep well. These brackets can be removed and swapped by unscrewing the module's thumb screw (the silver knob on the front)." ) }) it('should render magnetic module type modal and call onCloseClick when button is pressed', () => { @@ -43,7 +43,7 @@ describe('SecureLabwareModal', () => { render(props) screen.getByText('Securing labware to the Thermocycler') screen.getByText( - 'Opentrons recommends securing your labware to the Thermocycler module by closing its latch. Doing so ensures level and accurate plate placement for optimal results.' + 'Opentrons recommends securing your labware to the Thermocycler Module by closing its latch. Doing so ensures level and accurate plate placement for optimal results.' ) }) diff --git a/app/src/organisms/Devices/ProtocolRun/SetupLabwarePositionCheck/HowLPCWorksModal.tsx b/app/src/organisms/Devices/ProtocolRun/SetupLabwarePositionCheck/HowLPCWorksModal.tsx index 52f5d00c758..4e519d8af72 100644 --- a/app/src/organisms/Devices/ProtocolRun/SetupLabwarePositionCheck/HowLPCWorksModal.tsx +++ b/app/src/organisms/Devices/ProtocolRun/SetupLabwarePositionCheck/HowLPCWorksModal.tsx @@ -22,7 +22,7 @@ interface HowLPCWorksModalProps { } export const HowLPCWorksModal = (props: HowLPCWorksModalProps): JSX.Element => { - const { t } = useTranslation(['protocol_setup', 'shared']) + const { t } = useTranslation(['protocol_setup', 'shared', 'branded']) return createPortal( { /> - {t('why_use_lpc')} + {t('branded:why_use_lpc')} void + onCloseClick: () => void + deckDef: DeckDefinition + isOnDevice: boolean + requiredModuleModel: ModuleModel +} + +export const ChooseModuleToConfigureModal = ( + props: ChooseModuleToConfigureModalProps +): JSX.Element => { + const { + handleConfigureModule, + onCloseClick, + deckDef, + requiredModuleModel, + isOnDevice, + } = props + const { t } = useTranslation(['protocol_setup', 'shared']) + const attachedModules = useModulesQuery().data?.data ?? [] + const deckConfig = useDeckConfigurationQuery()?.data ?? [] + const unconfiguredModuleMatches = + attachedModules.filter( + attachedMod => + attachedMod.moduleModel === requiredModuleModel && + !deckConfig.some( + ({ opentronsModuleSerialNumber }) => + attachedMod.serialNumber === opentronsModuleSerialNumber + ) + ) ?? [] + + const connectedOptions: ModuleFixtureOption[] = unconfiguredModuleMatches.map( + attachedMod => ({ + moduleModel: attachedMod.moduleModel, + usbPort: attachedMod.usbPort.port, + serialNumber: attachedMod.serialNumber, + }) + ) + const passiveOptions: ModuleFixtureOption[] = + requiredModuleModel === MAGNETIC_BLOCK_V1 + ? [{ moduleModel: MAGNETIC_BLOCK_V1 }] + : [] + const fixtureOptions = [...connectedOptions, ...passiveOptions].map( + ({ moduleModel, serialNumber, usbPort }) => { + const moduleFixtures = getCutoutFixturesForModuleModel( + moduleModel, + deckDef + ) + return ( + { + handleConfigureModule(serialNumber) + }} + optionName={getFixtureDisplayName(moduleFixtures[0].id, usbPort)} + buttonText={t('shared:add')} + isOnDevice={isOnDevice} + /> + ) + } + ) + + return createPortal( + isOnDevice ? ( + + + + + {fixtureOptions} + + + + + ) : ( + + + + {t('deck_conflict')} + + + } + onClose={onCloseClick} + width="27.75rem" + > + + + + {fixtureOptions} + + + + + ), + getTopPortalEl() + ) +} diff --git a/app/src/organisms/Devices/ProtocolRun/SetupModuleAndDeck/LocationConflictModal.tsx b/app/src/organisms/Devices/ProtocolRun/SetupModuleAndDeck/LocationConflictModal.tsx index b4a8e634b0d..c696b4ecbdf 100644 --- a/app/src/organisms/Devices/ProtocolRun/SetupModuleAndDeck/LocationConflictModal.tsx +++ b/app/src/organisms/Devices/ProtocolRun/SetupModuleAndDeck/LocationConflictModal.tsx @@ -25,11 +25,10 @@ import { getCutoutDisplayName, getFixtureDisplayName, getModuleDisplayName, - SINGLE_RIGHT_CUTOUTS, - SINGLE_LEFT_SLOT_FIXTURE, - SINGLE_RIGHT_SLOT_FIXTURE, THERMOCYCLER_MODULE_V1, THERMOCYCLER_MODULE_V2, + getCutoutFixturesForModuleModel, + getFixtureIdByCutoutIdFromModuleSlotName, } from '@opentrons/shared-data' import { getTopPortalEl } from '../../../../App/portal' import { LegacyModal } from '../../../../molecules/LegacyModal' @@ -41,11 +40,14 @@ import type { CutoutId, CutoutFixtureId, ModuleModel, + DeckDefinition, } from '@opentrons/shared-data' +import { ChooseModuleToConfigureModal } from './ChooseModuleToConfigureModal' interface LocationConflictModalProps { onCloseClick: () => void cutoutId: CutoutId + deckDef: DeckDefinition missingLabwareDisplayName?: string | null requiredFixtureId?: CutoutFixtureId requiredModule?: ModuleModel @@ -61,9 +63,12 @@ export const LocationConflictModal = ( missingLabwareDisplayName, requiredFixtureId, requiredModule, + deckDef, isOnDevice = false, } = props const { t, i18n } = useTranslation(['protocol_setup', 'shared']) + + const [showModuleSelect, setShowModuleSelect] = React.useState(false) const deckConfig = useDeckConfigurationQuery().data ?? [] const { updateDeckConfiguration } = useUpdateDeckConfigurationMutation() const deckConfigurationAtLocationFixtureId = deckConfig.find( @@ -89,39 +94,54 @@ export const LocationConflictModal = ( ? getFixtureDisplayName(deckConfigurationAtA1) : currentFixtureDisplayName + const handleConfigureModule = (moduleSerialNumber?: string): void => { + if (requiredModule != null) { + const slotName = cutoutId.replace('cutout', '') + const moduleFixtures = getCutoutFixturesForModuleModel( + requiredModule, + deckDef + ) + const moduleFixtureIdByCutoutId = getFixtureIdByCutoutIdFromModuleSlotName( + slotName, + moduleFixtures, + deckDef + ) + + const newDeckConfig = deckConfig.map(existingCutoutConfig => { + const replacementCutoutFixtureId = + moduleFixtureIdByCutoutId[existingCutoutConfig.cutoutId] + return existingCutoutConfig.cutoutId in moduleFixtureIdByCutoutId && + replacementCutoutFixtureId != null + ? { + ...existingCutoutConfig, + cutoutFixtureId: replacementCutoutFixtureId, + opentronsModuleSerialNumber: moduleSerialNumber, + } + : existingCutoutConfig + }) + updateDeckConfiguration(newDeckConfig) + } + onCloseClick() + } + const handleUpdateDeck = (): void => { - if (requiredFixtureId != null) { + if (requiredModule != null) { + setShowModuleSelect(true) + } else if (requiredFixtureId != null) { const newRequiredFixtureDeckConfig = deckConfig.map(fixture => fixture.cutoutId === cutoutId - ? { ...fixture, cutoutFixtureId: requiredFixtureId } + ? { + ...fixture, + cutoutFixtureId: requiredFixtureId, + opentronsModuleSerialNumber: undefined, + } : fixture ) - updateDeckConfiguration(newRequiredFixtureDeckConfig) + onCloseClick() } else { - const isRightCutout = SINGLE_RIGHT_CUTOUTS.includes(cutoutId) - const singleSlotFixture = isRightCutout - ? SINGLE_RIGHT_SLOT_FIXTURE - : SINGLE_LEFT_SLOT_FIXTURE - - const newSingleSlotDeckConfig = deckConfig.map(fixture => - fixture.cutoutId === cutoutId - ? { ...fixture, cutoutFixtureId: singleSlotFixture } - : fixture - ) - - // add A1 and B1 single slot config for thermocycler - const newThermocyclerDeckConfig = isThermocycler - ? newSingleSlotDeckConfig.map(fixture => - fixture.cutoutId === 'cutoutA1' || fixture.cutoutId === 'cutoutB1' - ? { ...fixture, cutoutFixtureId: SINGLE_LEFT_SLOT_FIXTURE } - : fixture - ) - : newSingleSlotDeckConfig - - updateDeckConfiguration(newThermocyclerDeckConfig) + onCloseClick() } - onCloseClick() } let protocolSpecifiesDisplayName = '' @@ -133,6 +153,18 @@ export const LocationConflictModal = ( protocolSpecifiesDisplayName = getModuleDisplayName(requiredModule) } + if (showModuleSelect && requiredModule) { + return createPortal( + , + getTopPortalEl() + ) + } return createPortal( isOnDevice ? ( unknown -} - -export const MultipleModulesModal = ( - props: MultipleModulesModalProps -): JSX.Element => { - const { t } = useTranslation(['protocol_setup', 'shared']) - const isOnDevice = useSelector(getIsOnDevice) - return createPortal( - isOnDevice ? ( - - - {t('multiple_of_most_modules')} - 2 temperature modules plugged into the usb ports - - - ) : ( - - - - - - {t('multiple_modules_explanation')} - - - {t('multiple_modules_learn_more')} - - - - {t('example')} - - - {t('multiple_modules_example')} - - 2 temperature modules plugged into the usb ports - - - {t('shared:close')} - - - - ), - getTopPortalEl() - ) -} diff --git a/app/src/organisms/Devices/ProtocolRun/SetupModuleAndDeck/OT2MultipleModulesHelp.tsx b/app/src/organisms/Devices/ProtocolRun/SetupModuleAndDeck/OT2MultipleModulesHelp.tsx new file mode 100644 index 00000000000..eaac0c079a6 --- /dev/null +++ b/app/src/organisms/Devices/ProtocolRun/SetupModuleAndDeck/OT2MultipleModulesHelp.tsx @@ -0,0 +1,123 @@ +import * as React from 'react' +import { createPortal } from 'react-dom' +import { useTranslation } from 'react-i18next' +import { + ALIGN_FLEX_END, + Box, + DIRECTION_COLUMN, + DIRECTION_ROW, + Flex, + Icon, + Link, + PrimaryButton, + SPACING, + StyledText, + TYPOGRAPHY, +} from '@opentrons/components' +import { getTopPortalEl } from '../../../../App/portal' +import { Banner } from '../../../../atoms/Banner' +import { LegacyModal } from '../../../../molecules/LegacyModal' +import multipleModuleHelp from '../../../../assets/images/Moam_modal_image.png' + +const HOW_TO_MULTIPLE_MODULES_HREF = + 'https://support.opentrons.com/s/article/Using-modules-of-the-same-type-on-the-OT-2' + +export function OT2MultipleModulesHelp(): JSX.Element { + const { t } = useTranslation(['protocol_setup', 'shared']) + const [ + showMultipleModulesModal, + setShowMultipleModulesModal, + ] = React.useState(false) + + const onCloseClick = (): void => { + setShowMultipleModulesModal(false) + } + return ( + <> + + setShowMultipleModulesModal(true)} + closeButton={ + + {t('learn_more')} + + } + > + + + {t('multiple_modules')} + + {t('view_moam')} + + + + {showMultipleModulesModal + ? createPortal( + + + + + + {t('multiple_modules_explanation')} + + + {t('multiple_modules_learn_more')} + + + + {t('example')} + + + + {t('multiple_modules_example')} + + + 2 temperature modules plugged into the usb ports + + + {t('shared:close')} + + + , + getTopPortalEl() + ) + : null} + + ) +} diff --git a/app/src/organisms/Devices/ProtocolRun/SetupModuleAndDeck/SetupFixtureList.tsx b/app/src/organisms/Devices/ProtocolRun/SetupModuleAndDeck/SetupFixtureList.tsx index 4e65fd3759d..b8ad582af17 100644 --- a/app/src/organisms/Devices/ProtocolRun/SetupModuleAndDeck/SetupFixtureList.tsx +++ b/app/src/organisms/Devices/ProtocolRun/SetupModuleAndDeck/SetupFixtureList.tsx @@ -16,8 +16,11 @@ import { TYPOGRAPHY, } from '@opentrons/components' import { + FLEX_MODULE_ADDRESSABLE_AREAS, + FLEX_ROBOT_TYPE, SINGLE_SLOT_FIXTURES, getCutoutDisplayName, + getDeckDefFromRobotType, getFixtureDisplayName, } from '@opentrons/shared-data' import { StatusLabel } from '../../../../atoms/StatusLabel' @@ -27,73 +30,47 @@ import { NotConfiguredModal } from './NotConfiguredModal' import { getFixtureImage } from './utils' import { DeckFixtureSetupInstructionsModal } from '../../../DeviceDetailsDeckConfiguration/DeckFixtureSetupInstructionsModal' +import type { DeckDefinition } from '@opentrons/shared-data' import type { CutoutConfigAndCompatibility } from '../../../../resources/deck_configuration/hooks' interface SetupFixtureListProps { deckConfigCompatibility: CutoutConfigAndCompatibility[] } - +/** + * List items of all "non-module" fixtures e.g. staging slot, waste chute, trash bin... + * @param props + * @returns JSX.Element + */ export const SetupFixtureList = (props: SetupFixtureListProps): JSX.Element => { const { deckConfigCompatibility } = props - const { t, i18n } = useTranslation('protocol_setup') + const deckDef = getDeckDefFromRobotType(FLEX_ROBOT_TYPE) return ( <> - - - {i18n.format(t('fixture_name'), 'capitalize')} - - - {t('location')} - - - {t('status')} - - - - {deckConfigCompatibility.map(cutoutConfigAndCompatibility => { - return ( - - ) - })} - + {deckConfigCompatibility.map(cutoutConfigAndCompatibility => { + return cutoutConfigAndCompatibility.requiredAddressableAreas.some(raa => + FLEX_MODULE_ADDRESSABLE_AREAS.includes(raa) + ) ? null : ( // don't list modules here, they're covered by SetupModuleList + + ) + })} ) } -interface FixtureListItemProps extends CutoutConfigAndCompatibility {} +interface FixtureListItemProps extends CutoutConfigAndCompatibility { + deckDef: DeckDefinition +} export function FixtureListItem({ cutoutId, cutoutFixtureId, compatibleCutoutFixtureIds, missingLabwareDisplayName, + deckDef, }: FixtureListItemProps): JSX.Element { const { t } = useTranslation('protocol_setup') @@ -155,6 +132,7 @@ export function FixtureListItem({ setShowLocationConflictModal(false)} cutoutId={cutoutId} + deckDef={deckDef} missingLabwareDisplayName={missingLabwareDisplayName} requiredFixtureId={compatibleCutoutFixtureIds[0]} /> diff --git a/app/src/organisms/Devices/ProtocolRun/SetupModuleAndDeck/SetupModulesList.tsx b/app/src/organisms/Devices/ProtocolRun/SetupModuleAndDeck/SetupModulesList.tsx index 8f0879e9609..cf258c2bc00 100644 --- a/app/src/organisms/Devices/ProtocolRun/SetupModuleAndDeck/SetupModulesList.tsx +++ b/app/src/organisms/Devices/ProtocolRun/SetupModuleAndDeck/SetupModulesList.tsx @@ -27,11 +27,11 @@ import { HEATERSHAKER_MODULE_TYPE, HEATERSHAKER_MODULE_V1, MAGNETIC_BLOCK_V1, + OT2_ROBOT_TYPE, TC_MODULE_LOCATION_OT2, TC_MODULE_LOCATION_OT3, } from '@opentrons/shared-data' -import { Banner } from '../../../../atoms/Banner' import { TertiaryButton } from '../../../../atoms/buttons' import { StatusLabel } from '../../../../atoms/StatusLabel' import { Tooltip } from '../../../../atoms/Tooltip' @@ -48,7 +48,7 @@ import { useRunCalibrationStatus, } from '../../hooks' import { LocationConflictModal } from './LocationConflictModal' -import { MultipleModulesModal } from './MultipleModulesModal' +import { OT2MultipleModulesHelp } from './OT2MultipleModulesHelp' import { UnMatchedModuleWarning } from './UnMatchedModuleWarning' import { getModuleImage } from './utils' @@ -70,7 +70,6 @@ interface SetupModulesListProps { export const SetupModulesList = (props: SetupModulesListProps): JSX.Element => { const { robotName, runId } = props - const { t } = useTranslation('protocol_setup') const moduleRenderInfoForProtocolById = useModuleRenderInfoForProtocolById( runId ) @@ -85,125 +84,53 @@ export const SetupModulesList = (props: SetupModulesListProps): JSX.Element => { const calibrationStatus = useRunCalibrationStatus(robotName, runId) - const [ - showMultipleModulesModal, - setShowMultipleModulesModal, - ] = React.useState(false) - const moduleModels = map( moduleRenderInfoForProtocolById, ({ moduleDef }) => moduleDef.model ) - - const hasADuplicateModule = new Set(moduleModels).size !== moduleModels.length - + const showOT2MoamHelp = + robotModel === OT2_ROBOT_TYPE && + new Set(moduleModels).size !== moduleModels.length return ( <> - {showMultipleModulesModal ? ( - setShowMultipleModulesModal(false)} - /> - ) : null} - {hasADuplicateModule ? ( - - setShowMultipleModulesModal(true)} - closeButton={ - - {t('learn_more')} - - } - > - - - {t('multiple_modules')} - - {t('view_moam')} - - - - ) : null} + {showOT2MoamHelp ? : null} {remainingAttachedModules.length !== 0 && missingModuleIds.length !== 0 ? ( ) : null} - - - {t('module_name')} - - - {t('location')} - - - {t('status')} - - - - {map( - moduleRenderInfoForProtocolById, - ({ - moduleDef, - attachedModuleMatch, - slotName, - moduleId, - conflictedFixture, - }) => { - return ( - - ) - } - )} - + + {map( + moduleRenderInfoForProtocolById, + ({ + moduleDef, + attachedModuleMatch, + slotName, + moduleId, + conflictedFixture, + }) => { + return ( + + ) + } + )} ) } @@ -358,13 +285,13 @@ export function ModulesListItem({ onCloseClick={() => setShowLocationConflictModal(false)} cutoutId={cutoutIdForSlotName} requiredModule={moduleModel} + deckDef={deckDef} /> ) : null} {showModuleWizard && attachedModuleMatch != null ? ( setShowModuleWizard(false)} - initialSlotName={slotName} isPrepCommandLoading={isCommandMutationLoading} prepCommandErrorMessage={ prepCommandErrorMessage === '' ? undefined : prepCommandErrorMessage @@ -404,13 +331,26 @@ export function ModulesListItem({ {subText} - - {getModuleType(moduleModel) === 'thermocyclerModuleType' - ? isFlex - ? TC_MODULE_LOCATION_OT3 - : TC_MODULE_LOCATION_OT2 - : slotName} - + + + {getModuleType(moduleModel) === 'thermocyclerModuleType' + ? isFlex + ? TC_MODULE_LOCATION_OT3 + : TC_MODULE_LOCATION_OT2 + : slotName} + + {attachedModuleMatch?.usbPort.port != null ? ( + + {t('usb_port_number', { + port: attachedModuleMatch.usbPort.port, + })} + + ) : null} + { onCloseClick: vi.fn(), cutoutId: 'cutoutB3', requiredFixtureId: TRASH_BIN_ADAPTER_FIXTURE, + deckDef: ot3StandardDeckV5 as any, } + vi.mocked(useModulesQuery).mockReturnValue({ data: { data: [] } } as any) vi.mocked(useDeckConfigurationQuery).mockReturnValue({ data: [mockFixture], } as UseQueryResult) @@ -64,18 +69,23 @@ describe('LocationConflictModal', () => { expect(mockUpdate).toHaveBeenCalled() }) it('should render the modal information for a module fixture conflict', () => { + vi.mocked(useModulesQuery).mockReturnValue({ + data: { data: [mockHeaterShaker] }, + } as any) props = { onCloseClick: vi.fn(), cutoutId: 'cutoutB3', requiredModule: 'heaterShakerModuleV1', + deckDef: ot3StandardDeckV5 as any, } render(props) screen.getByText('Protocol specifies') screen.getByText('Currently configured') - screen.getByText('Heater-Shaker Module GEN1') fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) expect(props.onCloseClick).toHaveBeenCalled() fireEvent.click(screen.getByRole('button', { name: 'Update deck' })) + screen.getByText('Heater-Shaker Module GEN1 in USB-1') + fireEvent.click(screen.getByRole('button', { name: 'add' })) expect(mockUpdate).toHaveBeenCalled() }) it('should render the modal information for a single slot fixture conflict', () => { @@ -92,6 +102,7 @@ describe('LocationConflictModal', () => { cutoutId: 'cutoutB1', requiredFixtureId: SINGLE_RIGHT_SLOT_FIXTURE, missingLabwareDisplayName: 'a tiprack', + deckDef: ot3StandardDeckV5 as any, } render(props) screen.getByText('Deck location conflict') diff --git a/app/src/organisms/Devices/ProtocolRun/SetupModuleAndDeck/__tests__/MultipleModuleModal.test.tsx b/app/src/organisms/Devices/ProtocolRun/SetupModuleAndDeck/__tests__/OT2MultipleModulesHelp.test.tsx similarity index 60% rename from app/src/organisms/Devices/ProtocolRun/SetupModuleAndDeck/__tests__/MultipleModuleModal.test.tsx rename to app/src/organisms/Devices/ProtocolRun/SetupModuleAndDeck/__tests__/OT2MultipleModulesHelp.test.tsx index 532ab57c39b..984dc1e57e5 100644 --- a/app/src/organisms/Devices/ProtocolRun/SetupModuleAndDeck/__tests__/MultipleModuleModal.test.tsx +++ b/app/src/organisms/Devices/ProtocolRun/SetupModuleAndDeck/__tests__/OT2MultipleModulesHelp.test.tsx @@ -5,31 +5,30 @@ import { describe, it, beforeEach, vi, expect } from 'vitest' import { renderWithProviders } from '../../../../../__testing-utils__' import { i18n } from '../../../../../i18n' import { getIsOnDevice } from '../../../../../redux/config' -import { MultipleModulesModal } from '../MultipleModulesModal' +import { OT2MultipleModulesHelp } from '../OT2MultipleModulesHelp' vi.mock('../../../../../redux/config') -const render = (props: React.ComponentProps) => { - return renderWithProviders(, { +const render = () => + renderWithProviders(, { i18nInstance: i18n, })[0] -} -describe('MultipleModulesModal', () => { - let props: React.ComponentProps +describe('OT2MultipleModulesHelp', () => { beforeEach(() => { - props = { onCloseClick: vi.fn() } vi.mocked(getIsOnDevice).mockReturnValue(false) }) it('should render the correct header', () => { - render(props) + render() + fireEvent.click(screen.getByText('Learn more')) screen.getByRole('heading', { name: 'Setting up multiple modules of the same type', }) }) it('should render the correct body', () => { - render(props) + render() + fireEvent.click(screen.getByText('Learn more')) screen.getByText( 'To use more than one of the same module in a protocol, you first need to plug in the module that’s called first in your protocol to the lowest numbered USB port on the robot. Continue in the same manner with additional modules.' ) @@ -40,7 +39,8 @@ describe('MultipleModulesModal', () => { screen.getByAltText('2 temperature modules plugged into the usb ports') }) it('should render a link to the learn more page', () => { - render(props) + render() + fireEvent.click(screen.getByText('Learn more')) expect( screen .getByRole('link', { @@ -51,23 +51,13 @@ describe('MultipleModulesModal', () => { 'https://support.opentrons.com/s/article/Using-modules-of-the-same-type-on-the-OT-2' ) }) - it('should call onCloseClick when the close button is pressed', () => { - render(props) - expect(props.onCloseClick).not.toHaveBeenCalled() + it('should call close info modal when the close button is pressed', () => { + render() + fireEvent.click(screen.getByText('Learn more')) const closeButton = screen.getByRole('button', { name: 'close' }) fireEvent.click(closeButton) - expect(props.onCloseClick).toHaveBeenCalled() - }) - it('should render the correct text and img for on device display', () => { - vi.mocked(getIsOnDevice).mockReturnValue(true) - render(props) - screen.getByText( - 'You can use multiples of most module types within a single Python protocol by connecting and loading the modules in a specific order. The robot will initialize the matching module attached to the lowest numbered port first, regardless of what deck slot it occupies.' - ) - const img = screen.getByRole('img') - expect(img.getAttribute('src')).toBe( - '/app/src/assets/images/on-device-display/multiple_modules_modal.png' - ) - screen.getByAltText('2 temperature modules plugged into the usb ports') + expect( + screen.queryByText('Setting up multiple modules of the same type') + ).toBeNull() }) }) diff --git a/app/src/organisms/Devices/ProtocolRun/SetupModuleAndDeck/__tests__/SetupFixtureList.test.tsx b/app/src/organisms/Devices/ProtocolRun/SetupModuleAndDeck/__tests__/SetupFixtureList.test.tsx index 69813bdbd8f..2aba1928899 100644 --- a/app/src/organisms/Devices/ProtocolRun/SetupModuleAndDeck/__tests__/SetupFixtureList.test.tsx +++ b/app/src/organisms/Devices/ProtocolRun/SetupModuleAndDeck/__tests__/SetupFixtureList.test.tsx @@ -81,11 +81,8 @@ describe('SetupFixtureList', () => { ) }) - it('should render the headers and a fixture with configured status', () => { + it('should a fixture with configured status', () => { render(props) - screen.getByText('Fixture') - screen.getByText('Location') - screen.getByText('Status') screen.getByText('Waste chute with staging area slot') screen.getByRole('button', { name: 'View setup instructions' }) screen.getByText('D3') diff --git a/app/src/organisms/Devices/ProtocolRun/SetupModuleAndDeck/__tests__/SetupModulesList.test.tsx b/app/src/organisms/Devices/ProtocolRun/SetupModuleAndDeck/__tests__/SetupModulesList.test.tsx index 05df2fc9cef..b784e25d0c9 100644 --- a/app/src/organisms/Devices/ProtocolRun/SetupModuleAndDeck/__tests__/SetupModulesList.test.tsx +++ b/app/src/organisms/Devices/ProtocolRun/SetupModuleAndDeck/__tests__/SetupModulesList.test.tsx @@ -3,7 +3,11 @@ import { when } from 'vitest-when' import { fireEvent, screen, waitFor } from '@testing-library/react' import { describe, it, beforeEach, expect, vi } from 'vitest' import { renderWithProviders } from '../../../../../__testing-utils__' -import { STAGING_AREA_RIGHT_SLOT_FIXTURE } from '@opentrons/shared-data' +import { + FLEX_ROBOT_TYPE, + OT2_ROBOT_TYPE, + STAGING_AREA_RIGHT_SLOT_FIXTURE, +} from '@opentrons/shared-data' import { i18n } from '../../../../../i18n' import { mockMagneticModule as mockMagneticModuleFixture, @@ -20,16 +24,17 @@ import { ModuleWizardFlows } from '../../../../ModuleWizardFlows' import { useIsFlex, useModuleRenderInfoForProtocolById, - useRunHasStarted, useUnmatchedModulesForProtocol, useRunCalibrationStatus, + useRobot, } from '../../../hooks' -import { MultipleModulesModal } from '../MultipleModulesModal' +import { OT2MultipleModulesHelp } from '../OT2MultipleModulesHelp' import { UnMatchedModuleWarning } from '../UnMatchedModuleWarning' import { SetupModulesList } from '../SetupModulesList' import { LocationConflictModal } from '../LocationConflictModal' import type { ModuleModel, ModuleType } from '@opentrons/shared-data' +import type { DiscoveredRobot } from '../../../../../redux/discovery/types' vi.mock('@opentrons/react-api-client') vi.mock('../../../hooks') @@ -37,7 +42,7 @@ vi.mock('../LocationConflictModal') vi.mock('../UnMatchedModuleWarning') vi.mock('../../../../ModuleCard/ModuleSetupModal') vi.mock('../../../../ModuleWizardFlows') -vi.mock('../MultipleModulesModal') +vi.mock('../OT2MultipleModulesHelp') vi.mock('../../../../../resources/runs') vi.mock('../../../../../redux/config') @@ -92,6 +97,9 @@ describe('SetupModulesList', () => { robotName: ROBOT_NAME, runId: RUN_ID, } + when(vi.mocked(useRobot)) + .calledWith(ROBOT_NAME) + .thenReturn({ robotModel: FLEX_ROBOT_TYPE } as DiscoveredRobot) mockChainLiveCommands = vi.fn() mockChainLiveCommands.mockResolvedValue(null) vi.mocked(ModuleSetupModal).mockReturnValue(
mockModuleSetupModal
) @@ -118,15 +126,6 @@ describe('SetupModulesList', () => { ) }) - it('should render the list view headers', () => { - when(useRunHasStarted).calledWith(RUN_ID).thenReturn(false) - when(useModuleRenderInfoForProtocolById).calledWith(RUN_ID).thenReturn({}) - render(props) - screen.getByText('Module') - screen.getByText('Location') - screen.getByText('Status') - }) - it('should render a magnetic module that is connected', () => { vi.mocked(useModuleRenderInfoForProtocolById).mockReturnValue({ [mockMagneticModule.moduleId]: { @@ -301,8 +300,13 @@ describe('SetupModulesList', () => { screen.getByText('Connected') }) - it('should render the MoaM component when Moam is attached', () => { - vi.mocked(MultipleModulesModal).mockReturnValue(
mock Moam modal
) + it('should render the MoaM component when Moam is attached and robot is OT2', () => { + when(vi.mocked(useRobot)) + .calledWith(ROBOT_NAME) + .thenReturn({ robotModel: OT2_ROBOT_TYPE } as DiscoveredRobot) + vi.mocked(OT2MultipleModulesHelp).mockReturnValue( +
mock Moam modal
+ ) when(useUnmatchedModulesForProtocol) .calledWith(ROBOT_NAME, RUN_ID) .thenReturn({ @@ -355,8 +359,6 @@ describe('SetupModulesList', () => { }, }) render(props) - const help = screen.getByTestId('Banner_close-button') - fireEvent.click(help) screen.getByText('mock Moam modal') }) it('should render the module unmatching banner', () => { diff --git a/app/src/organisms/Devices/ProtocolRun/SetupModuleAndDeck/index.tsx b/app/src/organisms/Devices/ProtocolRun/SetupModuleAndDeck/index.tsx index 4e9afd58604..f1e06c2471a 100644 --- a/app/src/organisms/Devices/ProtocolRun/SetupModuleAndDeck/index.tsx +++ b/app/src/organisms/Devices/ProtocolRun/SetupModuleAndDeck/index.tsx @@ -7,6 +7,10 @@ import { SPACING, useHoverTooltip, PrimaryButton, + DIRECTION_ROW, + JUSTIFY_SPACE_BETWEEN, + StyledText, + TYPOGRAPHY, } from '@opentrons/components' import { useToggleGroup } from '../../../../molecules/ToggleGroup/useToggleGroup' @@ -46,7 +50,7 @@ export const SetupModuleAndDeck = ({ hasModules, protocolAnalysis, }: SetupModuleAndDeckProps): JSX.Element => { - const { t } = useTranslation('protocol_setup') + const { t, i18n } = useTranslation('protocol_setup') const [selectedValue, toggleGroup] = useToggleGroup( t('list_view'), t('map_view') @@ -75,14 +79,51 @@ export const SetupModuleAndDeck = ({ {toggleGroup} {selectedValue === t('list_view') ? ( <> - {hasModules ? ( - - ) : null} - {requiredDeckConfigCompatibility.length > 0 ? ( - - ) : null} + + + {i18n.format(t('deck_hardware'), 'capitalize')} + + + {t('location')} + + + {t('status')} + + + + {hasModules ? ( + + ) : null} + {requiredDeckConfigCompatibility.length > 0 ? ( + + ) : null} + ) : ( diff --git a/app/src/organisms/Devices/ProtocolRun/SetupModuleAndDeck/utils.ts b/app/src/organisms/Devices/ProtocolRun/SetupModuleAndDeck/utils.ts index 10bf9b5148d..b0702fccdf9 100644 --- a/app/src/organisms/Devices/ProtocolRun/SetupModuleAndDeck/utils.ts +++ b/app/src/organisms/Devices/ProtocolRun/SetupModuleAndDeck/utils.ts @@ -1,5 +1,10 @@ import { + HEATERSHAKER_MODULE_V1_FIXTURE, + MAGNETIC_BLOCK_V1_FIXTURE, STAGING_AREA_RIGHT_SLOT_FIXTURE, + TEMPERATURE_MODULE_V2_FIXTURE, + THERMOCYCLER_V2_FRONT_FIXTURE, + THERMOCYCLER_V2_REAR_FIXTURE, TRASH_BIN_ADAPTER_FIXTURE, WASTE_CHUTE_ONLY_FIXTURES, WASTE_CHUTE_STAGING_AREA_FIXTURES, @@ -48,6 +53,16 @@ export function getFixtureImage(cutoutFixtureId: CutoutFixtureId): string { return wasteChuteStagingArea } else if (cutoutFixtureId === TRASH_BIN_ADAPTER_FIXTURE) { return trashBin + } else if (cutoutFixtureId === THERMOCYCLER_V2_REAR_FIXTURE) { + return thermoModuleGen2 + } else if (cutoutFixtureId === THERMOCYCLER_V2_FRONT_FIXTURE) { + return thermoModuleGen2 + } else if (cutoutFixtureId === HEATERSHAKER_MODULE_V1_FIXTURE) { + return heaterShakerModule + } else if (cutoutFixtureId === TEMPERATURE_MODULE_V2_FIXTURE) { + return temperatureModule + } else if (cutoutFixtureId === MAGNETIC_BLOCK_V1_FIXTURE) { + return magneticBlockGen1 } else { return 'Error: unknown fixture' } diff --git a/app/src/organisms/Devices/ProtocolRun/__tests__/ProtocolRunHeader.test.tsx b/app/src/organisms/Devices/ProtocolRun/__tests__/ProtocolRunHeader.test.tsx index 65ea98c906f..3b6f0f9025b 100644 --- a/app/src/organisms/Devices/ProtocolRun/__tests__/ProtocolRunHeader.test.tsx +++ b/app/src/organisms/Devices/ProtocolRun/__tests__/ProtocolRunHeader.test.tsx @@ -814,7 +814,7 @@ describe('ProtocolRunHeader', () => { screen.getByText('Run completed.') }) - it('clicking close on a terminal run banner closes the run context and dismisses the banner', async () => { + it('clicking close on a terminal run banner closes the run context', async () => { when(vi.mocked(useNotifyRunQuery)) .calledWith(RUN_ID) .thenReturn({ @@ -827,9 +827,20 @@ describe('ProtocolRunHeader', () => { fireEvent.click(screen.getByTestId('Banner_close-button')) expect(mockCloseCurrentRun).toBeCalled() - await waitFor(() => { - expect(screen.queryByText('Run completed.')).not.toBeInTheDocument() - }) + }) + + it('does not display the "run successful" banner if the successful run is not current', async () => { + when(vi.mocked(useNotifyRunQuery)) + .calledWith(RUN_ID) + .thenReturn({ + data: { data: { ...mockSucceededRun, current: false } }, + } as UseQueryResult) + when(vi.mocked(useRunStatus)) + .calledWith(RUN_ID) + .thenReturn(RUN_STATUS_SUCCEEDED) + render() + + expect(screen.queryByText('Run completed.')).not.toBeInTheDocument() }) it('if a heater shaker is shaking, clicking on start run should render HeaterShakerIsRunningModal', async () => { diff --git a/app/src/organisms/Devices/ProtocolRun/__tests__/ProtocolRunRuntimeParameters.test.tsx b/app/src/organisms/Devices/ProtocolRun/__tests__/ProtocolRunRuntimeParameters.test.tsx index 8844f551d08..4be025a491e 100644 --- a/app/src/organisms/Devices/ProtocolRun/__tests__/ProtocolRunRuntimeParameters.test.tsx +++ b/app/src/organisms/Devices/ProtocolRun/__tests__/ProtocolRunRuntimeParameters.test.tsx @@ -1,12 +1,17 @@ import * as React from 'react' +import { UseQueryResult } from 'react-query' import { describe, it, vi, beforeEach, afterEach, expect } from 'vitest' import { screen } from '@testing-library/react' import { when } from 'vitest-when' -import { NoParameters } from '@opentrons/components' +import { Run } from '@opentrons/api-client' +import { InfoScreen } from '@opentrons/components' import { renderWithProviders } from '../../../../__testing-utils__' import { i18n } from '../../../../i18n' import { useMostRecentCompletedAnalysis } from '../../../LabwarePositionCheck/useMostRecentCompletedAnalysis' +import { useRunStatus } from '../../../RunTimeControl/hooks' +import { useNotifyRunQuery } from '../../../../resources/runs' +import { mockSucceededRun } from '../../../RunTimeControl/__fixtures__' import { ProtocolRunRuntimeParameters } from '../ProtocolRunRunTimeParameters' @@ -16,13 +21,15 @@ import type { } from '@opentrons/shared-data' vi.mock('@opentrons/components', async importOriginal => { - const actual = await importOriginal() + const actual = await importOriginal() return { ...actual, - NoParameters: vi.fn(), + InfoScreen: vi.fn(), } }) vi.mock('../../../LabwarePositionCheck/useMostRecentCompletedAnalysis') +vi.mock('../../../RunTimeControl/hooks') +vi.mock('../../../../resources/runs') const RUN_ID = 'mockId' @@ -31,7 +38,7 @@ const mockRunTimeParameterData: RunTimeParameter[] = [ displayName: 'Dry Run', variableName: 'DRYRUN', description: 'Is this a dry or wet run? Wet is true, dry is false', - type: 'boolean', + type: 'bool', default: false, value: false, }, @@ -94,19 +101,46 @@ describe('ProtocolRunRuntimeParameters', () => { props = { runId: RUN_ID, } - vi.mocked(NoParameters).mockReturnValue(
mock NoParameter
) + vi.mocked(InfoScreen).mockReturnValue(
mock InfoScreen
) when(vi.mocked(useMostRecentCompletedAnalysis)) .calledWith(RUN_ID) .thenReturn({ runTimeParameters: mockRunTimeParameterData, } as CompletedProtocolAnalysis) + vi.mocked(useRunStatus).mockReturnValue('running') + vi.mocked(useNotifyRunQuery).mockReturnValue(({ + data: { data: mockSucceededRun }, + } as unknown) as UseQueryResult) }) afterEach(() => { vi.resetAllMocks() }) - it('should render title, and banner when RunTimeParameters are note empty', () => { + it('should render title, and banner when RunTimeParameters are not empty and all values are default', () => { + render(props) + screen.getByText('Parameters') + screen.getByText('Default values') + screen.getByText('Values are view-only') + screen.getByText('Cancel the run and restart setup to edit') + screen.getByText('Name') + screen.getByText('Value') + }) + + it('should render title, and banner when RunTimeParameters are not empty and some value is changed', () => { + vi.mocked(useMostRecentCompletedAnalysis).mockReturnValue({ + runTimeParameters: [ + ...mockRunTimeParameterData, + { + displayName: 'Dry Run', + variableName: 'DRYRUN', + description: 'Is this a dry or wet run? Wet is true, dry is false', + type: 'bool', + default: false, + value: true, + }, + ], + } as CompletedProtocolAnalysis) render(props) screen.getByText('Parameters') screen.getByText('Custom values') @@ -116,7 +150,7 @@ describe('ProtocolRunRuntimeParameters', () => { screen.getByText('Value') }) - it('should render RunTimeParameters when RunTimeParameters are note empty', () => { + it('should render RunTimeParameters when RunTimeParameters are not empty', () => { render(props) screen.getByText('Dry Run') screen.getByText('Off') @@ -128,7 +162,7 @@ describe('ProtocolRunRuntimeParameters', () => { screen.getByText('No offsets') }) - it('should render mock NoParameter component when RunTimeParameters are empty', () => { + it('should render mock InfoScreen component when RunTimeParameters are empty', () => { when(vi.mocked(useMostRecentCompletedAnalysis)) .calledWith(RUN_ID) .thenReturn({ @@ -137,7 +171,7 @@ describe('ProtocolRunRuntimeParameters', () => { render(props) screen.getByText('Parameters') expect(screen.queryByText('Default values')).not.toBeInTheDocument() - screen.getByText('mock NoParameter') + screen.getByText('mock InfoScreen') }) // ToDo Additional test will be implemented when chip component is added diff --git a/app/src/organisms/Devices/ProtocolRun/utils/__tests__/getLabwareRenderInfo.test.ts b/app/src/organisms/Devices/ProtocolRun/utils/__tests__/getLabwareRenderInfo.test.ts index f96bacc93b6..0da562e9549 100644 --- a/app/src/organisms/Devices/ProtocolRun/utils/__tests__/getLabwareRenderInfo.test.ts +++ b/app/src/organisms/Devices/ProtocolRun/utils/__tests__/getLabwareRenderInfo.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest' -import { transfer_settings, ot2DeckDefV4 } from '@opentrons/shared-data' +import { transfer_settings, ot2DeckDefV5 } from '@opentrons/shared-data' import { getLabwareRenderInfo } from '../getLabwareRenderInfo' import type { CompletedProtocolAnalysis, @@ -8,7 +8,7 @@ import type { } from '@opentrons/shared-data' const protocolWithMagTempTC = (transfer_settings as unknown) as CompletedProtocolAnalysis -const standardDeckDef = ot2DeckDefV4 as any +const standardDeckDef = ot2DeckDefV5 as any describe('getLabwareRenderInfo', () => { it('should gather labware coordinates', () => { diff --git a/app/src/organisms/Devices/ProtocolRun/utils/__tests__/getProtocolModulesInfo.test.ts b/app/src/organisms/Devices/ProtocolRun/utils/__tests__/getProtocolModulesInfo.test.ts index cd6b5d06408..93528250b0d 100644 --- a/app/src/organisms/Devices/ProtocolRun/utils/__tests__/getProtocolModulesInfo.test.ts +++ b/app/src/organisms/Devices/ProtocolRun/utils/__tests__/getProtocolModulesInfo.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest' import { transfer_settings, multiple_temp_modules, - ot2DeckDefV4, + ot2DeckDefV5, getModuleDef2, ProtocolAnalysisOutput, LoadedLabware, @@ -174,7 +174,7 @@ const protocolWithMultipleTemps = ({ }, ] as LoadedModule[], } as unknown) as ProtocolAnalysisOutput -const standardDeckDef = ot2DeckDefV4 as any +const standardDeckDef = ot2DeckDefV5 as any describe('getProtocolModulesInfo', () => { it('should gather protocol module info for temp, mag, and tc', () => { diff --git a/app/src/organisms/Devices/RobotOverflowMenu.tsx b/app/src/organisms/Devices/RobotOverflowMenu.tsx index 751729b25a8..dd624e2dcd5 100644 --- a/app/src/organisms/Devices/RobotOverflowMenu.tsx +++ b/app/src/organisms/Devices/RobotOverflowMenu.tsx @@ -84,25 +84,27 @@ export function RobotOverflowMenu(props: RobotOverflowMenuProps): JSX.Element { if (robot.status === CONNECTABLE && runId == null) { menuItems = ( <> - {!isRobotBusy ? ( - - {t('run_a_protocol')} - - ) : null} + + {t('run_a_protocol')} + {isRobotOnWrongVersionOfSoftware && ( {t('shared:a_software_update_is_available')} )} + {!isRobotOnWrongVersionOfSoftware && isRobotBusy && ( + + {t('shared:robot_is_busy')} + + )} - {t('connection_lost_description')} + {t('branded:connection_lost_description')} void + robotName: string +} + +interface FormValues { + passwordInput: string +} + +export function FactoryModeSlideout({ + isExpanded, + onCloseClick, + robotName, +}: FactoryModeSlideoutProps): JSX.Element { + const { t } = useTranslation(['device_settings', 'shared', 'branded']) + + const dispatch = useDispatch() + + const { settings } = useRobotSettingsQuery().data ?? {} + const oemModeSetting = (settings ?? []).find( + (setting: RobotSettingsField) => setting?.id === 'enableOEMMode' + ) + const isOEMMode = oemModeSetting?.value ?? null + + const [currentStep, setCurrentStep] = React.useState(1) + const [toggleValue, setToggleValue] = React.useState(false) + const [file, setFile] = React.useState(null) + const [fileError, setFileError] = React.useState(null) + const [isUploading, setIsUploading] = React.useState(false) + + const onFinishCompleteClick = (): void => { + dispatch(restartRobot(robotName)) + onCloseClick() + setIsUploading(false) + } + + const { createSplash } = useCreateSplashMutation({ + onSuccess: () => { + onFinishCompleteClick() + }, + }) + + const { updateRobotSetting } = useUpdateRobotSettingMutation({ + onSuccess: () => { + if (toggleValue && file != null) { + createSplash({ file }) + } else { + onFinishCompleteClick() + } + }, + }) + + const { + handleSubmit, + control, + formState: { errors }, + trigger, + } = useForm({ + defaultValues: { + passwordInput: '', + }, + }) + const onSubmit = (data: FormValues): void => { + setCurrentStep(2) + } + + const handleSubmitFactoryPassword = (): void => { + // TODO: validation and errors: PLAT-281 + void handleSubmit(onSubmit)() + } + + const handleToggleClick: React.MouseEventHandler = () => { + setToggleValue(toggleValue => !toggleValue) + } + + const handleCompleteClick: React.MouseEventHandler = () => { + setIsUploading(true) + updateRobotSetting({ id: 'enableOEMMode', value: toggleValue }) + } + + const handleChooseFile = (file: File): void => { + // validation for file type + if (file.type !== 'image/png') { + setFileError('Incorrect file type') + setFile(file) + } else { + const imgUrl = URL.createObjectURL(file) + const logoImage = new Image() + logoImage.src = imgUrl + logoImage.onload = () => { + // validation for ODD screen size + if ( + logoImage.naturalWidth !== 1024 || + logoImage.naturalHeight !== 600 + ) { + setFileError('Incorrect image dimensions') + } + setFile(file) + } + } + } + + React.useEffect(() => { + // initialize local state to OEM mode value + if (isOEMMode != null) { + setToggleValue(isOEMMode) + } + }, [isOEMMode]) + + return ( + + {currentStep === 1 ? ( + + {t('shared:next')} + + ) : null} + {currentStep === 2 ? ( + + {isUploading ? ( + + ) : ( + t('complete_and_restart_robot') + )} + + ) : null} + + } + > + {currentStep === 1 ? ( + + ( + ) => { + field.onChange(e) + trigger('passwordInput') + }} + value={field.value} + error={fieldState.error?.message && ' '} + onBlur={field.onBlur} + title={t('enter_factory_password')} + /> + )} + /> + {errors.passwordInput != null ? ( + + {errors.passwordInput.message} + + ) : null} + + ) : null} + {currentStep === 2 ? ( + + + + {t('oem_mode')} + + + + + {toggleValue ? t('on') : t('off')} + + + {t('branded:oem_mode_description')} + + {toggleValue ? ( + + + + {t('upload_custom_logo')} + + + {t('upload_custom_logo_description')} + + + {t('upload_custom_logo_dimensions')} + + + {file == null ? ( + handleChooseFile(file)} + dragAndDropText={ + + , + }} + /> + + } + /> + ) : ( + { + setFile(null) + setFileError(null) + }} + /> + )} + + ) : null} + + ) : null} + + ) +} diff --git a/app/src/organisms/Devices/RobotSettings/AdvancedTab/AdvancedTabSlideouts/__tests__/DeviceResetModal.test.tsx b/app/src/organisms/Devices/RobotSettings/AdvancedTab/AdvancedTabSlideouts/__tests__/DeviceResetModal.test.tsx index 63cfd490c51..b741f3ef5c8 100644 --- a/app/src/organisms/Devices/RobotSettings/AdvancedTab/AdvancedTabSlideouts/__tests__/DeviceResetModal.test.tsx +++ b/app/src/organisms/Devices/RobotSettings/AdvancedTab/AdvancedTabSlideouts/__tests__/DeviceResetModal.test.tsx @@ -103,7 +103,7 @@ describe('RobotSettings DeviceResetModal', () => { }) screen.getByText('Connection to robot lost') screen.getByText( - 'The Opentrons App is unable to communicate with this robot right now. Double check the USB or Wifi connection to the robot, then try to reconnect.' + 'The Opentrons App is unable to communicate with this robot right now. Double check the USB or Wi-Fi connection to the robot, then try to reconnect.' ) screen.getByRole('button', { name: 'close' }) }) diff --git a/app/src/organisms/Devices/RobotSettings/AdvancedTab/FactoryMode.tsx b/app/src/organisms/Devices/RobotSettings/AdvancedTab/FactoryMode.tsx new file mode 100644 index 00000000000..8d2fda7c386 --- /dev/null +++ b/app/src/organisms/Devices/RobotSettings/AdvancedTab/FactoryMode.tsx @@ -0,0 +1,50 @@ +import * as React from 'react' +import { useTranslation } from 'react-i18next' + +import { + ALIGN_CENTER, + Box, + Flex, + JUSTIFY_SPACE_BETWEEN, + SPACING_AUTO, + SPACING, + StyledText, + TYPOGRAPHY, +} from '@opentrons/components' + +import { TertiaryButton } from '../../../../atoms/buttons' + +interface FactoryModeProps { + isRobotBusy: boolean + setShowFactoryModeSlideout: React.Dispatch> +} + +export function FactoryMode({ + isRobotBusy, + setShowFactoryModeSlideout, +}: FactoryModeProps): JSX.Element { + const { t } = useTranslation('device_settings') + + return ( + + + + {t('factory_mode')} + + + { + setShowFactoryModeSlideout(true) + }} + > + {t('setup_mode')} + + + ) +} diff --git a/app/src/organisms/Devices/RobotSettings/AdvancedTab/RobotServerVersion.tsx b/app/src/organisms/Devices/RobotSettings/AdvancedTab/RobotServerVersion.tsx index 193b0e140b9..0be4e872ed4 100644 --- a/app/src/organisms/Devices/RobotSettings/AdvancedTab/RobotServerVersion.tsx +++ b/app/src/organisms/Devices/RobotSettings/AdvancedTab/RobotServerVersion.tsx @@ -32,7 +32,7 @@ const GITHUB_LINK = export function RobotServerVersion({ robotName, }: RobotServerVersionProps): JSX.Element { - const { t } = useTranslation(['device_settings', 'shared']) + const { t } = useTranslation(['device_settings', 'shared', 'branded']) const robot = useRobot(robotName) const isFlex = useIsFlex(robotName) const { autoUpdateAction } = useSelector((state: State) => { @@ -65,7 +65,7 @@ export function RobotServerVersion({ {isFlex ? ( - {t('robot_server_version_ot3_description')} + {t('branded:robot_server_version_ot3_description')} ) : null} diff --git a/app/src/organisms/Devices/RobotSettings/AdvancedTab/SoftwareUpdateModal.tsx b/app/src/organisms/Devices/RobotSettings/AdvancedTab/SoftwareUpdateModal.tsx deleted file mode 100644 index 9884676e224..00000000000 --- a/app/src/organisms/Devices/RobotSettings/AdvancedTab/SoftwareUpdateModal.tsx +++ /dev/null @@ -1,121 +0,0 @@ -import * as React from 'react' -import { useTranslation } from 'react-i18next' -import { useSelector } from 'react-redux' -import { - DIRECTION_COLUMN, - Flex, - JUSTIFY_FLEX_END, - PrimaryButton, - SecondaryButton, - SPACING, - StyledText, - TYPOGRAPHY, -} from '@opentrons/components' -import { getShellUpdateState } from '../../../../redux/shell' -import { useCurrentRunId } from '../../../../organisms/ProtocolUpload/hooks' -// import { ReleaseNotes } from '../../../../molecules/ReleaseNotes' - -import { ExternalLink } from '../../../../atoms/Link/ExternalLink' -import { Banner } from '../../../../atoms/Banner' -import { LegacyModal } from '../../../../molecules/LegacyModal' -import { CONNECTABLE, REACHABLE } from '../../../../redux/discovery' -import { Divider } from '../../../../atoms/structure' -import { useRobot } from '../../hooks' -import { handleUpdateBuildroot } from '../UpdateBuildroot' - -const TECHNICAL_CHANGE_LOG_URL = - 'https://github.com/Opentrons/opentrons/blob/edge/CHANGELOG.md' -const ISSUE_TRACKER_URL = - 'https://github.com/Opentrons/opentrons/issues?q=is%3Aopen+is%3Aissue+label%3Abug' -const RELEASE_NOTES_URL = 'https://github.com/Opentrons/opentrons/releases' - -interface SoftwareUpdateModalProps { - robotName: string - closeModal: () => void -} - -export function SoftwareUpdateModal({ - robotName, - closeModal, -}: SoftwareUpdateModalProps): JSX.Element | null { - const { t } = useTranslation('device_settings') - - const currentRunId = useCurrentRunId() - // ToDo: Add release notes for the new design - const updateState = useSelector(getShellUpdateState) - // const { downloaded, downloading, error, info: updateInfo } = updateState - const { info: updateInfo } = updateState - const version = updateInfo?.version ?? '' - // const releaseNotes = updateInfo?.releaseNotes - const [showUpdateModal, setShowUpdateModal] = React.useState(false) - const robot = useRobot(robotName) - - if (robot?.status !== CONNECTABLE && robot?.status !== REACHABLE) return null - - return !showUpdateModal ? ( - - {t('requires_restarting_the_robot')} - - {/* ToDo: align with new design */} - - {t('app_change_in', { version })} - - - {'None in the Opentrons (Here will be change logs)'} - - - {t('new_features')} - - - {'None in the Opentrons (Here will be features info)'} - - - {t('bug_fixes')} - - - {'None in the Opentrons (Here will be fixes info)'} - - - - {t('view_opentrons_technical_change_log')} - - - {t('view_opentrons_issue_tracker')} - - - {t('view_opentrons_release_notes')} - - - - {t('remind_me_later')} - - { - setShowUpdateModal(true) - handleUpdateBuildroot(robot) - }} - disabled={currentRunId != null} - > - {t('update_robot_now')} - - - - - ) : null -} diff --git a/app/src/organisms/Devices/RobotSettings/AdvancedTab/UpdateRobotSoftware.tsx b/app/src/organisms/Devices/RobotSettings/AdvancedTab/UpdateRobotSoftware.tsx index a8febac7092..bf7a27e389b 100644 --- a/app/src/organisms/Devices/RobotSettings/AdvancedTab/UpdateRobotSoftware.tsx +++ b/app/src/organisms/Devices/RobotSettings/AdvancedTab/UpdateRobotSoftware.tsx @@ -40,7 +40,7 @@ export function UpdateRobotSoftware({ onUpdateStart, isRobotBusy, }: UpdateRobotSoftwareProps): JSX.Element { - const { t } = useTranslation('device_settings') + const { t } = useTranslation(['device_settings', 'branded']) const { updateFromFileDisabledReason } = useSelector((state: State) => { return getRobotUpdateDisplayInfo(state, robotName) }) @@ -77,10 +77,10 @@ export function UpdateRobotSoftware({ {t('update_robot_software')} - {t('update_robot_software_description')} + {t('branded:update_robot_software_description')} - {t('update_robot_software_link')} + {t('branded:update_robot_software_link')}
() const value = settings?.value ? settings.value : false const id = settings?.id ? settings.id : 'disableFastProtocolUpload' @@ -54,7 +54,7 @@ export function UseOlderProtocol({ {t('use_older_protocol_analysis_method')} - {t('use_older_protocol_analysis_method_description')} + {t('branded:use_older_protocol_analysis_method_description')} { - const actual = await importOriginal() - return { - ...actual, - getShellUpdateState: vi.fn(), - } -}) -vi.mock('../../../hooks') -vi.mock('../../../../../redux/discovery/selectors') - -const mockClose = vi.fn() - -const render = () => { - return renderWithProviders( - - - , - { i18nInstance: i18n } - ) -} - -describe('RobotSettings SoftwareUpdateModal', () => { - beforeEach(() => { - vi.mocked(useRobot).mockReturnValue(mockReachableRobot) - vi.mocked(getShellUpdateState).mockReturnValue({ - downloaded: true, - info: { - version: '1.2.3', - releaseNotes: 'this is a release', - }, - } as ShellUpdateState) - }) - - it('should render title ,description and button', () => { - render() - screen.getByText('Robot Update Available') - screen.getByText( - 'Updating the robot’s software requires restarting the robot' - ) - screen.getByText('App Changes in 1.2.3') - screen.getByText('New Features') - screen.getByText('Bug Fixes') - screen.getByText('View Opentrons technical change log') - screen.getByText('View Opentrons issue tracker') - screen.getByText('View full Opentrons release notes') - screen.getByRole('button', { name: 'Remind me later' }) - screen.getByRole('button', { name: 'Update robot now' }) - }) - - it('should have correct href', () => { - render() - const changeLogUrl = - 'https://github.com/Opentrons/opentrons/blob/edge/CHANGELOG.md' - const issueTrackerUrl = - 'https://github.com/Opentrons/opentrons/issues?q=is%3Aopen+is%3Aissue+label%3Abug' - const releaseNotesUrl = 'https://github.com/Opentrons/opentrons/releases' - - const linkForChangeLog = screen.getByRole('link', { - name: 'View Opentrons technical change log', - }) - expect(linkForChangeLog).toHaveAttribute('href', changeLogUrl) - - const linkForIssueTracker = screen.getByRole('link', { - name: 'View Opentrons issue tracker', - }) - expect(linkForIssueTracker.closest('a')).toHaveAttribute( - 'href', - issueTrackerUrl - ) - - const linkForReleaseNotes = screen.getByRole('link', { - name: 'View full Opentrons release notes', - }) - expect(linkForReleaseNotes.closest('a')).toHaveAttribute( - 'href', - releaseNotesUrl - ) - }) -}) diff --git a/app/src/organisms/Devices/RobotSettings/AdvancedTab/index.ts b/app/src/organisms/Devices/RobotSettings/AdvancedTab/index.ts index 1c5cb506bc7..b53134df945 100644 --- a/app/src/organisms/Devices/RobotSettings/AdvancedTab/index.ts +++ b/app/src/organisms/Devices/RobotSettings/AdvancedTab/index.ts @@ -1,13 +1,13 @@ export * from './DeviceReset' export * from './DisplayRobotName' export * from './EnableStatusLight' +export * from './FactoryMode' export * from './GantryHoming' export * from './LegacySettings' export * from './OpenJupyterControl' export * from './RobotInformation' export * from './RobotServerVersion' export * from './ShortTrashBin' -export * from './SoftwareUpdateModal' export * from './Troubleshooting' export * from './UpdateRobotSoftware' export * from './UsageSettings' diff --git a/app/src/organisms/Devices/RobotSettings/ConnectNetwork/DisconnectModal.tsx b/app/src/organisms/Devices/RobotSettings/ConnectNetwork/DisconnectModal.tsx index 660e04a1519..b489af43d1f 100644 --- a/app/src/organisms/Devices/RobotSettings/ConnectNetwork/DisconnectModal.tsx +++ b/app/src/organisms/Devices/RobotSettings/ConnectNetwork/DisconnectModal.tsx @@ -47,7 +47,7 @@ export const DisconnectModal = ({ onCancel, robotName, }: DisconnectModalProps): JSX.Element => { - const { t } = useTranslation(['device_settings', 'shared']) + const { t } = useTranslation(['device_settings', 'shared', 'branded']) const wifiList = useWifiList(robotName) const { wifi } = useSelector((state: State) => @@ -144,7 +144,7 @@ export const DisconnectModal = ({ {isError ? ( - {t('shared:general_error_message')} + {t('branded:general_error_message')} ) : null} diff --git a/app/src/organisms/Devices/RobotSettings/ConnectNetwork/__tests__/DisconnectModal.test.tsx b/app/src/organisms/Devices/RobotSettings/ConnectNetwork/__tests__/DisconnectModal.test.tsx index adf1e9a591a..79823d81ef3 100644 --- a/app/src/organisms/Devices/RobotSettings/ConnectNetwork/__tests__/DisconnectModal.test.tsx +++ b/app/src/organisms/Devices/RobotSettings/ConnectNetwork/__tests__/DisconnectModal.test.tsx @@ -160,7 +160,7 @@ describe('DisconnectModal', () => { 'Your robot was unable to disconnect from Wi-Fi network foo.' ) screen.getByText( - 'If you keep getting this message, try restarting your app and/or robot. If this does not resolve the issue please contact Opentrons Support.' + 'If you keep getting this message, try restarting your app and robot. If this does not resolve the issue, contact Opentrons Support.' ) screen.getByRole('button', { name: 'cancel' }) screen.getByRole('button', { name: 'Disconnect' }) diff --git a/app/src/organisms/Devices/RobotSettings/RobotSettingsAdvanced.tsx b/app/src/organisms/Devices/RobotSettings/RobotSettingsAdvanced.tsx index 8772f9a383a..be9cdcd2be4 100644 --- a/app/src/organisms/Devices/RobotSettings/RobotSettingsAdvanced.tsx +++ b/app/src/organisms/Devices/RobotSettings/RobotSettingsAdvanced.tsx @@ -19,6 +19,7 @@ import { DeviceReset, DisplayRobotName, EnableStatusLight, + FactoryMode, GantryHoming, LegacySettings, OpenJupyterControl, @@ -39,6 +40,7 @@ import { import { RenameRobotSlideout } from './AdvancedTab/AdvancedTabSlideouts/RenameRobotSlideout' import { DeviceResetSlideout } from './AdvancedTab/AdvancedTabSlideouts/DeviceResetSlideout' import { DeviceResetModal } from './AdvancedTab/AdvancedTabSlideouts/DeviceResetModal' +import { FactoryModeSlideout } from './AdvancedTab/AdvancedTabSlideouts/FactoryModeSlideout' import { handleUpdateBuildroot } from './UpdateBuildroot' import { UNREACHABLE } from '../../../redux/discovery' import { getTopPortalEl } from '../../../App/portal' @@ -72,6 +74,10 @@ export function RobotSettingsAdvanced({ showDeviceResetModal, setShowDeviceResetModal, ] = React.useState(false) + const [ + showFactoryModeSlideout, + setShowFactoryModeSlideout, + ] = React.useState(false) const isRobotBusy = useIsRobotBusy({ poll: true }) const isEstopNotDisengaged = useIsEstopNotDisengaged(robotName) @@ -131,6 +137,13 @@ export function RobotSettingsAdvanced({ robotName={robotName} /> )} + {showFactoryModeSlideout && ( + setShowFactoryModeSlideout(false)} + robotName={robotName} + /> + )} {showDeviceResetSlideout && ( handleUpdateBuildroot(robot)} /> + {isFlex ? ( + <> + + + + ) : null} ((state: State) => getRobotSettings(state, robotName) ) diff --git a/app/src/organisms/Devices/RobotSettings/UpdateBuildroot/UpdateRobotModal.tsx b/app/src/organisms/Devices/RobotSettings/UpdateBuildroot/UpdateRobotModal.tsx index f02ad6ae3ce..4a1ffaec5ea 100644 --- a/app/src/organisms/Devices/RobotSettings/UpdateBuildroot/UpdateRobotModal.tsx +++ b/app/src/organisms/Devices/RobotSettings/UpdateBuildroot/UpdateRobotModal.tsx @@ -155,7 +155,7 @@ export function UpdateRobotModal({ > - {t('update_requires_restarting')} + {t('update_requires_restarting_robot')} diff --git a/app/src/organisms/Devices/__tests__/HistoricalProtocolRun.test.tsx b/app/src/organisms/Devices/__tests__/HistoricalProtocolRun.test.tsx index bc59f8cf884..dccbb3dfefc 100644 --- a/app/src/organisms/Devices/__tests__/HistoricalProtocolRun.test.tsx +++ b/app/src/organisms/Devices/__tests__/HistoricalProtocolRun.test.tsx @@ -18,8 +18,8 @@ vi.mock('../../../redux/protocol-storage') vi.mock('../../RunTimeControl/hooks') vi.mock('../HistoricalProtocolRunOverflowMenu') vi.mock('react-router-dom', async importOriginal => { - const reactRouterDom = importOriginal() - return await { + const reactRouterDom = await importOriginal() + return { ...reactRouterDom, useHistory: () => ({ push: mockPush } as any), } diff --git a/app/src/organisms/Devices/__tests__/HistoricalProtocolRunOverflowMenu.test.tsx b/app/src/organisms/Devices/__tests__/HistoricalProtocolRunOverflowMenu.test.tsx index f7d537e88ff..c436bc04960 100644 --- a/app/src/organisms/Devices/__tests__/HistoricalProtocolRunOverflowMenu.test.tsx +++ b/app/src/organisms/Devices/__tests__/HistoricalProtocolRunOverflowMenu.test.tsx @@ -5,23 +5,24 @@ import '@testing-library/jest-dom/vitest' import { renderWithProviders } from '../../../__testing-utils__' import { when } from 'vitest-when' import { MemoryRouter } from 'react-router-dom' -import { UseQueryResult } from 'react-query' import { useAllCommandsQuery, useDeleteRunMutation, } from '@opentrons/react-api-client' import { i18n } from '../../../i18n' import runRecord from '../../../organisms/RunDetails/__fixtures__/runRecord.json' -import { useDownloadRunLog, useTrackProtocolRunEvent } from '../hooks' +import { useDownloadRunLog, useTrackProtocolRunEvent, useRobot } from '../hooks' import { useRunControls } from '../../RunTimeControl/hooks' import { useTrackEvent, ANALYTICS_PROTOCOL_PROCEED_TO_RUN, } from '../../../redux/analytics' +import { mockConnectableRobot } from '../../../redux/discovery/__fixtures__' import { getRobotUpdateDisplayInfo } from '../../../redux/robot-update' import { useIsEstopNotDisengaged } from '../../../resources/devices/hooks/useIsEstopNotDisengaged' import { HistoricalProtocolRunOverflowMenu } from '../HistoricalProtocolRunOverflowMenu' +import type { UseQueryResult } from 'react-query' import type { CommandsData } from '@opentrons/api-client' vi.mock('../../../redux/analytics') @@ -104,6 +105,9 @@ describe('HistoricalProtocolRunOverflowMenu', () => { robotName: ROBOT_NAME, robotIsBusy: false, } + when(vi.mocked(useRobot)) + .calledWith(ROBOT_NAME) + .thenReturn(mockConnectableRobot) }) it('renders the correct menu when a runId is present', () => { @@ -122,7 +126,10 @@ describe('HistoricalProtocolRunOverflowMenu', () => { fireEvent.click(rerunBtn) expect(mockTrackEvent).toHaveBeenCalledWith({ name: ANALYTICS_PROTOCOL_PROCEED_TO_RUN, - properties: { sourceLocation: 'HistoricalProtocolRun' }, + properties: { + robotSerialNumber: 'mock-serial', + sourceLocation: 'HistoricalProtocolRun', + }, }) expect(useRunControls).toHaveBeenCalled() expect(mockTrackProtocolRunEvent).toHaveBeenCalled() diff --git a/app/src/organisms/Devices/__tests__/RobotOverflowMenu.test.tsx b/app/src/organisms/Devices/__tests__/RobotOverflowMenu.test.tsx index 69ad5afc77a..6227cbd5675 100644 --- a/app/src/organisms/Devices/__tests__/RobotOverflowMenu.test.tsx +++ b/app/src/organisms/Devices/__tests__/RobotOverflowMenu.test.tsx @@ -20,6 +20,7 @@ vi.mock('../../../redux/robot-update/selectors') vi.mock('../../ProtocolUpload/hooks') vi.mock('../../ChooseProtocolSlideout') vi.mock('../hooks') +vi.mock('../../../resources/devices/hooks/useIsEstopNotDisengaged') const render = (props: React.ComponentProps) => { return renderWithProviders( @@ -85,19 +86,13 @@ describe('RobotOverflowMenu', () => { expect(run).toBeDisabled() }) - it('should only render robot settings when e-stop is pressed or disconnected', () => { + it('disables the run a protocol menu item if robot is busy', () => { vi.mocked(useCurrentRunId).mockReturnValue(null) - vi.mocked(getRobotUpdateDisplayInfo).mockReturnValue({ - autoUpdateAction: 'upgrade', - autoUpdateDisabledReason: null, - updateFromFileDisabledReason: null, - }) - vi.mocked(useIsRobotBusy).mockReturnValue(true) render(props) const btn = screen.getByLabelText('RobotOverflowMenu_button') fireEvent.click(btn) - expect(screen.queryByText('Run a protocol')).not.toBeInTheDocument() - screen.getByText('Robot settings') + const run = screen.getByText('Run a protocol') + expect(run).toBeDisabled() }) }) diff --git a/app/src/organisms/Devices/__tests__/RobotOverview.test.tsx b/app/src/organisms/Devices/__tests__/RobotOverview.test.tsx index b02e5ce600a..66f6d18b7d0 100644 --- a/app/src/organisms/Devices/__tests__/RobotOverview.test.tsx +++ b/app/src/organisms/Devices/__tests__/RobotOverview.test.tsx @@ -51,8 +51,8 @@ import type { State } from '../../../redux/types' import type * as ReactApiClient from '@opentrons/react-api-client' vi.mock('@opentrons/react-api-client', async importOriginal => { - const actual = importOriginal() - return await { + const actual = await importOriginal() + return { ...actual, useAuthorization: vi.fn(), } diff --git a/app/src/organisms/Devices/hooks/__tests__/useModuleRenderInfoForProtocolById.test.tsx b/app/src/organisms/Devices/hooks/__tests__/useModuleRenderInfoForProtocolById.test.tsx index 11b744f57a2..540b1532799 100644 --- a/app/src/organisms/Devices/hooks/__tests__/useModuleRenderInfoForProtocolById.test.tsx +++ b/app/src/organisms/Devices/hooks/__tests__/useModuleRenderInfoForProtocolById.test.tsx @@ -1,10 +1,11 @@ import { renderHook } from '@testing-library/react' import { vi, it, expect, describe, beforeEach } from 'vitest' import { when } from 'vitest-when' -import { UseQueryResult } from 'react-query' import { - STAGING_AREA_RIGHT_SLOT_FIXTURE, + TEMPERATURE_MODULE_TYPE, + TEMPERATURE_MODULE_V2, + TEMPERATURE_MODULE_V2_FIXTURE, heater_shaker_commands_with_results_key, } from '@opentrons/shared-data' import { useMostRecentCompletedAnalysis } from '../../../LabwarePositionCheck/useMostRecentCompletedAnalysis' @@ -13,7 +14,6 @@ import { useDeckConfigurationQuery } from '@opentrons/react-api-client' import { getProtocolModulesInfo } from '../../ProtocolRun/utils/getProtocolModulesInfo' import { - mockMagneticModuleGen2, mockTemperatureModuleGen2, mockThermocycler, } from '../../../../redux/modules/__fixtures__' @@ -30,6 +30,8 @@ import type { ModuleType, ProtocolAnalysisOutput, } from '@opentrons/shared-data' +import type { UseQueryResult } from 'react-query' +import type { AttachedModule } from '../../../../redux/modules/types' vi.mock('@opentrons/react-api-client') vi.mock('../../ProtocolRun/utils/getProtocolModulesInfo') @@ -53,25 +55,28 @@ const PROTOCOL_DETAILS = { protocolKey: 'fakeProtocolKey', } -const mockMagneticModuleDefinition = { - moduleId: 'someMagneticModule', - model: 'magneticModuleV2' as ModuleModel, - type: 'magneticModuleType' as ModuleType, - labwareOffset: { x: 5, y: 5, z: 5 }, - cornerOffsetFromSlot: { x: 1, y: 1, z: 1 }, - dimensions: { - xDimension: 100, - yDimension: 100, - footprintXDimension: 50, - footprintYDimension: 50, - labwareInterfaceXDimension: 80, - labwareInterfaceYDimension: 120, +const mockAttachedTempMod: AttachedModule = { + id: 'temp_mod_1', + moduleModel: TEMPERATURE_MODULE_V2, + moduleType: TEMPERATURE_MODULE_TYPE, + serialNumber: 'abc123', + hardwareRevision: 'heatershaker_v4.0', + firmwareVersion: 'v2.0.0', + hasAvailableUpdate: true, + data: { + currentTemperature: 40, + targetTemperature: null, + status: 'idle', + }, + usbPort: { + path: '/dev/ot_module_heatershaker0', + port: 1, + portGroup: 'unknown', + hub: false, }, - twoDimensionalRendering: { children: [] }, } const mockTemperatureModuleDefinition = { - moduleId: 'someMagneticModule', model: 'temperatureModuleV2' as ModuleModel, type: 'temperatureModuleType' as ModuleType, labwareOffset: { x: 5, y: 5, z: 5 }, @@ -87,19 +92,6 @@ const mockTemperatureModuleDefinition = { twoDimensionalRendering: { children: [] }, } -const MAGNETIC_MODULE_INFO = { - moduleId: 'magneticModuleId', - x: 0, - y: 0, - z: 0, - moduleDef: mockMagneticModuleDefinition as any, - nestedLabwareDef: null, - nestedLabwareId: null, - nestedLabwareDisplayName: null, - protocolLoadOrder: 0, - slotName: 'D1', -} - const TEMPERATURE_MODULE_INFO = { moduleId: 'temperatureModuleId', x: 0, @@ -111,11 +103,12 @@ const TEMPERATURE_MODULE_INFO = { nestedLabwareDisplayName: null, protocolLoadOrder: 0, slotName: 'D1', -} +} as any const mockCutoutConfig: CutoutConfig = { cutoutId: 'cutoutD1', - cutoutFixtureId: STAGING_AREA_RIGHT_SLOT_FIXTURE, + cutoutFixtureId: TEMPERATURE_MODULE_V2_FIXTURE, + opentronsModuleSerialNumber: 'abc123', } describe('useModuleRenderInfoForProtocolById hook', () => { @@ -123,8 +116,8 @@ describe('useModuleRenderInfoForProtocolById hook', () => { vi.mocked(useDeckConfigurationQuery).mockReturnValue({ data: [mockCutoutConfig], } as UseQueryResult) + vi.mocked(useAttachedModules).mockReturnValue([mockAttachedTempMod]) vi.mocked(useAttachedModules).mockReturnValue([ - mockMagneticModuleGen2, mockTemperatureModuleGen2, mockThermocycler, ]) @@ -134,10 +127,7 @@ describe('useModuleRenderInfoForProtocolById hook', () => { when(vi.mocked(useMostRecentCompletedAnalysis)) .calledWith('1') .thenReturn(PROTOCOL_DETAILS.protocolData as any) - vi.mocked(getProtocolModulesInfo).mockReturnValue([ - TEMPERATURE_MODULE_INFO, - MAGNETIC_MODULE_INFO, - ]) + vi.mocked(getProtocolModulesInfo).mockReturnValue([TEMPERATURE_MODULE_INFO]) }) it('should return no module render info when protocol details not found', () => { @@ -155,13 +145,8 @@ describe('useModuleRenderInfoForProtocolById hook', () => { useModuleRenderInfoForProtocolById('1', true) ) expect(result.current).toStrictEqual({ - magneticModuleId: { - conflictedFixture: mockCutoutConfig, - attachedModuleMatch: mockMagneticModuleGen2, - ...MAGNETIC_MODULE_INFO, - }, temperatureModuleId: { - conflictedFixture: mockCutoutConfig, + conflictedFixture: null, attachedModuleMatch: mockTemperatureModuleGen2, ...TEMPERATURE_MODULE_INFO, }, diff --git a/app/src/organisms/Devices/hooks/__tests__/useProtocolRunAnalyticsData.test.tsx b/app/src/organisms/Devices/hooks/__tests__/useProtocolRunAnalyticsData.test.tsx index ce08a6cab90..72d8084df6b 100644 --- a/app/src/organisms/Devices/hooks/__tests__/useProtocolRunAnalyticsData.test.tsx +++ b/app/src/organisms/Devices/hooks/__tests__/useProtocolRunAnalyticsData.test.tsx @@ -131,7 +131,7 @@ describe('useProtocolAnalysisErrors hook', () => { protocolText: 'hashedString', protocolType: '', robotType: 'OT-2 Standard', - robotSerialNumber: '', + robotSerialNumber: 'mock-serial', }, runTime: '1:00:00', }) @@ -160,7 +160,7 @@ describe('useProtocolAnalysisErrors hook', () => { protocolText: 'hashedString', protocolType: 'json', robotType: 'OT-2 Standard', - robotSerialNumber: '', + robotSerialNumber: 'mock-serial', }, runTime: '1:00:00', }) diff --git a/app/src/organisms/Devices/hooks/__tests__/useStoredProtocolAnalysis.test.tsx b/app/src/organisms/Devices/hooks/__tests__/useStoredProtocolAnalysis.test.tsx index 34365a075e7..4a165f628c5 100644 --- a/app/src/organisms/Devices/hooks/__tests__/useStoredProtocolAnalysis.test.tsx +++ b/app/src/organisms/Devices/hooks/__tests__/useStoredProtocolAnalysis.test.tsx @@ -1,9 +1,9 @@ import * as React from 'react' import { vi, it, expect, describe, beforeEach, afterEach } from 'vitest' import { when } from 'vitest-when' -import { QueryClient, QueryClientProvider, UseQueryResult } from 'react-query' +import { QueryClient, QueryClientProvider } from 'react-query' import { Provider } from 'react-redux' -import { createStore, Store } from 'redux' +import { createStore } from 'redux' import { renderHook } from '@testing-library/react' import { @@ -12,12 +12,10 @@ import { parsePipetteEntity, } from '@opentrons/api-client' import { useProtocolQuery } from '@opentrons/react-api-client' +import { OT2_ROBOT_TYPE } from '@opentrons/shared-data' import { storedProtocolData } from '../../../../redux/protocol-storage/__fixtures__' -import { - getStoredProtocol, - StoredProtocolData, -} from '../../../../redux/protocol-storage' +import { getStoredProtocol } from '../../../../redux/protocol-storage' import { useStoredProtocolAnalysis } from '../useStoredProtocolAnalysis' import { LABWARE_ENTITY, @@ -27,7 +25,10 @@ import { } from '../__fixtures__/storedProtocolAnalysis' import { useNotifyRunQuery } from '../../../../resources/runs' +import type { Store } from 'redux' +import type { UseQueryResult } from 'react-query' import type { Protocol, Run } from '@opentrons/api-client' +import type { StoredProtocolData } from '../../../../redux/protocol-storage' vi.mock('@opentrons/api-client') vi.mock('@opentrons/react-api-client') @@ -42,6 +43,9 @@ const modifiedStoredProtocolData = { commands: storedProtocolData?.mostRecentAnalysis?.commands, liquids: storedProtocolData?.mostRecentAnalysis?.liquids, errors: storedProtocolData?.mostRecentAnalysis?.errors, + runTimeParameters: + storedProtocolData?.mostRecentAnalysis?.runTimeParameters, + robotType: OT2_ROBOT_TYPE, }, } diff --git a/app/src/organisms/Devices/hooks/useModuleRenderInfoForProtocolById.ts b/app/src/organisms/Devices/hooks/useModuleRenderInfoForProtocolById.ts index 6ca57b24c4a..e606e846d53 100644 --- a/app/src/organisms/Devices/hooks/useModuleRenderInfoForProtocolById.ts +++ b/app/src/organisms/Devices/hooks/useModuleRenderInfoForProtocolById.ts @@ -1,12 +1,10 @@ import { checkModuleCompatibility, FLEX_ROBOT_TYPE, - getCutoutIdForSlotName, + getCutoutFixturesForModuleModel, + getCutoutIdsFromModuleSlotName, getDeckDefFromRobotType, - MAGNETIC_BLOCK_TYPE, - SINGLE_SLOT_FIXTURES, - STAGING_AREA_RIGHT_SLOT_FIXTURE, - THERMOCYCLER_MODULE_TYPE, + OT2_ROBOT_TYPE, } from '@opentrons/shared-data' import { useDeckConfigurationQuery } from '@opentrons/react-api-client' @@ -35,7 +33,7 @@ export function useModuleRenderInfoForProtocolById( pollModules?: boolean ): ModuleRenderInfoById { const robotProtocolAnalysis = useMostRecentCompletedAnalysis(runId) - const { data: deckConfig } = useDeckConfigurationQuery({ + const { data: deckConfig = [] } = useDeckConfigurationQuery({ refetchInterval: REFETCH_INTERVAL_5000_MS, }) const storedProtocolAnalysis = useStoredProtocolAnalysis(runId) @@ -45,50 +43,57 @@ export function useModuleRenderInfoForProtocolById( }) if (protocolAnalysis == null) return {} - const deckDef = getDeckDefFromRobotType( - protocolAnalysis.robotType ?? FLEX_ROBOT_TYPE - ) + const assumedRobotType = protocolAnalysis.robotType ?? FLEX_ROBOT_TYPE + const deckDef = getDeckDefFromRobotType(assumedRobotType) const protocolModulesInfo = getProtocolModulesInfo(protocolAnalysis, deckDef) const protocolModulesInfoInLoadOrder = protocolModulesInfo.sort( (modA, modB) => modA.protocolLoadOrder - modB.protocolLoadOrder ) + + const robotSupportsModuleConfig = assumedRobotType !== OT2_ROBOT_TYPE let matchedAmod: AttachedModule[] = [] const allModuleRenderInfo = protocolModulesInfoInLoadOrder.map( protocolMod => { + const moduleFixtures = getCutoutFixturesForModuleModel( + protocolMod.moduleDef.model, + deckDef + ) + const moduleCutoutIds = getCutoutIdsFromModuleSlotName( + protocolMod.slotName, + moduleFixtures, + deckDef + ) const compatibleAttachedModule = attachedModules.find( attachedMod => + // first check module model compatibility checkModuleCompatibility( attachedMod.moduleModel, protocolMod.moduleDef.model - ) && !matchedAmod.find(m => m === attachedMod) + ) && + // then check that the module hasn't already been matched + !matchedAmod.some( + m => m.serialNumber === attachedMod.serialNumber + ) && + // then if robotType supports configurable modules check the deck config has a + // a module with the expected serial number in the expected location + (!robotSupportsModuleConfig || + deckConfig.some( + ({ cutoutId, opentronsModuleSerialNumber }) => + attachedMod.serialNumber === opentronsModuleSerialNumber && + moduleCutoutIds.includes(cutoutId) + )) ) ?? null - const cutoutIdForSlotName = getCutoutIdForSlotName( - protocolMod.slotName, - deckDef - ) - - const isMagneticBlockModule = - protocolMod.moduleDef.moduleType === MAGNETIC_BLOCK_TYPE - - const isThermocycler = - protocolMod.moduleDef.moduleType === THERMOCYCLER_MODULE_TYPE - const conflictedFixture = deckConfig?.find( - fixture => - (fixture.cutoutId === cutoutIdForSlotName || - // special-case A1 for the thermocycler to require a single slot fixture - (isThermocycler && fixture.cutoutId === 'cutoutA1')) && - fixture.cutoutFixtureId != null && - // do not generate a conflict for single slot fixtures, because modules are not yet fixtures - !SINGLE_SLOT_FIXTURES.includes(fixture.cutoutFixtureId) && - // special case the magnetic module because unlike other modules it sits in a slot that can also be provided by a staging area fixture - (!isMagneticBlockModule || - fixture.cutoutFixtureId !== STAGING_AREA_RIGHT_SLOT_FIXTURE) + ({ cutoutId, cutoutFixtureId }) => + moduleCutoutIds.includes(cutoutId) && + !moduleFixtures.some(({ id }) => cutoutFixtureId === id) && + // if robotType supports module config, don't treat module fixture as conflict + (!robotSupportsModuleConfig || compatibleAttachedModule == null) ) ?? null if (compatibleAttachedModule !== null) { diff --git a/app/src/organisms/Devices/hooks/useRunStatuses.ts b/app/src/organisms/Devices/hooks/useRunStatuses.ts index bba83f76299..887de586f8e 100644 --- a/app/src/organisms/Devices/hooks/useRunStatuses.ts +++ b/app/src/organisms/Devices/hooks/useRunStatuses.ts @@ -1,15 +1,15 @@ import { + RUN_STATUSES_TERMINAL, RUN_STATUS_AWAITING_RECOVERY, - RUN_STATUS_FAILED, RUN_STATUS_IDLE, RUN_STATUS_PAUSED, RUN_STATUS_RUNNING, - RUN_STATUS_STOPPED, - RUN_STATUS_SUCCEEDED, } from '@opentrons/api-client' import { useCurrentRunId } from '../../ProtocolUpload/hooks' import { useRunStatus } from '../../RunTimeControl/hooks' +import type { RunStatus } from '@opentrons/api-client' + interface RunStatusesInfo { isRunStill: boolean isRunTerminal: boolean @@ -29,9 +29,9 @@ export function useRunStatuses(): RunStatusesInfo { runStatus === RUN_STATUS_RUNNING || runStatus === RUN_STATUS_AWAITING_RECOVERY const isRunTerminal = - runStatus === RUN_STATUS_SUCCEEDED || - runStatus === RUN_STATUS_STOPPED || - runStatus === RUN_STATUS_FAILED + runStatus != null + ? (RUN_STATUSES_TERMINAL as RunStatus[]).includes(runStatus) + : false const isRunStill = isRunTerminal || isRunIdle return { isRunStill, isRunTerminal, isRunIdle, isRunRunning } diff --git a/app/src/organisms/Devices/utils.ts b/app/src/organisms/Devices/utils.ts index a4d72e0d279..61c133f176b 100644 --- a/app/src/organisms/Devices/utils.ts +++ b/app/src/organisms/Devices/utils.ts @@ -9,7 +9,9 @@ import type { Instruments, PipetteData, PipetteOffsetCalibration, + RunTimeParameterCreateData, } from '@opentrons/api-client' +import type { RunTimeParameter } from '@opentrons/shared-data' /** * formats a string if it is in ISO 8601 date format @@ -89,3 +91,15 @@ export function getShowPipetteCalibrationWarning( }) ?? false ) } + +export function getRunTimeParameterValuesForRun( + runTimeParameters: RunTimeParameter[] +): RunTimeParameterCreateData { + return runTimeParameters.reduce( + (acc, param) => + param.value !== param.default + ? { ...acc, [param.variableName]: param.value } + : acc, + {} + ) +} diff --git a/app/src/organisms/DropTipWizard/BeforeBeginning.tsx b/app/src/organisms/DropTipWizard/BeforeBeginning.tsx index 69a8d7de694..cd21cc3e1a4 100644 --- a/app/src/organisms/DropTipWizard/BeforeBeginning.tsx +++ b/app/src/organisms/DropTipWizard/BeforeBeginning.tsx @@ -24,30 +24,20 @@ import { import { SmallButton, MediumButton } from '../../atoms/buttons' import { InProgressModal } from '../../molecules/InProgressModal/InProgressModal' -// import { NeedHelpLink } from '../CalibrationPanels' import blowoutVideo from '../../assets/videos/droptip-wizard/Blowout-Liquid.webm' import droptipVideo from '../../assets/videos/droptip-wizard/Drop-tip.webm' -// TODO: get help link article URL -// const NEED_HELP_URL = '' - interface BeforeBeginningProps { setShouldDispenseLiquid: (shouldDispenseLiquid: boolean) => void createdMaintenanceRunId: string | null isOnDevice: boolean - isRobotMoving: boolean } export const BeforeBeginning = ( props: BeforeBeginningProps ): JSX.Element | null => { - const { - setShouldDispenseLiquid, - createdMaintenanceRunId, - isOnDevice, - isRobotMoving, - } = props + const { setShouldDispenseLiquid, createdMaintenanceRunId, isOnDevice } = props const { i18n, t } = useTranslation(['drop_tip_wizard', 'shared']) const [flowType, setFlowType] = React.useState< 'liquid_and_tips' | 'only_tips' | null @@ -57,16 +47,8 @@ export const BeforeBeginning = ( setShouldDispenseLiquid(flowType === 'liquid_and_tips') } - if (isRobotMoving || createdMaintenanceRunId == null) { - return ( - - ) + if (createdMaintenanceRunId == null) { + return } if (isOnDevice) { diff --git a/app/src/organisms/DropTipWizard/ChooseLocation.tsx b/app/src/organisms/DropTipWizard/ChooseLocation.tsx index 8050c776698..7a86da67223 100644 --- a/app/src/organisms/DropTipWizard/ChooseLocation.tsx +++ b/app/src/organisms/DropTipWizard/ChooseLocation.tsx @@ -22,15 +22,13 @@ import { import { getDeckDefFromRobotType } from '@opentrons/shared-data' import { SmallButton } from '../../atoms/buttons' -import { InProgressModal } from '../../molecules/InProgressModal/InProgressModal' -// import { NeedHelpLink } from '../CalibrationPanels' import { TwoUpTileLayout } from '../LabwarePositionCheck/TwoUpTileLayout' import type { CommandData } from '@opentrons/api-client' import type { AddressableAreaName, RobotType } from '@opentrons/shared-data' +import type { ErrorDetails } from './utils' // TODO: get help link article URL -// const NEED_HELP_URL = '' interface ChooseLocationProps { handleProceed: () => void @@ -41,9 +39,8 @@ interface ChooseLocationProps { moveToAddressableArea: ( addressableArea: AddressableAreaName ) => Promise - isRobotMoving: boolean isOnDevice: boolean - setErrorMessage: (arg0: string) => void + setErrorDetails: (errorDetails: ErrorDetails) => void } export const ChooseLocation = ( @@ -56,9 +53,8 @@ export const ChooseLocation = ( body, robotType, moveToAddressableArea, - isRobotMoving, isOnDevice, - setErrorMessage, + setErrorDetails, } = props const { i18n, t } = useTranslation(['drop_tip_wizard', 'shared']) const deckDef = getDeckDefFromRobotType(robotType) @@ -74,14 +70,10 @@ export const ChooseLocation = ( if (deckSlot != null) { moveToAddressableArea(deckSlot) .then(() => handleProceed()) - .catch(e => setErrorMessage(`${e.message}`)) + .catch(e => setErrorDetails({ message: `${e.message}` })) } } - if (isRobotMoving) { - return - } - if (isOnDevice) { return ( void handleGoBack: () => void - isRobotMoving: boolean } export function ExitConfirmation(props: ExitConfirmationProps): JSX.Element { - const { handleGoBack, handleExit, isRobotMoving } = props + const { handleGoBack, handleExit } = props const { i18n, t } = useTranslation(['drop_tip_wizard', 'shared']) const flowTitle = t('drop_tips') const isOnDevice = useSelector(getIsOnDevice) - if (isRobotMoving) { - return - } - return ( void body: string - isRobotMoving: boolean currentStep: string isOnDevice: boolean } @@ -161,7 +160,6 @@ export const JogToPosition = ( handleJog, handleProceed, body, - isRobotMoving, currentStep, isOnDevice, } = props @@ -171,10 +169,10 @@ export const JogToPosition = ( setShowPositionConfirmation, ] = React.useState(false) // Includes special case homing only present in this step. - const [isRobotInMotion, setIsRobotInMotion] = React.useState(isRobotMoving) + const [isRobotInMotion, setIsRobotInMotion] = React.useState(false) const onGoBack = (): void => { - setIsRobotInMotion(() => true) + setIsRobotInMotion(true) handleGoBack() } @@ -201,11 +199,6 @@ export const JogToPosition = ( ) } - // Moving due to "Exit" or "Go back" click. - if (isRobotInMotion) { - return - } - if (isOnDevice) { return ( { + let props: UseDropTipErrorComponentsProps + let mockOnClose: Mock + let mockTranslation: Mock + let mockChainRunCommands: Mock + + beforeEach(() => { + mockOnClose = vi.fn() + mockTranslation = vi.fn() + mockChainRunCommands = vi.fn() + + props = { + maintenanceRunId: MOCK_MAINTENANCE_RUN_ID, + onClose: mockOnClose, + errorDetails: { + type: MOCK_ERROR_TYPE, + message: MOCK_ERROR_MESSAGE, + header: MOCK_ERROR_HEADER, + }, + isOnDevice: true, + t: mockTranslation, + chainRunCommands: mockChainRunCommands, + } + }) + + it('should return the generic text and error message if there is are no special-cased error details', () => { + const result = useDropTipErrorComponents(props) + expect(result.button).toBeNull() + render(result.subHeader) + expect(mockTranslation).toHaveBeenCalledWith('drop_tip_failed') + screen.getByText(MOCK_ERROR_MESSAGE) + }) + + it('should return a generic message only if there are no error details', () => { + props.errorDetails = null + const result = useDropTipErrorComponents(props) + expect(result.button).toBeNull() + render(result.subHeader) + expect(mockTranslation).toHaveBeenCalledWith('drop_tip_failed') + expect(screen.queryByText(MOCK_ERROR_MESSAGE)).not.toBeInTheDocument() + }) + + it(`should return correct special components if error type is ${DROP_TIP_SPECIAL_ERROR_TYPES.MUST_HOME_ERROR}`, () => { + // @ts-expect-error errorDetails is in fact not null in the test. + props.errorDetails.type = DROP_TIP_SPECIAL_ERROR_TYPES.MUST_HOME_ERROR + const result = useDropTipErrorComponents(props) + expect(mockTranslation).toHaveBeenCalledWith('confirm_removal_and_home') + + render(result.button) + const btn = screen.getByRole('button') + fireEvent.click(btn) + expect(mockOnClose).toHaveBeenCalled() + expect(mockChainRunCommands).toHaveBeenCalledWith( + MOCK_MAINTENANCE_RUN_ID, + [ + { + commandType: 'home' as const, + params: {}, + }, + ], + true + ) + + render(result.subHeader) + screen.getByText(MOCK_ERROR_MESSAGE) + }) +}) + +describe('useWizardExitHeader', () => { + let props: UseWizardExitHeaderProps + let mockHandleCleanUpAndClose: Mock + let mockConfirmExit: Mock + + beforeEach(() => { + mockHandleCleanUpAndClose = vi.fn() + mockConfirmExit = vi.fn() + + props = { + isFinalStep: true, + hasInitiatedExit: false, + errorDetails: null, + handleCleanUpAndClose: mockHandleCleanUpAndClose, + confirmExit: mockConfirmExit, + } + }) + + it('should appropriately return handleCleanUpAndClose', () => { + const handleExit = useWizardExitHeader(props) + expect(handleExit).toEqual(props.handleCleanUpAndClose) + }) + + it('should appropriately return confirmExit', () => { + props = { ...props, isFinalStep: false } + const handleExit = useWizardExitHeader(props) + expect(handleExit).toEqual(props.confirmExit) + }) + + it('should appropriately return handleCleanUpAndClose with homeOnError = false', () => { + const errorDetails = { message: 'Some error occurred' } + const modifiedProps = { ...props, errorDetails } + const handleExit = useWizardExitHeader(modifiedProps) + expect(mockHandleCleanUpAndClose.mock.calls.length).toBe(0) + handleExit() + expect(mockHandleCleanUpAndClose).toHaveBeenCalledWith(false) + }) + + it('should appropriately return a function that does nothing ', () => { + const modifiedProps = { ...props, hasInitiatedExit: true } + const handleExit = useWizardExitHeader(modifiedProps) + handleExit() + expect(mockHandleCleanUpAndClose.mock.calls.length).toBe(0) + expect(mockConfirmExit.mock.calls.length).toBe(0) + }) +}) diff --git a/app/src/organisms/DropTipWizard/constants.ts b/app/src/organisms/DropTipWizard/constants.ts index 0390fd1870f..6d322f779ec 100644 --- a/app/src/organisms/DropTipWizard/constants.ts +++ b/app/src/organisms/DropTipWizard/constants.ts @@ -16,3 +16,7 @@ export const DROP_TIP_STEPS = [ POSITION_AND_DROP_TIP, DROP_TIP_SUCCESS, ] + +export const DROP_TIP_SPECIAL_ERROR_TYPES = { + MUST_HOME_ERROR: 'MustHomeError', +} as const diff --git a/app/src/organisms/DropTipWizard/index.tsx b/app/src/organisms/DropTipWizard/index.tsx index 3d7896663d4..ca668cd7013 100644 --- a/app/src/organisms/DropTipWizard/index.tsx +++ b/app/src/organisms/DropTipWizard/index.tsx @@ -11,6 +11,7 @@ import { COLORS, BORDERS, StyledText, + JUSTIFY_FLEX_END, } from '@opentrons/components' import { useCreateMaintenanceCommandMutation, @@ -43,6 +44,12 @@ import { BeforeBeginning } from './BeforeBeginning' import { ChooseLocation } from './ChooseLocation' import { JogToPosition } from './JogToPosition' import { Success } from './Success' +import { InProgressModal } from '../../molecules/InProgressModal/InProgressModal' +import { + useHandleDropTipCommandErrors, + useDropTipErrorComponents, + useWizardExitHeader, +} from './utils' import type { PipetteData } from '@opentrons/api-client' import type { CreateMaintenanceRunType } from '@opentrons/react-api-client' @@ -54,6 +61,8 @@ import type { } from '@opentrons/shared-data' import type { Axis, Sign, StepSize } from '../../molecules/JogControls/types' import type { Jog } from '../../molecules/JogControls' +import type { ErrorDetails } from './utils' +import type { DropTipWizardStep } from './types' const RUN_REFETCH_INTERVAL_MS = 5000 const JOG_COMMAND_TIMEOUT_MS = 10000 @@ -110,7 +119,7 @@ export function DropTipWizard(props: MaintenanceRunManagerProps): JSX.Element { }) .catch(e => e) }, - onError: error => setErrorMessage(error.message), + onError: error => setErrorDetails({ message: error.message }), }) const { data: maintenanceRunData } = useNotifyCurrentMaintenanceRun({ @@ -141,14 +150,16 @@ export function DropTipWizard(props: MaintenanceRunManagerProps): JSX.Element { ]) const [isExiting, setIsExiting] = React.useState(false) - const [errorMessage, setErrorMessage] = React.useState(null) + const [errorDetails, setErrorDetails] = React.useState( + null + ) const { deleteMaintenanceRun } = useDeleteMaintenanceRunMutation({ onSuccess: () => closeFlow(), onError: () => closeFlow(), }) - const handleCleanUpAndClose = (): void => { + const handleCleanUpAndClose = (homeOnExit: boolean = true): void => { if (hasCleanedUpAndClosed.current) return hasCleanedUpAndClosed.current = true @@ -156,23 +167,23 @@ export function DropTipWizard(props: MaintenanceRunManagerProps): JSX.Element { if (maintenanceRunData?.data.id == null) { closeFlow() } else { - chainRunCommands( - maintenanceRunData?.data.id, - [ - { - commandType: 'home' as const, - params: { axes: ['leftZ', 'rightZ', 'x', 'y'] }, - }, - ], - true + ;(homeOnExit + ? chainRunCommands( + maintenanceRunData?.data.id, + [ + { + commandType: 'home' as const, + params: { axes: ['leftZ', 'rightZ', 'x', 'y'] }, + }, + ], + true + ) + : new Promise((resolve, reject) => resolve()) ) - .then(() => { - deleteMaintenanceRun(maintenanceRunData?.data.id) - }) .catch(error => { console.error(error.message) - deleteMaintenanceRun(maintenanceRunData?.data.id) }) + .finally(() => deleteMaintenanceRun(maintenanceRunData?.data.id)) } } @@ -188,8 +199,8 @@ export function DropTipWizard(props: MaintenanceRunManagerProps): JSX.Element { handleCleanUpAndClose={handleCleanUpAndClose} chainRunCommands={chainRunCommands} createRunCommand={createMaintenanceCommand} - errorMessage={errorMessage} - setErrorMessage={setErrorMessage} + errorDetails={errorDetails} + setErrorDetails={setErrorDetails} isExiting={isExiting} deckConfig={deckConfig} /> @@ -203,9 +214,9 @@ interface DropTipWizardProps { createMaintenanceRun: CreateMaintenanceRunType isRobotMoving: boolean isExiting: boolean - setErrorMessage: (message: string | null) => void - errorMessage: string | null - handleCleanUpAndClose: () => void + setErrorDetails: (errorDetails: ErrorDetails) => void + errorDetails: ErrorDetails | null + handleCleanUpAndClose: (homeOnError?: boolean) => void chainRunCommands: ReturnType< typeof useChainMaintenanceCommands >['chainRunCommands'] @@ -227,20 +238,23 @@ export const DropTipWizardComponent = ( chainRunCommands, isRobotMoving, createRunCommand, - setErrorMessage, - errorMessage, + setErrorDetails, + errorDetails, isExiting, createdMaintenanceRunId, instrumentModelSpecs, deckConfig, } = props - const isOnDevice = useSelector(getIsOnDevice) const { t, i18n } = useTranslation('drop_tip_wizard') - const [currentStepIndex, setCurrentStepIndex] = React.useState(0) const [shouldDispenseLiquid, setShouldDispenseLiquid] = React.useState< boolean | null >(null) + const hasInitiatedExit = React.useRef(false) + + const isOnDevice = useSelector(getIsOnDevice) + const setSpecificErrorDetails = useHandleDropTipCommandErrors(setErrorDetails) + const DropTipWizardSteps = getDropTipWizardSteps(shouldDispenseLiquid) const currentStep = shouldDispenseLiquid != null @@ -248,11 +262,31 @@ export const DropTipWizardComponent = ( : null const isFinalStep = currentStepIndex === DropTipWizardSteps.length - 1 + const { + confirm: confirmExit, + showConfirmation: showConfirmExit, + cancel: cancelExit, + } = useConditionalConfirm(handleCleanUpAndClose, true) + + const { + button: errorExitBtn, + subHeader: errorSubHeader, + } = useDropTipErrorComponents({ + t, + errorDetails, + isOnDevice, + chainRunCommands, + maintenanceRunId: createdMaintenanceRunId, + onClose: handleCleanUpAndClose, + }) + React.useEffect(() => { if (createdMaintenanceRunId == null) { - createMaintenanceRun({}).catch((e: Error) => - setErrorMessage(`Error creating maintenance run: ${e.message}`) - ) + createMaintenanceRun({}).catch((e: Error) => { + setSpecificErrorDetails({ + message: `Error creating maintenance run: ${e.message}`, + }) + }) } }, []) @@ -280,18 +314,14 @@ export const DropTipWizardComponent = ( }, waitUntilComplete: true, timeout: JOG_COMMAND_TIMEOUT_MS, - }).catch((e: Error) => - setErrorMessage(`Error issuing jog command: ${e.message}`) - ) + }).catch((e: Error) => { + setSpecificErrorDetails({ + message: `Error issuing jog command: ${e.message}`, + }) + }) } } - const { - confirm: confirmExit, - showConfirmation: showConfirmExit, - cancel: cancelExit, - } = useConditionalConfirm(handleCleanUpAndClose, true) - const moveToAddressableArea = ( addressableArea: AddressableAreaName ): Promise => { @@ -326,189 +356,228 @@ export const DropTipWizardComponent = ( ).then(commandData => { const error = commandData[0].data.error if (error != null) { - setErrorMessage(`error moving to position: ${error.detail}`) + setSpecificErrorDetails({ + runCommandError: error, + message: `Error moving to position: ${error.detail}`, + }) } return null }) } else { - setErrorMessage(`error moving to position: invalid addressable area.`) + setSpecificErrorDetails({ + message: `Error moving to position: invalid addressable area.`, + }) return Promise.resolve(null) } } - let modalContent: JSX.Element =
UNASSIGNED STEP
- if (showConfirmExit) { - modalContent = ( - { - hasInitiatedExit.current = true - confirmExit() - }} - isRobotMoving={isRobotMoving} - /> - ) - } else if (errorMessage != null) { - modalContent = ( - - {t('drop_tip_failed')} - {errorMessage} - - } - /> - ) - } else if (shouldDispenseLiquid == null) { - modalContent = ( - - ) - } else if ( - currentStep === CHOOSE_BLOWOUT_LOCATION || - currentStep === CHOOSE_DROP_TIP_LOCATION - ) { - let bodyTextKey - if (currentStep === CHOOSE_BLOWOUT_LOCATION) { - bodyTextKey = isOnDevice - ? 'select_blowout_slot_odd' - : 'select_blowout_slot' + const modalContent = buildModalContent() + + function buildModalContent(): JSX.Element { + if (isRobotMoving) { + return buildRobotInMotion() + } else if (showConfirmExit) { + return buildShowExitConfirmation() + } else if (errorDetails != null) { + return buildErrorScreen() + } else if (shouldDispenseLiquid == null) { + return buildBeforeBeginning() + } else if ( + currentStep === CHOOSE_BLOWOUT_LOCATION || + currentStep === CHOOSE_DROP_TIP_LOCATION + ) { + return buildChooseLocation() + } else if ( + currentStep === POSITION_AND_BLOWOUT || + currentStep === POSITION_AND_DROP_TIP + ) { + return buildJogToPosition() + } else if ( + currentStep === BLOWOUT_SUCCESS || + currentStep === DROP_TIP_SUCCESS + ) { + return buildSuccess() } else { - bodyTextKey = isOnDevice - ? 'select_drop_tip_slot_odd' - : 'select_drop_tip_slot' + return
UNASSIGNED STEP
} - modalContent = ( - { - setCurrentStepIndex(0) - setShouldDispenseLiquid(null) - }} - title={ - currentStep === CHOOSE_BLOWOUT_LOCATION - ? i18n.format(t('choose_blowout_location'), 'capitalize') - : i18n.format(t('choose_drop_tip_location'), 'capitalize') - } - body={ - }} - /> - } - moveToAddressableArea={moveToAddressableArea} - isRobotMoving={isRobotMoving} - isOnDevice={isOnDevice} - setErrorMessage={setErrorMessage} - /> - ) - } else if ( - currentStep === POSITION_AND_BLOWOUT || - currentStep === POSITION_AND_DROP_TIP - ) { - modalContent = ( - { - if (createdMaintenanceRunId != null) { - chainRunCommands( - createdMaintenanceRunId, - [ - currentStep === POSITION_AND_BLOWOUT - ? { - commandType: 'blowOutInPlace', - params: { - pipetteId: MANAGED_PIPETTE_ID, - flowRate: - instrumentModelSpecs.defaultBlowOutFlowRate.value, + + function buildRobotInMotion(): JSX.Element { + return + } + + function buildShowExitConfirmation(): JSX.Element { + return ( + { + hasInitiatedExit.current = true + confirmExit() + }} + /> + ) + } + + function buildErrorScreen(): JSX.Element { + return ( + + {errorExitBtn} + + ) + } + + function buildBeforeBeginning(): JSX.Element { + return ( + + ) + } + + function buildChooseLocation(): JSX.Element { + let bodyTextKey: string + if (currentStep === CHOOSE_BLOWOUT_LOCATION) { + bodyTextKey = isOnDevice + ? 'select_blowout_slot_odd' + : 'select_blowout_slot' + } else { + bodyTextKey = isOnDevice + ? 'select_drop_tip_slot_odd' + : 'select_drop_tip_slot' + } + return ( + { + setCurrentStepIndex(0) + setShouldDispenseLiquid(null) + }} + title={ + currentStep === CHOOSE_BLOWOUT_LOCATION + ? i18n.format(t('choose_blowout_location'), 'capitalize') + : i18n.format(t('choose_drop_tip_location'), 'capitalize') + } + body={ + }} + /> + } + moveToAddressableArea={moveToAddressableArea} + isOnDevice={isOnDevice} + setErrorDetails={setSpecificErrorDetails} + /> + ) + } + + function buildJogToPosition(): JSX.Element { + return ( + { + if (createdMaintenanceRunId != null) { + chainRunCommands( + createdMaintenanceRunId, + [ + currentStep === POSITION_AND_BLOWOUT + ? { + commandType: 'blowOutInPlace', + params: { + pipetteId: MANAGED_PIPETTE_ID, + flowRate: + instrumentModelSpecs.defaultBlowOutFlowRate.value, + }, + } + : { + commandType: 'dropTipInPlace', + params: { pipetteId: MANAGED_PIPETTE_ID }, }, - } - : { - commandType: 'dropTipInPlace', - params: { pipetteId: MANAGED_PIPETTE_ID }, - }, - ], - true - ) - .then(commandData => { - const error = commandData[0].data.error - if (error != null) { - setErrorMessage(`error moving to position: ${error.detail}`) - } else proceed() - }) - .catch(e => - setErrorMessage( - `Error issuing ${ - currentStep === POSITION_AND_BLOWOUT - ? 'blowout' - : 'drop tip' - } command: ${e.message}` - ) + ], + true ) + .then(commandData => { + const error = commandData[0].data.error + if (error != null) { + setSpecificErrorDetails({ + runCommandError: error, + message: `Error moving to position: ${error.detail}`, + }) + } else { + proceed() + } + }) + .catch(e => + setSpecificErrorDetails({ + message: `Error issuing ${ + currentStep === POSITION_AND_BLOWOUT + ? 'blowout' + : 'drop tip' + } command: ${e.message}`, + }) + ) + } + }} + handleGoBack={goBack} + body={ + currentStep === POSITION_AND_BLOWOUT + ? t('position_and_blowout') + : t('position_and_drop_tip') } - }} - isRobotMoving={isRobotMoving} - handleGoBack={goBack} - body={ - currentStep === POSITION_AND_BLOWOUT - ? t('position_and_blowout') - : t('position_and_drop_tip') - } - currentStep={currentStep} - isOnDevice={isOnDevice} - /> - ) - } else if ( - currentStep === BLOWOUT_SUCCESS || - currentStep === DROP_TIP_SUCCESS - ) { - modalContent = ( - - ) + currentStep={currentStep as DropTipWizardStep} + isOnDevice={isOnDevice} + /> + ) + } + + function buildSuccess(): JSX.Element { + return ( + + ) + } } - const hasInitiatedExit = React.useRef(false) - let handleExit: () => void = () => null - if (!hasInitiatedExit.current) handleExit = confirmExit - else if (errorMessage != null) handleExit = handleCleanUpAndClose + const wizardHeaderOnExit = useWizardExitHeader({ + isFinalStep, + hasInitiatedExit: hasInitiatedExit.current, + errorDetails, + confirmExit, + handleCleanUpAndClose, + }) const wizardHeader = ( ) diff --git a/app/src/organisms/DropTipWizard/utils.tsx b/app/src/organisms/DropTipWizard/utils.tsx new file mode 100644 index 00000000000..d0a38fc768b --- /dev/null +++ b/app/src/organisms/DropTipWizard/utils.tsx @@ -0,0 +1,185 @@ +import * as React from 'react' +import { useTranslation } from 'react-i18next' + +import { AlertPrimaryButton, SPACING } from '@opentrons/components' + +import { DROP_TIP_SPECIAL_ERROR_TYPES } from './constants' +import { SmallButton } from '../../atoms/buttons' + +import type { RunCommandError } from '@opentrons/api-client' +import type { useChainMaintenanceCommands } from '../../resources/runs' + +export interface ErrorDetails { + message: string + header?: string + type?: string +} + +interface HandleDropTipCommandErrorsCbProps { + runCommandError?: RunCommandError + message?: string + header?: string + type?: RunCommandError['errorType'] +} + +/** + * @description Wraps the error state setter, updating the setter if the error should be special-cased. + */ +export function useHandleDropTipCommandErrors( + setErrorDetails: (errorDetails: ErrorDetails) => void +): (cbProps: HandleDropTipCommandErrorsCbProps) => void { + const { t } = useTranslation('drop_tip_wizard') + + return ({ + runCommandError, + message, + header, + type, + }: HandleDropTipCommandErrorsCbProps) => { + if ( + runCommandError?.errorType === + DROP_TIP_SPECIAL_ERROR_TYPES.MUST_HOME_ERROR + ) { + const headerText = t('cant_safely_drop_tips') + const messageText = t('remove_the_tips_manually') + + setErrorDetails({ + header: headerText, + message: messageText, + type: DROP_TIP_SPECIAL_ERROR_TYPES.MUST_HOME_ERROR, + }) + } else { + const messageText = message ?? '' + setErrorDetails({ header, message: messageText, type }) + } + } +} + +interface DropTipErrorComponents { + button: JSX.Element | null + subHeader: JSX.Element +} + +export interface UseDropTipErrorComponentsProps { + isOnDevice: boolean + t: (translationString: string) => string + maintenanceRunId: string | null + onClose: () => void + errorDetails: ErrorDetails | null + chainRunCommands: ReturnType< + typeof useChainMaintenanceCommands + >['chainRunCommands'] +} + +/** + * @description Returns special-cased components given error details. + */ +export function useDropTipErrorComponents({ + t, + maintenanceRunId, + onClose, + errorDetails, + isOnDevice, + chainRunCommands, +}: UseDropTipErrorComponentsProps): DropTipErrorComponents { + return errorDetails?.type === DROP_TIP_SPECIAL_ERROR_TYPES.MUST_HOME_ERROR + ? buildHandleMustHome() + : buildGenericError() + + function buildGenericError(): DropTipErrorComponents { + return { + button: null, + subHeader: ( + <> + {t('drop_tip_failed')} +
+ {errorDetails?.message} + + ), + } + } + + function buildHandleMustHome(): DropTipErrorComponents { + const handleOnClick = (): void => { + if (maintenanceRunId !== null) { + void chainRunCommands( + maintenanceRunId, + [ + { + commandType: 'home' as const, + params: {}, + }, + ], + true + ) + onClose() + } + } + + return { + button: isOnDevice ? ( + + ) : ( + + {t('confirm_removal_and_home')} + + ), + subHeader: <>{errorDetails?.message}, + } + } +} + +export interface UseWizardExitHeaderProps { + isFinalStep: boolean + hasInitiatedExit: boolean + errorDetails: ErrorDetails | null + handleCleanUpAndClose: (homeOnError?: boolean) => void + confirmExit: (homeOnError?: boolean) => void +} + +/** + * @description Determines the appropriate onClick for the wizard exit button, ensuring the exit logic can occur at + * most one time. + */ +export function useWizardExitHeader({ + isFinalStep, + hasInitiatedExit, + errorDetails, + handleCleanUpAndClose, + confirmExit, +}: UseWizardExitHeaderProps): () => void { + return buildHandleExit() + + function buildHandleExit(): () => void { + if (!hasInitiatedExit) { + if (errorDetails != null) { + // When an error occurs, do not home when exiting the flow via the wizard header. + return buildNoHomeCleanUpAndClose() + } else if (isFinalStep) { + return buildHandleCleanUpAndClose() + } else { + return buildConfirmExit() + } + } else { + return buildGenericCase() + } + } + + function buildGenericCase(): () => void { + return () => null + } + function buildNoHomeCleanUpAndClose(): () => void { + return () => handleCleanUpAndClose(false) + } + function buildHandleCleanUpAndClose(): () => void { + return handleCleanUpAndClose + } + function buildConfirmExit(): () => void { + return confirmExit + } +} diff --git a/app/src/organisms/EmergencyStop/EstopPressedModal.tsx b/app/src/organisms/EmergencyStop/EstopPressedModal.tsx index dfec8424ed0..3cd06ff2fd8 100644 --- a/app/src/organisms/EmergencyStop/EstopPressedModal.tsx +++ b/app/src/organisms/EmergencyStop/EstopPressedModal.tsx @@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next' import { ALIGN_CENTER, BORDERS, + Chip, COLORS, DIRECTION_COLUMN, DIRECTION_ROW, @@ -22,7 +23,6 @@ import { useAcknowledgeEstopDisengageMutation } from '@opentrons/react-api-clien import { getTopPortalEl } from '../../App/portal' import { Banner } from '../../atoms/Banner' -import { Chip } from '../../atoms/Chip' import { ListItem } from '../../atoms/ListItem' import { SmallButton } from '../../atoms/buttons' import { LegacyModal } from '../../molecules/LegacyModal' @@ -73,7 +73,7 @@ function TouchscreenModal({ isEngaged, closeModal, }: EstopPressedModalProps): JSX.Element { - const { t } = useTranslation('device_settings') + const { t } = useTranslation(['device_settings', 'branded']) const [isResuming, setIsResuming] = React.useState(false) const { acknowledgeEstopDisengage } = useAcknowledgeEstopDisengageMutation() const modalHeader: ModalHeaderBaseProps = { @@ -94,7 +94,7 @@ function TouchscreenModal({ - {t('estop_pressed_description')} + {t('branded:estop_pressed_description')} - {t('estop_pressed_description')} + {t('branded:estop_pressed_description')} - {t('download_logs')} + {t('branded:firmware_update_download_logs')} { subsystem: 'pipette_right', } }) - it('renders text', () => { + it('renders pipette text', () => { const { getByText } = render(props) getByText('Updating pipette firmware...') }) + it('renders Hepa/UV text', () => { + props = { + subsystem: 'hepa_uv', + } + const { getByText } = render(props) + getByText('Updating HEPA/UV Module firmware...') + }) }) diff --git a/app/src/organisms/GripperCard/AboutGripperSlideout.tsx b/app/src/organisms/GripperCard/AboutGripperSlideout.tsx index 9bd6f8f6706..8eea030d615 100644 --- a/app/src/organisms/GripperCard/AboutGripperSlideout.tsx +++ b/app/src/organisms/GripperCard/AboutGripperSlideout.tsx @@ -22,11 +22,11 @@ export const AboutGripperSlideout = ( props: AboutGripperSlideoutProps ): JSX.Element | null => { const { serialNumber, firmwareVersion, isExpanded, onCloseClick } = props - const { i18n, t } = useTranslation(['device_details', 'shared']) + const { i18n, t } = useTranslation(['device_details', 'shared', 'branded']) return ( { if (createdMaintenanceRunId == null) { createMaintenanceRun({}) @@ -108,7 +108,7 @@ export const BeforeBeginning = ( displayName: t('hex_screwdriver'), subtitle: t('provided_with_robot_use_right_size'), }, - [GRIPPER_LOADNAME]: { displayName: t('gripper') }, + [GRIPPER_LOADNAME]: { displayName: t('branded:gripper') }, } const { bodyI18nKey, equipmentLoadNames } = INFO_BY_FLOW_TYPE[flowType] diff --git a/app/src/organisms/GripperWizardFlows/MountGripper.tsx b/app/src/organisms/GripperWizardFlows/MountGripper.tsx index 7e1636f6d05..a7049cf447d 100644 --- a/app/src/organisms/GripperWizardFlows/MountGripper.tsx +++ b/app/src/organisms/GripperWizardFlows/MountGripper.tsx @@ -58,7 +58,7 @@ export const MountGripper = ( props: GripperWizardStepProps ): JSX.Element | null => { const { proceed, isRobotMoving } = props - const { t } = useTranslation(['gripper_wizard_flows', 'shared']) + const { t } = useTranslation(['gripper_wizard_flows', 'shared', 'branded']) const isOnDevice = useSelector(getIsOnDevice) const [showUnableToDetect, setShowUnableToDetect] = React.useState(false) const [isPending, setIsPending] = React.useState(false) @@ -119,7 +119,7 @@ export const MountGripper = (
) : ( { const { proceed, successfulAction, isRobotMoving } = props - const { t, i18n } = useTranslation(['gripper_wizard_flows', 'shared']) + const { t, i18n } = useTranslation([ + 'gripper_wizard_flows', + 'shared', + 'branded', + ]) const isOnDevice = useSelector(getIsOnDevice) const infoByAction: { @@ -46,11 +50,11 @@ export const Success = ( } } = { [SUCCESSFULLY_ATTACHED_AND_CALIBRATED]: { - header: t('gripper_successfully_attached_and_calibrated'), + header: t('branded:gripper_successfully_attached_and_calibrated'), buttonText: i18n.format(t('shared:exit'), 'capitalize'), }, [SUCCESSFULLY_CALIBRATED]: { - header: t('gripper_successfully_calibrated'), + header: t('branded:gripper_successfully_calibrated'), buttonText: i18n.format(t('shared:exit'), 'capitalize'), }, [SUCCESSFULLY_ATTACHED]: { @@ -58,7 +62,7 @@ export const Success = ( buttonText: t('calibrate_gripper'), }, [SUCCESSFULLY_DETACHED]: { - header: t('gripper_successfully_detached'), + header: t('branded:gripper_successfully_detached'), buttonText: i18n.format(t('shared:exit'), 'capitalize'), }, } diff --git a/app/src/organisms/GripperWizardFlows/UnmountGripper.tsx b/app/src/organisms/GripperWizardFlows/UnmountGripper.tsx index c8e25bc0228..f0b2467e95d 100644 --- a/app/src/organisms/GripperWizardFlows/UnmountGripper.tsx +++ b/app/src/organisms/GripperWizardFlows/UnmountGripper.tsx @@ -51,7 +51,7 @@ export const UnmountGripper = ( props: GripperWizardStepProps ): JSX.Element | null => { const { proceed, isRobotMoving, goBack, chainRunCommands } = props - const { t } = useTranslation(['gripper_wizard_flows', 'shared']) + const { t } = useTranslation(['gripper_wizard_flows', 'shared', 'branded']) const isOnDevice = useSelector(getIsOnDevice) const [isPending, setIsPending] = React.useState(false) const { data: instrumentsQueryData, refetch } = useInstrumentsQuery({ @@ -100,7 +100,7 @@ export const UnmountGripper = ( return showGripperStillDetected ? ( ) : ( { const res = getRunLabwareRenderInfo( mockRunData, mockLabwareDefinitionsByUri, - ot2DeckDefV4 as any + ot2DeckDefV5 as any ) const labwareInfo = res[0] expect(labwareInfo).toBeTruthy() @@ -154,7 +154,7 @@ describe('getRunLabwareRenderInfo', () => { const res = getRunLabwareRenderInfo( mockRunData, mockLabwareDefinitionsByUri, - ot2DeckDefV4 as any + ot2DeckDefV5 as any ) expect(res).toHaveLength(1) // the offdeck labware still gets added because the mating surface doesn't exist for offdeck labware }) @@ -163,7 +163,7 @@ describe('getRunLabwareRenderInfo', () => { const res = getRunLabwareRenderInfo( mockRunData, mockLabwareDefinitionsByUri, - ot2DeckDefV4 as any + ot2DeckDefV5 as any ) expect(res).toHaveLength(2) const labwareInfo = res.find( @@ -172,7 +172,7 @@ describe('getRunLabwareRenderInfo', () => { expect(labwareInfo).toBeTruthy() expect(labwareInfo?.x).toEqual(0) expect(labwareInfo?.y).toEqual( - ot2DeckDefV4.cornerOffsetFromOrigin[1] - + ot2DeckDefV5.cornerOffsetFromOrigin[1] - mockLabwareDefinition.dimensions.yDimension ) }) @@ -189,7 +189,7 @@ describe('getRunLabwareRenderInfo', () => { const res = getRunLabwareRenderInfo( { labware: [mockBadSlotLabware] } as any, mockLabwareDefinitionsByUri, - ot2DeckDefV4 as any + ot2DeckDefV5 as any ) expect(res[0].x).toEqual(0) @@ -207,7 +207,7 @@ describe('getCurrentRunModuleRenderInfo', () => { it('returns run module render info with nested labware', () => { const res = getRunModuleRenderInfo( mockRunData, - ot2DeckDefV4 as any, + ot2DeckDefV5 as any, mockLabwareDefinitionsByUri ) const moduleInfo = res[0] @@ -228,7 +228,7 @@ describe('getCurrentRunModuleRenderInfo', () => { const res = getRunModuleRenderInfo( mockRunDataNoNesting, - ot2DeckDefV4 as any, + ot2DeckDefV5 as any, mockLabwareDefinitionsByUri ) @@ -245,7 +245,7 @@ describe('getCurrentRunModuleRenderInfo', () => { const res = getRunModuleRenderInfo( mockRunDataWithTC, - ot2DeckDefV4 as any, + ot2DeckDefV5 as any, mockLabwareDefinitionsByUri ) @@ -270,7 +270,7 @@ describe('getCurrentRunModuleRenderInfo', () => { const res = getRunModuleRenderInfo( mockRunDataWithBadModuleSlot, - ot2DeckDefV4 as any, + ot2DeckDefV5 as any, mockLabwareDefinitionsByUri ) diff --git a/app/src/organisms/LabwareCard/index.tsx b/app/src/organisms/LabwareCard/index.tsx index 708639711b2..2413b10e4fc 100644 --- a/app/src/organisms/LabwareCard/index.tsx +++ b/app/src/organisms/LabwareCard/index.tsx @@ -30,7 +30,7 @@ export interface LabwareCardProps { } export function LabwareCard(props: LabwareCardProps): JSX.Element { - const { t } = useTranslation('labware_landing') + const { t } = useTranslation(['labware_landing', 'branded']) const { definition, modified, filename } = props.labware const apiName = definition.parameters.loadName const displayName = definition?.metadata.displayName @@ -100,7 +100,7 @@ export function LabwareCard(props: LabwareCardProps): JSX.Element { id="LabwareCard_opentronsDef" marginLeft={SPACING.spacing4} > - {t('opentrons_def')} + {t('branded:opentrons_def')} )} diff --git a/app/src/organisms/LabwareDetails/index.tsx b/app/src/organisms/LabwareDetails/index.tsx index 4f0cc83b3a4..7787e13f57f 100644 --- a/app/src/organisms/LabwareDetails/index.tsx +++ b/app/src/organisms/LabwareDetails/index.tsx @@ -65,7 +65,7 @@ export interface LabwareDetailsProps { } export function LabwareDetails(props: LabwareDetailsProps): JSX.Element { - const { t } = useTranslation('labware_landing') + const { t } = useTranslation(['labware_landing', 'branded']) const { definition, modified, filename } = props.labware const { metadata, parameters, brand, wells, ordering } = definition const apiName = definition.parameters.loadName @@ -129,7 +129,7 @@ export function LabwareDetails(props: LabwareDetailsProps): JSX.Element { id="LabwareDetails_opentronsDef" marginLeft={SPACING.spacing4} > - {t('opentrons_def')} + {t('branded:opentrons_def')}
)} diff --git a/app/src/organisms/LabwarePositionCheck/TerseOffsetTable.stories.tsx b/app/src/organisms/LabwarePositionCheck/TerseOffsetTable.stories.tsx index 2077ce88598..8acb76eee45 100644 --- a/app/src/organisms/LabwarePositionCheck/TerseOffsetTable.stories.tsx +++ b/app/src/organisms/LabwarePositionCheck/TerseOffsetTable.stories.tsx @@ -5,24 +5,24 @@ import { Flex, JUSTIFY_SPACE_BETWEEN, SPACING, + VIEWPORT, } from '@opentrons/components' import { fixture12Trough, fixtureTiprack10ul, - LabwareDefinition2, getLabwareDefURI, } from '@opentrons/shared-data' -import { touchScreenViewport } from '../../DesignTokens/constants' import { SmallButton } from '../../atoms/buttons' import { TerseOffsetTable } from './ResultsSummary' import type { Story, Meta } from '@storybook/react' +import type { LabwareDefinition2 } from '@opentrons/shared-data' export default { title: 'ODD/Organisms/TerseOffsetTable', component: TerseOffsetTable, - parameters: touchScreenViewport, + parameters: VIEWPORT.touchScreenViewport, } as Meta // Note: 59rem(944px) is the size of ODD diff --git a/app/src/organisms/ModuleCard/ErrorInfo.tsx b/app/src/organisms/ModuleCard/ErrorInfo.tsx index 75158e7010f..d8bb5e28b6e 100644 --- a/app/src/organisms/ModuleCard/ErrorInfo.tsx +++ b/app/src/organisms/ModuleCard/ErrorInfo.tsx @@ -29,7 +29,7 @@ interface ErrorInfoProps { } export function ErrorInfo(props: ErrorInfoProps): JSX.Element | null { const { attachedModule } = props - const { t } = useTranslation(['device_details', 'shared']) + const { t } = useTranslation(['device_details', 'shared', 'branded']) const [showErrorDetails, setShowErrorDetails] = React.useState(false) let isError: boolean = false @@ -92,7 +92,7 @@ export function ErrorInfo(props: ErrorInfoProps): JSX.Element | null { {errorDetails} ) : null} - {t('module_error_contact_support')} + {t('branded:module_error_contact_support')}
diff --git a/app/src/organisms/ModuleCard/ModuleSetupModal.tsx b/app/src/organisms/ModuleCard/ModuleSetupModal.tsx index 8af56a5bcf4..21e3adb598a 100644 --- a/app/src/organisms/ModuleCard/ModuleSetupModal.tsx +++ b/app/src/organisms/ModuleCard/ModuleSetupModal.tsx @@ -26,7 +26,7 @@ interface ModuleSetupModalProps { export const ModuleSetupModal = (props: ModuleSetupModalProps): JSX.Element => { const { moduleDisplayName } = props - const { t, i18n } = useTranslation(['protocol_setup', 'shared']) + const { t, i18n } = useTranslation(['protocol_setup', 'shared', 'branded']) return createPortal( { width="50%" > - {t('modal_instructions')} + {t('branded:modal_instructions')} ) => { return renderWithProviders(, { i18nInstance: i18n, @@ -189,10 +190,12 @@ const render = (props: React.ComponentProps) => { } describe('ModuleCard', () => { - let dispatchApiRequest: DispatchApiRequestType let props: React.ComponentProps + let mockHandleModuleApiRequests: Mock beforeEach(() => { + mockHandleModuleApiRequests = vi.fn() + props = { module: mockMagneticModule, robotName: mockRobot.name, @@ -200,14 +203,11 @@ describe('ModuleCard', () => { attachPipetteRequired: false, calibratePipetteRequired: false, updatePipetteFWRequired: false, + handleModuleApiRequests: mockHandleModuleApiRequests, + latestRequestId: MOCK_LATEST_REQUEST_ID, } - dispatchApiRequest = vi.fn() vi.mocked(ErrorInfo).mockReturnValue(null) - vi.mocked(useDispatchApiRequest).mockReturnValue([ - dispatchApiRequest, - ['id'], - ]) vi.mocked(MagneticModuleData).mockReturnValue(
Mock Magnetic Module Data
) diff --git a/app/src/organisms/ModuleCard/__tests__/utils.test.ts b/app/src/organisms/ModuleCard/__tests__/utils.test.ts index 311c9676da0..5798efeb827 100644 --- a/app/src/organisms/ModuleCard/__tests__/utils.test.ts +++ b/app/src/organisms/ModuleCard/__tests__/utils.test.ts @@ -1,4 +1,5 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi, beforeEach } from 'vitest' +import { renderHook, act } from '@testing-library/react' import { mockHeaterShaker, @@ -9,7 +10,10 @@ import { mockThermocycler, mockThermocyclerGen2, } from '../../../redux/modules/__fixtures__' -import { getModuleCardImage } from '../utils' +import { getModuleCardImage, useModuleApiRequests } from '../utils' +import { useDispatchApiRequest } from '../../../redux/robot-api' + +vi.mock('../../../redux/robot-api') const mockThermocyclerGen2ClosedLid = { id: 'thermocycler_id2', @@ -83,3 +87,29 @@ describe('getModuleCardImage', () => { ) }) }) + +const updateModuleAction = { meta: { requestId: '12345' } } +const MOCK_ROBOT_NAME = 'MOCK_ROBOT' +const MOCK_SERIAL_NUMBER = '1234' +const mockDispatchApiRequest = () => updateModuleAction + +describe('useModuleApiRequests', () => { + beforeEach(() => { + vi.mocked(useDispatchApiRequest).mockReturnValue([ + mockDispatchApiRequest, + ] as any) + }) + + it('should dispatch an API request and update requestIdsBySerial on handleModuleApiRequests', () => { + const { result } = renderHook(() => useModuleApiRequests()) + + act(() => { + result.current[1](MOCK_ROBOT_NAME, MOCK_SERIAL_NUMBER) + }) + + expect(result.current[0](MOCK_SERIAL_NUMBER)).toEqual( + updateModuleAction.meta.requestId + ) + expect(result.current[0]('NON_EXISTENT_SERIAL')).toBeNull() + }) +}) diff --git a/app/src/organisms/ModuleCard/index.tsx b/app/src/organisms/ModuleCard/index.tsx index c2c42151eda..52f3ed99f65 100644 --- a/app/src/organisms/ModuleCard/index.tsx +++ b/app/src/organisms/ModuleCard/index.tsx @@ -1,7 +1,6 @@ import * as React from 'react' import { Trans, useTranslation } from 'react-i18next' import { useDispatch, useSelector } from 'react-redux' -import last from 'lodash/last' import { useHistory } from 'react-router-dom' import { @@ -31,9 +30,7 @@ import { import { RUN_STATUS_FINISHING, RUN_STATUS_RUNNING } from '@opentrons/api-client' import { OverflowBtn } from '../../atoms/MenuList/OverflowBtn' -import { updateModule } from '../../redux/modules' import { - useDispatchApiRequest, getRequestById, PENDING, FAILURE, @@ -85,6 +82,8 @@ interface ModuleCardProps { attachPipetteRequired: boolean calibratePipetteRequired: boolean updatePipetteFWRequired: boolean + latestRequestId: string | null + handleModuleApiRequests: (robotName: string, serialNumber: string) => void runId?: string slotName?: string } @@ -100,6 +99,8 @@ export const ModuleCard = (props: ModuleCardProps): JSX.Element | null => { attachPipetteRequired, calibratePipetteRequired, updatePipetteFWRequired, + latestRequestId, + handleModuleApiRequests, } = props const dispatch = useDispatch() const { @@ -115,13 +116,12 @@ export const ModuleCard = (props: ModuleCardProps): JSX.Element | null => { const [hasSecondary, setHasSecondary] = React.useState(false) const [showAboutModule, setShowAboutModule] = React.useState(false) const [showTestShake, setShowTestShake] = React.useState(false) - const [showHSWizard, setShowHSWizard] = React.useState(false) - const [showFWBanner, setShowFWBanner] = React.useState(true) - const [showCalModal, setShowCalModal] = React.useState(false) + const [showHSWizard, setShowHSWizard] = React.useState(false) + const [showFWBanner, setShowFWBanner] = React.useState(true) + const [showCalModal, setShowCalModal] = React.useState(false) const [targetProps, tooltipProps] = useHoverTooltip() const history = useHistory() - const [dispatchApiRequest, requestIds] = useDispatchApiRequest() const runStatus = useCurrentRunStatus({ onSettled: data => { if (data == null) { @@ -138,29 +138,31 @@ export const ModuleCard = (props: ModuleCardProps): JSX.Element | null => { (!attachPipetteRequired ?? false) && (!calibratePipetteRequired ?? false) && (!updatePipetteFWRequired ?? false) - const latestRequestId = last(requestIds) + const latestRequest = useSelector(state => - latestRequestId ? getRequestById(state, latestRequestId) : null + latestRequestId != null ? getRequestById(state, latestRequestId) : null ) - const isEstopNotDisengaged = useIsEstopNotDisengaged(robotName) - const handleCloseErrorModal = (): void => { - if (latestRequestId != null) { - dispatch(dismissRequest(latestRequestId)) - } + const hasUpdated = + !module.hasAvailableUpdate && latestRequest?.status === SUCCESS + const [showFirmwareToast, setShowFirmwareToast] = React.useState(hasUpdated) + const { makeToast } = useToaster() + if (showFirmwareToast) { + makeToast(t('firmware_updated_successfully'), SUCCESS_TOAST) + setShowFirmwareToast(false) } const handleFirmwareUpdateClick = (): void => { - robotName && - dispatchApiRequest(updateModule(robotName, module.serialNumber)) + robotName && handleModuleApiRequests(robotName, module.serialNumber) } - const { makeToast } = useToaster() - React.useEffect(() => { - if (!module.hasAvailableUpdate && latestRequest?.status === SUCCESS) { - makeToast(t('firmware_update_installation_successful'), SUCCESS_TOAST) + const isEstopNotDisengaged = useIsEstopNotDisengaged(robotName) + + const handleCloseErrorModal = (): void => { + if (latestRequestId != null) { + dispatch(dismissRequest(latestRequestId)) } - }, [module.hasAvailableUpdate, latestRequest?.status, makeToast, t]) + } const isPending = latestRequest?.status === PENDING const hotToTouch: IconProps = { name: 'ot-hot-to-touch' } diff --git a/app/src/organisms/ModuleCard/utils.ts b/app/src/organisms/ModuleCard/utils.ts index c80cfa2c4fe..dfd136bfcfc 100644 --- a/app/src/organisms/ModuleCard/utils.ts +++ b/app/src/organisms/ModuleCard/utils.ts @@ -1,3 +1,9 @@ +import * as React from 'react' +import last from 'lodash/last' + +import { useDispatchApiRequest } from '../../redux/robot-api' +import { updateModule } from '../../redux/modules' + import magneticModule from '../../assets/images/magnetic_module_gen_2_transparent.png' import temperatureModule from '../../assets/images/temp_deck_gen_2_transparent.png' import thermoModuleGen1Closed from '../../assets/images/thermocycler_closed.png' @@ -5,6 +11,7 @@ import thermoModuleGen1Opened from '../../assets/images/thermocycler_open_transp import heaterShakerModule from '../../assets/images/heater_shaker_module_transparent.png' import thermoModuleGen2Closed from '../../assets/images/thermocycler_gen_2_closed.png' import thermoModuleGen2Opened from '../../assets/images/thermocycler_gen_2_opened.png' + import type { AttachedModule } from '../../redux/modules/types' export function getModuleCardImage(attachedModule: AttachedModule): string { @@ -35,3 +42,58 @@ export function getModuleCardImage(attachedModule: AttachedModule): string { return 'unknown module model, this is an error' } } + +type RequestIdsBySerialNumber = Record +type HandleModuleApiRequestsType = (robotName: string, moduleId: string) => void +type GetLatestRequestIdType = (moduleId: string) => string | null + +export function useModuleApiRequests(): [ + GetLatestRequestIdType, + HandleModuleApiRequestsType +] { + const [dispatchApiRequest] = useDispatchApiRequest() + const [ + requestIdsBySerial, + setRequestIdsBySerial, + ] = React.useState({}) + + const handleModuleApiRequests = ( + robotName: string, + serialNumber: string + ): void => { + const action = dispatchApiRequest(updateModule(robotName, serialNumber)) + const { requestId } = action.meta + + if (requestId != null) { + if (serialNumber in requestIdsBySerial) { + setRequestIdsBySerial((prevState: RequestIdsBySerialNumber) => { + const existingRequestIds = prevState[serialNumber] || [] + return { + ...prevState, + [serialNumber]: [...existingRequestIds, requestId], + } + }) + } else { + setRequestIdsBySerial(prevState => { + return { + ...prevState, + [serialNumber]: [requestId], + } + }) + } + } + } + + const getLatestRequestId = React.useCallback( + (serialNumber: string): string | null => { + if (serialNumber in requestIdsBySerial) { + return last(requestIdsBySerial[serialNumber]) ?? null + } else { + return null + } + }, + [requestIdsBySerial] + ) + + return [getLatestRequestId, handleModuleApiRequests] +} diff --git a/app/src/organisms/ModuleWizardFlows/AttachProbe.tsx b/app/src/organisms/ModuleWizardFlows/AttachProbe.tsx index a4a324dc933..4a8f70656e4 100644 --- a/app/src/organisms/ModuleWizardFlows/AttachProbe.tsx +++ b/app/src/organisms/ModuleWizardFlows/AttachProbe.tsx @@ -1,12 +1,6 @@ import * as React from 'react' import { css } from 'styled-components' -import attachProbe1 from '../../assets/videos/pipette-wizard-flows/Pipette_Attach_Probe_1.webm' -import attachProbe8 from '../../assets/videos/pipette-wizard-flows/Pipette_Attach_Probe_8.webm' -import attachProbe96 from '../../assets/videos/pipette-wizard-flows/Pipette_Attach_Probe_96.webm' import { Trans, useTranslation } from 'react-i18next' -import { useDeckConfigurationQuery } from '@opentrons/react-api-client' -import { WASTE_CHUTE_CUTOUT, CreateCommand, LEFT } from '@opentrons/shared-data' -import { InProgressModal } from '../../molecules/InProgressModal/InProgressModal' import { Flex, RESPONSIVENESS, @@ -14,13 +8,26 @@ import { StyledText, TYPOGRAPHY, } from '@opentrons/components' +import { LEFT, WASTE_CHUTE_FIXTURES } from '@opentrons/shared-data' +import attachProbe1 from '../../assets/videos/pipette-wizard-flows/Pipette_Attach_Probe_1.webm' +import attachProbe8 from '../../assets/videos/pipette-wizard-flows/Pipette_Attach_Probe_8.webm' +import attachProbe96 from '../../assets/videos/pipette-wizard-flows/Pipette_Attach_Probe_96.webm' +import { InProgressModal } from '../../molecules/InProgressModal/InProgressModal' +import type { + CreateCommand, + DeckConfiguration, + CutoutId, + CutoutFixtureId, +} from '@opentrons/shared-data' import { Banner } from '../../atoms/Banner' import { GenericWizardTile } from '../../molecules/GenericWizardTile' import type { ModuleCalibrationWizardStepProps } from './types' interface AttachProbeProps extends ModuleCalibrationWizardStepProps { adapterId: string | null + deckConfig: DeckConfiguration + fixtureIdByCutoutId: { [cutoutId in CutoutId]?: CutoutFixtureId } } const BODY_STYLE = css` @@ -42,7 +49,8 @@ export const AttachProbe = (props: AttachProbeProps): JSX.Element | null => { attachedModule, attachedPipette, isOnDevice, - slotName, + deckConfig, + fixtureIdByCutoutId, } = props const { t, i18n } = useTranslation([ 'module_wizard_flows', @@ -65,12 +73,11 @@ export const AttachProbe = (props: AttachProbeProps): JSX.Element | null => { probeLocation = t('pipette_wizard_flows:ninety_six_probe_location') break } - const wasteChuteConflict = - slotName === 'C3' && attachedPipette.data.channels === 96 - const deckConfig = useDeckConfigurationQuery().data - const isWasteChuteOnDeck = - deckConfig?.find(fixture => fixture.cutoutId === WASTE_CHUTE_CUTOUT) ?? - false + const wasteChuteConflictWith96Channel = + 'cutoutC3' in fixtureIdByCutoutId && attachedPipette.data.channels === 96 + const isWasteChuteOnDeck = deckConfig.some(cc => + WASTE_CHUTE_FIXTURES.includes(cc.cutoutFixtureId) + ) const pipetteAttachProbeVid = ( @@ -101,7 +108,7 @@ export const AttachProbe = (props: AttachProbeProps): JSX.Element | null => { /> - {wasteChuteConflict && ( + {wasteChuteConflictWith96Channel && ( , - CreateMaintenanceRunData, - unknown - > - isCreateLoading: boolean - createdMaintenanceRunId: string | null -} +type BeforeBeginningProps = ModuleCalibrationWizardStepProps export const BeforeBeginning = ( props: BeforeBeginningProps ): JSX.Element | null => { - const { - proceed, - createMaintenanceRun, - isCreateLoading, - attachedModule, - maintenanceRunId, - createdMaintenanceRunId, - } = props + const { proceed, attachedModule } = props const { t } = useTranslation(['module_wizard_flows', 'shared']) - React.useEffect(() => { - if (createdMaintenanceRunId == null) { - createMaintenanceRun({}) - } - }, []) + const moduleDisplayName = getModuleDisplayName(attachedModule.moduleModel) let adapterLoadname: string @@ -103,13 +77,12 @@ export const BeforeBeginning = ( bodyText={ }} /> } proceedButtonText={t('start_setup')} - proceedIsDisabled={isCreateLoading || maintenanceRunId == null} proceed={proceed} /> ) diff --git a/app/src/organisms/ModuleWizardFlows/PlaceAdapter.tsx b/app/src/organisms/ModuleWizardFlows/PlaceAdapter.tsx index cd5c949d8ba..b5d5e5cf80d 100644 --- a/app/src/organisms/ModuleWizardFlows/PlaceAdapter.tsx +++ b/app/src/organisms/ModuleWizardFlows/PlaceAdapter.tsx @@ -16,7 +16,6 @@ import { TYPOGRAPHY, } from '@opentrons/components' import { - CreateCommand, getCalibrationAdapterLoadName, getModuleDisplayName, HEATERSHAKER_MODULE_TYPE, @@ -24,17 +23,34 @@ import { HEATERSHAKER_MODULE_MODELS, TEMPERATURE_MODULE_MODELS, THERMOCYCLER_MODULE_MODELS, + FLEX_SINGLE_SLOT_BY_CUTOUT_ID, + THERMOCYCLER_V2_FRONT_FIXTURE, } from '@opentrons/shared-data' +import { InProgressModal } from '../../molecules/InProgressModal/InProgressModal' import { GenericWizardTile } from '../../molecules/GenericWizardTile' import { LEFT_SLOTS } from './constants' +import type { DeckConfiguration, CreateCommand } from '@opentrons/shared-data' import type { ModuleCalibrationWizardStepProps } from './types' -import { InProgressModal } from '../../molecules/InProgressModal/InProgressModal' +import type { AxiosError } from 'axios' +import type { UseMutateFunction } from 'react-query' +import type { + CreateMaintenanceRunData, + MaintenanceRun, +} from '@opentrons/api-client' interface PlaceAdapterProps extends ModuleCalibrationWizardStepProps { - slotName: string + deckConfig: DeckConfiguration setCreatedAdapterId: (adapterId: string) => void + createMaintenanceRun: UseMutateFunction< + MaintenanceRun, + AxiosError, + CreateMaintenanceRunData, + unknown + > + isCreateLoading: boolean + createdMaintenanceRunId: string | null } export const BODY_STYLE = css` @@ -50,16 +66,33 @@ export const PlaceAdapter = (props: PlaceAdapterProps): JSX.Element | null => { const { proceed, goBack, + deckConfig, attachedModule, - slotName, chainRunCommands, setErrorMessage, setCreatedAdapterId, attachedPipette, isRobotMoving, + maintenanceRunId, + createMaintenanceRun, + isCreateLoading, + createdMaintenanceRunId, } = props const { t } = useTranslation('module_wizard_flows') + React.useEffect(() => { + if (createdMaintenanceRunId == null) { + createMaintenanceRun({}) + } + }, []) const mount = attachedPipette.mount + const cutoutId = deckConfig.find( + cc => + cc.opentronsModuleSerialNumber === attachedModule.serialNumber && + (attachedModule.moduleType !== THERMOCYCLER_MODULE_TYPE || + cc.cutoutFixtureId === THERMOCYCLER_V2_FRONT_FIXTURE) + )?.cutoutId + const slotName = + cutoutId != null ? FLEX_SINGLE_SLOT_BY_CUTOUT_ID[cutoutId] : null const handleOnClick = (): void => { const calibrationAdapterLoadName = getCalibrationAdapterLoadName( attachedModule.moduleModel @@ -70,15 +103,19 @@ export const PlaceAdapter = (props: PlaceAdapterProps): JSX.Element | null => { ) return } + if (slotName == null) { + console.error( + `could not load module ${attachedModule.moduleModel} into location ${slotName}` + ) + return + } const calibrationAdapterId = uuidv4() const commands: CreateCommand[] = [ { commandType: 'loadModule', params: { - location: { - slotName: slotName, - }, + location: { slotName }, model: attachedModule.moduleModel, moduleId: attachedModule.id, }, @@ -97,15 +134,21 @@ export const PlaceAdapter = (props: PlaceAdapterProps): JSX.Element | null => { { commandType: 'calibration/moveToMaintenancePosition', params: { - mount: mount, + mount, maintenancePosition: 'attachInstrument', }, }, ] chainRunCommands?.(commands, false) - .then(() => setCreatedAdapterId(calibrationAdapterId)) - .then(() => proceed()) - .catch((e: Error) => setErrorMessage(e.message)) + .then(() => { + setCreatedAdapterId(calibrationAdapterId) + }) + .then(() => { + proceed() + }) + .catch((e: Error) => { + setErrorMessage(e.message) + }) } const moduleType = attachedModule.moduleType @@ -188,6 +231,7 @@ export const PlaceAdapter = (props: PlaceAdapterProps): JSX.Element | null => { bodyText={bodyText} proceedButtonText={t('confirm_placement')} proceed={handleOnClick} + proceedIsDisabled={isCreateLoading || maintenanceRunId == null} back={goBack} /> ) diff --git a/app/src/organisms/ModuleWizardFlows/SelectLocation.tsx b/app/src/organisms/ModuleWizardFlows/SelectLocation.tsx index 6a694959079..af0301549d0 100644 --- a/app/src/organisms/ModuleWizardFlows/SelectLocation.tsx +++ b/app/src/organisms/ModuleWizardFlows/SelectLocation.tsx @@ -1,15 +1,23 @@ import * as React from 'react' +import isEqual from 'lodash/isEqual' import { useTranslation } from 'react-i18next' import { css } from 'styled-components' +import { useUpdateDeckConfigurationMutation } from '@opentrons/react-api-client' import { - FLEX_ROBOT_TYPE, - getDeckDefFromRobotType, getModuleDisplayName, - THERMOCYCLER_MODULE_TYPE, - CutoutConfig, + getDeckDefFromRobotType, + FLEX_ROBOT_TYPE, + getCutoutFixturesForModuleModel, + SINGLE_CENTER_SLOT_FIXTURE, + SINGLE_CENTER_CUTOUTS, + SINGLE_LEFT_SLOT_FIXTURE, + SINGLE_RIGHT_CUTOUTS, + SINGLE_RIGHT_SLOT_FIXTURE, + getFixtureIdByCutoutIdFromModuleAnchorCutoutId, + SINGLE_SLOT_FIXTURES, } from '@opentrons/shared-data' import { - DeckLocationSelect, + DeckConfigurator, RESPONSIVENESS, SIZE_1, SPACING, @@ -19,6 +27,12 @@ import { import { Banner } from '../../atoms/Banner' import { GenericWizardTile } from '../../molecules/GenericWizardTile' import type { ModuleCalibrationWizardStepProps } from './types' +import type { + CutoutConfig, + DeckConfiguration, + CutoutFixtureId, + CutoutId, +} from '@opentrons/shared-data' export const BODY_STYLE = css` ${TYPOGRAPHY.pRegular}; @@ -29,9 +43,10 @@ export const BODY_STYLE = css` } ` interface SelectLocationProps extends ModuleCalibrationWizardStepProps { - setSlotName: React.Dispatch> availableSlotNames: string[] occupiedCutouts: CutoutConfig[] + deckConfig: DeckConfiguration + configuredFixtureIdByCutoutId: { [cutoutId in CutoutId]?: CutoutFixtureId } } export const SelectLocation = ( props: SelectLocationProps @@ -39,17 +54,19 @@ export const SelectLocation = ( const { proceed, attachedModule, - slotName, - setSlotName, - availableSlotNames, - occupiedCutouts, + deckConfig, + configuredFixtureIdByCutoutId, } = props const { t } = useTranslation('module_wizard_flows') const moduleName = getModuleDisplayName(attachedModule.moduleModel) const handleOnClick = (): void => { proceed() } + const { updateDeckConfiguration } = useUpdateDeckConfigurationMutation() const deckDef = getDeckDefFromRobotType(FLEX_ROBOT_TYPE) + const cutoutConfig = deckConfig.find( + cc => cc.opentronsModuleSerialNumber === attachedModule.serialNumber + ) const bodyText = ( <> @@ -61,28 +78,111 @@ export const SelectLocation = ( ) + const moduleFixtures = getCutoutFixturesForModuleModel( + attachedModule.moduleModel, + deckDef + ) + const mayMountToCutoutIds = moduleFixtures.reduce( + (acc, { mayMountTo }) => [...acc, ...mayMountTo], + [] + ) + const editableCutoutIds = deckConfig.reduce( + (acc, { cutoutId, cutoutFixtureId, opentronsModuleSerialNumber }) => { + const isCurrentConfiguration = + Object.values(configuredFixtureIdByCutoutId).includes( + cutoutFixtureId + ) && attachedModule.serialNumber === opentronsModuleSerialNumber + if ( + mayMountToCutoutIds.includes(cutoutId) && + (isCurrentConfiguration || + SINGLE_SLOT_FIXTURES.includes(cutoutFixtureId)) + ) { + return [...acc, cutoutId] + } + return acc + }, + [] + ) + + const handleAddFixture = (anchorCutoutId: CutoutId): void => { + const selectedFixtureIdByCutoutIds = getFixtureIdByCutoutIdFromModuleAnchorCutoutId( + anchorCutoutId, + moduleFixtures + ) + if (!isEqual(selectedFixtureIdByCutoutIds, configuredFixtureIdByCutoutId)) { + updateDeckConfiguration( + deckConfig.map(cc => { + if (cc.cutoutId in configuredFixtureIdByCutoutId) { + let replacementFixtureId: CutoutFixtureId = SINGLE_LEFT_SLOT_FIXTURE + if (SINGLE_CENTER_CUTOUTS.includes(cc.cutoutId)) { + replacementFixtureId = SINGLE_CENTER_SLOT_FIXTURE + } else if (SINGLE_RIGHT_CUTOUTS.includes(cc.cutoutId)) { + replacementFixtureId = SINGLE_RIGHT_SLOT_FIXTURE + } + return { + ...cc, + cutoutFixtureId: replacementFixtureId, + opentronsModuleSerialNumber: undefined, + } + } else if (cc.cutoutId in selectedFixtureIdByCutoutIds) { + return { + ...cc, + cutoutFixtureId: + selectedFixtureIdByCutoutIds[cc.cutoutId] ?? cc.cutoutFixtureId, + opentronsModuleSerialNumber: attachedModule.serialNumber, + } + } else { + return cc + } + }) + ) + } + } + + const handleRemoveFixture = (anchorCutoutId: CutoutId): void => { + const removedFixtureIdByCutoutIds = getFixtureIdByCutoutIdFromModuleAnchorCutoutId( + anchorCutoutId, + moduleFixtures + ) + updateDeckConfiguration( + deckConfig.map(cc => { + if (cc.cutoutId in removedFixtureIdByCutoutIds) { + let replacementFixtureId: CutoutFixtureId = SINGLE_LEFT_SLOT_FIXTURE + if (SINGLE_CENTER_CUTOUTS.includes(cc.cutoutId)) { + replacementFixtureId = SINGLE_CENTER_SLOT_FIXTURE + } else if (SINGLE_RIGHT_CUTOUTS.includes(cc.cutoutId)) { + replacementFixtureId = SINGLE_RIGHT_SLOT_FIXTURE + } + return { + ...cc, + cutoutFixtureId: replacementFixtureId, + opentronsModuleSerialNumber: undefined, + } + } else { + return cc + } + }) + ) + } + return ( setSlotName(loc.slotName)} - availableSlotNames={availableSlotNames} - occupiedCutouts={occupiedCutouts} - isThermocycler={ - attachedModule.moduleType === THERMOCYCLER_MODULE_TYPE - } - showTooltipOnDisabled={true} + } bodyText={bodyText} proceedButtonText={t('confirm_location')} proceed={handleOnClick} - proceedIsDisabled={slotName == null} + proceedIsDisabled={cutoutConfig == null} disableProceedReason={ - slotName == null + cutoutConfig == null ? 'Current deck configuration prevents module placement' : undefined } diff --git a/app/src/organisms/ModuleWizardFlows/index.tsx b/app/src/organisms/ModuleWizardFlows/index.tsx index 39c235bd782..3e0977a4f23 100644 --- a/app/src/organisms/ModuleWizardFlows/index.tsx +++ b/app/src/organisms/ModuleWizardFlows/index.tsx @@ -13,6 +13,10 @@ import { getModuleDisplayName, FLEX_CUTOUT_BY_SLOT_ID, SINGLE_SLOT_FIXTURES, + getFixtureIdByCutoutIdFromModuleSlotName, + getCutoutFixturesForModuleModel, + getDeckDefFromRobotType, + FLEX_ROBOT_TYPE, } from '@opentrons/shared-data' import { LegacyModalShell } from '../../molecules/LegacyModal' import { getTopPortalEl } from '../../App/portal' @@ -45,7 +49,6 @@ interface ModuleWizardFlowsProps { attachedModule: AttachedModule closeFlow: () => void isPrepCommandLoading: boolean - initialSlotName?: string onComplete?: () => void prepCommandErrorMessage?: string } @@ -57,7 +60,6 @@ export const ModuleWizardFlows = ( ): JSX.Element | null => { const { attachedModule, - initialSlotName, isPrepCommandLoading, closeFlow, onComplete, @@ -72,12 +74,25 @@ export const ModuleWizardFlows = ( : attachedPipettes.right const moduleCalibrationSteps = getModuleCalibrationSteps() + const deckDef = getDeckDefFromRobotType(FLEX_ROBOT_TYPE) const deckConfig = useDeckConfigurationQuery().data ?? [] + const moduleCutoutConfig = deckConfig.find( + cc => cc.opentronsModuleSerialNumber === attachedModule.serialNumber + ) + // mapping of cutoutId's occupied by the target module and their cutoutFixtureId's per cutout + const fixtureIdByCutoutId = + moduleCutoutConfig != null + ? getFixtureIdByCutoutIdFromModuleSlotName( + moduleCutoutConfig.cutoutId.replace('cutout', ''), + getCutoutFixturesForModuleModel(attachedModule.moduleModel, deckDef), + deckDef + ) + : {} const occupiedCutouts = deckConfig.filter( - (fixture: CutoutConfig) => + (cutoutConfig: CutoutConfig) => !SINGLE_SLOT_FIXTURES.includes( - fixture.cutoutFixtureId as SingleSlotCutoutFixtureId - ) + cutoutConfig.cutoutFixtureId as SingleSlotCutoutFixtureId + ) && !Object.keys(fixtureIdByCutoutId).includes(cutoutConfig.cutoutId) ) const availableSlotNames = FLEX_SLOT_NAMES_BY_MOD_TYPE[ @@ -90,9 +105,6 @@ export const ModuleWizardFlows = ( ) ) ?? [] - const [slotName, setSlotName] = React.useState( - initialSlotName != null ? initialSlotName : availableSlotNames?.[0] ?? null - ) const [currentStepIndex, setCurrentStepIndex] = React.useState(0) const totalStepCount = moduleCalibrationSteps.length - 1 const currentStep = moduleCalibrationSteps?.[currentStepIndex] @@ -247,7 +259,6 @@ export const ModuleWizardFlows = ( errorMessage, isOnDevice, attachedModule, - slotName, isExiting, } @@ -276,7 +287,7 @@ export const ModuleWizardFlows = ( ) : ( , @@ -289,23 +300,16 @@ export const ModuleWizardFlows = ( } else if (isExiting) { modalContent = } else if (currentStep.section === SECTIONS.BEFORE_BEGINNING) { - modalContent = ( - - ) + modalContent = } else if (currentStep.section === SECTIONS.SELECT_LOCATION) { modalContent = ( ) } else if (currentStep.section === SECTIONS.PLACE_ADAPTER) { @@ -313,7 +317,11 @@ export const ModuleWizardFlows = ( ) } else if (currentStep.section === SECTIONS.ATTACH_PROBE) { @@ -321,7 +329,9 @@ export const ModuleWizardFlows = ( ) } else if (currentStep.section === SECTIONS.DETACH_PROBE) { diff --git a/app/src/organisms/ModuleWizardFlows/types.ts b/app/src/organisms/ModuleWizardFlows/types.ts index f2b2764b12c..df6020e9b36 100644 --- a/app/src/organisms/ModuleWizardFlows/types.ts +++ b/app/src/organisms/ModuleWizardFlows/types.ts @@ -24,7 +24,6 @@ export interface ModuleCalibrationWizardStepProps { attachedPipette: PipetteInformation errorMessage: string | null setErrorMessage: (message: string | null) => void - slotName: string isOnDevice: boolean | null } diff --git a/app/src/organisms/NetworkSettings/AlternativeSecurityTypeModal.tsx b/app/src/organisms/NetworkSettings/AlternativeSecurityTypeModal.tsx index b0e365d08fc..375476f2a2e 100644 --- a/app/src/organisms/NetworkSettings/AlternativeSecurityTypeModal.tsx +++ b/app/src/organisms/NetworkSettings/AlternativeSecurityTypeModal.tsx @@ -26,7 +26,7 @@ interface AlternativeSecurityTypeModalProps { export function AlternativeSecurityTypeModal({ setShowAlternativeSecurityTypeModal, }: AlternativeSecurityTypeModalProps): JSX.Element { - const { t } = useTranslation('device_settings') + const { t } = useTranslation(['device_settings', 'branded']) const history = useHistory() const modalHeader: ModalHeaderBaseProps = { title: t('alternative_security_types'), @@ -58,7 +58,7 @@ export function AlternativeSecurityTypeModal({ fontWeight={TYPOGRAPHY.fontWeightRegular} color={COLORS.grey60} > - {t('alternative_security_types_description')} + {t('branded:alternative_security_types_description')}
- e != null && setPassword(String(e))} keyboardRef={keyboardRef} /> diff --git a/app/src/organisms/NetworkSettings/SetWifiSsid.tsx b/app/src/organisms/NetworkSettings/SetWifiSsid.tsx index f9b2fdc8fff..9f920e9e519 100644 --- a/app/src/organisms/NetworkSettings/SetWifiSsid.tsx +++ b/app/src/organisms/NetworkSettings/SetWifiSsid.tsx @@ -12,7 +12,7 @@ import { } from '@opentrons/components' import { InputField } from '../../atoms/InputField' -import { NormalKeyboard } from '../../atoms/SoftwareKeyboard' +import { FullKeyboard } from '../../atoms/SoftwareKeyboard' import { useIsUnboxingFlowOngoing } from '../RobotSettingsDashboard/NetworkSettings/hooks' interface SetWifiSsidProps { @@ -57,7 +57,7 @@ export function SetWifiSsid({ /> - { e != null && setInputSsid(e) }} diff --git a/app/src/organisms/NetworkSettings/__tests__/SetWifiCred.test.tsx b/app/src/organisms/NetworkSettings/__tests__/SetWifiCred.test.tsx index 6532203b4cb..0af38eff22d 100644 --- a/app/src/organisms/NetworkSettings/__tests__/SetWifiCred.test.tsx +++ b/app/src/organisms/NetworkSettings/__tests__/SetWifiCred.test.tsx @@ -43,7 +43,7 @@ describe('SetWifiCred', () => { // software keyboard screen.getByRole('button', { name: 'del' }) screen.getByRole('button', { name: 'a' }) - screen.getByRole('button', { name: 'SHIFT' }) + screen.getByRole('button', { name: 'ABC' }) }) it('should display password', () => { diff --git a/app/src/organisms/OnDeviceDisplay/RobotDashboard/RecentRunProtocolCard.tsx b/app/src/organisms/OnDeviceDisplay/RobotDashboard/RecentRunProtocolCard.tsx index 6120614f954..2f640e7e522 100644 --- a/app/src/organisms/OnDeviceDisplay/RobotDashboard/RecentRunProtocolCard.tsx +++ b/app/src/organisms/OnDeviceDisplay/RobotDashboard/RecentRunProtocolCard.tsx @@ -3,10 +3,12 @@ import { css } from 'styled-components' import { useTranslation } from 'react-i18next' import { useHistory } from 'react-router-dom' import { formatDistance } from 'date-fns' +import last from 'lodash/last' import { BORDERS, COLORS, + Chip, DIRECTION_COLUMN, Flex, Icon, @@ -16,19 +18,21 @@ import { StyledText, TYPOGRAPHY, } from '@opentrons/components' -import { useProtocolQuery } from '@opentrons/react-api-client' +import { + useProtocolAnalysisAsDocumentQuery, + useProtocolQuery, +} from '@opentrons/react-api-client' import { RUN_STATUS_FAILED, RUN_STATUS_STOPPED, RUN_STATUS_SUCCEEDED, - Run, - RunData, - RunStatus, } from '@opentrons/api-client' -import { Chip } from '../../../atoms/Chip' import { ODD_FOCUS_VISIBLE } from '../../../atoms/buttons//constants' -import { useTrackEvent } from '../../../redux/analytics' +import { + useTrackEvent, + ANALYTICS_PROTOCOL_PROCEED_TO_RUN, +} from '../../../redux/analytics' import { Skeleton } from '../../../atoms/Skeleton' import { useMissingProtocolHardware } from '../../../pages/Protocols/hooks' import { useCloneRun } from '../../ProtocolUpload/hooks' @@ -38,6 +42,7 @@ import { INIT_STATUS, } from '../../../resources/health/hooks' +import type { Run, RunData, RunStatus } from '@opentrons/api-client' import type { ProtocolResource } from '@opentrons/shared-data' interface RecentRunProtocolCardProps { @@ -98,6 +103,14 @@ export function ProtocolWithLastRun({ const protocolName = protocolData.metadata.protocolName ?? protocolData.files[0].name + const protocolId = protocolData.id + + const { data: analysis } = useProtocolAnalysisAsDocumentQuery( + protocolId, + last(protocolData?.analysisSummaries)?.id ?? null, + { enabled: protocolData != null } + ) + const PROTOCOL_CARD_STYLE = css` flex: 1 0 0; &:active { @@ -125,13 +138,22 @@ export function ProtocolWithLastRun({ height: max-content; ` + const hasRunTimeParameters = + analysis?.runTimeParameters != null + ? analysis?.runTimeParameters.length > 0 + : false + const handleCardClick = (): void => { setShowSpinner(true) - cloneRun() - trackEvent({ - name: 'proceedToRun', - properties: { sourceLocation: 'RecentRunProtocolCard' }, - }) + if (hasRunTimeParameters) { + history.push(`/protocols/${protocolId}`) + } else { + cloneRun() + trackEvent({ + name: ANALYTICS_PROTOCOL_PROCEED_TO_RUN, + properties: { sourceLocation: 'RecentRunProtocolCard' }, + }) + } // TODO(BC, 08/29/23): reintroduce this analytics event when we refactor the hook to fetch data lazily (performance concern) // trackProtocolRunEvent({ name: 'runAgain' }) } diff --git a/app/src/organisms/OnDeviceDisplay/RobotDashboard/__tests__/RecentRunProtocolCard.test.tsx b/app/src/organisms/OnDeviceDisplay/RobotDashboard/__tests__/RecentRunProtocolCard.test.tsx index 1584e3ce723..e1a54944a99 100644 --- a/app/src/organisms/OnDeviceDisplay/RobotDashboard/__tests__/RecentRunProtocolCard.test.tsx +++ b/app/src/organisms/OnDeviceDisplay/RobotDashboard/__tests__/RecentRunProtocolCard.test.tsx @@ -5,8 +5,14 @@ import { MemoryRouter } from 'react-router-dom' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { when } from 'vitest-when' -import { useProtocolQuery } from '@opentrons/react-api-client' -import { RUN_STATUS_FAILED } from '@opentrons/api-client' +import { + useProtocolQuery, + useProtocolAnalysisAsDocumentQuery, +} from '@opentrons/react-api-client' +import { + RUN_STATUS_FAILED, + simpleAnalysisFileFixture, +} from '@opentrons/api-client' import { COLORS } from '@opentrons/components' import { renderWithProviders } from '../../../../__testing-utils__' @@ -14,7 +20,10 @@ import { i18n } from '../../../../i18n' import { Skeleton } from '../../../../atoms/Skeleton' import { useMissingProtocolHardware } from '../../../../pages/Protocols/hooks' import { useTrackProtocolRunEvent } from '../../../Devices/hooks' -import { useTrackEvent } from '../../../../redux/analytics' +import { + useTrackEvent, + ANALYTICS_PROTOCOL_PROCEED_TO_RUN, +} from '../../../../redux/analytics' import { useCloneRun } from '../../../ProtocolUpload/hooks' import { useRerunnableStatusText } from '../hooks' import { RecentRunProtocolCard } from '../' @@ -24,11 +33,23 @@ import { INIT_STATUS, } from '../../../../resources/health/hooks' +import type { useHistory } from 'react-router-dom' import type { ProtocolHardware } from '../../../../pages/Protocols/hooks' +const mockPush = vi.fn() + +vi.mock('react-router-dom', async importOriginal => { + const actual = await importOriginal() + return { + ...actual, + useHistory: () => ({ push: mockPush } as any), + } +}) + vi.mock('@opentrons/react-api-client') vi.mock('../../../../atoms/Skeleton') vi.mock('../../../../pages/Protocols/hooks') +vi.mock('../../../../pages/ProtocolDetails') vi.mock('../../../../organisms/Devices/hooks') vi.mock('../../../../organisms/RunTimeControl/hooks') vi.mock('../../../../organisms/ProtocolUpload/hooks') @@ -128,7 +149,18 @@ describe('RecentRunProtocolCard', () => { data: { data: [mockRunData] }, } as any) vi.mocked(useProtocolQuery).mockReturnValue({ - data: { data: { metadata: { protocolName: 'mockProtocol' } } }, + data: { + data: { + metadata: { protocolName: 'mockProtocol' }, + id: 'mockProtocolId', + }, + }, + } as any) + vi.mocked(useProtocolAnalysisAsDocumentQuery).mockReturnValue({ + data: { + ...simpleAnalysisFileFixture, + runTimeParameters: [], + }, } as any) vi.mocked(useRobotInitializationStatus).mockReturnValue( INIT_STATUS.SUCCEEDED @@ -221,7 +253,7 @@ describe('RecentRunProtocolCard', () => { expect(button).toHaveStyle(`background-color: ${COLORS.green40}`) fireEvent.click(button) expect(mockTrackEvent).toHaveBeenCalledWith({ - name: 'proceedToRun', + name: ANALYTICS_PROTOCOL_PROCEED_TO_RUN, properties: { sourceLocation: 'RecentRunProtocolCard' }, }) // TODO(BC, 08/30/23): reintroduce check for tracking when tracking is reintroduced lazily @@ -252,4 +284,14 @@ describe('RecentRunProtocolCard', () => { const [{ getByText }] = render(props) getByText('mock Skeleton') }) + + it('should push to protocol details if protocol contains runtime parameters', () => { + vi.mocked(useProtocolAnalysisAsDocumentQuery).mockReturnValue({ + data: simpleAnalysisFileFixture, + } as any) + render(props) + const button = screen.getByLabelText('RecentRunProtocolCard') + fireEvent.click(button) + expect(mockPush).toBeCalledWith('/protocols/mockProtocolId') + }) }) diff --git a/app/src/organisms/OnDeviceDisplay/RobotDashboard/__tests__/RecentRunProtocolCarousel.test.tsx b/app/src/organisms/OnDeviceDisplay/RobotDashboard/__tests__/RecentRunProtocolCarousel.test.tsx index 85e956ed977..8bc3a481843 100644 --- a/app/src/organisms/OnDeviceDisplay/RobotDashboard/__tests__/RecentRunProtocolCarousel.test.tsx +++ b/app/src/organisms/OnDeviceDisplay/RobotDashboard/__tests__/RecentRunProtocolCarousel.test.tsx @@ -26,6 +26,7 @@ const mockRun = { pipettes: [], protocolId: 'mockSortedProtocolID', status: 'stopped', + runTimeParameters: [], } const render = ( diff --git a/app/src/organisms/OnDeviceDisplay/RunningProtocol/ConfirmCancelRunModal.tsx b/app/src/organisms/OnDeviceDisplay/RunningProtocol/ConfirmCancelRunModal.tsx index c2841101133..b29b81f76aa 100644 --- a/app/src/organisms/OnDeviceDisplay/RunningProtocol/ConfirmCancelRunModal.tsx +++ b/app/src/organisms/OnDeviceDisplay/RunningProtocol/ConfirmCancelRunModal.tsx @@ -98,7 +98,7 @@ export function ConfirmCancelRunModal({ paddingBottom={SPACING.spacing32} paddingTop={`${isActiveRun ? SPACING.spacing32 : '0'}`} > - {t('cancel_run_alert_info')} + {t('cancel_run_alert_info_flex')} {t('cancel_run_module_info')} - {t('contact_information')} + {t('branded:contact_information')} void + errorType?: string + protocolName?: string +} + +export function RunPausedSplash({ + onClose, + errorType, + protocolName, +}: RunPausedSplashProps): JSX.Element { + const { t } = useTranslation('error_recovery') + + let subText: string | null + switch (errorType) { + default: + subText = protocolName ?? null + } + + return ( + + + + + {t('run_paused')} + + + {subText} + + + + ) +} + +const SplashHeader = styled.h1` + font-weight: ${TYPOGRAPHY.fontWeightBold}; + text-align: ${TYPOGRAPHY.textAlignLeft}; + font-size: ${TYPOGRAPHY.fontSize80}; + line-height: ${TYPOGRAPHY.lineHeight96}; + color: ${COLORS.white}; +` +const SplashBody = styled.h4` + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 4; + overflow: hidden; + overflow-wrap: ${OVERFLOW_WRAP_BREAK_WORD}; + font-weight: ${TYPOGRAPHY.fontWeightSemiBold}; + text-align: ${TYPOGRAPHY.textAlignCenter}; + text-transform: ${TYPOGRAPHY.textTransformCapitalize}; + font-size: ${TYPOGRAPHY.fontSize32}; + line-height: ${TYPOGRAPHY.lineHeight42}; + color: ${COLORS.white}; +` + +const SplashFrame = styled(Flex)` + width: 100%; + height: 100%; + flex-direction: ${DIRECTION_COLUMN}; + justify-content: ${JUSTIFY_CENTER}; + align-items: ${ALIGN_CENTER}; + grid-gap: ${SPACING.spacing40}; +` diff --git a/app/src/organisms/OnDeviceDisplay/RunningProtocol/__tests__/ConfirmCancelRunModal.test.tsx b/app/src/organisms/OnDeviceDisplay/RunningProtocol/__tests__/ConfirmCancelRunModal.test.tsx index fad8b4b8de1..36e23a35df6 100644 --- a/app/src/organisms/OnDeviceDisplay/RunningProtocol/__tests__/ConfirmCancelRunModal.test.tsx +++ b/app/src/organisms/OnDeviceDisplay/RunningProtocol/__tests__/ConfirmCancelRunModal.test.tsx @@ -97,12 +97,10 @@ describe('ConfirmCancelRunModal', () => { vi.restoreAllMocks() }) - it('should render text and buttons', () => { + it('should render correct text and buttons', () => { render(props) - screen.getByText('Are you sure you want to cancel this run?') - screen.getByText( - 'Doing so will terminate this run, drop any attached tips in the trash container and home your robot.' - ) + screen.getByText('Are you sure you want to cancel?') + screen.getByText('Doing so will terminate this run and home your robot.') screen.getByText( 'Additionally, any hardware modules used within the protocol will remain active and maintain their current states until deactivated.' ) @@ -111,7 +109,7 @@ describe('ConfirmCancelRunModal', () => { screen.getByText('Cancel run') }) - it('shoudler render the canceling run modal when run is dismissing', () => { + it('should render the canceling run modal when run is dismissing', () => { vi.mocked(useDismissCurrentRunMutation).mockReturnValue({ dismissCurrentRun: mockDismissCurrentRun, isLoading: true, diff --git a/app/src/organisms/OnDeviceDisplay/RunningProtocol/__tests__/RunPausedSplash.test.tsx b/app/src/organisms/OnDeviceDisplay/RunningProtocol/__tests__/RunPausedSplash.test.tsx new file mode 100644 index 00000000000..6a7061346a4 --- /dev/null +++ b/app/src/organisms/OnDeviceDisplay/RunningProtocol/__tests__/RunPausedSplash.test.tsx @@ -0,0 +1,51 @@ +import * as React from 'react' +import { MemoryRouter } from 'react-router-dom' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { fireEvent, screen } from '@testing-library/react' +import { renderWithProviders } from '../../../../__testing-utils__' +import { i18n } from '../../../../i18n' + +import { COLORS } from '@opentrons/components' + +import { RunPausedSplash } from '../RunPausedSplash' + +const render = (props: React.ComponentProps) => { + return renderWithProviders( + + + , + { + i18nInstance: i18n, + } + ) +} + +const MOCK_PROTOCOL_NAME = 'MOCK_PROTOCOL' + +describe('ConfirmCancelRunModal', () => { + let props: React.ComponentProps + const mockOnClose = vi.fn() + + beforeEach(() => { + props = { + onClose: mockOnClose, + protocolName: MOCK_PROTOCOL_NAME, + errorType: '', + } + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('should render a generic paused screen if there is no errorType', () => { + render(props) + expect(screen.getByText('Run paused')).toBeInTheDocument() + expect(screen.getByText(MOCK_PROTOCOL_NAME)) + expect(screen.getByRole('button')).toHaveStyle({ + 'background-color': COLORS.grey50, + }) + fireEvent.click(screen.getByRole('button')) + expect(mockOnClose).toHaveBeenCalled() + }) +}) diff --git a/app/src/organisms/PipetteWizardFlows/ChoosePipette.tsx b/app/src/organisms/PipetteWizardFlows/ChoosePipette.tsx index 8d9330315c7..f3926a711a3 100644 --- a/app/src/organisms/PipetteWizardFlows/ChoosePipette.tsx +++ b/app/src/organisms/PipetteWizardFlows/ChoosePipette.tsx @@ -207,7 +207,11 @@ export const ChoosePipette = (props: ChoosePipetteProps): JSX.Element => {
) : ( - + {showExitConfirmation ? ( setShowExitConfirmation(false)} @@ -218,7 +222,7 @@ export const ChoosePipette = (props: ChoosePipetteProps): JSX.Element => { ) : ( diff --git a/app/src/organisms/PipetteWizardFlows/ProbeNotAttached.tsx b/app/src/organisms/PipetteWizardFlows/ProbeNotAttached.tsx index 1056cf9831b..b73111af420 100644 --- a/app/src/organisms/PipetteWizardFlows/ProbeNotAttached.tsx +++ b/app/src/organisms/PipetteWizardFlows/ProbeNotAttached.tsx @@ -26,14 +26,20 @@ interface ProbeNotAttachedProps { export const ProbeNotAttached = ( props: ProbeNotAttachedProps ): JSX.Element | null => { - const { t, i18n } = useTranslation(['pipette_wizard_flows', 'shared']) + const { t, i18n } = useTranslation([ + 'pipette_wizard_flows', + 'shared', + 'branded', + ]) const { isOnDevice, handleOnClick, setShowUnableToDetect } = props const [numberOfTryAgains, setNumberOfTryAgains] = React.useState(0) return ( 2 ? t('something_seems_wrong') : undefined} + subHeader={ + numberOfTryAgains > 2 ? t('branded:something_seems_wrong') : undefined + } iconColor={COLORS.red50} isSuccess={false} > diff --git a/app/src/organisms/PipetteWizardFlows/Results.tsx b/app/src/organisms/PipetteWizardFlows/Results.tsx index 04549e686df..fda57800151 100644 --- a/app/src/organisms/PipetteWizardFlows/Results.tsx +++ b/app/src/organisms/PipetteWizardFlows/Results.tsx @@ -60,7 +60,11 @@ export const Results = (props: ResultsProps): JSX.Element => { setShowErrorMessage, nextMount, } = props - const { t, i18n } = useTranslation(['pipette_wizard_flows', 'shared']) + const { t, i18n } = useTranslation([ + 'pipette_wizard_flows', + 'shared', + 'branded', + ]) const pipetteName = attachedPipettes[mount] != null ? attachedPipettes[mount]?.displayName : '' @@ -263,7 +267,8 @@ export const Results = (props: ResultsProps): JSX.Element => { } } ` - subHeader = numberOfTryAgains > 2 ? t('something_seems_wrong') : undefined + subHeader = + numberOfTryAgains > 2 ? t('branded:something_seems_wrong') : undefined button = ( <> {isOnDevice ? ( diff --git a/app/src/organisms/PipetteWizardFlows/UnskippableModal.tsx b/app/src/organisms/PipetteWizardFlows/UnskippableModal.tsx index 5355349b656..497e5fc19b0 100644 --- a/app/src/organisms/PipetteWizardFlows/UnskippableModal.tsx +++ b/app/src/organisms/PipetteWizardFlows/UnskippableModal.tsx @@ -32,7 +32,7 @@ export function UnskippableModal(props: UnskippableModalProps): JSX.Element { diff --git a/app/src/organisms/PipetteWizardFlows/__tests__/ChoosePipette.test.tsx b/app/src/organisms/PipetteWizardFlows/__tests__/ChoosePipette.test.tsx index 37570d8c5ff..bca0e623619 100644 --- a/app/src/organisms/PipetteWizardFlows/__tests__/ChoosePipette.test.tsx +++ b/app/src/organisms/PipetteWizardFlows/__tests__/ChoosePipette.test.tsx @@ -150,7 +150,7 @@ describe('ChoosePipette', () => { props = { ...props, selectedPipette: NINETY_SIX_CHANNEL } render(props) screen.getByText( - 'Detach Flex 1-Channel 1000 μL and attach 96-Channel pipette' + 'Detach Flex 1-Channel 1000 μL and Attach 96-Channel pipette' ) }) @@ -163,7 +163,7 @@ describe('ChoosePipette', () => { props = { ...props, selectedPipette: NINETY_SIX_CHANNEL } render(props) screen.getByText( - 'Detach Flex 1-Channel 1000 μL and attach 96-Channel pipette' + 'Detach Flex 1-Channel 1000 μL and Attach 96-Channel pipette' ) }) }) diff --git a/app/src/organisms/PipetteWizardFlows/__tests__/UnskippableModal.test.tsx b/app/src/organisms/PipetteWizardFlows/__tests__/UnskippableModal.test.tsx index fd28aa5e8df..43fa441c7d1 100644 --- a/app/src/organisms/PipetteWizardFlows/__tests__/UnskippableModal.test.tsx +++ b/app/src/organisms/PipetteWizardFlows/__tests__/UnskippableModal.test.tsx @@ -41,7 +41,9 @@ describe('UnskippableModal', () => { screen.getByText( 'You must detach the mounting plate and reattach the z-axis carraige before using other pipettes. We do not recommend exiting this process before completion.' ) - fireEvent.click(screen.getByRole('button', { name: 'exit' })) + screen.getByText('Exit') + screen.getByText('Go back') + fireEvent.click(screen.getByRole('button', { name: 'Exit' })) expect(props.proceed).toHaveBeenCalled() }) }) diff --git a/app/src/organisms/PipetteWizardFlows/index.tsx b/app/src/organisms/PipetteWizardFlows/index.tsx index 1a671fb31fb..337a51028ed 100644 --- a/app/src/organisms/PipetteWizardFlows/index.tsx +++ b/app/src/organisms/PipetteWizardFlows/index.tsx @@ -421,7 +421,7 @@ export const PipetteWizardFlows = ( currentStep.section === SECTIONS.BEFORE_BEGINNING && selectedPipette === NINETY_SIX_CHANNEL && flowType === FLOWS.ATTACH - ? '70%' + ? '30rem' : 'auto' } header={wizardHeader} diff --git a/app/src/organisms/ProtocolDetails/ProtocolParameters/__tests__/ProtocolParameters.test.tsx b/app/src/organisms/ProtocolDetails/ProtocolParameters/__tests__/ProtocolParameters.test.tsx index 707aa5256cf..191329bbae8 100644 --- a/app/src/organisms/ProtocolDetails/ProtocolParameters/__tests__/ProtocolParameters.test.tsx +++ b/app/src/organisms/ProtocolDetails/ProtocolParameters/__tests__/ProtocolParameters.test.tsx @@ -7,6 +7,17 @@ import { i18n } from '../../../../i18n' import { ProtocolParameters } from '..' import type { RunTimeParameter } from '@opentrons/shared-data' +import type * as Components from '@opentrons/components' + +vi.mock('@opentrons/components', async importOriginal => { + const actual = await importOriginal() + return { + ...actual, + NoParameters: vi.fn(() => ( +
No parameters specified in this protocol
+ )), + } +}) const mockRunTimeParameter: RunTimeParameter[] = [ { @@ -14,7 +25,7 @@ const mockRunTimeParameter: RunTimeParameter[] = [ variableName: 'TIP_TRASH', description: 'to throw tip into the trash or to not throw tip into the trash', - type: 'boolean', + type: 'bool', default: true, value: true, }, @@ -111,11 +122,11 @@ describe('ProtocolParameters', () => { screen.getByText('EtoH Volume') screen.getByText('6.5 mL') - screen.getByText('1.5-10') + screen.getByText('1.5-10.0') screen.getByText('Default Module Offsets') screen.getByText('No offsets') - screen.getByText('3 choices') + screen.getByText('3 options') screen.getByText('pipette mount') screen.getByText('Left') diff --git a/app/src/organisms/ProtocolDetails/ProtocolParameters/index.tsx b/app/src/organisms/ProtocolDetails/ProtocolParameters/index.tsx index d7a64fd2396..797e18b930d 100644 --- a/app/src/organisms/ProtocolDetails/ProtocolParameters/index.tsx +++ b/app/src/organisms/ProtocolDetails/ProtocolParameters/index.tsx @@ -4,11 +4,11 @@ import { useTranslation } from 'react-i18next' import { DIRECTION_COLUMN, Flex, + InfoScreen, + ParametersTable, SPACING, StyledText, TYPOGRAPHY, - ParametersTable, - NoParameters, } from '@opentrons/components' import { Banner } from '../../../atoms/Banner' @@ -48,7 +48,7 @@ export function ProtocolParameters({
) : ( - + )}
) diff --git a/app/src/organisms/ProtocolDetails/index.tsx b/app/src/organisms/ProtocolDetails/index.tsx index 9329b6329b3..2dc5ecda65f 100644 --- a/app/src/organisms/ProtocolDetails/index.tsx +++ b/app/src/organisms/ProtocolDetails/index.tsx @@ -73,7 +73,6 @@ import { ProtocolLabwareDetails } from './ProtocolLabwareDetails' import { ProtocolLiquidsDetails } from './ProtocolLiquidsDetails' import { RobotConfigurationDetails } from './RobotConfigurationDetails' import { ProtocolParameters } from './ProtocolParameters' -import { useRunTimeParameters } from '../../pages/Protocols/hooks' import type { JsonConfig, PythonConfig } from '@opentrons/shared-data' import type { StoredProtocolData } from '../../redux/protocol-storage' @@ -200,10 +199,11 @@ export function ProtocolDetails( const { protocolKey, srcFileNames, mostRecentAnalysis, modified } = props const { t, i18n } = useTranslation(['protocol_details', 'shared']) const enableProtocolStats = useFeatureFlag('protocolStats') - const enableRunTimeParameters = useFeatureFlag('enableRunTimeParameters') + const runTimeParameters = mostRecentAnalysis?.runTimeParameters ?? [] + const hasRunTimeParameters = runTimeParameters.length > 0 const [currentTab, setCurrentTab] = React.useState< 'robot_config' | 'labware' | 'liquids' | 'stats' | 'parameters' - >('robot_config') + >(hasRunTimeParameters ? 'parameters' : 'robot_config') const [ showChooseRobotToRunProtocolSlideout, setShowChooseRobotToRunProtocolSlideout, @@ -218,8 +218,6 @@ export function ProtocolDetails( getIsProtocolAnalysisInProgress(state, protocolKey) ) - const runTimeParameters = useRunTimeParameters(protocolKey) - const analysisStatus = getAnalysisStatus(isAnalyzing, mostRecentAnalysis) if (analysisStatus === 'stale') { @@ -333,9 +331,7 @@ export function ProtocolDetails( stats: enableProtocolStats ? ( ) : null, - parameters: enableRunTimeParameters ? ( - - ) : null, + parameters: , } const deckMap = @@ -394,7 +390,6 @@ export function ProtocolDetails( onCloseClick={() => setShowChooseRobotToRunProtocolSlideout(false)} showSlideout={showChooseRobotToRunProtocolSlideout} storedProtocolData={props} - runTimeParameters={runTimeParameters} /> - {enableRunTimeParameters && mostRecentAnalysis != null && ( + {mostRecentAnalysis != null && ( { vi.mocked(useDeckConfigurationQuery).mockReturnValue(({ data: [], } as unknown) as UseQueryResult) + vi.mocked(useModulesQuery).mockReturnValue(({ + data: { data: [] }, + } as unknown) as UseQueryResult) }) afterEach(() => { diff --git a/app/src/organisms/ProtocolSetupLabware/__tests__/ProtocolSetupLabware.test.tsx b/app/src/organisms/ProtocolSetupLabware/__tests__/ProtocolSetupLabware.test.tsx index 57d7981c138..b44a314983a 100644 --- a/app/src/organisms/ProtocolSetupLabware/__tests__/ProtocolSetupLabware.test.tsx +++ b/app/src/organisms/ProtocolSetupLabware/__tests__/ProtocolSetupLabware.test.tsx @@ -8,7 +8,7 @@ import { useCreateLiveCommandMutation, useModulesQuery, } from '@opentrons/react-api-client' -import { ot3StandardDeckV4 as ot3StandardDeckDef } from '@opentrons/shared-data' +import { ot3StandardDeckV5 as ot3StandardDeckDef } from '@opentrons/shared-data' import { renderWithProviders } from '../../../__testing-utils__' import { i18n } from '../../../i18n' diff --git a/app/src/organisms/ProtocolSetupLabware/index.tsx b/app/src/organisms/ProtocolSetupLabware/index.tsx index bdb68944a67..3bc1ad62c56 100644 --- a/app/src/organisms/ProtocolSetupLabware/index.tsx +++ b/app/src/organisms/ProtocolSetupLabware/index.tsx @@ -108,6 +108,7 @@ export function ProtocolSetupLabware({ mostRecentAnalysis != null ? getProtocolModulesInfo(mostRecentAnalysis, deckDef) : [] + const attachedProtocolModuleMatches = getAttachedProtocolModuleMatches( attachedModules, protocolModulesInfo diff --git a/app/src/organisms/ProtocolSetupModulesAndDeck/FixtureTable.tsx b/app/src/organisms/ProtocolSetupModulesAndDeck/FixtureTable.tsx index 46d774f3857..23d490af287 100644 --- a/app/src/organisms/ProtocolSetupModulesAndDeck/FixtureTable.tsx +++ b/app/src/organisms/ProtocolSetupModulesAndDeck/FixtureTable.tsx @@ -4,6 +4,7 @@ import { ALIGN_CENTER, BORDERS, COLORS, + Chip, DIRECTION_COLUMN, DIRECTION_ROW, Flex, @@ -15,13 +16,13 @@ import { } from '@opentrons/components' import { getCutoutDisplayName, + getDeckDefFromRobotType, getFixtureDisplayName, getSimplestDeckConfigForProtocol, SINGLE_SLOT_FIXTURES, } from '@opentrons/shared-data' import { SmallButton } from '../../atoms/buttons' -import { Chip } from '../../atoms/Chip' import { useDeckConfigurationCompatibility } from '../../resources/deck_configuration/hooks' import { getRequiredDeckConfig } from '../../resources/deck_configuration/utils' import { LocationConflictModal } from '../Devices/ProtocolRun/SetupModuleAndDeck/LocationConflictModal' @@ -30,6 +31,7 @@ import type { CompletedProtocolAnalysis, CutoutFixtureId, CutoutId, + DeckDefinition, RobotType, } from '@opentrons/shared-data' import type { SetupScreens } from '../../pages/ProtocolSetup' @@ -59,6 +61,7 @@ export function FixtureTable({ robotType, mostRecentAnalysis ) + const deckDef = getDeckDefFromRobotType(robotType) const requiredDeckConfigCompatibility = getRequiredDeckConfig( deckConfigCompatibility @@ -96,6 +99,7 @@ export function FixtureTable({ setSetupScreen={setSetupScreen} setCutoutId={setCutoutId} setProvidedFixtureOptions={setProvidedFixtureOptions} + deckDef={deckDef} /> ) })} @@ -108,6 +112,7 @@ interface FixtureTableItemProps extends CutoutConfigAndCompatibility { setSetupScreen: React.Dispatch> setCutoutId: (cutoutId: CutoutId) => void setProvidedFixtureOptions: (providedFixtureOptions: CutoutFixtureId[]) => void + deckDef: DeckDefinition } function FixtureTableItem({ @@ -119,6 +124,7 @@ function FixtureTableItem({ setSetupScreen, setCutoutId, setProvidedFixtureOptions, + deckDef, }: FixtureTableItemProps): JSX.Element { const { t, i18n } = useTranslation('protocol_setup') @@ -183,6 +189,7 @@ function FixtureTableItem({ requiredFixtureId={compatibleCutoutFixtureIds[0]} isOnDevice={true} missingLabwareDisplayName={missingLabwareDisplayName} + deckDef={deckDef} /> ) : null} > } export function ModuleTable(props: ModuleTableProps): JSX.Element { - const { - attachedProtocolModuleMatches, - deckDef, - protocolModulesInfo, - runId, - setShowMultipleModulesModal, - } = props + const { attachedProtocolModuleMatches, deckDef, runId } = props const { t } = useTranslation('protocol_setup') @@ -95,16 +85,6 @@ export function ModuleTable(props: ModuleTableProps): JSX.Element { {t('status')} {attachedProtocolModuleMatches.map(module => { - // check for duplicate module model in list of modules for protocol - const isDuplicateModuleModel = protocolModulesInfo - // filter out current module - .filter(otherModule => otherModule.moduleId !== module.moduleId) - // check for existence of another module of same model - .some( - otherModule => - otherModule.moduleDef.model === module.moduleDef.model - ) - const cutoutIdForSlotName = getCutoutIdForSlotName( module.slotName, deckDef @@ -134,14 +114,13 @@ export function ModuleTable(props: ModuleTableProps): JSX.Element { ) })} @@ -156,24 +135,22 @@ interface ModuleTableItemProps { continuePastCommandFailure: boolean ) => Promise conflictedFixture: CutoutConfig | null - isDuplicateModuleModel: boolean isLoading: boolean module: AttachedProtocolModuleMatch prepCommandErrorMessage: string setPrepCommandErrorMessage: React.Dispatch> - setShowMultipleModulesModal: React.Dispatch> + deckDef: DeckDefinition } function ModuleTableItem({ - isDuplicateModuleModel, module, - setShowMultipleModulesModal, calibrationStatus, chainLiveCommands, isLoading, prepCommandErrorMessage, setPrepCommandErrorMessage, conflictedFixture, + deckDef, }: ModuleTableItemProps): JSX.Element { const { i18n, t } = useTranslation(['protocol_setup', 'module_wizard_flows']) @@ -216,7 +193,6 @@ function ModuleTableItem({ background={false} iconName="connection-status" /> - {isDuplicateModuleModel ? : null} ) if (conflictedFixture != null) { @@ -231,7 +207,9 @@ function ModuleTableItem({ setShowLocationConflictModal(true)} + onClick={() => { + setShowLocationConflictModal(true) + }} /> ) @@ -246,17 +224,12 @@ function ModuleTableItem({ module.attachedModuleMatch?.moduleOffset?.last_modified != null ) { moduleStatus = ( - <> - - {isDuplicateModuleModel ? ( - - ) : null} - + ) } else if ( isModuleReady && @@ -285,8 +258,9 @@ function ModuleTableItem({ {showModuleWizard && module.attachedModuleMatch != null ? ( setShowModuleWizard(false)} - initialSlotName={module.slotName} + closeFlow={() => { + setShowModuleWizard(false) + }} isPrepCommandLoading={isLoading} prepCommandErrorMessage={ prepCommandErrorMessage === '' ? undefined : prepCommandErrorMessage @@ -295,9 +269,12 @@ function ModuleTableItem({ ) : null} {showLocationConflictModal && conflictedFixture != null ? ( setShowLocationConflictModal(false)} + onCloseClick={() => { + setShowLocationConflictModal(false) + }} cutoutId={conflictedFixture.cutoutId} requiredModule={module.moduleDef.model} + deckDef={deckDef} isOnDevice={true} /> ) : null} @@ -313,12 +290,9 @@ function ModuleTableItem({ : COLORS.yellow35 } borderRadius={BORDERS.borderRadius8} - cursor={isDuplicateModuleModel ? 'pointer' : 'inherit'} + cursor="inherit" gridGap={SPACING.spacing24} padding={`${SPACING.spacing16} ${SPACING.spacing24}`} - onClick={() => - isDuplicateModuleModel ? setShowMultipleModulesModal(true) : null - } > diff --git a/app/src/organisms/ProtocolSetupModulesAndDeck/SetupInstructionsModal.tsx b/app/src/organisms/ProtocolSetupModulesAndDeck/SetupInstructionsModal.tsx index 7fbbf4f048e..c7acb6f2a42 100644 --- a/app/src/organisms/ProtocolSetupModulesAndDeck/SetupInstructionsModal.tsx +++ b/app/src/organisms/ProtocolSetupModulesAndDeck/SetupInstructionsModal.tsx @@ -26,7 +26,7 @@ interface SetupInstructionsModalProps { export function SetupInstructionsModal({ setShowSetupInstructionsModal, }: SetupInstructionsModalProps): JSX.Element { - const { i18n, t } = useTranslation('protocol_setup') + const { i18n, t } = useTranslation(['protocol_setup', 'branded']) const modalHeader: ModalHeaderBaseProps = { title: i18n.format(t('setup_instructions'), 'capitalize'), iconName: 'information', @@ -45,7 +45,9 @@ export function SetupInstructionsModal({ gridGap={SPACING.spacing40} > - {t('setup_instructions_description')} + + {t('branded:setup_instructions_description')} + (false) const [ showSetupInstructionsModal, setShowSetupInstructionsModal, @@ -93,11 +88,6 @@ export function ProtocolSetupModulesAndDeck({ <> {createPortal( <> - {showMultipleModulesModal ? ( - setShowMultipleModulesModal(false)} - /> - ) : null} {showSetupInstructionsModal ? ( ) : null} void + parameter: RunTimeParameter + setParameter: (value: boolean | string | number, variableName: string) => void + rawValue: number | string | boolean +} + +export function ChooseEnum({ + handleGoBack, + parameter, + setParameter, + rawValue, +}: ChooseEnumProps): JSX.Element { + const { makeSnackbar } = useToaster() + + const { t } = useTranslation(['protocol_setup', 'shared']) + const options = 'choices' in parameter ? parameter.choices : null + const handleOnClick = (newValue: string | number | boolean): void => { + setParameter(newValue, parameter.variableName) + } + const resetValueDisabled = parameter.default === rawValue + + return ( + <> + + resetValueDisabled + ? makeSnackbar(t('no_custom_values')) + : setParameter(parameter.default, parameter.variableName) + } + /> + + + {parameter.description} + + + {options?.map(option => { + return ( + handleOnClick(option.value)} + isSelected={option.value === rawValue} + /> + ) + })} + + + ) +} diff --git a/app/src/organisms/ProtocolSetupParameters/ChooseNumber.tsx b/app/src/organisms/ProtocolSetupParameters/ChooseNumber.tsx new file mode 100644 index 00000000000..da3c34a14c1 --- /dev/null +++ b/app/src/organisms/ProtocolSetupParameters/ChooseNumber.tsx @@ -0,0 +1,164 @@ +import * as React from 'react' +import { useTranslation } from 'react-i18next' +import { + ALIGN_CENTER, + DIRECTION_COLUMN, + Flex, + SPACING, + StyledText, + TYPOGRAPHY, +} from '@opentrons/components' +import { InputField } from '../../atoms/InputField' +import { useToaster } from '../ToasterOven' +import { ChildNavigation } from '../ChildNavigation' +import { NumericalKeyboard } from '../../atoms/SoftwareKeyboard' +import type { NumberParameter } from '@opentrons/shared-data' + +interface ChooseNumberProps { + handleGoBack: () => void + parameter: NumberParameter + setParameter: (value: number, variableName: string) => void +} + +export function ChooseNumber({ + handleGoBack, + parameter, + setParameter, +}: ChooseNumberProps): JSX.Element | null { + const { makeSnackbar } = useToaster() + + const { i18n, t } = useTranslation(['protocol_setup', 'shared']) + const keyboardRef = React.useRef(null) + const [paramValue, setParamValue] = React.useState( + String(parameter.value) + ) + + // We need to arbitrarily set the value of the keyboard to a string the + // same length as the initial parameter value (as string) when the component mounts + // so that the delete button operates properly on the exisiting input field value. + const [prevKeyboardValue, setPrevKeyboardValue] = React.useState('') + React.useEffect(() => { + const arbitraryInput = new Array(paramValue).join('*') + // @ts-expect-error keyboard should expose for `setInput` method + keyboardRef.current?.setInput(arbitraryInput) + setPrevKeyboardValue(arbitraryInput) + }, []) + + if (parameter.type !== 'int' && parameter.type !== 'float') { + console.log(`Incorrect parameter type: ${parameter.type}`) + return null + } + const handleClickGoBack = (newValue: number): void => { + if (error != null) { + makeSnackbar(t('value_out_of_range_generic')) + } else { + setParameter(newValue, parameter.variableName) + handleGoBack() + } + } + + const handleKeyboardInput = (e: string): void => { + if (prevKeyboardValue.length < e.length) { + const lastDigit = e.slice(-1) + if ( + !'.-'.includes(lastDigit) || + (lastDigit === '.' && !paramValue.includes('.')) || + (lastDigit === '-' && paramValue.length === 0) + ) { + setParamValue(paramValue + lastDigit) + } + } else { + setParamValue(paramValue.slice(0, paramValue.length - 1)) + } + setPrevKeyboardValue(e) + } + + const paramValueAsNumber = Number(paramValue) + const resetValueDisabled = parameter.default === paramValueAsNumber + const { min, max } = parameter + const error = + paramValue === '' || + Number.isNaN(paramValueAsNumber) || + paramValueAsNumber < min || + paramValueAsNumber > max + ? t(`value_out_of_range`, { + min: parameter.type === 'int' ? min : min.toFixed(1), + max: parameter.type === 'int' ? max : max.toFixed(1), + }) + : null + + return ( + <> + { + handleClickGoBack(paramValueAsNumber) + }} + buttonType="tertiaryLowLight" + buttonText={t('restore_default')} + onClickButton={() => + resetValueDisabled + ? makeSnackbar(t('no_custom_values')) + : setParamValue(String(parameter.default)) + } + /> + + + + {parameter.description} + + { + const updatedValue = + parameter.type === 'int' + ? Math.round(e.target.valueAsNumber) + : e.target.valueAsNumber + setParamValue( + Number.isNaN(updatedValue) ? '' : String(updatedValue) + ) + }} + /> + + + { + handleKeyboardInput(e) + }} + /> + + + + ) +} diff --git a/app/src/organisms/ProtocolSetupParameters/ResetValuesModal.stories.tsx b/app/src/organisms/ProtocolSetupParameters/ResetValuesModal.stories.tsx index ae7454efc47..975d8104a26 100644 --- a/app/src/organisms/ProtocolSetupParameters/ResetValuesModal.stories.tsx +++ b/app/src/organisms/ProtocolSetupParameters/ResetValuesModal.stories.tsx @@ -1,6 +1,5 @@ import * as React from 'react' - -import { touchScreenViewport } from '../../DesignTokens/constants' +import { VIEWPORT } from '@opentrons/components' import { ResetValuesModal } from './ResetValuesModal' import type { Story, Meta } from '@storybook/react' @@ -8,7 +7,7 @@ import type { Story, Meta } from '@storybook/react' export default { title: 'ODD/Organisms/ResetValuesModal', component: ResetValuesModal, - parameters: touchScreenViewport, + parameters: VIEWPORT.touchScreenViewport, } as Meta const Template: Story> = args => ( diff --git a/app/src/organisms/ProtocolSetupParameters/ResetValuesModal.tsx b/app/src/organisms/ProtocolSetupParameters/ResetValuesModal.tsx index 458b1172f3a..b49151f883b 100644 --- a/app/src/organisms/ProtocolSetupParameters/ResetValuesModal.tsx +++ b/app/src/organisms/ProtocolSetupParameters/ResetValuesModal.tsx @@ -14,13 +14,18 @@ import { import { SmallButton } from '../../atoms/buttons' import { Modal } from '../../molecules/Modal' +import type { RunTimeParameter } from '@opentrons/shared-data' import type { ModalHeaderBaseProps } from '../../molecules/Modal/types' interface ResetValuesModalProps { + runTimeParametersOverrides: RunTimeParameter[] + setRunTimeParametersOverrides: (parameters: RunTimeParameter[]) => void handleGoBack: () => void } export function ResetValuesModal({ + runTimeParametersOverrides, + setRunTimeParametersOverrides, handleGoBack, }: ResetValuesModalProps): JSX.Element { const { t } = useTranslation(['protocol_setup', 'shared']) @@ -33,7 +38,12 @@ export function ResetValuesModal({ // ToDo (kk:03/18/2024) reset values function will be implemented const handleResetValues = (): void => { - console.log('todo add reset values function') + setRunTimeParametersOverrides( + runTimeParametersOverrides.map(param => { + return { ...param, value: param.default } + }) + ) + handleGoBack() } const modalProps = { diff --git a/app/src/organisms/ProtocolSetupParameters/ViewOnlyParameters.tsx b/app/src/organisms/ProtocolSetupParameters/ViewOnlyParameters.tsx index 8eea44ba0cd..3ce9169f77f 100644 --- a/app/src/organisms/ProtocolSetupParameters/ViewOnlyParameters.tsx +++ b/app/src/organisms/ProtocolSetupParameters/ViewOnlyParameters.tsx @@ -4,6 +4,7 @@ import { formatRunTimeParameterValue } from '@opentrons/shared-data' import { ALIGN_CENTER, BORDERS, + Chip, COLORS, DIRECTION_COLUMN, DIRECTION_ROW, @@ -14,9 +15,7 @@ import { } from '@opentrons/components' import { useMostRecentCompletedAnalysis } from '../LabwarePositionCheck/useMostRecentCompletedAnalysis' import { ChildNavigation } from '../ChildNavigation' -import { Chip } from '../../atoms/Chip' import { useToaster } from '../ToasterOven' -import { mockData } from './index' import type { SetupScreens } from '../../pages/ProtocolSetup' @@ -36,8 +35,7 @@ export function ViewOnlyParameters({ makeSnackbar(t('reset_setup')) } - // TODO(jr, 3/18/24): remove mockData - const parameters = mostRecentAnalysis?.runTimeParameters ?? mockData + const parameters = mostRecentAnalysis?.runTimeParameters ?? [] return ( <> @@ -68,9 +66,6 @@ export function ViewOnlyParameters({ {t('value')} {parameters.map((parameter, index) => { - // TODO(jr, 3/20/24): plug in the info if the - // parameter changed from the default - const hasCustomValue = true return ( - + {formatRunTimeParameterValue(parameter, t)} - {hasCustomValue ? ( + {parameter.value !== parameter.default ? ( ) => { + return renderWithProviders(, { + i18nInstance: i18n, + }) +} +describe('ChooseEnum', () => { + let props: React.ComponentProps + + beforeEach(() => { + props = { + setParameter: vi.fn(), + handleGoBack: vi.fn(), + parameter: { + displayName: 'Default Module Offsets', + variableName: 'DEFAULT_OFFSETS', + value: 'none', + description: '', + type: 'str', + choices: [ + { + displayName: 'no offsets', + value: 'none', + }, + { + displayName: 'temp offset', + value: '1', + }, + { + displayName: 'heater-shaker offset', + value: '2', + }, + ], + default: 'none', + }, + rawValue: '1', + } + }) + it('renders the back icon and calls the prop', () => { + render(props) + fireEvent.click(screen.getAllByRole('button')[0]) + expect(props.handleGoBack).toHaveBeenCalled() + }) + it('calls the prop if reset default is clicked when the default has changed', () => { + render(props) + fireEvent.click(screen.getByText('Restore default value')) + expect(props.setParameter).toHaveBeenCalled() + }) + it('calls does not call prop if reset default is clicked when the default has not changed', () => { + props = { + ...props, + rawValue: 'none', + } + render(props) + fireEvent.click(screen.getByText('Restore default value')) + expect(props.setParameter).not.toHaveBeenCalled() + }) + it('should render the text and buttons for choice param', () => { + render(props) + screen.getByText('no offsets') + screen.getByText('temp offset') + screen.getByText('heater-shaker offset') + const notSelectedOption = screen.getByRole('label', { name: 'no offsets' }) + const selectedOption = screen.getByRole('label', { + name: 'temp offset', + }) + expect(notSelectedOption).toHaveStyle(`background-color: ${COLORS.blue40}`) + expect(selectedOption).toHaveStyle(`background-color: ${COLORS.blue60}`) + }) +}) diff --git a/app/src/organisms/ProtocolSetupParameters/__tests__/ProtocolSetupParameters.test.tsx b/app/src/organisms/ProtocolSetupParameters/__tests__/ProtocolSetupParameters.test.tsx index 4873745356c..4871eeaa379 100644 --- a/app/src/organisms/ProtocolSetupParameters/__tests__/ProtocolSetupParameters.test.tsx +++ b/app/src/organisms/ProtocolSetupParameters/__tests__/ProtocolSetupParameters.test.tsx @@ -2,16 +2,22 @@ import * as React from 'react' import { when } from 'vitest-when' import { it, describe, beforeEach, vi, expect } from 'vitest' import { fireEvent, screen } from '@testing-library/react' -import { useCreateRunMutation, useHost } from '@opentrons/react-api-client' +import { + useCreateProtocolAnalysisMutation, + useCreateRunMutation, + useHost, +} from '@opentrons/react-api-client' import { i18n } from '../../../i18n' import { renderWithProviders } from '../../../__testing-utils__' import { ProtocolSetupParameters } from '..' +import { ChooseEnum } from '../ChooseEnum' import { mockRunTimeParameterData } from '../../../pages/ProtocolDetails/fixtures' import type * as ReactRouterDom from 'react-router-dom' import type { HostConfig } from '@opentrons/api-client' const mockGoBack = vi.fn() +vi.mock('../ChooseEnum') vi.mock('@opentrons/react-api-client') vi.mock('../../LabwarePositionCheck/useMostRecentCompletedAnalysis') vi.mock('react-router-dom', async importOriginal => { @@ -22,6 +28,7 @@ vi.mock('react-router-dom', async importOriginal => { } }) const MOCK_HOST_CONFIG: HostConfig = { hostname: 'MOCK_HOST' } +const mockCreateProtocolAnalysis = vi.fn() const mockCreateRun = vi.fn() const render = ( props: React.ComponentProps @@ -39,7 +46,11 @@ describe('ProtocolSetupParameters', () => { labwareOffsets: [], runTimeParameters: mockRunTimeParameterData, } + vi.mocked(ChooseEnum).mockReturnValue(
mock ChooseEnum
) vi.mocked(useHost).mockReturnValue(MOCK_HOST_CONFIG) + when(vi.mocked(useCreateProtocolAnalysisMutation)) + .calledWith(expect.anything(), expect.anything()) + .thenReturn({ createProtocolAnalysis: mockCreateProtocolAnalysis } as any) when(vi.mocked(useCreateRunMutation)) .calledWith(expect.anything()) .thenReturn({ createRun: mockCreateRun } as any) @@ -52,6 +63,17 @@ describe('ProtocolSetupParameters', () => { screen.getByText('Dry Run') screen.getByText('a dry run description') }) + it('renders the ChooseEnum component when a str param is selected', () => { + render(props) + fireEvent.click(screen.getByText('Default Module Offsets')) + screen.getByText('mock ChooseEnum') + }) + it('renders the other setting when boolean param is selected', () => { + render(props) + expect(screen.getAllByText('On')).toHaveLength(2) + fireEvent.click(screen.getByText('Dry Run')) + expect(screen.getAllByText('On')).toHaveLength(3) + }) it('renders the back icon and calls useHistory', () => { render(props) fireEvent.click(screen.getAllByRole('button')[0]) @@ -73,6 +95,5 @@ describe('ProtocolSetupParameters', () => { const title = screen.getByText('Reset parameter values?') fireEvent.click(screen.getByRole('button', { name: 'Go back' })) expect(title).not.toBeInTheDocument() - // TODO(jr, 3/19/24): wire up the confirm button }) }) diff --git a/app/src/organisms/ProtocolSetupParameters/__tests__/ResetValuesModal.test.tsx b/app/src/organisms/ProtocolSetupParameters/__tests__/ResetValuesModal.test.tsx index a8f876b94f3..ec2eb28a81c 100644 --- a/app/src/organisms/ProtocolSetupParameters/__tests__/ResetValuesModal.test.tsx +++ b/app/src/organisms/ProtocolSetupParameters/__tests__/ResetValuesModal.test.tsx @@ -5,8 +5,10 @@ import { fireEvent, screen } from '@testing-library/react' import { renderWithProviders } from '../../../__testing-utils__' import { i18n } from '../../../i18n' import { ResetValuesModal } from '../ResetValuesModal' +import { RunTimeParameter } from '@opentrons/shared-data' const mockGoBack = vi.fn() +const mockSetRunTimeParametersOverrides = vi.fn() const render = (props: React.ComponentProps) => { return renderWithProviders(, { @@ -19,6 +21,8 @@ describe('ResetValuesModal', () => { beforeEach(() => { props = { + runTimeParametersOverrides: [] as RunTimeParameter[], + setRunTimeParametersOverrides: mockSetRunTimeParametersOverrides, handleGoBack: mockGoBack, } }) @@ -42,5 +46,11 @@ describe('ResetValuesModal', () => { }) // ToDo (kk: 03/18/2024) reset value button test will be added - it.todo('should call a mock function when tapping reset values button') + it('should call a mock function when tapping reset values button', () => { + render(props) + const resetValuesButton = screen.getByText('Reset values') + fireEvent.click(resetValuesButton) + expect(mockSetRunTimeParametersOverrides) + expect(mockGoBack).toHaveBeenCalled() + }) }) diff --git a/app/src/organisms/ProtocolSetupParameters/__tests__/ViewOnlyParameters.test.tsx b/app/src/organisms/ProtocolSetupParameters/__tests__/ViewOnlyParameters.test.tsx index 90893117b6f..6e20fe65658 100644 --- a/app/src/organisms/ProtocolSetupParameters/__tests__/ViewOnlyParameters.test.tsx +++ b/app/src/organisms/ProtocolSetupParameters/__tests__/ViewOnlyParameters.test.tsx @@ -60,6 +60,8 @@ describe('ViewOnlyParameters', () => { fireEvent.click(screen.getAllByRole('button')[0]) expect(props.setSetupScreen).toHaveBeenCalled() }) - // TODO(jr, 3/20/24):test the update chip when - // custom value boolean is wired up + it('renders chip for updated values', () => { + render(props) + screen.getByTestId('Chip_USE_GRIPPER') + }) }) diff --git a/app/src/organisms/ProtocolSetupParameters/index.tsx b/app/src/organisms/ProtocolSetupParameters/index.tsx index c99c4ebeff6..5dae07260f6 100644 --- a/app/src/organisms/ProtocolSetupParameters/index.tsx +++ b/app/src/organisms/ProtocolSetupParameters/index.tsx @@ -1,169 +1,33 @@ import * as React from 'react' import { useTranslation } from 'react-i18next' import { useHistory } from 'react-router-dom' -import { useCreateRunMutation, useHost } from '@opentrons/react-api-client' +import { + useCreateProtocolAnalysisMutation, + useCreateRunMutation, + useHost, +} from '@opentrons/react-api-client' import { useQueryClient } from 'react-query' -import { formatRunTimeParameterValue } from '@opentrons/shared-data' import { ALIGN_CENTER, DIRECTION_COLUMN, Flex, SPACING, } from '@opentrons/components' +import { formatRunTimeParameterValue } from '@opentrons/shared-data' import { ProtocolSetupStep } from '../../pages/ProtocolSetup' +import { getRunTimeParameterValuesForRun } from '../Devices/utils' import { ChildNavigation } from '../ChildNavigation' import { ResetValuesModal } from './ResetValuesModal' +import { ChooseEnum } from './ChooseEnum' +import { ChooseNumber } from './ChooseNumber' -import type { RunTimeParameter } from '@opentrons/shared-data' +import type { NumberParameter, RunTimeParameter } from '@opentrons/shared-data' import type { LabwareOffsetCreateData } from '@opentrons/api-client' -export const mockData: RunTimeParameter[] = [ - { - value: false, - displayName: 'Dry Run', - variableName: 'DRYRUN', - description: 'Is this a dry or wet run? Wet is true, dry is false', - type: 'boolean', - default: false, - }, - { - value: true, - displayName: 'Use Gripper', - variableName: 'USE_GRIPPER', - description: 'For using the gripper.', - type: 'boolean', - default: true, - }, - { - value: true, - displayName: 'Trash Tips', - variableName: 'TIP_TRASH', - description: - 'to throw tip into the trash or to not throw tip into the trash', - type: 'boolean', - default: true, - }, - { - value: true, - displayName: 'Deactivate Temperatures', - variableName: 'DEACTIVATE_TEMP', - description: 'deactivate temperature on the module', - type: 'boolean', - default: true, - }, - { - value: 4, - displayName: 'Columns of Samples', - variableName: 'COLUMNS', - description: 'How many columns do you want?', - type: 'int', - min: 1, - max: 14, - default: 4, - }, - { - value: 6, - displayName: 'PCR Cycles', - variableName: 'PCR_CYCLES', - description: 'number of PCR cycles on a thermocycler', - type: 'int', - min: 1, - max: 10, - default: 6, - }, - { - value: 6.5, - displayName: 'EtoH Volume', - variableName: 'ETOH_VOLUME', - description: '70% ethanol volume', - type: 'float', - suffix: 'mL', - min: 1.5, - max: 10.0, - default: 6.5, - }, - { - value: 'none', - displayName: 'Default Module Offsets', - variableName: 'DEFAULT_OFFSETS', - description: 'default module offsets for temp, H-S, and none', - type: 'str', - choices: [ - { - displayName: 'No offsets', - value: 'none', - }, - { - displayName: 'temp offset', - value: '1', - }, - { - displayName: 'heater-shaker offset', - value: '2', - }, - ], - default: 'none', - }, - { - value: 'left', - displayName: 'pipette mount', - variableName: 'mont', - description: 'pipette mount', - type: 'str', - choices: [ - { - displayName: 'Left', - value: 'left', - }, - { - displayName: 'Right', - value: 'right', - }, - ], - default: 'left', - }, - { - value: 'flex', - displayName: 'short test case', - variableName: 'short 2 options', - description: 'this play 2 short options', - type: 'str', - choices: [ - { - displayName: 'OT-2', - value: 'ot2', - }, - { - displayName: 'Flex', - value: 'flex', - }, - ], - default: 'flex', - }, - { - value: 'flex', - displayName: 'long test case', - variableName: 'long 2 options', - description: 'this play 2 long options', - type: 'str', - choices: [ - { - displayName: 'I am kind of long text version', - value: 'ot2', - }, - { - displayName: 'I am kind of long text version. Today is 3/15', - value: 'flex', - }, - ], - default: 'flex', - }, -] - interface ProtocolSetupParametersProps { protocolId: string - runTimeParameters?: RunTimeParameter[] + runTimeParameters: RunTimeParameter[] labwareOffsets?: LabwareOffsetCreateData[] } @@ -176,11 +40,68 @@ export function ProtocolSetupParameters({ const history = useHistory() const host = useHost() const queryClient = useQueryClient() + const [ + chooseValueScreen, + setChooseValueScreen, + ] = React.useState(null) + const [ + showNumericalInputScreen, + setShowNumericalInputScreen, + ] = React.useState(null) const [resetValuesModal, showResetValuesModal] = React.useState( false ) - const parameters = runTimeParameters ?? [] - // TODO(jr, 3/20/24): modify useCreateRunMutation to take in optional run time parameters + const [startSetup, setStartSetup] = React.useState(false) + const [ + runTimeParametersOverrides, + setRunTimeParametersOverrides, + ] = React.useState( + // present defaults rather than last-set value + runTimeParameters.map(param => { + return { ...param, value: param.default } + }) + ) + + const updateParameters = ( + value: boolean | string | number, + variableName: string + ): void => { + const updatedParameters = runTimeParametersOverrides.map(parameter => { + if (parameter.variableName === variableName) { + return { ...parameter, value } + } + return parameter + }) + setRunTimeParametersOverrides(updatedParameters) + if (chooseValueScreen && chooseValueScreen.variableName === variableName) { + const updatedParameter = updatedParameters.find( + parameter => parameter.variableName === variableName + ) + if (updatedParameter != null) { + setChooseValueScreen(updatedParameter) + } + } + if ( + showNumericalInputScreen && + showNumericalInputScreen.variableName === variableName + ) { + const updatedParameter = updatedParameters.find( + parameter => parameter.variableName === variableName + ) + if (updatedParameter != null) { + setShowNumericalInputScreen(updatedParameter as NumberParameter) + } + } + } + + const runTimeParameterValues = getRunTimeParameterValuesForRun( + runTimeParametersOverrides + ) + const { createProtocolAnalysis } = useCreateProtocolAnalysisMutation( + protocolId, + host + ) + const { createRun, isLoading } = useCreateRunMutation({ onSuccess: data => { queryClient @@ -191,25 +112,45 @@ export function ProtocolSetupParameters({ }, }) const handleConfirmValues = (): void => { - createRun({ protocolId, labwareOffsets }) + setStartSetup(true) + createProtocolAnalysis({ + protocolKey: protocolId, + runTimeParameterValues: runTimeParameterValues, + }) + createRun({ + protocolId, + labwareOffsets, + runTimeParameterValues: getRunTimeParameterValuesForRun( + runTimeParametersOverrides + ), + }) } - return ( - <> - {resetValuesModal ? ( - showResetValuesModal(false)} /> - ) : null} + const handleSetParameter = (parameter: RunTimeParameter): void => { + if ('choices' in parameter) { + setChooseValueScreen(parameter) + } else if (parameter.type === 'bool') { + updateParameters(!parameter.value, parameter.variableName) + } else if (parameter.type === 'int' || parameter.type === 'float') { + setShowNumericalInputScreen(parameter) + } else { + // bad param + console.log('error') + } + } + let children = ( + <> history.goBack()} onClickButton={handleConfirmValues} buttonText={t('confirm_values')} - iconName={isLoading ? 'ot-spinner' : undefined} + iconName={isLoading || startSetup ? 'ot-spinner' : undefined} iconPlacement="startIcon" secondaryButtonProps={{ buttonType: 'tertiaryLowLight', - buttonText: t('restore_default'), + buttonText: t('restore_defaults'), onClick: () => showResetValuesModal(true), }} /> @@ -218,16 +159,17 @@ export function ProtocolSetupParameters({ alignItems={ALIGN_CENTER} flexDirection={DIRECTION_COLUMN} gridGap={SPACING.spacing8} - paddingX={SPACING.spacing8} + paddingX={SPACING.spacing40} + paddingBottom={SPACING.spacing40} > - {parameters.map((parameter, index) => { + {runTimeParametersOverrides.map((parameter, index) => { return ( console.log('TODO: wire this up')} + onClickSetupStep={() => handleSetParameter(parameter)} detail={formatRunTimeParameterValue(parameter, t)} description={parameter.description} fontSize="h4" @@ -238,4 +180,36 @@ export function ProtocolSetupParameters({
) + if (chooseValueScreen != null) { + children = ( + setChooseValueScreen(null)} + parameter={chooseValueScreen} + setParameter={updateParameters} + rawValue={chooseValueScreen.value} + /> + ) + } + if (showNumericalInputScreen != null) { + children = ( + setShowNumericalInputScreen(null)} + parameter={showNumericalInputScreen} + setParameter={updateParameters} + /> + ) + } + + return ( + <> + {resetValuesModal ? ( + showResetValuesModal(false)} + /> + ) : null} + {children} + + ) } diff --git a/app/src/organisms/ProtocolUpload/hooks/__tests__/useCloneRun.test.tsx b/app/src/organisms/ProtocolUpload/hooks/__tests__/useCloneRun.test.tsx index 4f4fb33ab00..40726be91bf 100644 --- a/app/src/organisms/ProtocolUpload/hooks/__tests__/useCloneRun.test.tsx +++ b/app/src/organisms/ProtocolUpload/hooks/__tests__/useCloneRun.test.tsx @@ -4,7 +4,11 @@ import { renderHook } from '@testing-library/react' import { QueryClient, QueryClientProvider } from 'react-query' import { describe, it, beforeEach, afterEach, vi, expect } from 'vitest' -import { useHost, useCreateRunMutation } from '@opentrons/react-api-client' +import { + useHost, + useCreateRunMutation, + useCreateProtocolAnalysisMutation, +} from '@opentrons/react-api-client' import { useCloneRun } from '../useCloneRun' import { useNotifyRunQuery } from '../../../../resources/runs' @@ -30,12 +34,16 @@ describe('useCloneRun hook', () => { id: RUN_ID, protocolId: 'protocolId', labwareOffsets: 'someOffset', + runTimeParameters: [], }, }, } as any) when(vi.mocked(useCreateRunMutation)) .calledWith(expect.anything()) .thenReturn({ createRun: vi.fn() } as any) + vi.mocked(useCreateProtocolAnalysisMutation).mockReturnValue({ + createProtocolAnalysis: vi.fn(), + } as any) const queryClient = new QueryClient() const clientProvider: React.FunctionComponent<{ @@ -60,6 +68,7 @@ describe('useCloneRun hook', () => { expect(mockCreateRun).toHaveBeenCalledWith({ protocolId: 'protocolId', labwareOffsets: 'someOffset', + runTimeParameterValues: {}, }) }) }) diff --git a/app/src/organisms/ProtocolUpload/hooks/useCloneRun.ts b/app/src/organisms/ProtocolUpload/hooks/useCloneRun.ts index c7ba887ab54..fe6e3ab3649 100644 --- a/app/src/organisms/ProtocolUpload/hooks/useCloneRun.ts +++ b/app/src/organisms/ProtocolUpload/hooks/useCloneRun.ts @@ -1,10 +1,13 @@ import { useQueryClient } from 'react-query' -import { useHost, useCreateRunMutation } from '@opentrons/react-api-client' - +import { + useHost, + useCreateRunMutation, + useCreateProtocolAnalysisMutation, +} from '@opentrons/react-api-client' import { useNotifyRunQuery } from '../../../resources/runs' -import type { Run } from '@opentrons/api-client' +import type { Run, RunTimeParameterCreateData } from '@opentrons/api-client' interface UseCloneRunResult { cloneRun: () => void @@ -13,25 +16,46 @@ interface UseCloneRunResult { export function useCloneRun( runId: string | null, - onSuccessCallback?: (createRunResponse: Run) => unknown + onSuccessCallback?: (createRunResponse: Run) => unknown, + triggerAnalysis: boolean = false ): UseCloneRunResult { const host = useHost() const queryClient = useQueryClient() const { data: runRecord } = useNotifyRunQuery(runId) + const protocolKey = runRecord?.data.protocolId ?? null + const { createRun, isLoading } = useCreateRunMutation({ onSuccess: response => { - queryClient - .invalidateQueries([host, 'runs']) - .catch((e: Error) => - console.error(`error invalidating runs query: ${e.message}`) - ) + const invalidateRuns = queryClient.invalidateQueries([host, 'runs']) + const invalidateProtocols = queryClient.invalidateQueries([ + host, + 'protocols', + protocolKey, + ]) + Promise.all([invalidateRuns, invalidateProtocols]).catch((e: Error) => + console.error(`error invalidating runs query: ${e.message}`) + ) if (onSuccessCallback != null) onSuccessCallback(response) }, }) + const { createProtocolAnalysis } = useCreateProtocolAnalysisMutation( + protocolKey, + host + ) const cloneRun = (): void => { if (runRecord != null) { - const { protocolId, labwareOffsets } = runRecord.data - createRun({ protocolId, labwareOffsets }) + const { protocolId, labwareOffsets, runTimeParameters } = runRecord.data + const runTimeParameterValues = runTimeParameters.reduce( + (acc, param) => + param.value !== param.default + ? { ...acc, [param.variableName]: param.value } + : acc, + {} + ) + if (triggerAnalysis && protocolKey != null) { + createProtocolAnalysis({ protocolKey, runTimeParameterValues }) + } + createRun({ protocolId, labwareOffsets, runTimeParameterValues }) } else { console.info('failed to clone run record, source run record not found') } diff --git a/app/src/organisms/ProtocolsLanding/__tests__/hooks.test.tsx b/app/src/organisms/ProtocolsLanding/__tests__/hooks.test.tsx index 49243c2b790..cfba2402172 100644 --- a/app/src/organisms/ProtocolsLanding/__tests__/hooks.test.tsx +++ b/app/src/organisms/ProtocolsLanding/__tests__/hooks.test.tsx @@ -97,6 +97,7 @@ const mockStoredProtocolData = [ displayColor: '#ff4f4f', }, ], + runTimeParameters: [], errors: [], } as ProtocolAnalysisOutput, }, @@ -183,6 +184,7 @@ const mockStoredProtocolData = [ displayColor: '#ff4f4f', }, ], + runTimeParameters: [], errors: [], } as ProtocolAnalysisOutput, }, @@ -273,6 +275,7 @@ const mockStoredProtocolData = [ displayColor: '#ff4f4f', }, ], + runTimeParameters: [], errors: [], } as ProtocolAnalysisOutput, }, diff --git a/app/src/organisms/ProtocolsLanding/__tests__/utils.test.ts b/app/src/organisms/ProtocolsLanding/__tests__/utils.test.ts index e4383c842b9..1ff0d74f72a 100644 --- a/app/src/organisms/ProtocolsLanding/__tests__/utils.test.ts +++ b/app/src/organisms/ProtocolsLanding/__tests__/utils.test.ts @@ -1,5 +1,9 @@ import { describe, it, expect } from 'vitest' -import { getisFlexProtocol, getRobotTypeDisplayName } from '../utils' +import { + getAnalysisStatus, + getisFlexProtocol, + getRobotTypeDisplayName, +} from '../utils' import type { ProtocolAnalysisOutput } from '@opentrons/shared-data' const mockOT3ProtocolAnalysisOutput = { @@ -10,6 +14,36 @@ const mockOT2ProtocolAnalysisOutput = { robotType: 'OT-2 Standard', } as ProtocolAnalysisOutput +describe('getAnalysisStatus', () => { + it('should return stale if no liquids in analysis', () => { + const result = getAnalysisStatus(false, { + ...mockOT3ProtocolAnalysisOutput, + liquids: [], + errors: [], + }) + expect(result).toBe('stale') + }) + + it('should return stale if no runTimeParameters in analysis', () => { + const result = getAnalysisStatus(false, { + ...mockOT3ProtocolAnalysisOutput, + runTimeParameters: [], + errors: [], + }) + expect(result).toBe('stale') + }) + + it('should return complete if liquids and runTimeParameters in analysis', () => { + const result = getAnalysisStatus(false, { + ...mockOT3ProtocolAnalysisOutput, + liquids: [], + runTimeParameters: [], + errors: [], + }) + expect(result).toBe('complete') + }) +}) + describe('getisFlexProtocol', () => { it('should return true for protocols intended for a Flex', () => { const result = getisFlexProtocol(mockOT3ProtocolAnalysisOutput) diff --git a/app/src/organisms/ProtocolsLanding/utils.ts b/app/src/organisms/ProtocolsLanding/utils.ts index 59ccfc2e852..dfc9b4fafc3 100644 --- a/app/src/organisms/ProtocolsLanding/utils.ts +++ b/app/src/organisms/ProtocolsLanding/utils.ts @@ -10,7 +10,10 @@ export function getAnalysisStatus( ): AnalysisStatus { if (isAnalyzing) { return 'loading' - } else if (analysis != null && analysis?.liquids == null) { + } else if ( + analysis != null && + (analysis.liquids == null || analysis.runTimeParameters == null) + ) { return 'stale' } else if (analysis != null) { return analysis.errors.length > 0 ? 'error' : 'complete' diff --git a/app/src/organisms/QuickTransferFlow/CreateNewTransfer.tsx b/app/src/organisms/QuickTransferFlow/CreateNewTransfer.tsx new file mode 100644 index 00000000000..57d6ce14b54 --- /dev/null +++ b/app/src/organisms/QuickTransferFlow/CreateNewTransfer.tsx @@ -0,0 +1,74 @@ +import * as React from 'react' +import { useTranslation, Trans } from 'react-i18next' +import { + Flex, + SPACING, + StyledText, + DeckConfigurator, + TYPOGRAPHY, + DIRECTION_COLUMN, +} from '@opentrons/components' +import { SmallButton } from '../../atoms/buttons' +import { useDeckConfigurationQuery } from '@opentrons/react-api-client' +import { ChildNavigation } from '../ChildNavigation' + +interface CreateNewTransferProps { + onNext: () => void + exitButtonProps: React.ComponentProps +} + +export function CreateNewTransfer(props: CreateNewTransferProps): JSX.Element { + const { i18n, t } = useTranslation(['quick_transfer', 'shared']) + const deckConfig = useDeckConfigurationQuery().data ?? [] + return ( + + + + + + + ), + }} + /> + + + {}} + handleClickRemove={() => {}} + additionalStaticFixtures={[ + { location: 'cutoutB2', label: t('tip_rack') }, + { location: 'cutoutC2', label: t('labware') }, + { location: 'cutoutD2', label: t('labware') }, + ]} + /> + + + + + ) +} diff --git a/app/src/organisms/QuickTransferFlow/SelectPipette.tsx b/app/src/organisms/QuickTransferFlow/SelectPipette.tsx new file mode 100644 index 00000000000..6ef31157fdf --- /dev/null +++ b/app/src/organisms/QuickTransferFlow/SelectPipette.tsx @@ -0,0 +1,117 @@ +import * as React from 'react' +import { useTranslation } from 'react-i18next' +import { + Flex, + SPACING, + StyledText, + TYPOGRAPHY, + DIRECTION_COLUMN, +} from '@opentrons/components' +import { useInstrumentsQuery } from '@opentrons/react-api-client' +import { getPipetteSpecsV2, RIGHT, LEFT } from '@opentrons/shared-data' +import { SmallButton, LargeButton } from '../../atoms/buttons' +import { ChildNavigation } from '../ChildNavigation' + +import type { PipetteData, Mount } from '@opentrons/api-client' +import type { + QuickTransferSetupState, + QuickTransferWizardAction, +} from './types' + +interface SelectPipetteProps { + onNext: () => void + onBack: () => void + exitButtonProps: React.ComponentProps + state: QuickTransferSetupState + dispatch: React.Dispatch +} + +export function SelectPipette(props: SelectPipetteProps): JSX.Element { + const { onNext, onBack, exitButtonProps, state, dispatch } = props + const { i18n, t } = useTranslation(['quick_transfer', 'shared']) + const { data: attachedInstruments } = useInstrumentsQuery() + + const leftPipette = attachedInstruments?.data.find( + (i): i is PipetteData => i.ok && i.mount === LEFT + ) + const leftPipetteSpecs = + leftPipette != null ? getPipetteSpecsV2(leftPipette.instrumentModel) : null + + const rightPipette = attachedInstruments?.data.find( + (i): i is PipetteData => i.ok && i.mount === RIGHT + ) + const rightPipetteSpecs = + rightPipette != null + ? getPipetteSpecsV2(rightPipette.instrumentModel) + : null + + // automatically select 96 channel if it is attached + const [selectedPipette, setSelectedPipette] = React.useState< + Mount | undefined + >(leftPipetteSpecs?.channels === 96 ? LEFT : state.mount) + + const handleClickNext = (): void => { + const selectedPipetteSpecs = + selectedPipette === LEFT ? leftPipetteSpecs : rightPipetteSpecs + + // the button will be disabled if these values are null + if (selectedPipette != null && selectedPipetteSpecs != null) { + dispatch({ + type: 'SELECT_PIPETTE', + pipette: selectedPipetteSpecs, + mount: selectedPipette, + }) + onNext() + } + } + return ( + + + + + {t('pipette_currently_attached')} + + {leftPipetteSpecs != null ? ( + { + setSelectedPipette(LEFT) + }} + buttonText={ + leftPipetteSpecs.channels === 96 + ? t('both_mounts') + : t('left_mount') + } + subtext={leftPipetteSpecs.displayName} + /> + ) : null} + {rightPipetteSpecs != null ? ( + { + setSelectedPipette(RIGHT) + }} + buttonText={t('right_mount')} + subtext={rightPipetteSpecs.displayName} + /> + ) : null} + + + ) +} diff --git a/app/src/organisms/QuickTransferFlow/SelectTipRack.tsx b/app/src/organisms/QuickTransferFlow/SelectTipRack.tsx new file mode 100644 index 00000000000..bed59baa54b --- /dev/null +++ b/app/src/organisms/QuickTransferFlow/SelectTipRack.tsx @@ -0,0 +1,80 @@ +import * as React from 'react' +import { useTranslation } from 'react-i18next' +import { Flex, SPACING, DIRECTION_COLUMN } from '@opentrons/components' +import { getAllDefinitions } from '@opentrons/shared-data' +import { SmallButton, LargeButton } from '../../atoms/buttons' +import { ChildNavigation } from '../ChildNavigation' + +import type { LabwareDefinition2 } from '@opentrons/shared-data' +import type { + QuickTransferSetupState, + QuickTransferWizardAction, +} from './types' + +interface SelectTipRackProps { + onNext: () => void + onBack: () => void + exitButtonProps: React.ComponentProps + state: QuickTransferSetupState + dispatch: React.Dispatch +} + +export function SelectTipRack(props: SelectTipRackProps): JSX.Element { + const { onNext, onBack, exitButtonProps, state, dispatch } = props + const { i18n, t } = useTranslation(['quick_transfer', 'shared']) + + const allLabwareDefinitionsByUri = getAllDefinitions() + const selectedPipetteDefaultTipracks = + state.pipette?.liquids.default.defaultTipracks ?? [] + + const [selectedTipRack, setSelectedTipRack] = React.useState< + LabwareDefinition2 | undefined + >(state.tipRack) + + const handleClickNext = (): void => { + // the button will be disabled if this values is null + if (selectedTipRack != null) { + dispatch({ + type: 'SELECT_TIP_RACK', + tipRack: selectedTipRack, + }) + onNext() + } + } + return ( + + + + {selectedPipetteDefaultTipracks.map(tipRack => { + const tipRackDef = allLabwareDefinitionsByUri[tipRack] + + return tipRackDef != null ? ( + { + setSelectedTipRack(tipRackDef) + }} + buttonText={tipRackDef.metadata.displayName} + /> + ) : null + })} + + + ) +} diff --git a/app/src/organisms/QuickTransferFlow/__tests__/CreateNewTransfer.test.tsx b/app/src/organisms/QuickTransferFlow/__tests__/CreateNewTransfer.test.tsx new file mode 100644 index 00000000000..abeba9a2b1d --- /dev/null +++ b/app/src/organisms/QuickTransferFlow/__tests__/CreateNewTransfer.test.tsx @@ -0,0 +1,62 @@ +import * as React from 'react' +import { fireEvent, screen } from '@testing-library/react' +import { describe, it, expect, afterEach, vi, beforeEach } from 'vitest' +import { DeckConfigurator } from '@opentrons/components' + +import { renderWithProviders } from '../../../__testing-utils__' +import { i18n } from '../../../i18n' +import { CreateNewTransfer } from '../CreateNewTransfer' + +import type * as OpentronsComponents from '@opentrons/components' + +vi.mock('@opentrons/components', async importOriginal => { + const actual = await importOriginal() + return { + ...actual, + DeckConfigurator: vi.fn(), + } +}) +const render = (props: React.ComponentProps) => { + return renderWithProviders(, { + i18nInstance: i18n, + }) +} + +describe('CreateNewTransfer', () => { + let props: React.ComponentProps + + beforeEach(() => { + props = { + onNext: vi.fn(), + exitButtonProps: { + buttonType: 'tertiaryLowLight', + buttonText: 'Exit', + onClick: vi.fn(), + }, + } + }) + afterEach(() => { + vi.resetAllMocks() + }) + + it('renders the create new transfer screen and header', () => { + render(props) + screen.getByText('Create new quick transfer') + screen.getByText( + 'Quick transfers use deck slots B2-D2. These slots hold a tip rack, a source labware, and a destination labware.' + ) + screen.getByText( + 'Make sure that your deck configuration is up to date to avoid collisions.' + ) + expect(vi.mocked(DeckConfigurator)).toHaveBeenCalled() + }) + it('renders exit and continue buttons and they work as expected', () => { + render(props) + const exitBtn = screen.getByText('Exit') + fireEvent.click(exitBtn) + expect(props.exitButtonProps.onClick).toHaveBeenCalled() + const continueBtn = screen.getByText('Continue') + fireEvent.click(continueBtn) + expect(props.onNext).toHaveBeenCalled() + }) +}) diff --git a/app/src/organisms/QuickTransferFlow/__tests__/SelectPipette.test.tsx b/app/src/organisms/QuickTransferFlow/__tests__/SelectPipette.test.tsx new file mode 100644 index 00000000000..2d6faa6ffa7 --- /dev/null +++ b/app/src/organisms/QuickTransferFlow/__tests__/SelectPipette.test.tsx @@ -0,0 +1,126 @@ +import * as React from 'react' +import { fireEvent, screen } from '@testing-library/react' +import { describe, it, expect, afterEach, vi, beforeEach } from 'vitest' +import { useInstrumentsQuery } from '@opentrons/react-api-client' + +import { renderWithProviders } from '../../../__testing-utils__' +import { i18n } from '../../../i18n' +import { SelectPipette } from '../SelectPipette' + +vi.mock('@opentrons/react-api-client') +const render = (props: React.ComponentProps) => { + return renderWithProviders(, { + i18nInstance: i18n, + }) +} + +describe('SelectPipette', () => { + let props: React.ComponentProps + + beforeEach(() => { + props = { + onNext: vi.fn(), + onBack: vi.fn(), + exitButtonProps: { + buttonType: 'tertiaryLowLight', + buttonText: 'Exit', + onClick: vi.fn(), + }, + state: {}, + dispatch: vi.fn(), + } + vi.mocked(useInstrumentsQuery).mockReturnValue({ + data: { + data: [ + { + instrumentType: 'pipette', + mount: 'left', + ok: true, + firmwareVersion: 12, + instrumentName: 'p10_single', + instrumentModel: 'p1000_multi_v3.4', + data: {}, + } as any, + { + instrumentType: 'pipette', + mount: 'right', + ok: true, + firmwareVersion: 12, + instrumentName: 'p10_single', + instrumentModel: 'p1000_multi_v3.4', + data: {}, + } as any, + ], + }, + } as any) + }) + afterEach(() => { + vi.resetAllMocks() + }) + + it('renders the select pipette screen, header, and exit button', () => { + render(props) + screen.getByText('Select attached pipette') + screen.getByText( + 'Quick transfer options depend on the pipettes currently attached to your robot.' + ) + const exitBtn = screen.getByText('Exit') + fireEvent.click(exitBtn) + expect(props.exitButtonProps.onClick).toHaveBeenCalled() + }) + + it('renders continue button and it is disabled if no pipette is selected', () => { + render(props) + screen.getByText('Continue') + const continueBtn = screen.getByTestId('ChildNavigation_Primary_Button') + expect(continueBtn).toBeDisabled() + }) + + it('renders both pipette buttons if there are two attached', () => { + render(props) + screen.getByText('Left Mount') + screen.getByText('Right Mount') + }) + + it('selects pipette by default if there is one in state, button will be enabled', () => { + render({ ...props, state: { mount: 'left' } }) + const continueBtn = screen.getByTestId('ChildNavigation_Primary_Button') + expect(continueBtn).toBeEnabled() + fireEvent.click(continueBtn) + expect(props.onNext).toHaveBeenCalled() + }) + + it('enables continue button if you click a pipette', () => { + render(props) + const continueBtn = screen.getByTestId('ChildNavigation_Primary_Button') + expect(continueBtn).toBeDisabled() + const leftButton = screen.getByText('Left Mount') + fireEvent.click(leftButton) + expect(continueBtn).toBeEnabled() + fireEvent.click(continueBtn) + expect(props.dispatch).toHaveBeenCalled() + expect(props.onNext).toHaveBeenCalled() + }) + + it('renders left and right button if 96 is attached and automatically selects the pipette', () => { + vi.mocked(useInstrumentsQuery).mockReturnValue({ + data: { + data: [ + { + instrumentType: 'pipette', + mount: 'left', + ok: true, + firmwareVersion: 12, + instrumentName: 'p1000_96', + instrumentModel: 'p1000_96_v1', + data: {}, + } as any, + ], + }, + } as any) + render(props) + screen.getByText('Left + Right Mount') + const continueBtn = screen.getByTestId('ChildNavigation_Primary_Button') + expect(continueBtn).toBeEnabled() + }) +}) diff --git a/app/src/organisms/QuickTransferFlow/__tests__/SelectTipRack.test.tsx b/app/src/organisms/QuickTransferFlow/__tests__/SelectTipRack.test.tsx new file mode 100644 index 00000000000..b32b3188910 --- /dev/null +++ b/app/src/organisms/QuickTransferFlow/__tests__/SelectTipRack.test.tsx @@ -0,0 +1,86 @@ +import * as React from 'react' +import { fireEvent, screen } from '@testing-library/react' +import { describe, it, expect, afterEach, vi, beforeEach } from 'vitest' + +import { renderWithProviders } from '../../../__testing-utils__' +import { i18n } from '../../../i18n' +import { SelectTipRack } from '../SelectTipRack' + +vi.mock('@opentrons/react-api-client') +const render = (props: React.ComponentProps) => { + return renderWithProviders(, { + i18nInstance: i18n, + }) +} + +describe('SelectTipRack', () => { + let props: React.ComponentProps + + beforeEach(() => { + props = { + onNext: vi.fn(), + onBack: vi.fn(), + exitButtonProps: { + buttonType: 'tertiaryLowLight', + buttonText: 'Exit', + onClick: vi.fn(), + }, + state: { + mount: 'left', + pipette: { + liquids: { + default: { + defaultTipracks: [ + 'opentrons/opentrons_flex_96_tiprack_1000ul/1', + 'opentrons/opentrons_flex_96_tiprack_200ul/1', + 'opentrons/opentrons_flex_96_tiprack_50ul/1', + 'opentrons/opentrons_flex_96_filtertiprack_1000ul/1', + 'opentrons/opentrons_flex_96_filtertiprack_200ul/1', + 'opentrons/opentrons_flex_96_filtertiprack_50ul/1', + ], + }, + }, + } as any, + }, + dispatch: vi.fn(), + } + }) + afterEach(() => { + vi.resetAllMocks() + }) + + it('renders the select tip rack screen, header, and exit button', () => { + render(props) + screen.getByText('Select tip rack') + const exitBtn = screen.getByText('Exit') + fireEvent.click(exitBtn) + expect(props.exitButtonProps.onClick).toHaveBeenCalled() + }) + + it('renders continue button and it is disabled if no tip rack is selected', () => { + render(props) + screen.getByText('Continue') + const continueBtn = screen.getByTestId('ChildNavigation_Primary_Button') + expect(continueBtn).toBeDisabled() + }) + + it('selects tip rack by default if there is one in state, button will be enabled', () => { + render({ ...props, state: { tipRack: { def: 'definition' } as any } }) + const continueBtn = screen.getByTestId('ChildNavigation_Primary_Button') + expect(continueBtn).toBeEnabled() + fireEvent.click(continueBtn) + expect(props.onNext).toHaveBeenCalled() + }) + + it('enables continue button if you click a tip rack', () => { + render(props) + const continueBtn = screen.getByTestId('ChildNavigation_Primary_Button') + expect(continueBtn).toBeDisabled() + const tipRackButton = screen.getByText('Opentrons Flex 96 Tip Rack 200 µL') + fireEvent.click(tipRackButton) + expect(continueBtn).toBeEnabled() + fireEvent.click(continueBtn) + expect(props.dispatch).toHaveBeenCalled() + expect(props.onNext).toHaveBeenCalled() + }) +}) diff --git a/app/src/organisms/QuickTransferFlow/constants.ts b/app/src/organisms/QuickTransferFlow/constants.ts new file mode 100644 index 00000000000..3241759a044 --- /dev/null +++ b/app/src/organisms/QuickTransferFlow/constants.ts @@ -0,0 +1,9 @@ +export const ACTIONS = { + SELECT_PIPETTE: 'SELECT_PIPETTE', + SELECT_TIP_RACK: 'SELECT_TIP_RACK', + SET_SOURCE_LABWARE: 'SET_SOURCE_LABWARE', + SET_SOURCE_WELLS: 'SET_SOURCE_WELLS', + SET_DEST_LABWARE: 'SET_DEST_LABWARE', + SET_DEST_WELLS: 'SET_DEST_WELLS', + SET_VOLUME: 'SET_VOLUME', +} as const diff --git a/app/src/organisms/QuickTransferFlow/index.tsx b/app/src/organisms/QuickTransferFlow/index.tsx new file mode 100644 index 00000000000..cdfecc4fbe2 --- /dev/null +++ b/app/src/organisms/QuickTransferFlow/index.tsx @@ -0,0 +1,134 @@ +import * as React from 'react' +import { useHistory } from 'react-router-dom' +import { useTranslation } from 'react-i18next' +import { + Flex, + StepMeter, + SPACING, + POSITION_STICKY, +} from '@opentrons/components' +import { SmallButton } from '../../atoms/buttons' +import { ChildNavigation } from '../ChildNavigation' +import { CreateNewTransfer } from './CreateNewTransfer' +import { SelectPipette } from './SelectPipette' +import { SelectTipRack } from './SelectTipRack' +import { quickTransferReducer } from './utils' + +import type { QuickTransferSetupState } from './types' + +const QUICK_TRANSFER_WIZARD_STEPS = 8 +const initialQuickTransferState: QuickTransferSetupState = {} + +export const QuickTransferFlow = (): JSX.Element => { + const history = useHistory() + const { i18n, t } = useTranslation(['quick_transfer', 'shared']) + const [state, dispatch] = React.useReducer( + quickTransferReducer, + initialQuickTransferState + ) + const [currentStep, setCurrentStep] = React.useState(1) + const [continueIsDisabled] = React.useState(false) + + // every child component will take state as a prop, an anonymous + // dispatch function related to that step (except create new), + // and a function to disable the continue button + + const exitButtonProps: React.ComponentProps = { + buttonType: 'tertiaryLowLight', + buttonText: i18n.format(t('shared:exit'), 'capitalize'), + onClick: () => { + history.push('protocols') + }, + } + + // these will be moved to the child components once they all exist + const ORDERED_STEP_HEADERS: string[] = [ + t('create_new_transfer'), + t('select_attached_pipette'), + t('select_tip_rack'), + t('select_source_labware'), + t('select_source_wells'), + t('select_dest_labware'), + t('select_dest_wells'), + t('set_transfer_volume'), + ] + + const header = ORDERED_STEP_HEADERS[currentStep - 1] + let modalContent: JSX.Element | null = null + if (currentStep === 1) { + modalContent = ( + setCurrentStep(prevStep => prevStep + 1)} + exitButtonProps={exitButtonProps} + /> + ) + } else if (currentStep === 2) { + modalContent = ( + setCurrentStep(prevStep => prevStep - 1)} + onNext={() => setCurrentStep(prevStep => prevStep + 1)} + exitButtonProps={exitButtonProps} + /> + ) + } else if (currentStep === 3) { + modalContent = ( + setCurrentStep(prevStep => prevStep - 1)} + onNext={() => setCurrentStep(prevStep => prevStep + 1)} + exitButtonProps={exitButtonProps} + /> + ) + } else { + modalContent = null + } + + // until each page is wired up, show header title with empty screen + return ( + <> + + {modalContent == null ? ( + + { + setCurrentStep(prevStep => prevStep - 1) + } + } + buttonText={i18n.format(t('shared:continue'), 'capitalize')} + onClickButton={() => { + if (currentStep === 8) { + history.push('protocols') + } else { + setCurrentStep(prevStep => prevStep + 1) + } + }} + buttonIsDisabled={continueIsDisabled} + secondaryButtonProps={{ + buttonType: 'tertiaryLowLight', + buttonText: i18n.format(t('shared:exit'), 'capitalize'), + onClick: () => { + history.push('protocols') + }, + }} + top={SPACING.spacing8} + /> + {modalContent} + + ) : ( + modalContent + )} + + ) +} diff --git a/app/src/organisms/QuickTransferFlow/types.ts b/app/src/organisms/QuickTransferFlow/types.ts new file mode 100644 index 00000000000..1d43017a58c --- /dev/null +++ b/app/src/organisms/QuickTransferFlow/types.ts @@ -0,0 +1,53 @@ +import { ACTIONS } from './constants' +import type { Mount } from '@opentrons/api-client' +import type { LabwareDefinition2, PipetteV2Specs } from '@opentrons/shared-data' + +export interface QuickTransferSetupState { + pipette?: PipetteV2Specs + mount?: Mount + tipRack?: LabwareDefinition2 + source?: LabwareDefinition2 + sourceWells?: string[] + destination?: LabwareDefinition2 + destinationWells?: string[] + volume?: number +} + +export type QuickTransferWizardAction = + | SelectPipetteAction + | SelectTipRackAction + | SetSourceLabwareAction + | SetSourceWellsAction + | SetDestLabwareAction + | SetDestWellsAction + | SetVolumeAction + +interface SelectPipetteAction { + type: typeof ACTIONS.SELECT_PIPETTE + mount: Mount + pipette: PipetteV2Specs +} +interface SelectTipRackAction { + type: typeof ACTIONS.SELECT_TIP_RACK + tipRack: LabwareDefinition2 +} +interface SetSourceLabwareAction { + type: typeof ACTIONS.SET_SOURCE_LABWARE + labware: LabwareDefinition2 +} +interface SetSourceWellsAction { + type: typeof ACTIONS.SET_SOURCE_WELLS + wells: string[] +} +interface SetDestLabwareAction { + type: typeof ACTIONS.SET_DEST_LABWARE + labware: LabwareDefinition2 +} +interface SetDestWellsAction { + type: typeof ACTIONS.SET_DEST_WELLS + wells: string[] +} +interface SetVolumeAction { + type: typeof ACTIONS.SET_VOLUME + volume: number +} diff --git a/app/src/organisms/QuickTransferFlow/utils.ts b/app/src/organisms/QuickTransferFlow/utils.ts new file mode 100644 index 00000000000..ee13d4c1720 --- /dev/null +++ b/app/src/organisms/QuickTransferFlow/utils.ts @@ -0,0 +1,75 @@ +import type { + QuickTransferSetupState, + QuickTransferWizardAction, +} from './types' + +export function quickTransferReducer( + state: QuickTransferSetupState, + action: QuickTransferWizardAction +): QuickTransferSetupState { + switch (action.type) { + case 'SELECT_PIPETTE': { + return { + pipette: action.pipette, + mount: action.mount, + } + } + case 'SELECT_TIP_RACK': { + return { + pipette: state.pipette, + mount: state.mount, + tipRack: action.tipRack, + } + } + case 'SET_SOURCE_LABWARE': { + return { + pipette: state.pipette, + mount: state.mount, + tipRack: state.tipRack, + source: action.labware, + } + } + case 'SET_SOURCE_WELLS': { + return { + pipette: state.pipette, + mount: state.mount, + tipRack: state.tipRack, + source: state.source, + sourceWells: action.wells, + } + } + case 'SET_DEST_LABWARE': { + return { + pipette: state.pipette, + mount: state.mount, + tipRack: state.tipRack, + source: state.source, + sourceWells: state.sourceWells, + destination: action.labware, + } + } + case 'SET_DEST_WELLS': { + return { + pipette: state.pipette, + mount: state.mount, + tipRack: state.tipRack, + source: state.source, + sourceWells: state.sourceWells, + destination: state.destination, + destinationWells: action.wells, + } + } + case 'SET_VOLUME': { + return { + pipette: state.pipette, + mount: state.mount, + tipRack: state.tipRack, + source: state.source, + sourceWells: state.sourceWells, + destination: state.destination, + destinationWells: state.destinationWells, + volume: action.volume, + } + } + } +} diff --git a/app/src/organisms/RobotSettingsDashboard/NetworkSettings/index.tsx b/app/src/organisms/RobotSettingsDashboard/NetworkSettings/index.tsx index 9fdd651eb5d..11c2a13d783 100644 --- a/app/src/organisms/RobotSettingsDashboard/NetworkSettings/index.tsx +++ b/app/src/organisms/RobotSettingsDashboard/NetworkSettings/index.tsx @@ -6,6 +6,7 @@ import { ALIGN_CENTER, BORDERS, Btn, + Chip, COLORS, DIRECTION_COLUMN, DIRECTION_ROW, @@ -16,12 +17,10 @@ import { TYPOGRAPHY, } from '@opentrons/components' -import { Chip } from '../../../atoms/Chip' import { ChildNavigation } from '../../../organisms/ChildNavigation' -import type { IconName } from '@opentrons/components' +import type { IconName, ChipType } from '@opentrons/components' import type { NetworkConnection } from '../../../resources/networking/hooks/useNetworkConnection' -import type { ChipType } from '../../../atoms/Chip' import type { SetSettingOption } from '../../../pages/RobotSettingsDashboard' export type ConnectionType = 'wifi' | 'ethernet' | 'usb' diff --git a/app/src/organisms/RobotSettingsDashboard/Privacy.tsx b/app/src/organisms/RobotSettingsDashboard/Privacy.tsx index ceca1dea718..7f8963b15e8 100644 --- a/app/src/organisms/RobotSettingsDashboard/Privacy.tsx +++ b/app/src/organisms/RobotSettingsDashboard/Privacy.tsx @@ -32,7 +32,7 @@ export function Privacy({ robotName, setCurrentOption, }: PrivacyProps): JSX.Element { - const { t } = useTranslation('app_settings') + const { t } = useTranslation(['app_settings', 'branded']) const dispatch = useDispatch() const allRobotSettings = useSelector((state: State) => @@ -62,7 +62,7 @@ export function Privacy({ lineHeight={TYPOGRAPHY.lineHeight36} fontWeight={TYPOGRAPHY.fontWeightRegular} > - {t('opentrons_cares_about_privacy')} + {t('branded:opentrons_cares_about_privacy')}
} onClick={() => dispatch(toggleAnalyticsOptedIn())} diff --git a/app/src/organisms/RobotSettingsDashboard/RobotSystemVersionModal.tsx b/app/src/organisms/RobotSettingsDashboard/RobotSystemVersionModal.tsx index e1fffe74e30..8e2a8675f18 100644 --- a/app/src/organisms/RobotSettingsDashboard/RobotSystemVersionModal.tsx +++ b/app/src/organisms/RobotSettingsDashboard/RobotSystemVersionModal.tsx @@ -51,7 +51,7 @@ export function RobotSystemVersionModal({ > diff --git a/app/src/organisms/RunDetails/ConfirmCancelModal.tsx b/app/src/organisms/RunDetails/ConfirmCancelModal.tsx index 172d8b15394..809ee0eee88 100644 --- a/app/src/organisms/RunDetails/ConfirmCancelModal.tsx +++ b/app/src/organisms/RunDetails/ConfirmCancelModal.tsx @@ -22,7 +22,7 @@ import { useStopRunMutation } from '@opentrons/react-api-client' import { getModalPortalEl } from '../../App/portal' import { LegacyModal } from '../../molecules/LegacyModal' -import { useTrackProtocolRunEvent } from '../Devices/hooks' +import { useTrackProtocolRunEvent, useIsFlex } from '../Devices/hooks' import { useRunStatus } from '../RunTimeControl/hooks' import { ANALYTICS_PROTOCOL_RUN_CANCEL } from '../../redux/analytics' @@ -39,9 +39,14 @@ export function ConfirmCancelModal( const { stopRun } = useStopRunMutation() const [isCanceling, setIsCanceling] = React.useState(false) const runStatus = useRunStatus(runId) + const isFlex = useIsFlex(robotName) const { trackProtocolRunEvent } = useTrackProtocolRunEvent(runId, robotName) const { t } = useTranslation('run_details') + const cancelRunAlertInfo = isFlex + ? t('cancel_run_alert_info_flex') + : t('cancel_run_alert_info_ot2') + const cancelRun: React.MouseEventHandler = (e): void => { e.preventDefault() e.stopPropagation() @@ -72,7 +77,7 @@ export function ConfirmCancelModal( > - {t('cancel_run_alert_info')} + {cancelRunAlertInfo} {t('cancel_run_module_info')} diff --git a/app/src/organisms/RunDetails/__tests__/ConfirmCancelModal.test.tsx b/app/src/organisms/RunDetails/__tests__/ConfirmCancelModal.test.tsx index 872a23b8daa..92fed1f5e4f 100644 --- a/app/src/organisms/RunDetails/__tests__/ConfirmCancelModal.test.tsx +++ b/app/src/organisms/RunDetails/__tests__/ConfirmCancelModal.test.tsx @@ -11,7 +11,10 @@ import { import { useStopRunMutation } from '@opentrons/react-api-client' import { i18n } from '../../../i18n' -import { useTrackProtocolRunEvent } from '../../../organisms/Devices/hooks' +import { + useIsFlex, + useTrackProtocolRunEvent, +} from '../../../organisms/Devices/hooks' import { useTrackEvent } from '../../../redux/analytics' import { renderWithProviders } from '../../../__testing-utils__' import { ConfirmCancelModal } from '../../../organisms/RunDetails/ConfirmCancelModal' @@ -56,6 +59,7 @@ describe('ConfirmCancelModal', () => { when(useTrackProtocolRunEvent).calledWith(RUN_ID, ROBOT_NAME).thenReturn({ trackProtocolRunEvent: mockTrackProtocolRunEvent, }) + vi.mocked(useIsFlex).mockReturnValue(true) props = { onClose: vi.fn(), runId: RUN_ID, robotName: ROBOT_NAME } }) @@ -66,15 +70,20 @@ describe('ConfirmCancelModal', () => { it('should render the correct title', () => { render(props) - screen.getByText('Are you sure you want to cancel this run?') + screen.getByText('Are you sure you want to cancel?') }) - it('should render the correct body', () => { + it('should render the correct body text for a Flex', () => { render(props) + screen.getByText('Doing so will terminate this run and home your robot.') screen.getByText( - 'Doing so will terminate this run, drop any attached tips in the trash container and home your robot.' + 'Additionally, any hardware modules used within the protocol will remain active and maintain their current states until deactivated.' ) + }) + it('should render correct alternative body text for an OT-2', () => { + vi.mocked(useIsFlex).mockReturnValue(false) + render(props) screen.getByText( - 'Additionally, any hardware modules used within the protocol will remain active and maintain their current states until deactivated.' + 'Doing so will terminate this run, drop any attached tips in the trash container, and home your robot.' ) }) it('should render both buttons', () => { diff --git a/app/src/organisms/RunPreview/index.tsx b/app/src/organisms/RunPreview/index.tsx index a75257c1952..a7e4aa2591b 100644 --- a/app/src/organisms/RunPreview/index.tsx +++ b/app/src/organisms/RunPreview/index.tsx @@ -3,6 +3,8 @@ import { css } from 'styled-components' import { useTranslation } from 'react-i18next' import { ViewportList, ViewportListRef } from 'react-viewport-list' +import { RUN_STATUSES_TERMINAL } from '@opentrons/api-client' +import { useAllCommandsQuery } from '@opentrons/react-api-client' import { ALIGN_CENTER, BORDERS, @@ -24,6 +26,9 @@ import { CommandText } from '../CommandText' import { Divider } from '../../atoms/structure' import { NAV_BAR_WIDTH } from '../../App/constants' import { CommandIcon } from './CommandIcon' +import { useRunStatus } from '../RunTimeControl/hooks' + +import type { RunStatus } from '@opentrons/api-client' import type { RobotType } from '@opentrons/shared-data' const COLOR_FADE_MS = 500 @@ -41,6 +46,17 @@ export const RunPreviewComponent = ( ): JSX.Element | null => { const { t } = useTranslation('run_details') const robotSideAnalysis = useMostRecentCompletedAnalysis(runId) + const runStatus = useRunStatus(runId) + const isRunTerminal = + runStatus != null + ? (RUN_STATUSES_TERMINAL as RunStatus[]).includes(runStatus) + : false + // we only ever want one request done for terminal runs because this is a heavy request + const commandsFromQuery = useAllCommandsQuery(runId, null, { + staleTime: Infinity, + cacheTime: Infinity, + enabled: isRunTerminal, + }).data?.data const viewPortRef = React.useRef(null) const currentRunCommandKey = useNotifyLastRunCommandKey(runId, { refetchInterval: LIVE_RUN_COMMANDS_POLL_MS, @@ -50,7 +66,9 @@ export const RunPreviewComponent = ( setIsCurrentCommandVisible, ] = React.useState(true) if (robotSideAnalysis == null) return null - const currentRunCommandIndex = robotSideAnalysis.commands.findIndex( + const commands = + (isRunTerminal ? commandsFromQuery : robotSideAnalysis.commands) ?? [] + const currentRunCommandIndex = commands.findIndex( c => c.key === currentRunCommandKey ) @@ -69,7 +87,7 @@ export const RunPreviewComponent = ( {t('run_preview')} - {t('steps_total', { count: robotSideAnalysis.commands.length })} + {t('steps_total', { count: commands.length })} @@ -79,7 +97,7 @@ export const RunPreviewComponent = ( ) : null} - {currentRunCommandIndex === robotSideAnalysis.commands.length - 1 ? ( + {currentRunCommandIndex === commands.length - 1 ? ( {t('end_of_protocol')} diff --git a/app/src/organisms/RunTimeControl/__fixtures__/index.ts b/app/src/organisms/RunTimeControl/__fixtures__/index.ts index 1a18a9a6bcf..33f2e0c4393 100644 --- a/app/src/organisms/RunTimeControl/__fixtures__/index.ts +++ b/app/src/organisms/RunTimeControl/__fixtures__/index.ts @@ -41,6 +41,7 @@ export const mockPausedRun: RunData = { pipettes: [], labware: [], modules: [], + runTimeParameters: [], } export const mockPauseRequestedRun: RunData = { @@ -65,6 +66,7 @@ export const mockPauseRequestedRun: RunData = { pipettes: [], labware: [], modules: [], + runTimeParameters: [], } export const mockRunningRun: RunData = { @@ -94,6 +96,7 @@ export const mockRunningRun: RunData = { pipettes: [], labware: [], modules: [], + runTimeParameters: [], } export const mockFailedRun: RunData = { @@ -133,6 +136,7 @@ export const mockFailedRun: RunData = { pipettes: [], labware: [], modules: [], + runTimeParameters: [], } export const mockStopRequestedRun: RunData = { @@ -167,6 +171,7 @@ export const mockStopRequestedRun: RunData = { pipettes: [], labware: [], modules: [], + runTimeParameters: [], } export const mockStoppedRun: RunData = { @@ -201,6 +206,7 @@ export const mockStoppedRun: RunData = { pipettes: [], labware: [], modules: [], + runTimeParameters: [], } export const mockSucceededRun: RunData = { @@ -230,6 +236,7 @@ export const mockSucceededRun: RunData = { pipettes: [], labware: [], modules: [], + runTimeParameters: [], } export const mockIdleUnstartedRun: RunData = { @@ -243,6 +250,7 @@ export const mockIdleUnstartedRun: RunData = { pipettes: [], labware: [], modules: [], + runTimeParameters: [], } export const mockIdleStartedRun: RunData = { @@ -272,6 +280,7 @@ export const mockIdleStartedRun: RunData = { pipettes: [], labware: [], modules: [], + runTimeParameters: [], } export const mockCommand = { diff --git a/app/src/organisms/RunTimeControl/__tests__/hooks.test.tsx b/app/src/organisms/RunTimeControl/__tests__/hooks.test.tsx index 21adedbd165..a46bc37d865 100644 --- a/app/src/organisms/RunTimeControl/__tests__/hooks.test.tsx +++ b/app/src/organisms/RunTimeControl/__tests__/hooks.test.tsx @@ -61,7 +61,7 @@ describe('useRunControls hook', () => { isStopRunActionLoading: false, }) when(useCloneRun) - .calledWith(mockPausedRun.id, undefined) + .calledWith(mockPausedRun.id, undefined, true) .thenReturn({ cloneRun: mockCloneRun, isLoading: false }) const { result } = renderHook(() => useRunControls(mockPausedRun.id)) diff --git a/app/src/organisms/RunTimeControl/hooks.ts b/app/src/organisms/RunTimeControl/hooks.ts index 1bed99157be..d513fcbe118 100644 --- a/app/src/organisms/RunTimeControl/hooks.ts +++ b/app/src/organisms/RunTimeControl/hooks.ts @@ -12,6 +12,7 @@ import { RUN_STATUS_SUCCEEDED, RUN_ACTION_TYPE_STOP, RUN_STATUS_STOP_REQUESTED, + RUN_STATUSES_TERMINAL, } from '@opentrons/api-client' import { useRunActionMutations } from '@opentrons/react-api-client' @@ -21,7 +22,6 @@ import { useRunCommands, } from '../ProtocolUpload/hooks' import { useNotifyRunQuery } from '../../resources/runs' -import { useFeatureFlag } from '../../redux/config' import { useMostRecentCompletedAnalysis } from '../LabwarePositionCheck/useMostRecentCompletedAnalysis' import type { UseQueryOptions } from 'react-query' @@ -53,7 +53,8 @@ export function useRunControls( const { cloneRun, isLoading: isResetRunLoading } = useCloneRun( runId ?? null, - onCloneRunSuccess + onCloneRunSuccess, + true ) return { @@ -79,11 +80,7 @@ export function useRunStatus( refetchInterval: DEFAULT_STATUS_REFETCH_INTERVAL, enabled: lastRunStatus.current == null || - !([ - RUN_STATUS_FAILED, - RUN_STATUS_SUCCEEDED, - RUN_STATUS_STOP_REQUESTED, - ] as RunStatus[]).includes(lastRunStatus.current), + !(RUN_STATUSES_TERMINAL as RunStatus[]).includes(lastRunStatus.current), onSuccess: data => (lastRunStatus.current = data?.data?.status ?? null), ...options, }) @@ -188,11 +185,6 @@ export function useRunErrors(runId: string | null): RunData['errors'] { export function useProtocolHasRunTimeParameters(runId: string | null): boolean { const mostRecentAnalysis = useMostRecentCompletedAnalysis(runId) - const runTimeParametersFF = useFeatureFlag('enableRunTimeParameters') - - console.log( - 'TODO: delete the feature flag logic', - mostRecentAnalysis?.runTimeParameters - ) - return runTimeParametersFF + const runTimeParameters = mostRecentAnalysis?.runTimeParameters ?? [] + return runTimeParameters.length > 0 } diff --git a/app/src/organisms/TakeoverModal/TakeoverModal.tsx b/app/src/organisms/TakeoverModal/TakeoverModal.tsx index 3dab071bdec..c87f33fc150 100644 --- a/app/src/organisms/TakeoverModal/TakeoverModal.tsx +++ b/app/src/organisms/TakeoverModal/TakeoverModal.tsx @@ -32,7 +32,7 @@ export function TakeoverModal(props: TakeoverModalProps): JSX.Element { confirmTerminate, terminateInProgress, } = props - const { i18n, t } = useTranslation('shared') + const { i18n, t } = useTranslation(['shared', 'branded']) const terminateHeader: ModalHeaderBaseProps = { title: t('terminate') + '?', @@ -46,7 +46,7 @@ export function TakeoverModal(props: TakeoverModalProps): JSX.Element { - {t('confirm_terminate')} + {t('branded:confirm_terminate')} - {t('computer_in_app_is_controlling_robot')} + {t('branded:computer_in_app_is_controlling_robot')} ) : null} {(downloading || downloaded) && error == null ? ( - + closeModal(true)} closeOnOutsideClick={true} footer={appUpdateFooter} @@ -191,7 +194,7 @@ export function UpdateAppModal(props: UpdateAppModalProps): JSX.Element { > - {t('update_requires_restarting')} + {t('branded:update_requires_restarting_app')} diff --git a/app/src/organisms/UpdateRobotBanner/index.tsx b/app/src/organisms/UpdateRobotBanner/index.tsx index ced443a2018..86e2201bf84 100644 --- a/app/src/organisms/UpdateRobotBanner/index.tsx +++ b/app/src/organisms/UpdateRobotBanner/index.tsx @@ -25,7 +25,7 @@ export function UpdateRobotBanner( props: UpdateRobotBannerProps ): JSX.Element | null { const { robot, ...styleProps } = props - const { t } = useTranslation('device_settings') + const { t } = useTranslation(['device_settings', 'branded']) const { autoUpdateAction } = useSelector((state: State) => { return getRobotUpdateDisplayInfo(state, robot?.name) @@ -40,7 +40,7 @@ export function UpdateRobotBanner( > - {t('robot_software_update_required')} + {t('branded:robot_software_update_required')} handleUpdateBuildroot(robot)} diff --git a/app/src/organisms/UpdateRobotSoftware/UpdateSoftware.tsx b/app/src/organisms/UpdateRobotSoftware/UpdateSoftware.tsx index 60ff6cc18de..7d625254a2f 100644 --- a/app/src/organisms/UpdateRobotSoftware/UpdateSoftware.tsx +++ b/app/src/organisms/UpdateRobotSoftware/UpdateSoftware.tsx @@ -4,25 +4,21 @@ import { useTranslation } from 'react-i18next' import { ALIGN_CENTER, BORDERS, - Box, COLORS, DIRECTION_COLUMN, Flex, + Icon, JUSTIFY_CENTER, SPACING, StyledText, TYPOGRAPHY, } from '@opentrons/components' -import { ProgressBar } from '../../atoms/ProgressBar' - interface UpdateSoftwareProps { updateType: 'downloading' | 'validating' | 'sendingFile' | 'installing' | null - processProgress: number } export function UpdateSoftware({ updateType, - processProgress, }: UpdateSoftwareProps): JSX.Element { const { t } = useTranslation('device_settings') const renderText = (): string | null => { @@ -52,6 +48,13 @@ export function UpdateSoftware({ height="33rem" borderRadius={BORDERS.borderRadius12} > + - - - ) } diff --git a/app/src/organisms/UpdateRobotSoftware/__tests__/UpdateRobotSoftware.test.tsx b/app/src/organisms/UpdateRobotSoftware/__tests__/UpdateRobotSoftware.test.tsx index 242b40c4be8..5db3c1358eb 100644 --- a/app/src/organisms/UpdateRobotSoftware/__tests__/UpdateRobotSoftware.test.tsx +++ b/app/src/organisms/UpdateRobotSoftware/__tests__/UpdateRobotSoftware.test.tsx @@ -113,7 +113,7 @@ describe('UpdateRobotSoftware', () => { render() expect(mockBeforeCommitting).toBeCalled() expect(UpdateSoftware).toBeCalledWith( - { updateType: 'installing', processProgress: 0 }, + { updateType: 'installing' }, expect.anything() ) screen.getByText('mock UpdateSoftware') diff --git a/app/src/organisms/UpdateRobotSoftware/__tests__/UpdateSoftware.test.tsx b/app/src/organisms/UpdateRobotSoftware/__tests__/UpdateSoftware.test.tsx index 913f2c26dea..680de1b0147 100644 --- a/app/src/organisms/UpdateRobotSoftware/__tests__/UpdateSoftware.test.tsx +++ b/app/src/organisms/UpdateRobotSoftware/__tests__/UpdateSoftware.test.tsx @@ -1,9 +1,8 @@ import * as React from 'react' import { screen } from '@testing-library/react' -import { describe, it, beforeEach, expect } from 'vitest' +import { describe, it, beforeEach } from 'vitest' import '@testing-library/jest-dom/vitest' import { renderWithProviders } from '../../../__testing-utils__' -import { COLORS } from '@opentrons/components' import { i18n } from '../../../i18n' import { UpdateSoftware } from '../UpdateSoftware' @@ -18,47 +17,34 @@ describe('UpdateSoftware', () => { beforeEach(() => { props = { updateType: 'downloading', - processProgress: 50, } }) - it('should render text and progressbar - downloading software', () => { + it('should render text - downloading software', () => { render(props) screen.getByText('Downloading software...') - const bar = screen.getByTestId('ProgressBar_Bar') - expect(bar).toHaveStyle(`background: ${String(COLORS.blue50)}`) - expect(bar).toHaveStyle('width: 50%') }) - it('should render text and progressbar - sending software', () => { + it('should render text - sending software', () => { props = { ...props, - processProgress: 20, updateType: 'sendingFile', } render(props) screen.getByText('Sending software...') - const bar = screen.getByTestId('ProgressBar_Bar') - expect(bar).toHaveStyle('width: 20%') }) - it('should render text and progressbar - validating software', () => { + it('should render text - validating software', () => { props = { ...props, - processProgress: 80, updateType: 'validating', } render(props) screen.getByText('Validating software...') - const bar = screen.getByTestId('ProgressBar_Bar') - expect(bar).toHaveStyle('width: 80%') }) - it('should render text and progressbar - installing software', () => { + it('should render text - installing software', () => { props = { ...props, - processProgress: 5, updateType: 'installing', } render(props) screen.getByText('Installing software...') - const bar = screen.getByTestId('ProgressBar_Bar') - expect(bar).toHaveStyle('width: 5%') }) }) diff --git a/app/src/organisms/UpdateRobotSoftware/index.tsx b/app/src/organisms/UpdateRobotSoftware/index.tsx index c88f3197491..4d61272ac6f 100644 --- a/app/src/organisms/UpdateRobotSoftware/index.tsx +++ b/app/src/organisms/UpdateRobotSoftware/index.tsx @@ -37,7 +37,7 @@ export function UpdateRobotSoftware( const dispatch = useDispatch() const session = useSelector(getRobotUpdateSession) - const { step, stage, progress, error: sessionError } = session ?? { + const { step, stage, error: sessionError } = session ?? { step: null, error: null, } @@ -76,11 +76,6 @@ export function UpdateRobotSoftware( beforeCommittingSuccessfulUpdate && beforeCommittingSuccessfulUpdate() } } - return ( - - ) + return } } diff --git a/app/src/pages/AppSettings/GeneralSettings.tsx b/app/src/pages/AppSettings/GeneralSettings.tsx index 99bdf464d04..553f0e56356 100644 --- a/app/src/pages/AppSettings/GeneralSettings.tsx +++ b/app/src/pages/AppSettings/GeneralSettings.tsx @@ -54,7 +54,7 @@ const GITHUB_LINK = const ENABLE_APP_UPDATE_NOTIFICATIONS = 'Enable app update notifications' export function GeneralSettings(): JSX.Element { - const { t } = useTranslation(['app_settings', 'shared']) + const { t } = useTranslation(['app_settings', 'shared', 'branded']) const dispatch = useDispatch() const trackEvent = useTrackEvent() const [ @@ -113,7 +113,7 @@ export function GeneralSettings(): JSX.Element { type="warning" onCloseClick={() => setShowUpdateBanner(false)} > - {t('opentrons_app_update_available_variation')} + {t('branded:opentrons_app_update_available_variation')} - {t('versions_sync')} + {t('branded:versions_sync')}
@@ -218,7 +218,7 @@ export function GeneralSettings(): JSX.Element { alignItems={ALIGN_CENTER} justifyContent={JUSTIFY_SPACE_BETWEEN} > - {t('receive_alert')} + {t('branded:receive_alert')} () const analyticsOptedIn = useSelector((s: State) => getAnalyticsOptedIn(s)) diff --git a/app/src/pages/ConnectViaUSB/index.tsx b/app/src/pages/ConnectViaUSB/index.tsx index 961da9b6092..72130c5444c 100644 --- a/app/src/pages/ConnectViaUSB/index.tsx +++ b/app/src/pages/ConnectViaUSB/index.tsx @@ -22,7 +22,7 @@ import { StepMeter } from '../../atoms/StepMeter' import { MediumButton } from '../../atoms/buttons' export function ConnectViaUSB(): JSX.Element { - const { i18n, t } = useTranslation(['device_settings', 'shared']) + const { i18n, t } = useTranslation(['device_settings', 'shared', 'branded']) const history = useHistory() // TODO(bh, 2023-5-31): active connections from /system/connected isn't exactly the right way to monitor for a usb connection - // the system-server tracks active connections by authorization token, which is valid for 2 hours @@ -92,7 +92,7 @@ export function ConnectViaUSB(): JSX.Element { color={COLORS.grey60} textAlign={TYPOGRAPHY.textAlignCenter} > - {t('find_your_robot')} + {t('branded:find_your_robot')} @@ -134,7 +134,7 @@ export function ConnectViaUSB(): JSX.Element { {t('connect_via_usb_description_2')} - {t('connect_via_usb_description_3')} + {t('branded:connect_via_usb_description_3')} diff --git a/app/src/pages/DeckConfiguration/index.tsx b/app/src/pages/DeckConfiguration/index.tsx index d2e5b508325..27d0d83a25c 100644 --- a/app/src/pages/DeckConfiguration/index.tsx +++ b/app/src/pages/DeckConfiguration/index.tsx @@ -19,6 +19,10 @@ import { SINGLE_RIGHT_CUTOUTS, SINGLE_LEFT_SLOT_FIXTURE, SINGLE_RIGHT_SLOT_FIXTURE, + SINGLE_LEFT_CUTOUTS, + SINGLE_CENTER_SLOT_FIXTURE, + getDeckDefFromRobotType, + FLEX_ROBOT_TYPE, } from '@opentrons/shared-data' import { SmallButton } from '../../atoms/buttons' @@ -28,7 +32,11 @@ import { DeckFixtureSetupInstructionsModal } from '../../organisms/DeviceDetails import { DeckConfigurationDiscardChangesModal } from '../../organisms/DeviceDetailsDeckConfiguration/DeckConfigurationDiscardChangesModal' import { getTopPortalEl } from '../../App/portal' -import type { CutoutId, DeckConfiguration } from '@opentrons/shared-data' +import type { + CutoutFixtureId, + CutoutId, + DeckConfiguration, +} from '@opentrons/shared-data' export function DeckConfigurationEditor(): JSX.Element { const { t, i18n } = useTranslation([ @@ -53,6 +61,7 @@ export function DeckConfigurationEditor(): JSX.Element { setShowDiscardChangeModal, ] = React.useState(false) + const deckDef = getDeckDefFromRobotType(FLEX_ROBOT_TYPE) const deckConfig = useDeckConfigurationQuery().data ?? [] const { updateDeckConfiguration } = useUpdateDeckConfigurationMutation() @@ -66,19 +75,53 @@ export function DeckConfigurationEditor(): JSX.Element { setShowConfigurationModal(true) } - const handleClickRemove = (cutoutId: CutoutId): void => { - setCurrentDeckConfig(prevDeckConfig => - prevDeckConfig.map(fixture => - fixture.cutoutId === cutoutId + const handleClickRemove = ( + cutoutId: CutoutId, + cutoutFixtureId: CutoutFixtureId + ): void => { + let replacementFixtureId: CutoutFixtureId = SINGLE_CENTER_SLOT_FIXTURE + if (SINGLE_RIGHT_CUTOUTS.includes(cutoutId)) { + replacementFixtureId = SINGLE_RIGHT_SLOT_FIXTURE + } else if (SINGLE_LEFT_CUTOUTS.includes(cutoutId)) { + replacementFixtureId = SINGLE_LEFT_SLOT_FIXTURE + } + + const fixtureGroup = + deckDef.cutoutFixtures.find(cf => cf.id === cutoutFixtureId) + ?.fixtureGroup ?? {} + + let newDeckConfig = deckConfig + if (cutoutId in fixtureGroup) { + const groupMap = + fixtureGroup[cutoutId]?.find(group => + Object.entries(group).every(([cId, cfId]) => + deckConfig.find( + config => + config.cutoutId === cId && config.cutoutFixtureId === cfId + ) + ) + ) ?? {} + newDeckConfig = deckConfig.map(cutoutConfig => + cutoutConfig.cutoutId in groupMap ? { - ...fixture, - cutoutFixtureId: SINGLE_RIGHT_CUTOUTS.includes(cutoutId) - ? SINGLE_RIGHT_SLOT_FIXTURE - : SINGLE_LEFT_SLOT_FIXTURE, + ...cutoutConfig, + cutoutFixtureId: replacementFixtureId, + opentronsModuleSerialNumber: undefined, } - : fixture + : cutoutConfig ) - ) + } else { + newDeckConfig = deckConfig.map(cutoutConfig => + cutoutConfig.cutoutId === cutoutId + ? { + ...cutoutConfig, + cutoutFixtureId: replacementFixtureId, + opentronsModuleSerialNumber: undefined, + } + : cutoutConfig + ) + } + updateDeckConfiguration(newDeckConfig) } const handleClickConfirm = (): void => { diff --git a/app/src/pages/Devices/ProtocolRunDetails/index.tsx b/app/src/pages/Devices/ProtocolRunDetails/index.tsx index 8c36199315f..672c2231e31 100644 --- a/app/src/pages/Devices/ProtocolRunDetails/index.tsx +++ b/app/src/pages/Devices/ProtocolRunDetails/index.tsx @@ -37,7 +37,6 @@ import { useCurrentRunId } from '../../../organisms/ProtocolUpload/hooks' import { OPENTRONS_USB } from '../../../redux/discovery' import { fetchProtocols } from '../../../redux/protocol-storage' import { appShellRequestor } from '../../../redux/shell/remote' -import { useFeatureFlag } from '../../../redux/config' import type { DesktopRouteParams, @@ -180,7 +179,7 @@ function PageContents(props: PageContentsProps): JSX.Element { const protocolRunHeaderRef = React.useRef(null) const listRef = React.useRef(null) const [jumpedIndex, setJumpedIndex] = React.useState(null) - const enableRunTimeParameters = useFeatureFlag('enableRunTimeParameters') + React.useEffect(() => { if (jumpedIndex != null) { setTimeout(() => setJumpedIndex(null), JUMPED_STEP_HIGHLIGHT_DELAY_MS) @@ -236,9 +235,7 @@ function PageContents(props: PageContentsProps): JSX.Element { /> - {enableRunTimeParameters ? ( - - ) : null} + diff --git a/app/src/pages/InitialLoadingScreen/__tests__/InitialLoadingScreen.test.tsx b/app/src/pages/InitialLoadingScreen/__tests__/InitialLoadingScreen.test.tsx index 940c7694c54..a7e9076bb63 100644 --- a/app/src/pages/InitialLoadingScreen/__tests__/InitialLoadingScreen.test.tsx +++ b/app/src/pages/InitialLoadingScreen/__tests__/InitialLoadingScreen.test.tsx @@ -2,33 +2,31 @@ import * as React from 'react' import { vi, it, describe, beforeEach, afterEach } from 'vitest' import { screen } from '@testing-library/react' +import { useRobotSettingsQuery } from '@opentrons/react-api-client' + import { renderWithProviders } from '../../../__testing-utils__' -import { getOnDeviceDisplaySettings } from '../../../redux/config' import { getIsShellReady } from '../../../redux/shell' import { InitialLoadingScreen } from '..' -import type { OnDeviceDisplaySettings } from '../../../redux/config/schema-types' +import type { UseQueryResult } from 'react-query' +import type { RobotSettingsResponse } from '@opentrons/api-client' +vi.mock('@opentrons/react-api-client') vi.mock('../../../redux/config') vi.mock('../../../redux/shell') -const mockSettings = { - sleepMs: 60 * 1000 * 60 * 24 * 7, - brightness: 4, - textSize: 1, - unfinishedUnboxingFlowRoute: null, -} as OnDeviceDisplaySettings - const render = () => { return renderWithProviders() } describe('InitialLoadingScreen', () => { beforeEach(() => { - vi.mocked(getOnDeviceDisplaySettings).mockReturnValue(mockSettings) vi.mocked(getIsShellReady).mockReturnValue(false) + vi.mocked(useRobotSettingsQuery).mockReturnValue(({ + data: { settings: [] }, + } as unknown) as UseQueryResult) }) afterEach(() => { diff --git a/app/src/pages/InitialLoadingScreen/index.tsx b/app/src/pages/InitialLoadingScreen/index.tsx index 5171b2720b3..d57519bfa3b 100644 --- a/app/src/pages/InitialLoadingScreen/index.tsx +++ b/app/src/pages/InitialLoadingScreen/index.tsx @@ -1,5 +1,4 @@ import * as React from 'react' -import { Redirect } from 'react-router-dom' import { useSelector } from 'react-redux' import { ALIGN_CENTER, @@ -10,30 +9,23 @@ import { JUSTIFY_CENTER, SPACING, } from '@opentrons/components' -import { getOnDeviceDisplaySettings } from '../../redux/config' +import { useRobotSettingsQuery } from '@opentrons/react-api-client' import { getIsShellReady } from '../../redux/shell' -const getTargetPath = ( - isShellReady: boolean, - unfinishedUnboxingFlowRoute: string | null -): string | null => { - if (!isShellReady) { - return null - } - if (unfinishedUnboxingFlowRoute != null) { - return unfinishedUnboxingFlowRoute - } - - return '/dashboard' -} -export function InitialLoadingScreen(): JSX.Element { - const { unfinishedUnboxingFlowRoute } = useSelector( - getOnDeviceDisplaySettings - ) +export function InitialLoadingScreen({ + children, +}: { + children?: React.ReactNode +}): JSX.Element { const isShellReady = useSelector(getIsShellReady) - const targetPath = getTargetPath(isShellReady, unfinishedUnboxingFlowRoute) - return ( + // ensure robot-server api is up and settings query data available for localization provider + const { settings } = + useRobotSettingsQuery({ retry: true, retryDelay: 1000 }).data ?? {} + + return isShellReady && settings != null ? ( + <>{children} + ) : ( - {targetPath != null && } ) } diff --git a/app/src/pages/Labware/hooks.tsx b/app/src/pages/Labware/hooks.tsx index caf37544be5..b1453738652 100644 --- a/app/src/pages/Labware/hooks.tsx +++ b/app/src/pages/Labware/hooks.tsx @@ -69,7 +69,7 @@ export function useLabwareFailure(): { labwareFailureMessage: string | null clearLabwareFailure: () => unknown } { - const { t } = useTranslation('labware_landing') + const { t } = useTranslation(['labware_landing', 'branded']) const dispatch = useDispatch() const labwareFailure = useSelector(getAddLabwareFailure) @@ -82,7 +82,7 @@ export function useLabwareFailure(): { } else if (failedFile?.type === 'DUPLICATE_LABWARE_FILE') { errorMessage = t('duplicate_labware_def') } else if (failedFile?.type === 'OPENTRONS_LABWARE_FILE') { - errorMessage = t('opentrons_labware_def') + errorMessage = t('branded:opentrons_labware_def') } labwareFailureMessage = failedFile != null diff --git a/app/src/pages/NameRobot/index.tsx b/app/src/pages/NameRobot/index.tsx index 16a868dddb8..3823525ccb4 100644 --- a/app/src/pages/NameRobot/index.tsx +++ b/app/src/pages/NameRobot/index.tsx @@ -18,8 +18,8 @@ import { POSITION_FIXED, POSITION_RELATIVE, SPACING, - TYPOGRAPHY, StyledText, + TYPOGRAPHY, } from '@opentrons/components' import { useUpdateRobotNameMutation } from '@opentrons/react-api-client' @@ -32,7 +32,7 @@ import { } from '../../redux/discovery' import { useTrackEvent, ANALYTICS_RENAME_ROBOT } from '../../redux/analytics' import { InputField } from '../../atoms/InputField' -import { CustomKeyboard } from '../../atoms/SoftwareKeyboard' +import { AlphanumericKeyboard } from '../../atoms/SoftwareKeyboard' import { SmallButton } from '../../atoms/buttons' import { StepMeter } from '../../atoms/StepMeter' import { useIsUnboxingFlowOngoing } from '../../organisms/RobotSettingsDashboard/NetworkSettings/hooks' @@ -121,7 +121,7 @@ export function NameRobot(): JSX.Element { defaultValues: { newRobotName: '', }, - resolver: resolver, + resolver, }) const newRobotName = watch('newRobotName') @@ -295,10 +295,10 @@ export function NameRobot(): JSX.Element { control={control} name="newRobotName" render={({ field }) => ( - { field.onChange(input) - trigger('newRobotName') + void trigger('newRobotName') }} keyboardRef={keyboardRef} /> diff --git a/app/src/pages/NetworkSetupMenu/index.tsx b/app/src/pages/NetworkSetupMenu/index.tsx index 7250eaa3dda..11909bdb77f 100644 --- a/app/src/pages/NetworkSetupMenu/index.tsx +++ b/app/src/pages/NetworkSetupMenu/index.tsx @@ -34,13 +34,13 @@ const NetworkSetupOptions = [ { title: 'usb', iconName: 'usb' as IconName, - description: 'connection_description_usb', + description: 'branded:connection_description_usb', destinationPath: '/network-setup/usb', }, ] export function NetworkSetupMenu(): JSX.Element { - const { t } = useTranslation('device_settings') + const { t } = useTranslation(['device_settings', 'branded']) return ( <> @@ -73,7 +73,7 @@ export function NetworkSetupMenu(): JSX.Element { color={COLORS.grey60} textAlign={TYPOGRAPHY.textAlignCenter} > - {t('network_setup_menu_description')} + {t('branded:network_setup_menu_description')} - {t('send_a_protocol_to_store')} + {t('branded:send_a_protocol_to_store')} ) diff --git a/app/src/pages/ProtocolDashboard/ProtocolCard.tsx b/app/src/pages/ProtocolDashboard/ProtocolCard.tsx index 1ed35b8632f..305ca99c7bc 100644 --- a/app/src/pages/ProtocolDashboard/ProtocolCard.tsx +++ b/app/src/pages/ProtocolDashboard/ProtocolCard.tsx @@ -60,7 +60,7 @@ export function ProtocolCard(props: { showFailedAnalysisModal, setShowFailedAnalysisModal, ] = React.useState(false) - const { t, i18n } = useTranslation('protocol_info') + const { t, i18n } = useTranslation(['protocol_info', 'branded']) const protocolName = protocol.metadata.protocolName ?? protocol.files[0].name const longpress = useLongPress() const queryClient = useQueryClient() @@ -264,7 +264,9 @@ export function ProtocolCard(props: { }} /> - {t('delete_protocol_from_app')} + + {t('branded:delete_protocol_from_app')} + () const [navMenuIsOpened, setNavMenuIsOpened] = React.useState(false) @@ -57,6 +60,11 @@ export function ProtocolDashboard(): JSX.Element { const pinnedProtocolIds = useSelector(getPinnedProtocolIds) ?? [] const pinnedProtocols: ProtocolResource[] = [] + // TODO(sb, 4/15/24): The quick transfer button is going to be moved to a new quick transfer + // tab before the feature is released. Because of this, we're not adding test cov + // for this button in ProtocolDashboard + const enableQuickTransferFF = useFeatureFlag('enableQuickTransfer') + // We only need to grab out the pinned protocol data once all the protocols load // and if we have pinned ids stored in config. if (protocolsData.length > 0 && pinnedProtocolIds.length > 0) { @@ -272,6 +280,15 @@ export function ProtocolDashboard(): JSX.Element { ) : null} + {enableQuickTransferFF && ( + { + history.push('/quick-transfer') + }} + /> + )} ) } diff --git a/app/src/pages/ProtocolDetails/Parameters.tsx b/app/src/pages/ProtocolDetails/Parameters.tsx index 0e12e8d7997..0b280a2af3d 100644 --- a/app/src/pages/ProtocolDetails/Parameters.tsx +++ b/app/src/pages/ProtocolDetails/Parameters.tsx @@ -1,7 +1,11 @@ import * as React from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' -import { formatRunTimeParameterValue } from '@opentrons/shared-data' +import { + formatRunTimeParameterDefaultValue, + formatRunTimeParameterMinMax, + orderRuntimeParameterRangeOptions, +} from '@opentrons/shared-data' import { BORDERS, COLORS, @@ -59,23 +63,22 @@ export const Parameters = (props: { protocolId: string }): JSX.Element => { makeSnackbar(t('start_setup_customize_values')) } - const getRange = (parameter: RunTimeParameter): string => { + const formatRange = (parameter: RunTimeParameter): string => { const { type } = parameter - const min = 'min' in parameter ? parameter.min : 0 - const max = 'max' in parameter ? parameter.max : 0 const numChoices = 'choices' in parameter ? parameter.choices.length : 0 + const minMax = formatRunTimeParameterMinMax(parameter) let range: string | null = null if (numChoices === 2 && 'choices' in parameter) { - range = `${parameter.choices[0].displayName}, ${parameter.choices[1].displayName}` + range = orderRuntimeParameterRangeOptions(parameter.choices) } switch (type) { - case 'boolean': { + case 'bool': { return t('on_off') } case 'float': case 'int': { - return `${min}-${max}` + return minMax } case 'str': { return range ?? t('num_choices', { num: numChoices }) @@ -118,12 +121,12 @@ export const Parameters = (props: { protocolId: string }): JSX.Element => { - {formatRunTimeParameterValue(parameter, t)} + {formatRunTimeParameterDefaultValue(parameter, t)} - {getRange(parameter)} + {formatRange(parameter)} diff --git a/app/src/pages/ProtocolDetails/fixtures.ts b/app/src/pages/ProtocolDetails/fixtures.ts index 4f5cfa6cdad..dd23bc4623e 100644 --- a/app/src/pages/ProtocolDetails/fixtures.ts +++ b/app/src/pages/ProtocolDetails/fixtures.ts @@ -5,7 +5,7 @@ export const mockRunTimeParameterData: RunTimeParameter[] = [ displayName: 'Dry Run', variableName: 'DRYRUN', description: 'a dry run description', - type: 'boolean', + type: 'bool', default: false, value: false, }, @@ -13,15 +13,15 @@ export const mockRunTimeParameterData: RunTimeParameter[] = [ displayName: 'Use Gripper', variableName: 'USE_GRIPPER', description: '', - type: 'boolean', - default: true, + type: 'bool', + default: false, value: true, }, { displayName: 'Trash Tips', variableName: 'TIP_TRASH', description: 'throw tip in trash', - type: 'boolean', + type: 'bool', default: true, value: true, }, @@ -29,7 +29,7 @@ export const mockRunTimeParameterData: RunTimeParameter[] = [ displayName: 'Deactivate Temperatures', variableName: 'DEACTIVATE_TEMP', description: 'deactivate temperature?', - type: 'boolean', + type: 'bool', default: true, value: true, }, diff --git a/app/src/pages/ProtocolDetails/index.tsx b/app/src/pages/ProtocolDetails/index.tsx index a919df19e9d..850fd0a8016 100644 --- a/app/src/pages/ProtocolDetails/index.tsx +++ b/app/src/pages/ProtocolDetails/index.tsx @@ -9,6 +9,7 @@ import { ALIGN_CENTER, BORDERS, Btn, + Chip, COLORS, DIRECTION_COLUMN, DIRECTION_ROW, @@ -31,7 +32,6 @@ import { } from '@opentrons/react-api-client' import { MAXIMUM_PINNED_PROTOCOLS } from '../../App/constants' import { MediumButton, SmallButton, TabbedButton } from '../../atoms/buttons' -import { Chip } from '../../atoms/Chip' import { ProtocolDetailsHeaderChipSkeleton, ProcotolDetailsHeaderTitleSkeleton, @@ -44,7 +44,6 @@ import { getApplyHistoricOffsets, getPinnedProtocolIds, updateConfigValue, - useFeatureFlag, } from '../../redux/config' import { useOffsetCandidatesForAnalysis } from '../../organisms/ApplyHistoricOffsets/hooks/useOffsetCandidatesForAnalysis' import { @@ -189,10 +188,8 @@ const ProtocolSectionTabs = ({ currentOption, setCurrentOption, }: ProtocolSectionTabsProps): JSX.Element => { - const enableRtpFF = useFeatureFlag('enableRunTimeParameters') - const options = enableRtpFF - ? protocolSectionTabOptions - : protocolSectionTabOptionsWithoutParameters + const options = protocolSectionTabOptions + return ( {options.map(option => { @@ -308,7 +305,6 @@ export function ProtocolDetails(): JSX.Element | null { 'protocol_info', 'shared', ]) - const enableRtpFF = useFeatureFlag('enableRunTimeParameters') const { protocolId } = useParams() const { missingProtocolHardware, @@ -326,9 +322,7 @@ export function ProtocolDetails(): JSX.Element | null { const [showParameters, setShowParameters] = React.useState(false) const queryClient = useQueryClient() const [currentOption, setCurrentOption] = React.useState( - enableRtpFF - ? protocolSectionTabOptions[0] - : protocolSectionTabOptionsWithoutParameters[0] + protocolSectionTabOptions[0] ) const [showMaxPinsAlert, setShowMaxPinsAlert] = React.useState(false) @@ -352,13 +346,12 @@ export function ProtocolDetails(): JSX.Element | null { let pinnedProtocolIds = useSelector(getPinnedProtocolIds) ?? [] const pinned = pinnedProtocolIds.includes(protocolId) - const { data: protocolData } = useProtocolQuery(protocolId) const { data: mostRecentAnalysis, } = useProtocolAnalysisAsDocumentQuery( protocolId, - last(protocolData?.data.analysisSummaries)?.id ?? null, - { enabled: protocolData != null } + last(protocolRecord?.data.analysisSummaries)?.id ?? null, + { enabled: protocolRecord != null } ) const shouldApplyOffsets = useSelector(getApplyHistoricOffsets) diff --git a/app/src/pages/ProtocolSetup/__tests__/ProtocolSetup.test.tsx b/app/src/pages/ProtocolSetup/__tests__/ProtocolSetup.test.tsx index b4b5af7e0c8..030e3c1a9a0 100644 --- a/app/src/pages/ProtocolSetup/__tests__/ProtocolSetup.test.tsx +++ b/app/src/pages/ProtocolSetup/__tests__/ProtocolSetup.test.tsx @@ -20,7 +20,7 @@ import { getDeckDefFromRobotType, FLEX_ROBOT_TYPE, STAGING_AREA_RIGHT_SLOT_FIXTURE, - flexDeckDefV4, + flexDeckDefV5, } from '@opentrons/shared-data' import { i18n } from '../../../i18n' @@ -229,14 +229,14 @@ describe('ProtocolSetup', () => { .calledWith(RUN_ID) .thenReturn(CREATED_AT) when(vi.mocked(getProtocolModulesInfo)) - .calledWith(mockEmptyAnalysis, flexDeckDefV4 as any) + .calledWith(mockEmptyAnalysis, flexDeckDefV5 as any) .thenReturn([]) when(vi.mocked(getUnmatchedModulesForProtocol)) .calledWith([], []) .thenReturn({ missingModuleIds: [], remainingAttachedModules: [] }) when(vi.mocked(getDeckDefFromRobotType)) .calledWith('OT-3 Standard') - .thenReturn(flexDeckDefV4 as any) + .thenReturn(flexDeckDefV5 as any) when(vi.mocked(useNotifyRunQuery)) .calledWith(RUN_ID, { staleTime: Infinity }) .thenReturn({ @@ -320,7 +320,7 @@ describe('ProtocolSetup', () => { data: mockRobotSideAnalysis, } as any) when(vi.mocked(getProtocolModulesInfo)) - .calledWith(mockRobotSideAnalysis, flexDeckDefV4 as any) + .calledWith(mockRobotSideAnalysis, flexDeckDefV5 as any) .thenReturn(mockProtocolModuleInfo) when(vi.mocked(getUnmatchedModulesForProtocol)) .calledWith([], mockProtocolModuleInfo) @@ -337,7 +337,7 @@ describe('ProtocolSetup', () => { when(vi.mocked(getProtocolModulesInfo)) .calledWith( { ...mockRobotSideAnalysis, liquids: mockLiquids }, - flexDeckDefV4 as any + flexDeckDefV5 as any ) .thenReturn(mockProtocolModuleInfo) when(vi.mocked(getUnmatchedModulesForProtocol)) @@ -364,7 +364,7 @@ describe('ProtocolSetup', () => { ...mockRobotSideAnalysis, runTimeParameters: mockRunTimeParameterData, }, - flexDeckDefV4 as any + flexDeckDefV5 as any ) .thenReturn(mockProtocolModuleInfo) when(vi.mocked(getUnmatchedModulesForProtocol)) diff --git a/app/src/pages/ProtocolSetup/index.tsx b/app/src/pages/ProtocolSetup/index.tsx index be90fcfa80e..14b871f839c 100644 --- a/app/src/pages/ProtocolSetup/index.tsx +++ b/app/src/pages/ProtocolSetup/index.tsx @@ -69,7 +69,6 @@ import { getProtocolUsesGripper, } from '../../organisms/ProtocolSetupInstruments/utils' import { - useProtocolHasRunTimeParameters, useRunControls, useRunStatus, } from '../../organisms/RunTimeControl/hooks' @@ -82,7 +81,7 @@ import { ANALYTICS_PROTOCOL_RUN_START, useTrackEvent, } from '../../redux/analytics' -import { getIsHeaterShakerAttached, useFeatureFlag } from '../../redux/config' +import { getIsHeaterShakerAttached } from '../../redux/config' import { ConfirmAttachedModal } from './ConfirmAttachedModal' import { getLatestCurrentOffsets } from '../../organisms/Devices/ProtocolRun/SetupLabwarePositionCheck/utils' import { CloseButton, PlayButton } from './Buttons' @@ -257,9 +256,6 @@ function PrepareToRun({ const { t, i18n } = useTranslation(['protocol_setup', 'shared']) const history = useHistory() const { makeSnackbar } = useToaster() - const enableRunTimeParametersFF = useFeatureFlag('enableRunTimeParameters') - const hasRunTimeParameters = useProtocolHasRunTimeParameters(runId) - // Watch for scrolling to toggle dropshadow const scrollRef = React.useRef(null) const [isScrolled, setIsScrolled] = React.useState(false) const observer = new IntersectionObserver(([entry]) => { @@ -366,6 +362,12 @@ function PrepareToRun({ }) const moduleCalibrationStatus = useModuleCalibrationStatus(robotName, runId) + const runTimeParameters = mostRecentAnalysis?.runTimeParameters ?? [] + const hasRunTimeParameters = runTimeParameters.length > 0 + const hasCustomRunTimeParameters = runTimeParameters.some( + parameter => parameter.value !== parameter.default + ) + const [ showConfirmCancelModal, setShowConfirmCancelModal, @@ -623,11 +625,11 @@ function PrepareToRun({ doorStatus?.data.status === 'open' && doorStatus?.data.doorRequiredClosedForProtocol - // TODO(Jr, 3/20/24): wire up custom values - const hasCustomValues = false - const parametersDetail = hasCustomValues - ? t('custom_values') - : t('default_values') + const parametersDetail = hasRunTimeParameters + ? hasCustomRunTimeParameters + ? t('custom_values') + : t('default_values') + : t('no_parameters_specified') return ( <> @@ -730,20 +732,14 @@ function PrepareToRun({ disabled={lpcDisabledReason != null} disabledReason={lpcDisabledReason} /> - {enableRunTimeParametersFF ? ( - setSetupScreen('view only parameters')} - title={t('parameters')} - detail={t( - hasRunTimeParameters - ? parametersDetail - : t('no_parameters_specified') - )} - subDetail={null} - status="general" - disabled={!hasRunTimeParameters} - /> - ) : null} + setSetupScreen('view only parameters')} + title={t('parameters')} + detail={parametersDetail} + subDetail={null} + status="general" + disabled={!hasRunTimeParameters} + /> setSetupScreen('labware')} title={t('labware')} diff --git a/app/src/pages/Protocols/hooks/__tests__/hooks.test.tsx b/app/src/pages/Protocols/hooks/__tests__/hooks.test.tsx index ce09a610ff7..7827c82175f 100644 --- a/app/src/pages/Protocols/hooks/__tests__/hooks.test.tsx +++ b/app/src/pages/Protocols/hooks/__tests__/hooks.test.tsx @@ -38,28 +38,28 @@ const mockRTPData = [ displayName: 'Dry Run', variableName: 'DRYRUN', description: 'a dry run description', - type: 'boolean', + type: 'bool', default: false, }, { displayName: 'Use Gripper', variableName: 'USE_GRIPPER', description: '', - type: 'boolean', + type: 'bool', default: true, }, { displayName: 'Trash Tips', variableName: 'TIP_TRASH', description: 'throw tip in trash', - type: 'boolean', + type: 'bool', default: true, }, { displayName: 'Deactivate Temperatures', variableName: 'DEACTIVATE_TEMP', description: 'deactivate temperature?', - type: 'boolean', + type: 'bool', default: true, }, { @@ -261,7 +261,7 @@ describe('useRequiredProtocolLabware', () => { }) }) -describe('useMissingProtocolHardware', () => { +describe.only('useMissingProtocolHardware', () => { let wrapper: React.FunctionComponent<{ children: React.ReactNode }> beforeEach(() => { vi.mocked(useInstrumentsQuery).mockReturnValue({ @@ -343,14 +343,6 @@ describe('useMissingProtocolHardware', () => { connected: false, hasSlotConflict: true, }, - { - hardwareType: 'fixture', - cutoutFixtureId: 'singleRightSlot', - location: { - cutout: 'cutoutD3', - }, - hasSlotConflict: true, - }, ], conflictedSlots: ['D3'], }) @@ -374,6 +366,21 @@ describe('useMissingProtocolHardware', () => { data: { data: [mockHeaterShaker] }, isLoading: false, } as any) + vi.mocked(useDeckConfigurationQuery).mockReturnValue({ + data: [ + omitBy( + FLEX_SIMPLEST_DECK_CONFIG, + ({ cutoutId }) => cutoutId === 'cutoutD3' + ), + { + cutoutId: 'cutoutD3', + cutoutFixtureId: 'heaterShakerModuleV1', + opentronsModuleSerialNumber: mockHeaterShaker.serialNumber, + }, + ], + isLoading: false, + } as any) + const { result } = renderHook( () => useMissingProtocolHardware(PROTOCOL_ANALYSIS.id), { wrapper } @@ -384,7 +391,7 @@ describe('useMissingProtocolHardware', () => { conflictedSlots: [], }) }) - it('should return conflicting slot when module location is configured with something other than single slot fixture', () => { + it('should return conflicting slot when module location is configured with something other than module fixture', () => { vi.mocked(useInstrumentsQuery).mockReturnValue({ data: { data: [ @@ -425,11 +432,10 @@ describe('useMissingProtocolHardware', () => { expect(result.current).toEqual({ missingProtocolHardware: [ { - hardwareType: 'fixture', - cutoutFixtureId: 'singleRightSlot', - location: { - cutout: 'cutoutD3', - }, + hardwareType: 'module', + moduleModel: 'heaterShakerModuleV1', + slot: 'D3', + connected: false, hasSlotConflict: true, }, ], diff --git a/app/src/pages/Protocols/hooks/index.ts b/app/src/pages/Protocols/hooks/index.ts index 9931a49444f..22e049f4ca8 100644 --- a/app/src/pages/Protocols/hooks/index.ts +++ b/app/src/pages/Protocols/hooks/index.ts @@ -9,10 +9,12 @@ import { import { FLEX_ROBOT_TYPE, FLEX_SINGLE_SLOT_ADDRESSABLE_AREAS, - SINGLE_SLOT_FIXTURES, getCutoutIdForSlotName, getDeckDefFromRobotType, RunTimeParameter, + getCutoutFixtureIdsForModuleModel, + getCutoutFixturesForModuleModel, + FLEX_MODULE_ADDRESSABLE_AREAS, } from '@opentrons/shared-data' import { getLabwareSetupItemGroups } from '../utils' import { getProtocolUsesGripper } from '../../../organisms/ProtocolSetupInstruments/utils' @@ -28,7 +30,6 @@ import type { RobotType, } from '@opentrons/shared-data' import type { LabwareSetupItem } from '../utils' -import type { AttachedModule } from '@opentrons/api-client' interface ProtocolPipette { hardwareType: 'pipette' @@ -105,29 +106,38 @@ export const useRequiredProtocolHardwareFromAnalysis = ( ] : [] - const handleModuleConnectionCheckFor = ( - attachedModules: AttachedModule[], - model: ModuleModel - ): boolean => { - const ASSUME_ALWAYS_CONNECTED_MODULES = ['magneticBlockV1'] - - return !ASSUME_ALWAYS_CONNECTED_MODULES.includes(model) - ? attachedModules.some(m => m.moduleModel === model) - : true - } - const requiredModules: ProtocolModule[] = analysis.modules.map( ({ location, model }) => { + const cutoutIdForSlotName = getCutoutIdForSlotName( + location.slotName, + deckDef + ) + const moduleFixtures = getCutoutFixturesForModuleModel(model, deckDef) + + const configuredModuleSerialNumber = + deckConfig.find( + ({ cutoutId, cutoutFixtureId }) => + cutoutId === cutoutIdForSlotName && + moduleFixtures.map(mf => mf.id).includes(cutoutFixtureId) + )?.opentronsModuleSerialNumber ?? null + const isConnected = moduleFixtures.every( + mf => mf.expectOpentronsModuleSerialNumber + ) + ? attachedModules.some( + m => + m.moduleModel === model && + m.serialNumber === configuredModuleSerialNumber + ) + : true return { hardwareType: 'module', moduleModel: model, slot: location.slotName, - connected: handleModuleConnectionCheckFor(attachedModules, model), + connected: isConnected, hasSlotConflict: deckConfig.some( ({ cutoutId, cutoutFixtureId }) => cutoutId === getCutoutIdForSlotName(location.slotName, deckDef) && - cutoutFixtureId != null && - !SINGLE_SLOT_FIXTURES.includes(cutoutFixtureId) + cutoutFixtureId !== getCutoutFixtureIdsForModuleModel(model)[0] ), } } @@ -161,16 +171,22 @@ export const useRequiredProtocolHardwareFromAnalysis = ( } ) - const requiredFixtures = requiredDeckConfigCompatibility.map( - ({ cutoutFixtureId, cutoutId, compatibleCutoutFixtureIds }) => ({ + const requiredFixtures = requiredDeckConfigCompatibility + // filter out all module fixtures as they're handled in the requiredModules section via hardwareType === 'module' + .filter( + ({ requiredAddressableAreas }) => + !FLEX_MODULE_ADDRESSABLE_AREAS.some(modAA => + requiredAddressableAreas.includes(modAA) + ) + ) + .map(({ cutoutFixtureId, cutoutId, compatibleCutoutFixtureIds }) => ({ hardwareType: 'fixture' as const, cutoutFixtureId: compatibleCutoutFixtureIds[0], location: { cutout: cutoutId }, hasSlotConflict: cutoutFixtureId != null && !compatibleCutoutFixtureIds.includes(cutoutFixtureId), - }) - ) + })) return { requiredProtocolHardware: [ @@ -200,150 +216,7 @@ export const useRunTimeParameters = ( { enabled: protocolData != null } ) - const mockData: RunTimeParameter[] = [ - { - value: false, - displayName: 'Dry Run', - variableName: 'DRYRUN', - description: 'Is this a dry or wet run? Wet is true, dry is false', - type: 'boolean', - default: false, - }, - { - value: true, - displayName: 'Use Gripper', - variableName: 'USE_GRIPPER', - description: 'For using the gripper.', - type: 'boolean', - default: true, - }, - { - value: true, - displayName: 'Trash Tips', - variableName: 'TIP_TRASH', - description: - 'to throw tip into the trash or to not throw tip into the trash', - type: 'boolean', - default: true, - }, - { - value: true, - displayName: 'Deactivate Temperatures', - variableName: 'DEACTIVATE_TEMP', - description: 'deactivate temperature on the module', - type: 'boolean', - default: true, - }, - { - value: 4, - displayName: 'Columns of Samples', - variableName: 'COLUMNS', - description: 'How many columns do you want?', - type: 'int', - min: 1, - max: 14, - default: 4, - }, - { - value: 6, - displayName: 'PCR Cycles', - variableName: 'PCR_CYCLES', - description: 'number of PCR cycles on a thermocycler', - type: 'int', - min: 1, - max: 10, - default: 6, - }, - { - value: 6.5, - displayName: 'EtoH Volume', - variableName: 'ETOH_VOLUME', - description: '70% ethanol volume', - type: 'float', - suffix: 'mL', - min: 1.5, - max: 10.0, - default: 6.5, - }, - { - value: 'none', - displayName: 'Default Module Offsets', - variableName: 'DEFAULT_OFFSETS', - description: 'default module offsets for temp, H-S, and none', - type: 'str', - choices: [ - { - displayName: 'No offsets', - value: 'none', - }, - { - displayName: 'temp offset', - value: '1', - }, - { - displayName: 'heater-shaker offset', - value: '2', - }, - ], - default: 'none', - }, - { - value: 'left', - displayName: 'pipette mount', - variableName: 'mont', - description: 'pipette mount', - type: 'str', - choices: [ - { - displayName: 'Left', - value: 'left', - }, - { - displayName: 'Right', - value: 'right', - }, - ], - default: 'left', - }, - { - value: 'flex', - displayName: 'short test case', - variableName: 'short 2 options', - description: 'this play 2 short options', - type: 'str', - choices: [ - { - displayName: 'OT-2', - value: 'ot2', - }, - { - displayName: 'Flex', - value: 'flex', - }, - ], - default: 'flex', - }, - { - value: 'flex', - displayName: 'long test case', - variableName: 'long 2 options', - description: 'this play 2 long options', - type: 'str', - choices: [ - { - displayName: 'I am kind of long text version', - value: 'ot2', - }, - { - displayName: 'I am kind of long text version. Today is 3/15', - value: 'flex', - }, - ], - default: 'flex', - }, - ] - // TODO(jr, 3/14/24): remove the mockData - return analysis?.runTimeParameters ?? mockData + return analysis?.runTimeParameters ?? [] } /** @@ -421,9 +294,16 @@ const useMissingProtocolHardwareFromRequiredProtocolHardware = ( ), ...deckConfigCompatibility .filter( - ({ cutoutFixtureId, compatibleCutoutFixtureIds }) => + ({ + cutoutFixtureId, + compatibleCutoutFixtureIds, + requiredAddressableAreas, + }) => cutoutFixtureId != null && - !compatibleCutoutFixtureIds.some(id => id === cutoutFixtureId) + !compatibleCutoutFixtureIds.some(id => id === cutoutFixtureId) && + !FLEX_MODULE_ADDRESSABLE_AREAS.some(modAA => + requiredAddressableAreas.includes(modAA) + ) // modules are already included via requiredProtocolHardware ) .map(({ compatibleCutoutFixtureIds, cutoutId }) => ({ hardwareType: 'fixture' as const, diff --git a/app/src/pages/RobotDashboard/AnalyticsOptInModal.tsx b/app/src/pages/RobotDashboard/AnalyticsOptInModal.tsx index e7772654a38..4073a356aee 100644 --- a/app/src/pages/RobotDashboard/AnalyticsOptInModal.tsx +++ b/app/src/pages/RobotDashboard/AnalyticsOptInModal.tsx @@ -28,7 +28,7 @@ interface AnalyticsOptInModalProps { export function AnalyticsOptInModal({ setShowAnalyticsOptInModal, }: AnalyticsOptInModalProps): JSX.Element { - const { t } = useTranslation(['app_settings', 'shared']) + const { t } = useTranslation(['app_settings', 'shared', 'branded']) const dispatch = useDispatch() const localRobot = useSelector(getLocalRobot) @@ -57,7 +57,7 @@ export function AnalyticsOptInModal({ } return ( - + () const localRobot = useSelector(getLocalRobot) const robotName = localRobot?.name != null ? localRobot.name : 'no name' @@ -144,7 +148,7 @@ export function RobotSettingsList(props: RobotSettingsListProps): JSX.Element { setCurrentOption('Privacy')} iconName="privacy" /> diff --git a/app/src/pages/RunSummary/index.tsx b/app/src/pages/RunSummary/index.tsx index e76a73ce1b9..7666cc8ada6 100644 --- a/app/src/pages/RunSummary/index.tsx +++ b/app/src/pages/RunSummary/index.tsx @@ -57,6 +57,7 @@ import { // ANALYTICS_PROTOCOL_RUN_CANCEL, ANALYTICS_PROTOCOL_RUN_AGAIN, ANALYTICS_PROTOCOL_RUN_FINISH, + ANALYTICS_PROTOCOL_PROCEED_TO_RUN, } from '../../redux/analytics' import { getLocalRobot } from '../../redux/discovery' import { RunFailedModal } from '../../organisms/OnDeviceDisplay/RunningProtocol' @@ -124,6 +125,10 @@ export function RunSummary(): JSX.Element { const [showRunAgainSpinner, setShowRunAgainSpinner] = React.useState( false ) + const robotSerialNumber = + localRobot?.health?.robot_serial ?? + localRobot?.serverHealth?.serialNumber ?? + null let headerText = t('run_complete_splash') if (runStatus === RUN_STATUS_FAILED) { @@ -167,8 +172,8 @@ export function RunSummary(): JSX.Element { setShowRunAgainSpinner(true) reset() trackEvent({ - name: 'proceedToRun', - properties: { sourceLocation: 'RunSummary' }, + name: ANALYTICS_PROTOCOL_PROCEED_TO_RUN, + properties: { sourceLocation: 'RunSummary', robotSerialNumber }, }) trackProtocolRunEvent({ name: ANALYTICS_PROTOCOL_RUN_AGAIN }) } diff --git a/app/src/pages/RunningProtocol/__tests__/RunningProtocol.test.tsx b/app/src/pages/RunningProtocol/__tests__/RunningProtocol.test.tsx index 32f87a8047c..2b43991a88f 100644 --- a/app/src/pages/RunningProtocol/__tests__/RunningProtocol.test.tsx +++ b/app/src/pages/RunningProtocol/__tests__/RunningProtocol.test.tsx @@ -7,6 +7,7 @@ import { RUN_STATUS_BLOCKED_BY_OPEN_DOOR, RUN_STATUS_IDLE, RUN_STATUS_STOP_REQUESTED, + RUN_STATUS_AWAITING_RECOVERY, } from '@opentrons/api-client' import { useAllCommandsQuery, @@ -30,12 +31,14 @@ import { getLocalRobot } from '../../../redux/discovery' import { CancelingRunModal } from '../../../organisms/OnDeviceDisplay/RunningProtocol/CancelingRunModal' import { useTrackProtocolRunEvent } from '../../../organisms/Devices/hooks' import { useMostRecentCompletedAnalysis } from '../../../organisms/LabwarePositionCheck/useMostRecentCompletedAnalysis' +import { RunPausedSplash } from '../../../organisms/OnDeviceDisplay/RunningProtocol/RunPausedSplash' import { OpenDoorAlertModal } from '../../../organisms/OpenDoorAlertModal' import { RunningProtocol } from '..' import { useNotifyLastRunCommandKey, useNotifyRunQuery, } from '../../../resources/runs' +import { useFeatureFlag } from '../../../redux/config' import type { UseQueryResult } from 'react-query' import type { ProtocolAnalyses } from '@opentrons/api-client' @@ -47,12 +50,15 @@ vi.mock('../../../organisms/RunTimeControl/hooks') vi.mock( '../../../organisms/LabwarePositionCheck/useMostRecentCompletedAnalysis' ) +vi.mock('../../../organisms/OnDeviceDisplay/RunningProtocol/RunPausedSplash') vi.mock('../../../organisms/RunTimeControl/hooks') vi.mock('../../../organisms/OnDeviceDisplay/RunningProtocol') vi.mock('../../../redux/discovery') vi.mock('../../../organisms/OnDeviceDisplay/RunningProtocol/CancelingRunModal') vi.mock('../../../organisms/OpenDoorAlertModal') vi.mock('../../../resources/runs') +vi.mock('../../../redux/config') + const RUN_ID = 'run_id' const ROBOT_NAME = 'otie' const PROTOCOL_ID = 'protocol_id' @@ -85,6 +91,7 @@ describe('RunningProtocol', () => { data: { id: RUN_ID, protocolId: PROTOCOL_ID, + errors: [], }, }, } as any) @@ -133,6 +140,9 @@ describe('RunningProtocol', () => { vi.mocked(useNotifyLastRunCommandKey).mockReturnValue({ data: {}, } as any) + when(vi.mocked(useFeatureFlag)) + .calledWith('enableRunNotes') + .thenReturn(true) }) afterEach(() => { @@ -166,6 +176,14 @@ describe('RunningProtocol', () => { expect(vi.mocked(OpenDoorAlertModal)).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) + render(`/runs/${RUN_ID}/run`) + expect(vi.mocked(RunPausedSplash)).toHaveBeenCalled() + }) + // ToDo (kj:04/04/2023) need to figure out the way to simulate swipe it.todo('should render RunningProtocolCommandList when swiping left') // const [{ getByText }] = render(`/runs/${RUN_ID}/run`) diff --git a/app/src/pages/RunningProtocol/index.tsx b/app/src/pages/RunningProtocol/index.tsx index 2fc56806679..3ebe3b1c0ab 100644 --- a/app/src/pages/RunningProtocol/index.tsx +++ b/app/src/pages/RunningProtocol/index.tsx @@ -25,8 +25,10 @@ import { import { RUN_STATUS_STOP_REQUESTED, RUN_STATUS_BLOCKED_BY_OPEN_DOOR, + RUN_STATUS_AWAITING_RECOVERY, } from '@opentrons/api-client' +import { useFeatureFlag } from '../../redux/config' import { StepMeter } from '../../atoms/StepMeter' import { useMostRecentCompletedAnalysis } from '../../organisms/LabwarePositionCheck/useMostRecentCompletedAnalysis' import { @@ -51,6 +53,7 @@ import { } from '../../organisms/Devices/hooks' import { CancelingRunModal } from '../../organisms/OnDeviceDisplay/RunningProtocol/CancelingRunModal' import { ConfirmCancelRunModal } from '../../organisms/OnDeviceDisplay/RunningProtocol/ConfirmCancelRunModal' +import { RunPausedSplash } from '../../organisms/OnDeviceDisplay/RunningProtocol/RunPausedSplash' import { getLocalRobot } from '../../redux/discovery' import { OpenDoorAlertModal } from '../../organisms/OpenDoorAlertModal' @@ -102,6 +105,7 @@ export function RunningProtocol(): JSX.Element { const runStatus = useRunStatus(runId, { refetchInterval: RUN_STATUS_REFETCH_INTERVAL, }) + const [enableSplash, setEnableSplash] = React.useState(true) const { startedAt, stoppedAt, completedAt } = useRunTimestamps(runId) const { data: runRecord } = useNotifyRunQuery(runId, { staleTime: Infinity }) const protocolId = runRecord?.data.protocolId ?? null @@ -117,6 +121,10 @@ export function RunningProtocol(): JSX.Element { const { trackProtocolRunEvent } = useTrackProtocolRunEvent(runId, robotName) const robotAnalyticsData = useRobotAnalyticsData(robotName) const robotType = useRobotType(robotName) + const errorType = runRecord?.data.errors[0]?.errorType + + const enableRunNotes = useFeatureFlag('enableRunNotes') + React.useEffect(() => { if ( currentOption === 'CurrentRunningProtocolCommand' && @@ -160,114 +168,137 @@ export function RunningProtocol(): JSX.Element { return ( <> - {runStatus === RUN_STATUS_BLOCKED_BY_OPEN_DOOR ? ( - - ) : null} - {runStatus === RUN_STATUS_STOP_REQUESTED ? : null} - - {robotSideAnalysis != null ? ( - - ) : null} - {showConfirmCancelRunModal ? ( - - ) : null} - {interventionModalCommandKey != null && - runRecord?.data != null && - lastRunCommand != null && - isInterventionCommand(lastRunCommand) ? ( - - ) : null} - - {robotSideAnalysis != null ? ( - currentOption === 'CurrentRunningProtocolCommand' ? ( - - (lastAnimatedCommand.current = newCommandKey) + {enableSplash && + runStatus === RUN_STATUS_AWAITING_RECOVERY && + enableRunNotes ? ( + setEnableSplash(false)} + errorType={errorType} + protocolName={protocolName} + /> + ) : ( + <> + {runStatus === RUN_STATUS_BLOCKED_BY_OPEN_DOOR ? ( + + ) : null} + {runStatus === RUN_STATUS_STOP_REQUESTED ? ( + + ) : null} + + {robotSideAnalysis != null ? ( + - ) : ( - <> - + ) : null} + {interventionModalCommandKey != null && + runRecord?.data != null && + lastRunCommand != null && + isInterventionCommand(lastRunCommand) ? ( + + ) : null} + + {robotSideAnalysis != null ? ( + currentOption === 'CurrentRunningProtocolCommand' ? ( + + (lastAnimatedCommand.current = newCommandKey) + } + /> + ) : ( + <> + + + + ) + ) : ( + + )} + + - - - ) - ) : ( - - )} - - - + + - - + + )} ) } diff --git a/app/src/pages/Welcome/index.tsx b/app/src/pages/Welcome/index.tsx index 47bb9cbcb50..f5c1ac686bd 100644 --- a/app/src/pages/Welcome/index.tsx +++ b/app/src/pages/Welcome/index.tsx @@ -17,7 +17,7 @@ import screenImage from '../../assets/images/on-device-display/welcome_backgroun const IMAGE_ALT = 'Welcome screen background image' export function Welcome(): JSX.Element { - const { t } = useTranslation(['device_settings', 'shared']) + const { t } = useTranslation(['device_settings', 'shared', 'branded']) const history = useHistory() return ( @@ -30,7 +30,7 @@ export function Welcome(): JSX.Element { {IMAGE_ALT} - {t('welcome_title')} + {t('branded:welcome_title')} diff --git a/app/src/redux/config/constants.ts b/app/src/redux/config/constants.ts index 1dc64fea2f4..5a72622f98e 100644 --- a/app/src/redux/config/constants.ts +++ b/app/src/redux/config/constants.ts @@ -2,7 +2,6 @@ import type { DevInternalFlag } from './types' export const DEV_INTERNAL_FLAGS: DevInternalFlag[] = [ 'protocolStats', - 'enableRunTimeParameters', 'enableRunNotes', 'enableQuickTransfer', ] diff --git a/app/src/redux/config/schema-types.ts b/app/src/redux/config/schema-types.ts index e69186f5f07..5728a2e4eb1 100644 --- a/app/src/redux/config/schema-types.ts +++ b/app/src/redux/config/schema-types.ts @@ -9,7 +9,6 @@ export type DiscoveryCandidates = string[] export type DevInternalFlag = | 'protocolStats' - | 'enableRunTimeParameters' | 'enableRunNotes' | 'enableQuickTransfer' diff --git a/app/src/redux/discovery/__fixtures__/index.ts b/app/src/redux/discovery/__fixtures__/index.ts index 329e18504dd..ea7a4e0f195 100644 --- a/app/src/redux/discovery/__fixtures__/index.ts +++ b/app/src/redux/discovery/__fixtures__/index.ts @@ -18,6 +18,7 @@ export const mockHealthResponse = { api_version: '0.0.0-mock', fw_version: '0.0.0-mock', system_version: '0.0.0-mock', + robot_serial: 'mock-serial', logs: [] as string[], protocol_api_version: [2, 0] as [number, number], } diff --git a/app/src/redux/protocol-storage/__fixtures__/index.ts b/app/src/redux/protocol-storage/__fixtures__/index.ts index 12e350efb38..be5500203a2 100644 --- a/app/src/redux/protocol-storage/__fixtures__/index.ts +++ b/app/src/redux/protocol-storage/__fixtures__/index.ts @@ -1,5 +1,5 @@ import { simpleAnalysisFileFixture } from '@opentrons/api-client' -import { StoredProtocolData, StoredProtocolDir } from '../types' +import type { StoredProtocolData, StoredProtocolDir } from '../types' import type { ProtocolAnalysisOutput } from '@opentrons/shared-data' @@ -11,6 +11,17 @@ export const storedProtocolData: StoredProtocolData = { modified: 123456789, } +export const storedProtocolDataWithoutRunTimeParameters: StoredProtocolData = { + protocolKey: 'protocolKeyStub', + mostRecentAnalysis: ({ + ...simpleAnalysisFileFixture, + runTimeParameters: [], + } as any) as ProtocolAnalysisOutput, + srcFileNames: ['fakeSrcFileName'], + srcFiles: ['fakeSrcFile' as any], + modified: 123456789, +} + export const storedProtocolDir: StoredProtocolDir = { dirPath: 'path/to/protocol/dir', modified: 1234556789, diff --git a/app/src/redux/robot-settings/types.ts b/app/src/redux/robot-settings/types.ts index 5571be6a441..3f998311c46 100644 --- a/app/src/redux/robot-settings/types.ts +++ b/app/src/redux/robot-settings/types.ts @@ -1,20 +1,10 @@ +import type { + RobotSettings, + RobotSettingsField, + RobotSettingsResponse, +} from '@opentrons/api-client' import type { RobotApiRequestMeta } from '../robot-api/types' -export interface RobotSettingsField { - id: string - title: string - description: string - value: boolean | null - restart_required?: boolean -} - -export type RobotSettings = RobotSettingsField[] - -export interface RobotSettingsResponse { - settings: RobotSettings - links?: { restart?: string } -} - export interface PerRobotRobotSettingsState { settings: RobotSettings restartPath: string | null @@ -94,3 +84,6 @@ export type RobotSettingsAction = | UpdateSettingAction | UpdateSettingSuccessAction | UpdateSettingFailureAction + +// TODO(bh, 2024-03-26): update type imports elsewhere to @opentrons/api-client +export type { RobotSettings, RobotSettingsField, RobotSettingsResponse } diff --git a/app/src/redux/shell/index.ts b/app/src/redux/shell/index.ts index a709a770d7f..5a918f75eb3 100644 --- a/app/src/redux/shell/index.ts +++ b/app/src/redux/shell/index.ts @@ -5,4 +5,4 @@ export * from './update' export * from './is-ready/actions' export * from './is-ready/selectors' -export const CURRENT_VERSION: string = (global as any)._PKG_VERSION_ +export const CURRENT_VERSION: string = _PKG_VERSION_ diff --git a/app/src/redux/shell/types.ts b/app/src/redux/shell/types.ts index 1a4cb343d64..d83cee94b15 100644 --- a/app/src/redux/shell/types.ts +++ b/app/src/redux/shell/types.ts @@ -20,7 +20,7 @@ export type IpcListener = ( ) => void export interface NotifyRefetchData { - refetchUsingHTTP: boolean + refetch: boolean } export interface NotifyUnsubscribeData { diff --git a/app/src/resources/__tests__/useNotifyService.test.ts b/app/src/resources/__tests__/useNotifyService.test.ts index 1e2ba78c744..fdb531ab1cd 100644 --- a/app/src/resources/__tests__/useNotifyService.test.ts +++ b/app/src/resources/__tests__/useNotifyService.test.ts @@ -53,7 +53,7 @@ describe('useNotifyService', () => { renderHook(() => useNotifyService({ topic: MOCK_TOPIC, - setRefetchUsingHTTP: mockHTTPRefetch, + setRefetch: mockHTTPRefetch, options: MOCK_OPTIONS, } as any) ) @@ -68,7 +68,7 @@ describe('useNotifyService', () => { renderHook(() => useNotifyService({ topic: MOCK_TOPIC, - setRefetchUsingHTTP: mockHTTPRefetch, + setRefetch: mockHTTPRefetch, options: { ...MOCK_OPTIONS, forceHttpPolling: true }, } as any) ) @@ -81,7 +81,7 @@ describe('useNotifyService', () => { renderHook(() => useNotifyService({ topic: MOCK_TOPIC, - setRefetchUsingHTTP: mockHTTPRefetch, + setRefetch: mockHTTPRefetch, options: { ...MOCK_OPTIONS, enabled: false }, } as any) ) @@ -94,7 +94,7 @@ describe('useNotifyService', () => { renderHook(() => useNotifyService({ topic: MOCK_TOPIC, - setRefetchUsingHTTP: mockHTTPRefetch, + setRefetch: mockHTTPRefetch, options: { ...MOCK_OPTIONS, staleTime: Infinity }, } as any) ) @@ -111,7 +111,7 @@ describe('useNotifyService', () => { renderHook(() => useNotifyService({ topic: MOCK_TOPIC, - setRefetchUsingHTTP: mockHTTPRefetch, + setRefetch: mockHTTPRefetch, options: MOCK_OPTIONS, } as any) ) @@ -128,7 +128,7 @@ describe('useNotifyService', () => { const { rerender } = renderHook(() => useNotifyService({ topic: MOCK_TOPIC, - setRefetchUsingHTTP: mockHTTPRefetch, + setRefetch: mockHTTPRefetch, options: MOCK_OPTIONS, } as any) ) @@ -142,12 +142,12 @@ describe('useNotifyService', () => { callback, }): any { // eslint-disable-next-line n/no-callback-literal - callback({ refetchUsingHTTP: true }) + callback({ refetch: true }) }) const { rerender } = renderHook(() => useNotifyService({ topic: MOCK_TOPIC, - setRefetchUsingHTTP: mockHTTPRefetch, + setRefetch: mockHTTPRefetch, options: MOCK_OPTIONS, } as any) ) @@ -165,7 +165,7 @@ describe('useNotifyService', () => { const { rerender } = renderHook(() => useNotifyService({ topic: MOCK_TOPIC, - setRefetchUsingHTTP: mockHTTPRefetch, + setRefetch: mockHTTPRefetch, options: MOCK_OPTIONS, } as any) ) @@ -177,11 +177,27 @@ describe('useNotifyService', () => { const { unmount } = renderHook(() => useNotifyService({ topic: MOCK_TOPIC, - setRefetchUsingHTTP: mockHTTPRefetch, + setRefetch: mockHTTPRefetch, options: MOCK_OPTIONS, }) ) unmount() expect(appShellListener).toHaveBeenCalled() }) + + it('should still clean up the listener if the hostname changes to null after subscribing', () => { + const { unmount, rerender } = renderHook(() => + useNotifyService({ + hostOverride: MOCK_HOST_CONFIG, + topic: MOCK_TOPIC, + setRefetch: mockHTTPRefetch, + options: MOCK_OPTIONS, + }) + ) + rerender({ hostOverride: null }) + unmount() + expect(appShellListener).toHaveBeenCalledWith( + expect.objectContaining({ hostname: MOCK_HOST_CONFIG.hostname }) + ) + }) }) diff --git a/app/src/resources/deck_configuration/hooks.ts b/app/src/resources/deck_configuration/hooks.ts index 95b92e9f7dc..beae36d9821 100644 --- a/app/src/resources/deck_configuration/hooks.ts +++ b/app/src/resources/deck_configuration/hooks.ts @@ -7,13 +7,9 @@ import { getCutoutIdForAddressableArea, getDeckDefFromRobotType, getLabwareDisplayName, - SINGLE_LEFT_SLOT_FIXTURE, SINGLE_SLOT_FIXTURES, - THERMOCYCLER_MODULE_TYPE, } from '@opentrons/shared-data' -import { getProtocolModulesInfo } from '../../organisms/Devices/ProtocolRun/utils/getProtocolModulesInfo' - import type { CompletedProtocolAnalysis, CutoutConfigProtocolSpec, @@ -47,17 +43,6 @@ export function useDeckConfigurationCompatibility( ? getInitialAndMovedLabwareInSlots(protocolAnalysis) : [] - const protocolModulesInfo = - protocolAnalysis != null - ? getProtocolModulesInfo(protocolAnalysis, deckDef) - : [] - - const hasThermocycler = - protocolModulesInfo.find( - protocolMod => - protocolMod.moduleDef.moduleType === THERMOCYCLER_MODULE_TYPE - ) != null - return deckConfig.reduce( (acc, { cutoutId, cutoutFixtureId }) => { const fixturesThatMountToCutoutId = getCutoutFixturesForCutoutId( @@ -69,7 +54,6 @@ export function useDeckConfigurationCompatibility( getCutoutIdForAddressableArea(aa, fixturesThatMountToCutoutId) === cutoutId ) - const compatibleCutoutFixtureIds = fixturesThatMountToCutoutId .filter(cf => requiredAddressableAreasForCutoutId.every(aa => @@ -106,11 +90,7 @@ export function useDeckConfigurationCompatibility( cutoutId, cutoutFixtureId, requiredAddressableAreas: requiredAddressableAreasForCutoutId, - // Thermocycler requires an "empty" (single slot) fixture in A1 that is not referenced directly in protocol - compatibleCutoutFixtureIds: - hasThermocycler && cutoutId === 'cutoutA1' - ? [SINGLE_LEFT_SLOT_FIXTURE] - : compatibleCutoutFixtureIds, + compatibleCutoutFixtureIds, missingLabwareDisplayName, }, ] diff --git a/app/src/resources/deck_configuration/utils.ts b/app/src/resources/deck_configuration/utils.ts index 9efaeea3a57..5306b967d4b 100644 --- a/app/src/resources/deck_configuration/utils.ts +++ b/app/src/resources/deck_configuration/utils.ts @@ -25,8 +25,9 @@ export function getIsFixtureMismatch( deckConfigProtocolSpec: CutoutConfigAndCompatibility[] ): boolean { const isFixtureMismatch = !deckConfigProtocolSpec.every( - ({ cutoutFixtureId, compatibleCutoutFixtureIds }) => - isMatchedFixture(cutoutFixtureId, compatibleCutoutFixtureIds) + ({ cutoutFixtureId, compatibleCutoutFixtureIds }) => { + return isMatchedFixture(cutoutFixtureId, compatibleCutoutFixtureIds) + } ) return isFixtureMismatch } diff --git a/app/src/resources/maintenance_runs/useNotifyCurrentMaintenanceRun.ts b/app/src/resources/maintenance_runs/useNotifyCurrentMaintenanceRun.ts index 2692e032d6a..28859afe393 100644 --- a/app/src/resources/maintenance_runs/useNotifyCurrentMaintenanceRun.ts +++ b/app/src/resources/maintenance_runs/useNotifyCurrentMaintenanceRun.ts @@ -14,24 +14,18 @@ import type { export function useNotifyCurrentMaintenanceRun( options: QueryOptionsWithPolling = {} ): UseQueryResult | UseQueryResult { - const [ - refetchUsingHTTP, - setRefetchUsingHTTP, - ] = React.useState(null) + const [refetch, setRefetch] = React.useState(null) useNotifyService({ topic: 'robot-server/maintenance_runs/current_run', - setRefetchUsingHTTP, + setRefetch, options, }) const httpQueryResult = useCurrentMaintenanceRun({ ...options, - enabled: options?.enabled !== false && refetchUsingHTTP != null, - onSettled: - refetchUsingHTTP === 'once' - ? () => setRefetchUsingHTTP(null) - : () => null, + enabled: options?.enabled !== false && refetch != null, + onSettled: refetch === 'once' ? () => setRefetch(null) : () => null, }) return httpQueryResult diff --git a/app/src/resources/runs/useNotifyAllRunsQuery.ts b/app/src/resources/runs/useNotifyAllRunsQuery.ts index 690d7a4ac11..1ae93ffc713 100644 --- a/app/src/resources/runs/useNotifyAllRunsQuery.ts +++ b/app/src/resources/runs/useNotifyAllRunsQuery.ts @@ -18,14 +18,11 @@ export function useNotifyAllRunsQuery( options: QueryOptionsWithPolling = {}, hostOverride?: HostConfig | null ): UseQueryResult { - const [ - refetchUsingHTTP, - setRefetchUsingHTTP, - ] = React.useState(null) + const [refetch, setRefetch] = React.useState(null) useNotifyService({ topic: 'robot-server/runs', - setRefetchUsingHTTP, + setRefetch, options, hostOverride, }) @@ -34,11 +31,8 @@ export function useNotifyAllRunsQuery( params, { ...(options as UseAllRunsQueryOptions), - enabled: options?.enabled !== false && refetchUsingHTTP != null, - onSettled: - refetchUsingHTTP === 'once' - ? () => setRefetchUsingHTTP(null) - : () => null, + enabled: options?.enabled !== false && refetch != null, + onSettled: refetch === 'once' ? () => setRefetch(null) : () => null, }, hostOverride ) diff --git a/app/src/resources/runs/useNotifyLastRunCommandKey.ts b/app/src/resources/runs/useNotifyLastRunCommandKey.ts index 8600c4d66b6..9c908a70749 100644 --- a/app/src/resources/runs/useNotifyLastRunCommandKey.ts +++ b/app/src/resources/runs/useNotifyLastRunCommandKey.ts @@ -13,24 +13,18 @@ export function useNotifyLastRunCommandKey( runId: string, options: QueryOptionsWithPolling = {} ): string | null { - const [ - refetchUsingHTTP, - setRefetchUsingHTTP, - ] = React.useState(null) + const [refetch, setRefetch] = React.useState(null) useNotifyService({ topic: 'robot-server/runs/current_command', - setRefetchUsingHTTP, + setRefetch, options, }) const httpResponse = useLastRunCommandKey(runId, { ...options, - enabled: options?.enabled !== false && refetchUsingHTTP != null, - onSettled: - refetchUsingHTTP === 'once' - ? () => setRefetchUsingHTTP(null) - : () => null, + enabled: options?.enabled !== false && refetch != null, + onSettled: refetch === 'once' ? () => setRefetch(null) : () => null, }) return httpResponse diff --git a/app/src/resources/runs/useNotifyRunQuery.ts b/app/src/resources/runs/useNotifyRunQuery.ts index dde7bc84448..2ca72687341 100644 --- a/app/src/resources/runs/useNotifyRunQuery.ts +++ b/app/src/resources/runs/useNotifyRunQuery.ts @@ -16,26 +16,20 @@ export function useNotifyRunQuery( runId: string | null, options: QueryOptionsWithPolling = {} ): UseQueryResult { - const [ - refetchUsingHTTP, - setRefetchUsingHTTP, - ] = React.useState(null) + const [refetch, setRefetch] = React.useState(null) const isEnabled = options.enabled !== false && runId != null useNotifyService({ topic: `robot-server/runs/${runId}` as NotifyTopic, - setRefetchUsingHTTP, + setRefetch, options: { ...options, enabled: options.enabled != null && runId != null }, }) const httpResponse = useRunQuery(runId, { ...options, - enabled: isEnabled && refetchUsingHTTP != null, - onSettled: - refetchUsingHTTP === 'once' - ? () => setRefetchUsingHTTP(null) - : () => null, + enabled: isEnabled && refetch != null, + onSettled: refetch === 'once' ? () => setRefetch(null) : () => null, }) return httpResponse diff --git a/app/src/resources/useNotifyService.ts b/app/src/resources/useNotifyService.ts index f6cfaefa2b8..19831dc9c62 100644 --- a/app/src/resources/useNotifyService.ts +++ b/app/src/resources/useNotifyService.ts @@ -25,14 +25,14 @@ export interface QueryOptionsWithPolling interface UseNotifyServiceProps { topic: NotifyTopic - setRefetchUsingHTTP: (refetch: HTTPRefetchFrequency) => void + setRefetch: (refetch: HTTPRefetchFrequency) => void options: QueryOptionsWithPolling hostOverride?: HostConfig | null } export function useNotifyService({ topic, - setRefetchUsingHTTP, + setRefetch, options, hostOverride, }: UseNotifyServiceProps): void { @@ -42,7 +42,7 @@ export function useNotifyService({ const hostname = host?.hostname ?? null const doTrackEvent = useTrackEvent() const isFlex = useIsFlex(host?.robotName ?? '') - const hasUsedNotifyService = React.useRef(false) + const seenHostname = React.useRef(null) const { enabled, staleTime, forceHttpPolling } = options const shouldUseNotifications = @@ -54,33 +54,33 @@ export function useNotifyService({ React.useEffect(() => { if (shouldUseNotifications) { // Always fetch on initial mount. - setRefetchUsingHTTP('once') + setRefetch('once') appShellListener({ hostname, topic, callback: onDataEvent, }) dispatch(notifySubscribeAction(hostname, topic)) - hasUsedNotifyService.current = true + seenHostname.current = hostname } else { - setRefetchUsingHTTP('always') + setRefetch('always') } return () => { - if (hasUsedNotifyService.current) { + if (seenHostname.current != null) { appShellListener({ - hostname: hostname as string, + hostname: seenHostname.current, topic, callback: onDataEvent, isDismounting: true, }) } } - }, [topic, host, shouldUseNotifications]) + }, [topic, hostname, shouldUseNotifications]) function onDataEvent(data: NotifyResponseData): void { if (data === 'ECONNFAILED' || data === 'ECONNREFUSED') { - setRefetchUsingHTTP('always') + setRefetch('always') // TODO(jh 2023-02-23): remove the robot type check once OT-2s support MQTT. if (data === 'ECONNREFUSED' && isFlex) { doTrackEvent({ @@ -88,8 +88,8 @@ export function useNotifyService({ properties: {}, }) } - } else if ('refetchUsingHTTP' in data || 'unsubscribe' in data) { - setRefetchUsingHTTP('once') + } else if ('refetch' in data || 'unsubscribe' in data) { + setRefetch('once') } } } diff --git a/app/src/styles.global.module.css b/app/src/styles.global.module.css index 9cdcb703387..2247749b91b 100644 --- a/app/src/styles.global.module.css +++ b/app/src/styles.global.module.css @@ -3,6 +3,7 @@ */ @import '../../node_modules/react-simple-keyboard/build/css/index.css'; -@import './atoms/SoftwareKeyboard/CustomKeyboard/index.css'; -@import './atoms/SoftwareKeyboard/NormalKeyboard/index.css'; -@import './atoms/SoftwareKeyboard/Numpad/index.css'; +@import './atoms/SoftwareKeyboard/AlphanumericKeyboard/index.css'; +@import './atoms/SoftwareKeyboard/FullKeyboard/index.css'; +@import './atoms/SoftwareKeyboard/NumericalKeyboard/index.css'; +@import './atoms/SoftwareKeyboard/IndividualKey/index.css'; diff --git a/app/typings/global.d.ts b/app/typings/global.d.ts index 772bcf9ffd0..de736627240 100644 --- a/app/typings/global.d.ts +++ b/app/typings/global.d.ts @@ -1,6 +1,4 @@ declare const global: typeof globalThis & { - _PKG_VERSION_: string - _OPENTRONS_PROJECT_: string APP_SHELL_REMOTE: { // sa 02-02-2024 any typing this because importing the IpcRenderer type // from electron makes this ambient type declaration a module instead of @@ -9,3 +7,6 @@ declare const global: typeof globalThis & { [key: string]: any } } + +declare const _PKG_VERSION_: string +declare const _OPENTRONS_PROJECT_: string diff --git a/app/typings/remark.d.ts b/app/typings/remark.d.ts deleted file mode 100644 index 2eb55d6f77e..00000000000 --- a/app/typings/remark.d.ts +++ /dev/null @@ -1,11 +0,0 @@ -declare module 'remark' { - const remark: any - // eslint-disable-next-line import/no-default-export - export default remark -} - -declare module 'remark-react' { - const reactRenderer: any - // eslint-disable-next-line import/no-default-export - export default reactRenderer -} diff --git a/app/vite.config.ts b/app/vite.config.ts index 9710acdd240..f88d492056a 100644 --- a/app/vite.config.ts +++ b/app/vite.config.ts @@ -6,57 +6,66 @@ import postCssApply from 'postcss-apply' import postColorModFunction from 'postcss-color-mod-function' import postCssPresetEnv from 'postcss-preset-env' import lostCss from 'lost' +import { versionForProject } from '../scripts/git-version.mjs' +import type { UserConfig } from 'vite' -export default defineConfig({ - // this makes imports relative rather than absolute - base: '', - build: { - // Relative to the root - outDir: 'dist', - }, - plugins: [ - react({ - include: '**/*.tsx', - babel: { - // Use babel.config.js files - configFile: true, +export default defineConfig( + async(): Promise => { + const project = process.env.OPENTRONS_PROJECT ?? 'robot-stack' + const version = await versionForProject(project) + return { + // this makes imports relative rather than absolute + base: '', + build: { + // Relative to the root + outDir: 'dist', }, - }), - ], - optimizeDeps: { - esbuildOptions: { - target: 'es2020', - }, - }, - css: { - postcss: { plugins: [ - postCssImport({ root: 'src/' }), - postCssApply(), - postColorModFunction(), - postCssPresetEnv({ stage: 0 }), - lostCss(), + react({ + include: '**/*.tsx', + babel: { + // Use babel.config.js files + configFile: true, + }, + }), ], - }, - }, - define: { - 'process.env': process.env, - global: 'globalThis', - }, - resolve: { - alias: { - '@opentrons/components/styles': path.resolve( - '../components/src/index.module.css' - ), - '@opentrons/components': path.resolve('../components/src/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' - ), - }, - }, -}) + optimizeDeps: { + esbuildOptions: { + target: 'es2020', + }, + }, + css: { + postcss: { + plugins: [ + postCssImport({ root: 'src/' }), + postCssApply(), + postColorModFunction(), + postCssPresetEnv({ stage: 0 }), + lostCss(), + ], + }, + }, + define: { + 'process.env': process.env, + global: 'globalThis', + _PKG_VERSION_: JSON.stringify(version), + _OPENTRONS_PROJECT_: JSON.stringify(project), + }, + resolve: { + alias: { + '@opentrons/components/styles': path.resolve( + '../components/src/index.module.css' + ), + '@opentrons/components': path.resolve('../components/src/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' + ), + }, + }, + } + }) diff --git a/app/src/atoms/Chip/Chip.stories.tsx b/components/src/atoms/Chip/Chip.stories.tsx similarity index 75% rename from app/src/atoms/Chip/Chip.stories.tsx rename to components/src/atoms/Chip/Chip.stories.tsx index 26cb9025911..027ea4cbdbe 100644 --- a/app/src/atoms/Chip/Chip.stories.tsx +++ b/components/src/atoms/Chip/Chip.stories.tsx @@ -1,42 +1,40 @@ import * as React from 'react' -import { Flex, COLORS, SPACING } from '@opentrons/components' -import { touchScreenViewport } from '../../DesignTokens/constants' + +import { Flex } from '../../primitives' +import { COLORS } from '../../helix-design-system' +import { SPACING, VIEWPORT } from '../../ui-style-constants' import { Chip } from '.' import type { Meta, StoryObj } from '@storybook/react' const meta: Meta = { - title: 'ODD/Atoms/Chip', + title: 'Library/Atoms/Chip', argTypes: { type: { options: ['basic', 'error', 'info', 'neutral', 'success', 'warning'], control: { type: 'select', }, - defaultValue: 'basic', }, hasIcon: { control: { type: 'boolean', }, - defaultValue: true, }, chipSize: { options: ['medium', 'small'], control: { type: 'select', }, - defaultValue: 'medium', }, iconName: { options: ['connection-status', 'ot-check', 'ot-alert'], control: { type: 'select', }, - defaultValue: 'ot-alert', }, }, component: Chip, - parameters: touchScreenViewport, + parameters: VIEWPORT.touchScreenViewport, decorators: [ Story => ( export const ChipComponent: Story = { args: { - type: 'basic', + type: 'success', text: 'Chip component', hasIcon: true, chipSize: 'medium', diff --git a/components/src/atoms/Chip/__tests__/Chip.test.tsx b/components/src/atoms/Chip/__tests__/Chip.test.tsx new file mode 100644 index 00000000000..9eafde0d144 --- /dev/null +++ b/components/src/atoms/Chip/__tests__/Chip.test.tsx @@ -0,0 +1,465 @@ +import * as React from 'react' +import { describe, it, expect, beforeEach } from 'vitest' +import { screen } from '@testing-library/react' +import { BORDERS, COLORS } from '../../../helix-design-system' +import { SPACING } from '../../../ui-style-constants' +import { renderWithProviders } from '../../../testing/utils' +import { Chip } from '..' + +const render = (props: React.ComponentProps) => { + return renderWithProviders() +} + +describe('Chip Touchscreen', () => { + let props: React.ComponentProps + + it('should render text, no icon with basic colors', () => { + props = { + text: 'mockBasic', + type: 'basic', + } + render(props) + const chip = screen.getByTestId('Chip_basic') + const chipText = screen.getByText('mockBasic') + expect(chip).toHaveStyle( + `background-color: ${COLORS.black90}${COLORS.opacity20HexCode}` + ) + expect(chipText).toHaveStyle(`color: ${COLORS.grey60}`) + // ToDo (kk:03/28/2024) seems that jsdom doesn't support switching via media query + // I will keep investigating this + // expect(chipText).toHaveStyle( + // `padding: ${SPACING.spacing8} ${SPACING.spacing16}` + // ) + expect(screen.queryByLabelText('icon_mockBasic')).not.toBeInTheDocument() + }) + + it('should render text, icon, bgcolor with success colors', () => { + props = { + text: 'mockSuccess', + type: 'success', + } + render(props) + const chip = screen.getByTestId('Chip_success') + const chipText = screen.getByText('mockSuccess') + expect(chip).toHaveStyle(`background-color: ${COLORS.green35}`) + expect(chip).toHaveStyle(`border-radius: ${BORDERS.borderRadius40}`) + expect(chipText).toHaveStyle(`color: ${COLORS.green60}`) + const icon = screen.getByLabelText('icon_mockSuccess') + expect(icon).toHaveStyle(`color: ${COLORS.green60}`) + // ToDo (kk:03/28/2024) seems that jsdom doesn't support switching via media query + // I will keep investigating this + // expect(icon).toHaveStyle(`width: 1.5rem`) + }) + + it('should render text, icon, no bgcolor with success colors and bg false', () => { + props = { + background: false, + text: 'mockSuccess', + type: 'success', + } + render(props) + const chip = screen.getByTestId('Chip_success') + const chipText = screen.getByText('mockSuccess') + expect(chip).toHaveStyle(`background-color: ${COLORS.transparent}`) + expect(chip).toHaveStyle(`border-radius: ${BORDERS.borderRadius40}`) + expect(chipText).toHaveStyle(`color: ${COLORS.green60}`) + const icon = screen.getByLabelText('icon_mockSuccess') + expect(icon).toHaveStyle(`color: ${COLORS.green60}`) + }) + + it('should render text, icon, bgcolor with warning colors', () => { + props = { + text: 'mockWarning', + type: 'warning', + } + render(props) + const chip = screen.getByTestId('Chip_warning') + const chipText = screen.getByText('mockWarning') + expect(chip).toHaveStyle(`background-color: ${COLORS.yellow35}`) + expect(chip).toHaveStyle(`border-radius: ${BORDERS.borderRadius40}`) + expect(chipText).toHaveStyle(`color: ${COLORS.yellow60}`) + const icon = screen.getByLabelText('icon_mockWarning') + expect(icon).toHaveStyle(`color: ${COLORS.yellow60}`) + }) + + it('should render text, icon, no bgcolor with warning colors and bg false', () => { + props = { + background: false, + text: 'mockWarning', + type: 'warning', + } + render(props) + const chip = screen.getByTestId('Chip_warning') + const chipText = screen.getByText('mockWarning') + expect(chip).toHaveStyle(`background-color: ${COLORS.transparent}`) + expect(chip).toHaveStyle(`border-radius: ${BORDERS.borderRadius40}`) + expect(chipText).toHaveStyle(`color: ${COLORS.yellow60}`) + const icon = screen.getByLabelText('icon_mockWarning') + expect(icon).toHaveStyle(`color: ${COLORS.yellow60}`) + }) + + it('should render text, icon, bgcolor with neutral colors', () => { + props = { + text: 'mockNeutral', + type: 'neutral', + } + render(props) + const chip = screen.getByTestId('Chip_neutral') + const chipText = screen.getByText('mockNeutral') + expect(chip).toHaveStyle( + `background-color: ${COLORS.black90}${COLORS.opacity20HexCode}` + ) + expect(chip).toHaveStyle(`border-radius: ${BORDERS.borderRadius40}`) + expect(chipText).toHaveStyle(`color: ${COLORS.grey60}`) + const icon = screen.getByLabelText('icon_mockNeutral') + expect(icon).toHaveStyle(`color: ${COLORS.grey60}`) + }) + + it('should render text, icon, no bgcolor with neutral colors and bg false', () => { + props = { + background: false, + text: 'mockNeutral', + type: 'neutral', + } + render(props) + const chip = screen.getByTestId('Chip_neutral') + const chipText = screen.getByText('mockNeutral') + expect(chip).toHaveStyle(`background-color: ${COLORS.transparent}`) + expect(chip).toHaveStyle(`border-radius: ${BORDERS.borderRadius40}`) + expect(chipText).toHaveStyle(`color: ${COLORS.grey60}`) + const icon = screen.getByLabelText('icon_mockNeutral') + expect(icon).toHaveStyle(`color: ${COLORS.grey60}`) + }) + + it('should render text, icon, bgcolor with error colors', () => { + props = { + text: 'mockError', + type: 'error', + } + render(props) + const chip = screen.getByTestId('Chip_error') + const chipText = screen.getByText('mockError') + expect(chip).toHaveStyle(`background-color: ${COLORS.red35}`) + expect(chip).toHaveStyle(`border-radius: ${BORDERS.borderRadius40}`) + expect(chipText).toHaveStyle(`color: ${COLORS.red60}`) + const icon = screen.getByLabelText('icon_mockError') + expect(icon).toHaveStyle(`color: ${COLORS.red60}`) + }) + + it('should render text, icon, no bgcolor with error colors and bg false', () => { + props = { + background: false, + text: 'mockError', + type: 'error', + } + render(props) + const chip = screen.getByTestId('Chip_error') + const chipText = screen.getByText('mockError') + expect(chip).toHaveStyle(`background-color: ${COLORS.transparent}`) + expect(chip).toHaveStyle(`border-radius: ${BORDERS.borderRadius40}`) + expect(chipText).toHaveStyle(`color: ${COLORS.red60}`) + const icon = screen.getByLabelText('icon_mockError') + expect(icon).toHaveStyle(`color: ${COLORS.red60}`) + }) + + it('should render text, icon, bgcolor with info colors', () => { + props = { + text: 'mockInfo', + type: 'info', + } + render(props) + const chip = screen.getByTestId('Chip_info') + const chipText = screen.getByText('mockInfo') + expect(chip).toHaveStyle(`background-color: ${COLORS.blue35}`) + expect(chip).toHaveStyle(`border-radius: ${BORDERS.borderRadius40}`) + expect(chipText).toHaveStyle(`color: ${COLORS.blue60}`) + const icon = screen.getByLabelText('icon_mockInfo') + expect(icon).toHaveStyle(`color: ${COLORS.blue60}`) + }) + + it('should render text, icon, no bgcolor with info colors and bg false', () => { + props = { + background: false, + text: 'mockInfo', + type: 'info', + } + render(props) + const chip = screen.getByTestId('Chip_info') + const chipText = screen.getByText('mockInfo') + expect(chip).toHaveStyle(`background-color: ${COLORS.transparent}`) + expect(chip).toHaveStyle(`border-radius: ${BORDERS.borderRadius40}`) + expect(chipText).toHaveStyle(`color: ${COLORS.blue60}`) + const icon = screen.getByLabelText('icon_mockInfo') + expect(icon).toHaveStyle(`color: ${COLORS.blue60}`) + }) + it('renders no icon when hasIcon is false', () => { + props = { + text: 'mockInfo', + hasIcon: false, + type: 'info', + } + render(props) + expect(screen.queryByText('icon_mockInfo')).not.toBeInTheDocument() + }) + + it('render text with smaller padding and smaller icon when chip size is small and background is false', () => { + props = { + background: false, + text: 'mockInfo', + type: 'info', + chipSize: 'small', + } + render(props) + const chip = screen.getByTestId('Chip_info') + expect(chip).toHaveStyle(`padding: ${SPACING.spacing4} 0`) + const icon = screen.getByLabelText('icon_mockInfo') + expect(icon).toHaveStyle(`width: 0.75rem`) + }) + + // ToDo (kk:03/28/2024) seems that jsdom doesn't support switching via media query + // I will keep investigating this + // it('render text with smaller padding and smaller icon when chip size is small and background is true', () => { + // props = { + // background: true, + // text: 'mockInfo', + // type: 'info', + // chipSize: 'small', + // } + // render(props) + // const chip = screen.getByTestId('Chip_info') + // expect(chip).toHaveStyle(`padding: ${SPACING.spacing4} ${SPACING.spacing8}`) + // const icon = screen.getByLabelText('icon_mockInfo') + // expect(icon).toHaveStyle(`width: 1.25rem`) + // }) +}) + +describe('Chip Web', () => { + let props: React.ComponentProps + + beforeEach(() => { + Object.defineProperty(window, 'innerWidth', { + writable: true, + configurable: true, + value: 1024, + }) + + Object.defineProperty(window, 'innerHeight', { + writable: true, + configurable: true, + value: 768, + }) + }) + + it('should render text, no icon with basic colors', () => { + props = { + text: 'mockBasic', + type: 'basic', + } + render(props) + const chip = screen.getByTestId('Chip_basic') + const chipText = screen.getByText('mockBasic') + expect(chip).toHaveStyle( + `background-color: ${COLORS.black90}${COLORS.opacity20HexCode}` + ) + expect(chipText).toHaveStyle(`color: ${COLORS.grey60}`) + expect(screen.queryByLabelText('icon_mockBasic')).not.toBeInTheDocument() + }) + + it('should render text, icon, bgcolor with success colors', () => { + props = { + text: 'mockSuccess', + type: 'success', + } + render(props) + const chip = screen.getByTestId('Chip_success') + const chipText = screen.getByText('mockSuccess') + expect(chip).toHaveStyle(`background-color: ${COLORS.green35}`) + expect(chip).toHaveStyle(`border-radius: ${BORDERS.borderRadius40}`) + expect(chipText).toHaveStyle(`color: ${COLORS.green60}`) + // expect(chipText).toHaveStyle( + // `padding: ${SPACING.spacing2} ${SPACING.spacing8}` + // ) + const icon = screen.getByLabelText('icon_mockSuccess') + expect(icon).toHaveStyle(`color: ${COLORS.green60}`) + expect(icon).toHaveStyle(`width: 1rem`) + }) + + it('should render text, icon, no bgcolor with success colors and bg false', () => { + props = { + background: false, + text: 'mockSuccess', + type: 'success', + } + render(props) + const chip = screen.getByTestId('Chip_success') + const chipText = screen.getByText('mockSuccess') + expect(chip).toHaveStyle(`background-color: ${COLORS.transparent}`) + expect(chip).toHaveStyle(`border-radius: ${BORDERS.borderRadius40}`) + expect(chipText).toHaveStyle(`color: ${COLORS.green60}`) + const icon = screen.getByLabelText('icon_mockSuccess') + expect(icon).toHaveStyle(`color: ${COLORS.green60}`) + }) + + it('should render text, icon, bgcolor with warning colors', () => { + props = { + text: 'mockWarning', + type: 'warning', + } + render(props) + const chip = screen.getByTestId('Chip_warning') + const chipText = screen.getByText('mockWarning') + expect(chip).toHaveStyle(`background-color: ${COLORS.yellow35}`) + expect(chip).toHaveStyle(`border-radius: ${BORDERS.borderRadius40}`) + expect(chipText).toHaveStyle(`color: ${COLORS.yellow60}`) + const icon = screen.getByLabelText('icon_mockWarning') + expect(icon).toHaveStyle(`color: ${COLORS.yellow60}`) + }) + + it('should render text, icon, no bgcolor with warning colors and bg false', () => { + props = { + background: false, + text: 'mockWarning', + type: 'warning', + } + render(props) + const chip = screen.getByTestId('Chip_warning') + const chipText = screen.getByText('mockWarning') + expect(chip).toHaveStyle(`background-color: ${COLORS.transparent}`) + expect(chip).toHaveStyle(`border-radius: ${BORDERS.borderRadius40}`) + expect(chipText).toHaveStyle(`color: ${COLORS.yellow60}`) + const icon = screen.getByLabelText('icon_mockWarning') + expect(icon).toHaveStyle(`color: ${COLORS.yellow60}`) + }) + + it('should render text, icon, bgcolor with neutral colors', () => { + props = { + text: 'mockNeutral', + type: 'neutral', + } + render(props) + const chip = screen.getByTestId('Chip_neutral') + const chipText = screen.getByText('mockNeutral') + expect(chip).toHaveStyle( + `background-color: ${COLORS.black90}${COLORS.opacity20HexCode}` + ) + expect(chip).toHaveStyle(`border-radius: ${BORDERS.borderRadius40}`) + expect(chipText).toHaveStyle(`color: ${COLORS.grey60}`) + const icon = screen.getByLabelText('icon_mockNeutral') + expect(icon).toHaveStyle(`color: ${COLORS.grey60}`) + }) + + it('should render text, icon, no bgcolor with neutral colors and bg false', () => { + props = { + background: false, + text: 'mockNeutral', + type: 'neutral', + } + render(props) + const chip = screen.getByTestId('Chip_neutral') + const chipText = screen.getByText('mockNeutral') + expect(chip).toHaveStyle(`background-color: ${COLORS.transparent}`) + expect(chip).toHaveStyle(`border-radius: ${BORDERS.borderRadius40}`) + expect(chipText).toHaveStyle(`color: ${COLORS.grey60}`) + const icon = screen.getByLabelText('icon_mockNeutral') + expect(icon).toHaveStyle(`color: ${COLORS.grey60}`) + }) + + it('should render text, icon, bgcolor with error colors', () => { + props = { + text: 'mockError', + type: 'error', + } + render(props) + const chip = screen.getByTestId('Chip_error') + const chipText = screen.getByText('mockError') + expect(chip).toHaveStyle(`background-color: ${COLORS.red35}`) + expect(chip).toHaveStyle(`border-radius: ${BORDERS.borderRadius40}`) + expect(chipText).toHaveStyle(`color: ${COLORS.red60}`) + const icon = screen.getByLabelText('icon_mockError') + expect(icon).toHaveStyle(`color: ${COLORS.red60}`) + }) + + it('should render text, icon, no bgcolor with error colors and bg false', () => { + props = { + background: false, + text: 'mockError', + type: 'error', + } + render(props) + const chip = screen.getByTestId('Chip_error') + const chipText = screen.getByText('mockError') + expect(chip).toHaveStyle(`background-color: ${COLORS.transparent}`) + expect(chip).toHaveStyle(`border-radius: ${BORDERS.borderRadius40}`) + expect(chipText).toHaveStyle(`color: ${COLORS.red60}`) + const icon = screen.getByLabelText('icon_mockError') + expect(icon).toHaveStyle(`color: ${COLORS.red60}`) + }) + + it('should render text, icon, bgcolor with info colors', () => { + props = { + text: 'mockInfo', + type: 'info', + } + render(props) + const chip = screen.getByTestId('Chip_info') + const chipText = screen.getByText('mockInfo') + expect(chip).toHaveStyle(`background-color: ${COLORS.blue35}`) + expect(chip).toHaveStyle(`border-radius: ${BORDERS.borderRadius40}`) + expect(chipText).toHaveStyle(`color: ${COLORS.blue60}`) + const icon = screen.getByLabelText('icon_mockInfo') + expect(icon).toHaveStyle(`color: ${COLORS.blue60}`) + }) + + it('should render text, icon, no bgcolor with info colors and bg false', () => { + props = { + background: false, + text: 'mockInfo', + type: 'info', + } + render(props) + const chip = screen.getByTestId('Chip_info') + const chipText = screen.getByText('mockInfo') + expect(chip).toHaveStyle(`background-color: ${COLORS.transparent}`) + expect(chip).toHaveStyle(`border-radius: ${BORDERS.borderRadius40}`) + expect(chipText).toHaveStyle(`color: ${COLORS.blue60}`) + const icon = screen.getByLabelText('icon_mockInfo') + expect(icon).toHaveStyle(`color: ${COLORS.blue60}`) + }) + it('renders no icon when hasIcon is false', () => { + props = { + text: 'mockInfo', + hasIcon: false, + type: 'info', + } + render(props) + expect(screen.queryByText('icon_mockInfo')).not.toBeInTheDocument() + }) + + it('render text with smaller padding and smaller icon when chip size is small and background is false', () => { + props = { + background: false, + text: 'mockInfo', + type: 'info', + chipSize: 'small', + } + render(props) + const chip = screen.getByTestId('Chip_info') + expect(chip).toHaveStyle(`padding: ${SPACING.spacing4} 0`) + const icon = screen.getByLabelText('icon_mockInfo') + expect(icon).toHaveStyle(`width: 0.75rem`) + }) + + it('render text with smaller padding and smaller icon when chip size is small and background is true', () => { + props = { + background: true, + text: 'mockInfo', + type: 'info', + chipSize: 'small', + } + render(props) + const chip = screen.getByTestId('Chip_info') + expect(chip).toHaveStyle(`padding: ${SPACING.spacing4} ${SPACING.spacing6}`) + const icon = screen.getByLabelText('icon_mockInfo') + expect(icon).toHaveStyle(`width: 0.75rem`) + }) +}) diff --git a/app/src/atoms/Chip/index.tsx b/components/src/atoms/Chip/index.tsx similarity index 56% rename from app/src/atoms/Chip/index.tsx rename to components/src/atoms/Chip/index.tsx index 06d26cf21c7..36a10bc3a90 100644 --- a/app/src/atoms/Chip/index.tsx +++ b/components/src/atoms/Chip/index.tsx @@ -1,19 +1,16 @@ import * as React from 'react' import { css } from 'styled-components' -import { - ALIGN_CENTER, - BORDERS, - COLORS, - DIRECTION_ROW, - Flex, - Icon, - SPACING, - StyledText, - TYPOGRAPHY, -} from '@opentrons/components' +import { BORDERS, COLORS } from '../../helix-design-system' +import { Flex } from '../../primitives' +import { StyledText } from '../StyledText' +import { ALIGN_CENTER, DIRECTION_ROW } from '../../styles' +import { RESPONSIVENESS, SPACING, TYPOGRAPHY } from '../../ui-style-constants' +import { Icon } from '../../icons' -import type { IconName, StyleProps } from '@opentrons/components' +import type { IconName } from '../../icons' +import type { StyleProps } from '../../primitives' +// ToDo (kk:03/26/2024) basic will be removed when we add Tag component export type ChipType = | 'basic' | 'error' @@ -103,14 +100,42 @@ export function Chip(props: ChipProps): JSX.Element { : CHIP_PROPS_BY_TYPE[type].backgroundColor const icon = iconName ?? CHIP_PROPS_BY_TYPE[type].iconName ?? 'ot-alert' - const TOUCHSCREEN_MEDIUM_CONTAINER_STYLE = css` - padding: ${SPACING.spacing8} ${background === false ? 0 : SPACING.spacing16}; - grid-gap: ${SPACING.spacing8}; + const MEDIUM_CONTAINER_STYLE = css` + padding: ${SPACING.spacing2} ${background === false ? 0 : SPACING.spacing8}; + grid-gap: ${SPACING.spacing4}; + @media ${RESPONSIVENESS.touchscreenMediaQuerySpecs} { + padding: ${SPACING.spacing8} + ${background === false ? 0 : SPACING.spacing16}; + grid-gap: ${SPACING.spacing8}; + } ` - const TOUCHSCREEN_SMALL_CONTAINER_STYLE = css` - padding: ${SPACING.spacing4} ${background === false ? 0 : SPACING.spacing8}; + const SMALL_CONTAINER_STYLE = css` + padding: ${SPACING.spacing4} ${background === false ? 0 : SPACING.spacing6}; grid-gap: ${SPACING.spacing4}; + @media ${RESPONSIVENESS.touchscreenMediaQuerySpecs} { + padding: ${SPACING.spacing4} + ${background === false ? 0 : SPACING.spacing8}; + grid-gap: ${SPACING.spacing4}; + } + ` + + const ICON_STYLE = css` + width: ${chipSize === 'medium' ? '1rem' : '0.75rem'}; + height: ${chipSize === 'medium' ? '1rem' : '0.75rem'}; + @media ${RESPONSIVENESS.touchscreenMediaQuerySpecs} { + width: ${chipSize === 'medium' ? '1.5rem' : '1.25rem'}; + height: ${chipSize === 'medium' ? '1.5rem' : '1.25rem'}; + } + ` + + const TEXT_STYLE = css` + ${chipSize === 'medium' ? WEB_MEDIUM_TEXT_STYLE : WEB_SMALL_TEXT_STYLE} + @media ${RESPONSIVENESS.touchscreenMediaQuerySpecs} { + ${chipSize === 'medium' + ? TYPOGRAPHY.bodyTextSemiBold + : TYPOGRAPHY.smallBodyTextSemiBold} + } ` return ( @@ -120,9 +145,7 @@ export function Chip(props: ChipProps): JSX.Element { borderRadius={CHIP_PROPS_BY_TYPE[type].borderRadius} flexDirection={DIRECTION_ROW} css={ - chipSize === 'medium' - ? TOUCHSCREEN_MEDIUM_CONTAINER_STYLE - : TOUCHSCREEN_SMALL_CONTAINER_STYLE + chipSize === 'medium' ? MEDIUM_CONTAINER_STYLE : SMALL_CONTAINER_STYLE } data-testid={`Chip_${type}`} {...styleProps} @@ -132,19 +155,23 @@ export function Chip(props: ChipProps): JSX.Element { name={icon} color={CHIP_PROPS_BY_TYPE[type].iconColor} aria-label={`icon_${text}`} - size={chipSize === 'medium' ? '1.5rem' : '1.25rem'} + css={ICON_STYLE} /> ) : null} - + {text} ) } + +const WEB_MEDIUM_TEXT_STYLE = css` + font-size: ${TYPOGRAPHY.fontSizeH4}; + line-height: ${TYPOGRAPHY.lineHeight20}; + font-weight: ${TYPOGRAPHY.fontWeightSemiBold}; +` +const WEB_SMALL_TEXT_STYLE = css` + font-size: ${TYPOGRAPHY.fontSizeLabel}; + line-height: ${TYPOGRAPHY.lineHeight12}; + font-weight: ${TYPOGRAPHY.fontWeightSemiBold}; +` diff --git a/components/src/atoms/StepMeter/index.tsx b/components/src/atoms/StepMeter/index.tsx index 14bbf48c6ca..91f151fb5c9 100644 --- a/components/src/atoms/StepMeter/index.tsx +++ b/components/src/atoms/StepMeter/index.tsx @@ -5,13 +5,15 @@ import { RESPONSIVENESS, SPACING } from '../../ui-style-constants' import { COLORS } from '../../helix-design-system' import { POSITION_ABSOLUTE, POSITION_RELATIVE } from '../../styles' -interface StepMeterProps { +import type { StyleProps } from '../../primitives' + +interface StepMeterProps extends StyleProps { totalSteps: number currentStep: number | null } export const StepMeter = (props: StepMeterProps): JSX.Element => { - const { totalSteps, currentStep } = props + const { totalSteps, currentStep, ...styleProps } = props const progress = currentStep != null ? currentStep : 0 const percentComplete = `${ // this logic puts a cap at 100% percentComplete which we should never run into @@ -21,7 +23,7 @@ export const StepMeter = (props: StepMeterProps): JSX.Element => { }%` const StepMeterContainer = css` - position: ${POSITION_RELATIVE}; + position: ${styleProps.position ? styleProps.position : POSITION_RELATIVE}; height: ${SPACING.spacing4}; background-color: ${COLORS.grey30}; @media ${RESPONSIVENESS.touchscreenMediaQuerySpecs} { @@ -41,7 +43,11 @@ export const StepMeter = (props: StepMeterProps): JSX.Element => { ` return ( - + ) diff --git a/components/src/atoms/StyledText/StyledText.stories.tsx b/components/src/atoms/StyledText/StyledText.stories.tsx index 12f8ab8c16a..388f7e79bdf 100644 --- a/components/src/atoms/StyledText/StyledText.stories.tsx +++ b/components/src/atoms/StyledText/StyledText.stories.tsx @@ -1,87 +1,107 @@ +/* eslint-disable storybook/prefer-pascal-case */ import * as React from 'react' -import { StyledText } from './' -import { TYPOGRAPHY } from '../../ui-style-constants' -import type { Story, Meta } from '@storybook/react' +import { SPACING, TYPOGRAPHY } from '../../ui-style-constants' +import { Flex } from '../../primitives' +import { StyledText } from './index' +import type { Meta, StoryObj } from '@storybook/react' -export default { +const meta: Meta = { title: 'Library/Atoms/StyledText', component: StyledText, -} as Meta + decorators: [ + Story => ( + + + + ), + ], +} + +export default meta -const Template: Story> = args => ( - -) +type Story = StoryObj const dummyText = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Purus sapien nunc dolor, aliquet nibh placerat et nisl, arcu. Pellentesque blandit sollicitudin vitae morbi morbi vulputate cursus tellus. Amet proin donec proin id aliquet in nullam.' -export const h1 = Template.bind({}) -h1.args = { - as: 'h1', - children: dummyText, +export const h1: Story = { + args: { + as: 'h1', + children: dummyText, + }, } -export const h2 = Template.bind({}) -h2.args = { - as: 'h2', - children: dummyText, +export const h2: Story = { + args: { + as: 'h2', + children: dummyText, + }, } -export const h3 = Template.bind({}) -h3.args = { - as: 'h3', - children: dummyText, +export const h3: Story = { + args: { + as: 'h3', + children: dummyText, + }, } -export const h6 = Template.bind({}) -h6.args = { - as: 'h6', - children: dummyText, +export const h6: Story = { + args: { + as: 'h6', + children: dummyText, + }, } -export const p = Template.bind({}) -p.args = { - as: 'p', - children: dummyText, +export const p: Story = { + args: { + as: 'p', + children: dummyText, + }, } -export const label = Template.bind({}) -label.args = { - as: 'label', - children: dummyText, +export const label: Story = { + args: { + as: 'label', + children: dummyText, + }, } -export const h2SemiBold = Template.bind({}) -h2SemiBold.args = { - as: 'h2', - fontWeight: TYPOGRAPHY.fontWeightSemiBold, - children: dummyText, +export const h2SemiBold: Story = { + args: { + as: 'h2', + fontWeight: TYPOGRAPHY.fontWeightSemiBold, + children: dummyText, + }, } -export const h3SemiBold = Template.bind({}) -h3SemiBold.args = { - as: 'h3', - fontWeight: TYPOGRAPHY.fontWeightSemiBold, - children: dummyText, +export const h3SemiBold: Story = { + args: { + as: 'h3', + fontWeight: TYPOGRAPHY.fontWeightSemiBold, + children: dummyText, + }, } -export const h6SemiBold = Template.bind({}) -h6SemiBold.args = { - as: 'h6', - fontWeight: TYPOGRAPHY.fontWeightSemiBold, - children: dummyText, +export const h6SemiBold: Story = { + args: { + as: 'h6', + fontWeight: TYPOGRAPHY.fontWeightSemiBold, + children: dummyText, + }, } -export const pSemiBold = Template.bind({}) -pSemiBold.args = { - as: 'p', - fontWeight: TYPOGRAPHY.fontWeightSemiBold, - children: dummyText, +export const pSemiBold: Story = { + args: { + as: 'p', + fontWeight: TYPOGRAPHY.fontWeightSemiBold, + children: dummyText, + }, } -export const labelSemiBold = Template.bind({}) -labelSemiBold.args = { - as: 'label', - fontWeight: TYPOGRAPHY.fontWeightSemiBold, - children: dummyText, +export const labelSemiBold: Story = { + args: { + as: 'label', + fontWeight: TYPOGRAPHY.fontWeightSemiBold, + children: dummyText, + }, } diff --git a/components/src/atoms/index.ts b/components/src/atoms/index.ts index 345d50ac38c..93a5eb64f26 100644 --- a/components/src/atoms/index.ts +++ b/components/src/atoms/index.ts @@ -1,4 +1,6 @@ export * from './buttons' export * from './CheckboxField' +export * from './Chip' +export * from './StepMeter' export * from './StepMeter' export * from './StyledText' diff --git a/components/src/hardware-sim/BaseDeck/BaseDeck.tsx b/components/src/hardware-sim/BaseDeck/BaseDeck.tsx index e664cb10277..d896c0c9370 100644 --- a/components/src/hardware-sim/BaseDeck/BaseDeck.tsx +++ b/components/src/hardware-sim/BaseDeck/BaseDeck.tsx @@ -15,6 +15,7 @@ import { WASTE_CHUTE_ONLY_FIXTURES, WASTE_CHUTE_STAGING_AREA_FIXTURES, HEATERSHAKER_MODULE_V1, + MODULE_FIXTURES_BY_MODEL, } from '@opentrons/shared-data' import { RobotCoordinateSpace } from '../RobotCoordinateSpace' @@ -32,6 +33,7 @@ import { WasteChuteFixture } from './WasteChuteFixture' import { WasteChuteStagingAreaFixture } from './WasteChuteStagingAreaFixture' import type { + CutoutFixtureId, DeckConfiguration, LabwareDefinition2, LabwareLocation, @@ -101,7 +103,14 @@ export function BaseDeck(props: BaseDeckProps): JSX.Element { const singleSlotFixtures = deckConfig.filter( fixture => fixture.cutoutFixtureId != null && - SINGLE_SLOT_FIXTURES.includes(fixture.cutoutFixtureId) + (SINGLE_SLOT_FIXTURES.includes(fixture.cutoutFixtureId) || + // If module fixture is loaded, still visualize singleSlotFixture underneath for consistency + Object.entries(MODULE_FIXTURES_BY_MODEL) + .reduce( + (acc, [_model, fixtures]) => [...acc, ...fixtures], + [] + ) + .includes(fixture.cutoutFixtureId)) ) const stagingAreaFixtures = deckConfig.filter( fixture => diff --git a/components/src/hardware-sim/DeckConfigurator/DeckConfigurator.stories.tsx b/components/src/hardware-sim/DeckConfigurator/DeckConfigurator.stories.tsx index dc900541fd6..f29b2cffc02 100644 --- a/components/src/hardware-sim/DeckConfigurator/DeckConfigurator.stories.tsx +++ b/components/src/hardware-sim/DeckConfigurator/DeckConfigurator.stories.tsx @@ -71,6 +71,12 @@ const deckConfig: CutoutConfig[] = [ }, ] +const staticFixtures = [ + { location: 'cutoutB2', label: 'Tip rack' }, + { location: 'cutoutC2', label: 'Labware' }, + { location: 'cutoutD2', label: 'Labware' }, +] + export const Default = Template.bind({}) Default.args = { deckConfig, @@ -85,3 +91,12 @@ ReadOnly.args = { handleClickRemove: cutoutId => console.log(`remove at ${cutoutId}`), readOnly: true, } + +export const ReadOnlyWithStaticFixtures = Template.bind({}) +ReadOnlyWithStaticFixtures.args = { + deckConfig, + handleClickAdd: () => {}, + handleClickRemove: () => {}, + readOnly: true, + additionalStaticFixtures: staticFixtures, +} diff --git a/components/src/hardware-sim/DeckConfigurator/EmptyConfigFixture.tsx b/components/src/hardware-sim/DeckConfigurator/EmptyConfigFixture.tsx index fba7dfc57cf..d790f2e922d 100644 --- a/components/src/hardware-sim/DeckConfigurator/EmptyConfigFixture.tsx +++ b/components/src/hardware-sim/DeckConfigurator/EmptyConfigFixture.tsx @@ -8,10 +8,13 @@ import { RESPONSIVENESS } from '../../ui-style-constants' import { BORDERS, COLORS } from '../../helix-design-system' import { RobotCoordsForeignObject } from '../Deck/RobotCoordsForeignObject' import { + COLUMN_1_SINGLE_SLOT_FIXTURE_WIDTH, + COLUMN_2_SINGLE_SLOT_FIXTURE_WIDTH, + COLUMN_3_SINGLE_SLOT_FIXTURE_WIDTH, COLUMN_1_X_ADJUSTMENT, + COLUMN_2_X_ADJUSTMENT, COLUMN_3_X_ADJUSTMENT, FIXTURE_HEIGHT, - SINGLE_SLOT_FIXTURE_WIDTH, Y_ADJUSTMENT, } from './constants' @@ -39,22 +42,40 @@ export function EmptyConfigFixture( */ const [xSlotPosition = 0, ySlotPosition = 0] = standardSlotCutout?.position ?? [] - - const isColumnOne = - fixtureLocation === 'cutoutA1' || - fixtureLocation === 'cutoutB1' || - fixtureLocation === 'cutoutC1' || - fixtureLocation === 'cutoutD1' - const xAdjustment = isColumnOne - ? COLUMN_1_X_ADJUSTMENT - : COLUMN_3_X_ADJUSTMENT - const x = xSlotPosition + xAdjustment + let x = xSlotPosition + let width = 0 + switch (fixtureLocation) { + case 'cutoutA1': + case 'cutoutB1': + case 'cutoutC1': + case 'cutoutD1': { + x = xSlotPosition + COLUMN_1_X_ADJUSTMENT + width = COLUMN_1_SINGLE_SLOT_FIXTURE_WIDTH + break + } + case 'cutoutA2': + case 'cutoutB2': + case 'cutoutC2': + case 'cutoutD2': { + x = xSlotPosition + COLUMN_2_X_ADJUSTMENT + width = COLUMN_2_SINGLE_SLOT_FIXTURE_WIDTH + break + } + case 'cutoutA3': + case 'cutoutB3': + case 'cutoutC3': + case 'cutoutD3': { + x = xSlotPosition + COLUMN_3_X_ADJUSTMENT + width = COLUMN_3_SINGLE_SLOT_FIXTURE_WIDTH + break + } + } const y = ySlotPosition + Y_ADJUSTMENT return ( void +} + +const HEATER_SHAKER_MODULE_FIXTURE_DISPLAY_NAME = 'Heater-Shaker' + +export function HeaterShakerFixture( + props: HeaterShakerFixtureProps +): JSX.Element { + const { + deckDefinition, + handleClickRemove, + fixtureLocation, + cutoutFixtureId, + } = props + + const cutoutDef = deckDefinition.locations.cutouts.find( + cutout => cutout.id === fixtureLocation + ) + + /** + * deck definition cutout position is the position of the single slot located within that cutout + * so, to get the position of the cutout itself we must add an adjustment to the slot position + * the adjustment for x is different for right side/left side + */ + const [xSlotPosition = 0, ySlotPosition = 0] = cutoutDef?.position ?? [] + + const isColumnOne = + fixtureLocation === 'cutoutA1' || + fixtureLocation === 'cutoutB1' || + fixtureLocation === 'cutoutC1' || + fixtureLocation === 'cutoutD1' + const xAdjustment = isColumnOne + ? COLUMN_1_X_ADJUSTMENT + : COLUMN_3_X_ADJUSTMENT + const x = xSlotPosition + xAdjustment + + const y = ySlotPosition + Y_ADJUSTMENT + + return ( + + handleClickRemove(fixtureLocation, cutoutFixtureId) + : () => {} + } + > + + {HEATER_SHAKER_MODULE_FIXTURE_DISPLAY_NAME} + + {handleClickRemove != null ? ( + + ) : null} + + + ) +} diff --git a/components/src/hardware-sim/DeckConfigurator/MagneticBlockFixture.tsx b/components/src/hardware-sim/DeckConfigurator/MagneticBlockFixture.tsx new file mode 100644 index 00000000000..a9f1485c2bd --- /dev/null +++ b/components/src/hardware-sim/DeckConfigurator/MagneticBlockFixture.tsx @@ -0,0 +1,130 @@ +import * as React from 'react' + +import { Icon } from '../../icons' +import { Btn, Text } from '../../primitives' +import { TYPOGRAPHY } from '../../ui-style-constants' +import { COLORS } from '../../helix-design-system' +import { RobotCoordsForeignObject } from '../Deck/RobotCoordsForeignObject' +import { + COLUMN_1_SINGLE_SLOT_FIXTURE_WIDTH, + COLUMN_2_SINGLE_SLOT_FIXTURE_WIDTH, + COLUMN_3_SINGLE_SLOT_FIXTURE_WIDTH, + COLUMN_1_X_ADJUSTMENT, + COLUMN_2_X_ADJUSTMENT, + COLUMN_3_X_ADJUSTMENT, + FIXTURE_HEIGHT, + Y_ADJUSTMENT, + CONFIG_STYLE_EDITABLE, + CONFIG_STYLE_READ_ONLY, + STAGING_AREA_FIXTURE_WIDTH, +} from './constants' + +import type { + CutoutFixtureId, + CutoutId, + DeckDefinition, +} from '@opentrons/shared-data' + +interface MagneticBlockFixtureProps { + deckDefinition: DeckDefinition + fixtureLocation: CutoutId + cutoutFixtureId: CutoutFixtureId + handleClickRemove?: ( + fixtureLocation: CutoutId, + cutoutFixtureId: CutoutFixtureId + ) => void + hasStagingArea?: boolean +} + +const MAGNETIC_BLOCK_FIXTURE_DISPLAY_NAME = 'Mag Block' +const STAGING_AREA_WITH_MAGNETIC_BLOCK_DISPLAY_NAME = 'Mag + staging' + +export function MagneticBlockFixture( + props: MagneticBlockFixtureProps +): JSX.Element { + const { + deckDefinition, + fixtureLocation, + handleClickRemove, + cutoutFixtureId, + hasStagingArea, + } = props + + const standardSlotCutout = deckDefinition.locations.cutouts.find( + cutout => cutout.id === fixtureLocation + ) + + /** + * deck definition cutout position is the position of the single slot located within that cutout + * so, to get the position of the cutout itself we must add an adjustment to the slot position + * the adjustment for x is different for right side/left side + */ + const [xSlotPosition = 0, ySlotPosition = 0] = + standardSlotCutout?.position ?? [] + let x = xSlotPosition + let width = 0 + let displayName = hasStagingArea + ? STAGING_AREA_WITH_MAGNETIC_BLOCK_DISPLAY_NAME + : MAGNETIC_BLOCK_FIXTURE_DISPLAY_NAME + switch (fixtureLocation) { + case 'cutoutA1': + case 'cutoutB1': + case 'cutoutC1': + case 'cutoutD1': { + x = xSlotPosition + COLUMN_1_X_ADJUSTMENT + width = COLUMN_1_SINGLE_SLOT_FIXTURE_WIDTH + break + } + case 'cutoutA2': + case 'cutoutB2': + case 'cutoutC2': + case 'cutoutD2': { + x = xSlotPosition + COLUMN_2_X_ADJUSTMENT + width = COLUMN_2_SINGLE_SLOT_FIXTURE_WIDTH + displayName = 'Mag' + break + } + case 'cutoutA3': + case 'cutoutB3': + case 'cutoutC3': + case 'cutoutD3': { + x = xSlotPosition + COLUMN_3_X_ADJUSTMENT + width = hasStagingArea + ? STAGING_AREA_FIXTURE_WIDTH + : COLUMN_3_SINGLE_SLOT_FIXTURE_WIDTH + break + } + } + + const y = ySlotPosition + Y_ADJUSTMENT + + return ( + + handleClickRemove(fixtureLocation, cutoutFixtureId) + : () => {} + } + > + {displayName} + {handleClickRemove != null ? ( + + ) : null} + + + ) +} diff --git a/components/src/hardware-sim/DeckConfigurator/StagingAreaConfigFixture.tsx b/components/src/hardware-sim/DeckConfigurator/StagingAreaConfigFixture.tsx index d1cd3af27d0..2ab3de6c3be 100644 --- a/components/src/hardware-sim/DeckConfigurator/StagingAreaConfigFixture.tsx +++ b/components/src/hardware-sim/DeckConfigurator/StagingAreaConfigFixture.tsx @@ -15,18 +15,31 @@ import { Y_ADJUSTMENT, } from './constants' -import type { CutoutId, DeckDefinition } from '@opentrons/shared-data' +import type { + CutoutFixtureId, + CutoutId, + DeckDefinition, +} from '@opentrons/shared-data' interface StagingAreaConfigFixtureProps { deckDefinition: DeckDefinition fixtureLocation: CutoutId - handleClickRemove?: (fixtureLocation: CutoutId) => void + cutoutFixtureId: CutoutFixtureId + handleClickRemove?: ( + fixtureLocation: CutoutId, + cutoutFixtureId: CutoutFixtureId + ) => void } export function StagingAreaConfigFixture( props: StagingAreaConfigFixtureProps ): JSX.Element { - const { deckDefinition, handleClickRemove, fixtureLocation } = props + const { + deckDefinition, + handleClickRemove, + fixtureLocation, + cutoutFixtureId, + } = props const stagingAreaCutout = deckDefinition.locations.cutouts.find( cutout => cutout.id === fixtureLocation @@ -60,7 +73,7 @@ export function StagingAreaConfigFixture( cursor={handleClickRemove != null ? 'pointer' : 'default'} onClick={ handleClickRemove != null - ? () => handleClickRemove(fixtureLocation) + ? () => handleClickRemove(fixtureLocation, cutoutFixtureId) : () => {} } > diff --git a/components/src/hardware-sim/DeckConfigurator/StaticFixture.tsx b/components/src/hardware-sim/DeckConfigurator/StaticFixture.tsx new file mode 100644 index 00000000000..a0fcd1d97d1 --- /dev/null +++ b/components/src/hardware-sim/DeckConfigurator/StaticFixture.tsx @@ -0,0 +1,56 @@ +import * as React from 'react' + +import { Btn, Text } from '../../primitives' +import { TYPOGRAPHY } from '../../ui-style-constants' +import { RobotCoordsForeignObject } from '../Deck/RobotCoordsForeignObject' +import { + CONFIG_STYLE_READ_ONLY, + FIXTURE_HEIGHT, + COLUMN_2_SINGLE_SLOT_FIXTURE_WIDTH, + Y_ADJUSTMENT, + COLUMN_2_X_ADJUSTMENT, +} from './constants' + +import type { CutoutId, DeckDefinition } from '@opentrons/shared-data' + +interface StaticFixtureProps { + deckDefinition: DeckDefinition + fixtureLocation: CutoutId + label: string +} + +/** + * this component allows us to add static labeled fixtures to the center column of a deck + * config map + */ + +export function StaticFixture(props: StaticFixtureProps): JSX.Element { + const { deckDefinition, fixtureLocation, label } = props + + const staticCutout = deckDefinition.locations.cutouts.find( + cutout => cutout.id === fixtureLocation + ) + + /** + * deck definition cutout position is the position of the single slot located within that cutout + * so, to get the position of the cutout itself we must add an adjustment to the slot position + */ + const [xSlotPosition = 0, ySlotPosition = 0] = staticCutout?.position ?? [] + const y = ySlotPosition + Y_ADJUSTMENT + const x = xSlotPosition + COLUMN_2_X_ADJUSTMENT + + return ( + + {}}> + {label} + + + ) +} diff --git a/components/src/hardware-sim/DeckConfigurator/TemperatureModuleFixture.tsx b/components/src/hardware-sim/DeckConfigurator/TemperatureModuleFixture.tsx new file mode 100644 index 00000000000..54d2a5aec6a --- /dev/null +++ b/components/src/hardware-sim/DeckConfigurator/TemperatureModuleFixture.tsx @@ -0,0 +1,101 @@ +import * as React from 'react' +import { Icon } from '../../icons' +import { Btn, Text } from '../../primitives' +import { TYPOGRAPHY } from '../../ui-style-constants' +import { COLORS } from '../../helix-design-system' +import { RobotCoordsForeignObject } from '../Deck/RobotCoordsForeignObject' +import { + COLUMN_1_X_ADJUSTMENT, + COLUMN_3_X_ADJUSTMENT, + CONFIG_STYLE_EDITABLE, + CONFIG_STYLE_READ_ONLY, + FIXTURE_HEIGHT, + COLUMN_3_SINGLE_SLOT_FIXTURE_WIDTH, + Y_ADJUSTMENT, +} from './constants' + +import type { + CutoutFixtureId, + CutoutId, + DeckDefinition, +} from '@opentrons/shared-data' + +// TODO(BC, 2024-03-21): This component is almost identical to HeaterShakerFixture, consider consolidating? + +const TEMPERATURE_MODULE_FIXTURE_DISPLAY_NAME = 'Temperature' + +interface TemperatureModuleFixtureProps { + deckDefinition: DeckDefinition + fixtureLocation: CutoutId + cutoutFixtureId: CutoutFixtureId + handleClickRemove?: ( + fixtureLocation: CutoutId, + cutoutFixtureId: CutoutFixtureId + ) => void +} + +export function TemperatureModuleFixture( + props: TemperatureModuleFixtureProps +): JSX.Element { + const { + deckDefinition, + handleClickRemove, + fixtureLocation, + cutoutFixtureId, + } = props + + const cutoutDef = deckDefinition.locations.cutouts.find( + cutout => cutout.id === fixtureLocation + ) + + /** + * deck definition cutout position is the position of the single slot located within that cutout + * so, to get the position of the cutout itself we must add an adjustment to the slot position + * the adjustment for x is different for right side/left side + */ + const [xSlotPosition = 0, ySlotPosition = 0] = cutoutDef?.position ?? [] + + const isColumnOne = + fixtureLocation === 'cutoutA1' || + fixtureLocation === 'cutoutB1' || + fixtureLocation === 'cutoutC1' || + fixtureLocation === 'cutoutD1' + const xAdjustment = isColumnOne + ? COLUMN_1_X_ADJUSTMENT + : COLUMN_3_X_ADJUSTMENT + const x = xSlotPosition + xAdjustment + + const y = ySlotPosition + Y_ADJUSTMENT + + return ( + + handleClickRemove(fixtureLocation, cutoutFixtureId) + : () => {} + } + > + + {TEMPERATURE_MODULE_FIXTURE_DISPLAY_NAME} + + {handleClickRemove != null ? ( + + ) : null} + + + ) +} diff --git a/components/src/hardware-sim/DeckConfigurator/ThermocyclerFixture.tsx b/components/src/hardware-sim/DeckConfigurator/ThermocyclerFixture.tsx new file mode 100644 index 00000000000..83369a736ad --- /dev/null +++ b/components/src/hardware-sim/DeckConfigurator/ThermocyclerFixture.tsx @@ -0,0 +1,88 @@ +import * as React from 'react' +import { Icon } from '../../icons' +import { Btn, Text } from '../../primitives' +import { TYPOGRAPHY } from '../../ui-style-constants' +import { COLORS } from '../../helix-design-system' +import { RobotCoordsForeignObject } from '../Deck/RobotCoordsForeignObject' +import { + COLUMN_1_X_ADJUSTMENT, + CONFIG_STYLE_EDITABLE, + CONFIG_STYLE_READ_ONLY, + COLUMN_3_SINGLE_SLOT_FIXTURE_WIDTH, + Y_ADJUSTMENT, + THERMOCYCLER_FIXTURE_HEIGHT, +} from './constants' + +import type { + CutoutFixtureId, + CutoutId, + DeckDefinition, +} from '@opentrons/shared-data' + +interface ThermocyclerFixtureProps { + deckDefinition: DeckDefinition + fixtureLocation: CutoutId + cutoutFixtureId: CutoutFixtureId + handleClickRemove?: ( + fixtureLocation: CutoutId, + cutoutFixtureId: CutoutFixtureId + ) => void +} + +const THERMOCYCLER_FIXTURE_DISPLAY_NAME = 'Thermocycler' + +export function ThermocyclerFixture( + props: ThermocyclerFixtureProps +): JSX.Element { + const { + deckDefinition, + handleClickRemove, + fixtureLocation, + cutoutFixtureId, + } = props + + const cutoutDef = deckDefinition.locations.cutouts.find( + cutout => cutout.id === fixtureLocation + ) + + /** + * deck definition cutout position is the position of the single slot located within that cutout + * so, to get the position of the cutout itself we must add an adjustment to the slot position + * the adjustment for x is different for right side/left side + */ + const [xSlotPosition = 0, ySlotPosition = 0] = cutoutDef?.position ?? [] + const x = xSlotPosition + COLUMN_1_X_ADJUSTMENT + const y = ySlotPosition + Y_ADJUSTMENT + + return ( + + handleClickRemove(fixtureLocation, cutoutFixtureId) + : () => {} + } + > + + {THERMOCYCLER_FIXTURE_DISPLAY_NAME} + + {handleClickRemove != null ? ( + + ) : null} + + + ) +} diff --git a/components/src/hardware-sim/DeckConfigurator/TrashBinConfigFixture.tsx b/components/src/hardware-sim/DeckConfigurator/TrashBinConfigFixture.tsx index 9a9a100d92f..5dd31a57750 100644 --- a/components/src/hardware-sim/DeckConfigurator/TrashBinConfigFixture.tsx +++ b/components/src/hardware-sim/DeckConfigurator/TrashBinConfigFixture.tsx @@ -11,23 +11,36 @@ import { CONFIG_STYLE_EDITABLE, CONFIG_STYLE_READ_ONLY, FIXTURE_HEIGHT, - SINGLE_SLOT_FIXTURE_WIDTH, + COLUMN_3_SINGLE_SLOT_FIXTURE_WIDTH, TRASH_BIN_DISPLAY_NAME, Y_ADJUSTMENT, } from './constants' -import type { CutoutId, DeckDefinition } from '@opentrons/shared-data' +import type { + CutoutFixtureId, + CutoutId, + DeckDefinition, +} from '@opentrons/shared-data' interface TrashBinConfigFixtureProps { deckDefinition: DeckDefinition fixtureLocation: CutoutId - handleClickRemove?: (fixtureLocation: CutoutId) => void + cutoutFixtureId: CutoutFixtureId + handleClickRemove?: ( + fixtureLocation: CutoutId, + cutoutFixtureId: CutoutFixtureId + ) => void } export function TrashBinConfigFixture( props: TrashBinConfigFixtureProps ): JSX.Element { - const { deckDefinition, handleClickRemove, fixtureLocation } = props + const { + deckDefinition, + handleClickRemove, + fixtureLocation, + cutoutFixtureId, + } = props const trashBinCutout = deckDefinition.locations.cutouts.find( cutout => cutout.id === fixtureLocation @@ -54,7 +67,7 @@ export function TrashBinConfigFixture( return ( handleClickRemove(fixtureLocation) + ? () => handleClickRemove(fixtureLocation, cutoutFixtureId) : () => {} } > diff --git a/components/src/hardware-sim/DeckConfigurator/WasteChuteConfigFixture.tsx b/components/src/hardware-sim/DeckConfigurator/WasteChuteConfigFixture.tsx index 1932fe3674b..d1e3c5959f2 100644 --- a/components/src/hardware-sim/DeckConfigurator/WasteChuteConfigFixture.tsx +++ b/components/src/hardware-sim/DeckConfigurator/WasteChuteConfigFixture.tsx @@ -11,17 +11,25 @@ import { CONFIG_STYLE_READ_ONLY, FIXTURE_HEIGHT, STAGING_AREA_FIXTURE_WIDTH, - SINGLE_SLOT_FIXTURE_WIDTH, + COLUMN_3_SINGLE_SLOT_FIXTURE_WIDTH, WASTE_CHUTE_DISPLAY_NAME, Y_ADJUSTMENT, } from './constants' -import type { CutoutId, DeckDefinition } from '@opentrons/shared-data' +import type { + CutoutFixtureId, + CutoutId, + DeckDefinition, +} from '@opentrons/shared-data' interface WasteChuteConfigFixtureProps { deckDefinition: DeckDefinition fixtureLocation: CutoutId - handleClickRemove?: (fixtureLocation: CutoutId) => void + cutoutFixtureId: CutoutFixtureId + handleClickRemove?: ( + fixtureLocation: CutoutId, + cutoutFixtureId: CutoutFixtureId + ) => void hasStagingAreas?: boolean } @@ -32,6 +40,7 @@ export function WasteChuteConfigFixture( deckDefinition, handleClickRemove, fixtureLocation, + cutoutFixtureId, hasStagingAreas = false, } = props @@ -52,7 +61,9 @@ export function WasteChuteConfigFixture( return ( handleClickRemove(fixtureLocation) + ? () => handleClickRemove(fixtureLocation, cutoutFixtureId) : () => {} } > diff --git a/components/src/hardware-sim/DeckConfigurator/constants.ts b/components/src/hardware-sim/DeckConfigurator/constants.ts index 53faef10b7e..4b47b9c5917 100644 --- a/components/src/hardware-sim/DeckConfigurator/constants.ts +++ b/components/src/hardware-sim/DeckConfigurator/constants.ts @@ -9,10 +9,14 @@ import { RESPONSIVENESS, SPACING } from '../../ui-style-constants' * Position is relative to deck definition slot positions and a custom stroke applied to the single slot fixture SVG */ export const FIXTURE_HEIGHT = 102.0 -export const SINGLE_SLOT_FIXTURE_WIDTH = 243.5 +export const THERMOCYCLER_FIXTURE_HEIGHT = 290.0 +export const COLUMN_1_SINGLE_SLOT_FIXTURE_WIDTH = 243.5 +export const COLUMN_2_SINGLE_SLOT_FIXTURE_WIDTH = 159.0 +export const COLUMN_3_SINGLE_SLOT_FIXTURE_WIDTH = 243.5 export const STAGING_AREA_FIXTURE_WIDTH = 314.5 export const COLUMN_1_X_ADJUSTMENT = -100 +export const COLUMN_2_X_ADJUSTMENT = -15.5 export const COLUMN_3_X_ADJUSTMENT = -15.5 export const Y_ADJUSTMENT = -8 diff --git a/components/src/hardware-sim/DeckConfigurator/index.tsx b/components/src/hardware-sim/DeckConfigurator/index.tsx index 9378471d8e0..8de6ba4da70 100644 --- a/components/src/hardware-sim/DeckConfigurator/index.tsx +++ b/components/src/hardware-sim/DeckConfigurator/index.tsx @@ -8,6 +8,11 @@ import { TRASH_BIN_ADAPTER_FIXTURE, WASTE_CHUTE_ONLY_FIXTURES, WASTE_CHUTE_STAGING_AREA_FIXTURES, + THERMOCYCLER_V2_FRONT_FIXTURE, + HEATERSHAKER_MODULE_V1_FIXTURE, + TEMPERATURE_MODULE_V2_FIXTURE, + MAGNETIC_BLOCK_V1_FIXTURE, + STAGING_AREA_SLOT_WITH_MAGNETIC_BLOCK_V1_FIXTURE, } from '@opentrons/shared-data' import { COLORS } from '../../helix-design-system' @@ -18,18 +23,32 @@ import { EmptyConfigFixture } from './EmptyConfigFixture' import { StagingAreaConfigFixture } from './StagingAreaConfigFixture' import { TrashBinConfigFixture } from './TrashBinConfigFixture' import { WasteChuteConfigFixture } from './WasteChuteConfigFixture' +import { StaticFixture } from './StaticFixture' -import type { CutoutId, DeckConfiguration } from '@opentrons/shared-data' +import type { + CutoutFixtureId, + CutoutId, + DeckConfiguration, +} from '@opentrons/shared-data' +import { TemperatureModuleFixture } from './TemperatureModuleFixture' +import { HeaterShakerFixture } from './HeaterShakerFixture' +import { MagneticBlockFixture } from './MagneticBlockFixture' +import { ThermocyclerFixture } from './ThermocyclerFixture' interface DeckConfiguratorProps { deckConfig: DeckConfiguration handleClickAdd: (cutoutId: CutoutId) => void - handleClickRemove: (cutoutId: CutoutId) => void + handleClickRemove: ( + cutoutId: CutoutId, + cutoutFixtureId: CutoutFixtureId + ) => void lightFill?: string darkFill?: string - readOnly?: boolean + editableCutoutIds?: CutoutId[] showExpansion?: boolean children?: React.ReactNode + additionalStaticFixtures?: Array<{ location: CutoutId; label: string }> + height?: string } export function DeckConfigurator(props: DeckConfiguratorProps): JSX.Element { @@ -37,56 +56,57 @@ export function DeckConfigurator(props: DeckConfiguratorProps): JSX.Element { deckConfig, handleClickAdd, handleClickRemove, + additionalStaticFixtures, + children, lightFill = COLORS.grey35, darkFill = COLORS.black90, - readOnly = false, + editableCutoutIds = deckConfig.map(({ cutoutId }) => cutoutId), showExpansion = true, - children, + height = '455px', } = props const deckDef = getDeckDefFromRobotType(FLEX_ROBOT_TYPE) - // restrict configuration to certain locations - const configurableFixtureLocations: CutoutId[] = [ - 'cutoutA1', - 'cutoutB1', - 'cutoutC1', - 'cutoutD1', - 'cutoutA3', - 'cutoutB3', - 'cutoutC3', - 'cutoutD3', - ] - const configurableDeckConfig = deckConfig.filter(({ cutoutId }) => - configurableFixtureLocations.includes(cutoutId) - ) - - const stagingAreaFixtures = configurableDeckConfig.filter( + const stagingAreaFixtures = deckConfig.filter( ({ cutoutFixtureId }) => cutoutFixtureId === STAGING_AREA_RIGHT_SLOT_FIXTURE ) - const wasteChuteFixtures = configurableDeckConfig.filter( + const wasteChuteFixtures = deckConfig.filter( ({ cutoutFixtureId }) => cutoutFixtureId != null && WASTE_CHUTE_ONLY_FIXTURES.includes(cutoutFixtureId) ) - const wasteChuteStagingAreaFixtures = configurableDeckConfig.filter( + const wasteChuteStagingAreaFixtures = deckConfig.filter( ({ cutoutFixtureId }) => cutoutFixtureId != null && WASTE_CHUTE_STAGING_AREA_FIXTURES.includes(cutoutFixtureId) ) - const emptyFixtures = readOnly - ? [] - : configurableDeckConfig.filter( - ({ cutoutFixtureId }) => - cutoutFixtureId != null && - SINGLE_SLOT_FIXTURES.includes(cutoutFixtureId) - ) - const trashBinFixtures = configurableDeckConfig.filter( + const emptyCutouts = deckConfig.filter( + ({ cutoutFixtureId, cutoutId }) => + editableCutoutIds.includes(cutoutId) && + cutoutFixtureId != null && + SINGLE_SLOT_FIXTURES.includes(cutoutFixtureId) + ) + const trashBinFixtures = deckConfig.filter( ({ cutoutFixtureId }) => cutoutFixtureId === TRASH_BIN_ADAPTER_FIXTURE ) + const thermocyclerFixtures = deckConfig.filter( + ({ cutoutFixtureId }) => cutoutFixtureId === THERMOCYCLER_V2_FRONT_FIXTURE + ) + const heaterShakerFixtures = deckConfig.filter( + ({ cutoutFixtureId }) => cutoutFixtureId === HEATERSHAKER_MODULE_V1_FIXTURE + ) + const temperatureModuleFixtures = deckConfig.filter( + ({ cutoutFixtureId }) => cutoutFixtureId === TEMPERATURE_MODULE_V2_FIXTURE + ) + const magneticBlockFixtures = deckConfig.filter(({ cutoutFixtureId }) => + ([ + MAGNETIC_BLOCK_V1_FIXTURE, + STAGING_AREA_SLOT_WITH_MAGNETIC_BLOCK_V1_FIXTURE, + ] as CutoutFixtureId[]).includes(cutoutFixtureId) + ) return ( @@ -102,15 +122,18 @@ export function DeckConfigurator(props: DeckConfiguratorProps): JSX.Element { /> ))} - {stagingAreaFixtures.map(({ cutoutId }) => ( + {stagingAreaFixtures.map(({ cutoutId, cutoutFixtureId }) => ( ))} - {emptyFixtures.map(({ cutoutId }) => ( + {emptyCutouts.map(({ cutoutId }) => ( ))} - {wasteChuteFixtures.map(({ cutoutId }) => ( + {wasteChuteFixtures.map(({ cutoutId, cutoutFixtureId }) => ( ))} - {wasteChuteStagingAreaFixtures.map(({ cutoutId }) => ( + {wasteChuteStagingAreaFixtures.map(({ cutoutId, cutoutFixtureId }) => ( ))} - {trashBinFixtures.map(({ cutoutId }) => ( + {trashBinFixtures.map(({ cutoutId, cutoutFixtureId }) => ( + ))} + {temperatureModuleFixtures.map(({ cutoutId, cutoutFixtureId }) => ( + + ))} + {heaterShakerFixtures.map(({ cutoutId, cutoutFixtureId }) => ( + + ))} + {magneticBlockFixtures.map(({ cutoutId, cutoutFixtureId }) => ( + + ))} + {thermocyclerFixtures.map(({ cutoutId, cutoutFixtureId }) => ( + + ))} + {additionalStaticFixtures?.map(staticFixture => ( + ))} s.id === slotName ) if (slotDef == null) { @@ -52,7 +52,7 @@ export function LegacyDeckSlotLocation( const slotPosition = getPositionFromSlotId( slotName, - (ot2DeckDefV4 as unknown) as DeckDefinition + (ot2DeckDefV5 as unknown) as DeckDefinition ) const isFixedTrash = slotName === 'fixedTrash' diff --git a/components/src/icons/Icon.stories.tsx b/components/src/icons/Icon.stories.tsx index 1be5df8581c..9d7b0f1141a 100644 --- a/components/src/icons/Icon.stories.tsx +++ b/components/src/icons/Icon.stories.tsx @@ -1,33 +1,38 @@ import * as React from 'react' - -import { Box, SIZE_3 } from '@opentrons/components' +import { Flex } from '../primitives' +import { SPACING } from '../ui-style-constants' import { ICON_DATA_BY_NAME } from './icon-data' import { Icon as IconComponent } from './Icon' +import type { Meta, StoryObj } from '@storybook/react' -import type { Story, Meta } from '@storybook/react' - -export default { +const meta: Meta = { title: 'Library/Atoms/Icon', + component: IconComponent, argTypes: { name: { + options: Object.keys(ICON_DATA_BY_NAME), control: { type: 'select', - options: Object.keys(ICON_DATA_BY_NAME), }, - defaultValue: 'alert', }, }, decorators: [ Story => ( - + - + ), ], -} as Meta +} -const Template: Story> = args => { - return +export default meta + +type Story = StoryObj + +export const Icon: Story = { + args: { + name: 'alert', + spin: false, + size: '4rem', + }, } -export const Icon = Template.bind({}) -Icon.args = { spin: false } diff --git a/components/src/icons/icon-data.ts b/components/src/icons/icon-data.ts index c805a8bbfba..e4f43123e13 100644 --- a/components/src/icons/icon-data.ts +++ b/components/src/icons/icon-data.ts @@ -632,6 +632,11 @@ export const ICON_DATA_BY_NAME = { 'M8.01487 8.84912C8.47511 8.84912 8.84821 8.47603 8.84821 8.01579C8.84821 7.55555 8.47511 7.18245 8.01487 7.18245C7.55464 7.18245 7.18154 7.55555 7.18154 8.01579C7.18154 8.47603 7.55464 8.84912 8.01487 8.84912Z M8.66654 0.928711V2.36089C11.27 2.66533 13.3354 4.73075 13.6398 7.33418H15.072V8.66751H13.6398C13.3354 11.2709 11.27 13.3363 8.66654 13.6408V15.073H7.3332V13.6408C4.72979 13.3363 2.66437 11.2709 2.35992 8.66751H0.927734V7.33418H2.35992C2.66436 4.73075 4.72978 2.66533 7.3332 2.36089V0.928711H8.66654ZM12.2944 7.33418H11.6184C11.2502 7.33418 10.9518 7.63266 10.9518 8.00085C10.9518 8.36904 11.2502 8.66751 11.6184 8.66751H12.2944C12.0071 10.5336 10.5326 12.008 8.66654 12.2953V11.6194C8.66654 11.2512 8.36806 10.9527 7.99987 10.9527C7.63168 10.9527 7.3332 11.2512 7.3332 11.6194V12.2953C5.46716 12.008 3.99268 10.5336 3.70536 8.66751H4.38132C4.74951 8.66751 5.04798 8.36904 5.04798 8.00085C5.04798 7.63266 4.74951 7.33418 4.38132 7.33418H3.70536C3.99267 5.46812 5.46715 3.99364 7.3332 3.70632V4.38229C7.3332 4.75048 7.63168 5.04896 7.99987 5.04896C8.36806 5.04896 8.66654 4.75048 8.66654 4.38229V3.70632C10.5326 3.99364 12.0071 5.46812 12.2944 7.33418Z', viewBox: '0 0 16 16', }, + send: { + path: + 'M6.96216 26.6667V5.33337L32.2955 16L6.96216 26.6667ZM9.62882 22.6667L25.4288 16L9.62882 9.33337V14L17.6288 16L9.62882 18V22.6667Z', + viewBox: '0 0 32 32', + }, settings: { path: 'M12,15.5A3.5,3.5 0 0,1 8.5,12A3.5,3.5 0 0,1 12,8.5A3.5,3.5 0 0,1 15.5,12A3.5,3.5 0 0,1 12,15.5M19.43,12.97C19.47,12.65 19.5,12.33 19.5,12C19.5,11.67 19.47,11.34 19.43,11L21.54,9.37C21.73,9.22 21.78,8.95 21.66,8.73L19.66,5.27C19.54,5.05 19.27,4.96 19.05,5.05L16.56,6.05C16.04,5.66 15.5,5.32 14.87,5.07L14.5,2.42C14.46,2.18 14.25,2 14,2H10C9.75,2 9.54,2.18 9.5,2.42L9.13,5.07C8.5,5.32 7.96,5.66 7.44,6.05L4.95,5.05C4.73,4.96 4.46,5.05 4.34,5.27L2.34,8.73C2.21,8.95 2.27,9.22 2.46,9.37L4.57,11C4.53,11.34 4.5,11.67 4.5,12C4.5,12.33 4.53,12.65 4.57,12.97L2.46,14.63C2.27,14.78 2.21,15.05 2.34,15.27L4.34,18.73C4.46,18.95 4.73,19.03 4.95,18.95L7.44,17.94C7.96,18.34 8.5,18.68 9.13,18.93L9.5,21.58C9.54,21.82 9.75,22 10,22H14C14.25,22 14.46,21.82 14.5,21.58L14.87,18.93C15.5,18.67 16.04,18.34 16.56,17.94L19.05,18.95C19.27,19.03 19.54,18.95 19.66,18.73L21.66,15.27C21.78,15.05 21.73,14.78 21.54,14.63L19.43,12.97Z', diff --git a/components/src/instrument/InfoItem.tsx b/components/src/instrument/InfoItem.tsx deleted file mode 100644 index 82b5a491a37..00000000000 --- a/components/src/instrument/InfoItem.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import * as React from 'react' - -import styles from './instrument.module.css' - -export interface InfoItemProps { - title: string | null - value: string - className?: string -} - -/** - * Used by `InstrumentInfo` for its titled values. - * But if you're using this, you probably want `LabeledValue` instead. - */ -export function InfoItem(props: InfoItemProps): JSX.Element { - const { title, value, className } = props - - return ( -
- {title != null ?

{title}

: null} - {value} -
- ) -} diff --git a/components/src/instrument/InstrumentInfo.tsx b/components/src/instrument/InstrumentInfo.tsx index d5d26a3b4b4..57ff12e0ed4 100644 --- a/components/src/instrument/InstrumentInfo.tsx +++ b/components/src/instrument/InstrumentInfo.tsx @@ -1,77 +1,103 @@ import * as React from 'react' import { LEFT, RIGHT } from '@opentrons/shared-data' -import { InfoItem } from './InfoItem' -import { InstrumentDiagram } from './InstrumentDiagram' -import styles from './instrument.module.css' import { Flex } from '../primitives' -import { SPACING } from '../ui-style-constants' +import { SPACING, TYPOGRAPHY } from '../ui-style-constants' +import { StyledText } from '../atoms' import { DIRECTION_COLUMN, JUSTIFY_CENTER } from '../styles' +import { InstrumentDiagram } from './InstrumentDiagram' import type { Mount } from '../robot-types' import type { InstrumentDiagramProps } from './InstrumentDiagram' +import styles from './instrument.module.css' + export interface InstrumentInfoProps { /** 'left' or 'right' */ mount: Mount - /** if true, show labels 'LEFT PIPETTE' / 'RIGHT PIPETTE' */ - showMountLabel?: boolean | null /** human-readable description, eg 'p300 Single-channel' */ description: string - /** paired tiprack models */ - tiprackModels?: string[] - /** if disabled, pipette & its info are grayed out */ - isDisabled: boolean /** specs of mounted pipette */ pipetteSpecs?: InstrumentDiagramProps['pipetteSpecs'] | null - /** classes to apply */ - className?: string - /** classes to apply to the info group child */ - infoClassName?: string + /** paired tiprack models */ + tiprackModels?: string[] /** children to display under the info */ children?: React.ReactNode + /** if true, show labels 'LEFT PIPETTE' / 'RIGHT PIPETTE' */ + showMountLabel?: boolean | null } +const MAX_WIDTH = '14rem' + export function InstrumentInfo(props: InstrumentInfoProps): JSX.Element { - const has96Channel = props.pipetteSpecs?.channels === 96 + const { + mount, + showMountLabel, + description, + tiprackModels, + pipetteSpecs, + children, + } = props + + const has96Channel = pipetteSpecs?.channels === 96 return ( - {props.mount === RIGHT && props.pipetteSpecs && ( + {mount === RIGHT && pipetteSpecs ? ( - )} + ) : null} + {/* NOTE: the color is our legacy c-font-dark, which matches the other colors in this component **/} + + + + {showMountLabel && !has96Channel ? `${mount} pipette` : 'pipette'} + + + {description} + + - - - {props.tiprackModels != null - ? props.tiprackModels.map((model, index) => ( - - )) - : null} + + + {'Tip rack'} + +
    + {tiprackModels != null && tiprackModels.length > 0 ? ( + tiprackModels.map((model, index) => ( +
  • + + {model} + +
  • + )) + ) : ( + + {'None'} + + )} +
+
- {props.children} - {props.mount === LEFT && props.pipetteSpecs && ( + {children} + {mount === LEFT && pipetteSpecs ? ( - )} + ) : null}
) } diff --git a/components/src/instrument/__tests__/InstrumentInfo.test.tsx b/components/src/instrument/__tests__/InstrumentInfo.test.tsx new file mode 100644 index 00000000000..bf92c48d4cb --- /dev/null +++ b/components/src/instrument/__tests__/InstrumentInfo.test.tsx @@ -0,0 +1,54 @@ +import * as React from 'react' +import { screen } from '@testing-library/react' +import { describe, beforeEach, it, vi } from 'vitest' +import { LEFT, RIGHT, fixtureP1000SingleV2Specs } from '@opentrons/shared-data' +import { renderWithProviders } from '../../testing/utils' +import { InstrumentInfo } from '../InstrumentInfo' +import { InstrumentDiagram } from '../InstrumentDiagram' + +vi.mock('../InstrumentDiagram') +const render = (props: React.ComponentProps) => { + return renderWithProviders()[0] +} + +describe('InstrumentInfo', () => { + let props: React.ComponentProps + + beforeEach(() => { + props = { + mount: LEFT, + description: 'mock description', + pipetteSpecs: fixtureP1000SingleV2Specs, + tiprackModels: ['mock1', 'mock2'], + showMountLabel: true, + } + vi.mocked(InstrumentDiagram).mockReturnValue( +
mock instrumentDiagram
+ ) + }) + it('renders a p1000 pipette with 2 tiprack models for left mount', () => { + render(props) + screen.getByText('mock instrumentDiagram') + screen.getByText('left pipette') + screen.getByText('mock description') + screen.getByText('Tip rack') + screen.getByText('mock1') + screen.getByText('mock2') + }) + it('renders a p1000 pipette with 1 tiprack model for right mount', () => { + props.mount = RIGHT + props.tiprackModels = ['mock1'] + render(props) + screen.getByText('mock instrumentDiagram') + screen.getByText('right pipette') + screen.getByText('mock description') + screen.getByText('Tip rack') + screen.getByText('mock1') + }) + it('renders none for pip and tiprack if none are selected', () => { + props.pipetteSpecs = undefined + props.tiprackModels = undefined + render(props) + screen.getByText('None') + }) +}) diff --git a/components/src/instrument/index.ts b/components/src/instrument/index.ts index 1153df43ae7..d566fb66e5b 100644 --- a/components/src/instrument/index.ts +++ b/components/src/instrument/index.ts @@ -1,4 +1,3 @@ -export * from './InfoItem' export * from './InstrumentDiagram' export * from './InstrumentGroup' export * from './InstrumentInfo' diff --git a/components/src/lists/TitledList.tsx b/components/src/lists/TitledList.tsx index 58a12d19b6e..4fbe4ab58ee 100644 --- a/components/src/lists/TitledList.tsx +++ b/components/src/lists/TitledList.tsx @@ -2,10 +2,13 @@ import * as React from 'react' import cx from 'classnames' -import styles from './lists.module.css' import { Icon } from '../icons' +import { StyledText } from '../atoms' +import { COLORS } from '../helix-design-system' import type { IconName, IconProps } from '../icons' +import styles from './lists.module.css' + // TODO(bc, 2021-03-31): reconsider whether this belongs in components library // it is bloated with application specific functionality @@ -98,6 +101,15 @@ export function TitledList(props: TitledListProps): JSX.Element { iconProps && iconProps.className ) + let textColor = '' + if (disabled) { + // the below hex code is for our legacy --c-font-disabled to match other text colors + textColor = '#9c9c9c' + } else if (props.selected && !disabled) { + // the below hex code is for our legacy --c-highlight to match other text colors + textColor = '#5fd8ee' + } + return (
)} -

+ {props.title} -

+ {collapsible && (
= { + title: 'Library/Molecules/LocationIcon', argTypes: { iconName: { control: { type: 'select', - options: Object.keys(ICON_DATA_BY_NAME), }, - defaultValue: undefined, + options: Object.keys(ICON_DATA_BY_NAME), }, slotName: { control: { type: 'select', - options: slots, }, - defaultValue: undefined, + options: slots, }, }, component: LocationIcon, - // Note (kk:08/29/2023) this component is located in components so avoid importing const from app parameters: { viewport: { viewports: customViewports, @@ -56,26 +51,25 @@ export default { }, decorators: [ Story => ( - <> + - + ), ], -} as Meta - -const Template: Story> = args => ( - - - -) +} +export default meta +type Story = StoryObj -export const DisplaySlot = Template.bind({}) -DisplaySlot.args = { - slotName: 'A1', +export const DisplaySlot: Story = { + args: { + slotName: 'A1', + iconName: undefined, + }, } -export const DisplayIcon = Template.bind({}) -DisplayIcon.args = { - iconName: 'ot-temperature-v2', +export const DisplayIcon: Story = { + args: { + iconName: 'ot-temperature-v2', + }, } diff --git a/components/src/molecules/ParametersTable/NoParameters.tsx b/components/src/molecules/ParametersTable/InfoScreen.tsx similarity index 52% rename from components/src/molecules/ParametersTable/NoParameters.tsx rename to components/src/molecules/ParametersTable/InfoScreen.tsx index b0afb82530f..cd6db0d622b 100644 --- a/components/src/molecules/ParametersTable/NoParameters.tsx +++ b/components/src/molecules/ParametersTable/InfoScreen.tsx @@ -7,10 +7,33 @@ import { Icon } from '../../icons' import { Flex } from '../../primitives' import { ALIGN_CENTER, DIRECTION_COLUMN } from '../../styles' -interface NoParametersProps { +interface InfoScreenProps { + contentType: 'parameters' | 'moduleControls' | 'runNotStarted' t?: any } -export function NoParameters({ t }: NoParametersProps): JSX.Element { + +export function InfoScreen({ contentType, t }: InfoScreenProps): JSX.Element { + let bodyText: string = '' + switch (contentType) { + case 'parameters': + bodyText = + t != null + ? t('no_parameters_specified_in_protocol') + : 'No parameters specified in this protocol' + break + case 'moduleControls': + bodyText = + t != null + ? t('connect_modules_for_controls') + : 'Connect modules to see controls' + break + case 'runNotStarted': + bodyText = t != null ? t('run_never_started') : 'Run was never started' + break + default: + bodyText = contentType + } + return ( - {t != null - ? t('no_parameters') - : 'No parameters specified in this protocol'} + {bodyText} ) diff --git a/components/src/molecules/ParametersTable/ParametersTable.stories.tsx b/components/src/molecules/ParametersTable/ParametersTable.stories.tsx index ce55f700dc3..d68e2f80a95 100644 --- a/components/src/molecules/ParametersTable/ParametersTable.stories.tsx +++ b/components/src/molecules/ParametersTable/ParametersTable.stories.tsx @@ -1,15 +1,10 @@ -import * as React from 'react' -import { ParametersTable } from '@opentrons/components' -import type { Story, Meta } from '@storybook/react' -import type { RunTimeParameter } from '@opentrons/shared-data' - -export default { - title: 'Library/Molecules/ParametersTable', -} as Meta +import * as React from 'react-remove-scroll' +import { Flex } from '../../primitives' +import { SPACING } from '../../ui-style-constants' +import { ParametersTable } from './index' -const Template: Story> = args => ( - -) +import type { Meta, StoryObj } from '@storybook/react' +import type { RunTimeParameter } from '@opentrons/shared-data' const runTimeParameters: RunTimeParameter[] = [ { @@ -17,7 +12,7 @@ const runTimeParameters: RunTimeParameter[] = [ displayName: 'Dry Run', variableName: 'DRYRUN', description: 'Is this a dry or wet run? Wet is true, dry is false', - type: 'boolean', + type: 'bool', default: false, }, { @@ -25,7 +20,7 @@ const runTimeParameters: RunTimeParameter[] = [ displayName: 'Use Gripper', variableName: 'USE_GRIPPER', description: 'For using the gripper.', - type: 'boolean', + type: 'bool', default: true, }, { @@ -34,7 +29,7 @@ const runTimeParameters: RunTimeParameter[] = [ variableName: 'TIP_TRASH', description: 'to throw tip into the trash or to not throw tip into the trash', - type: 'boolean', + type: 'bool', default: true, }, { @@ -42,7 +37,7 @@ const runTimeParameters: RunTimeParameter[] = [ displayName: 'Deactivate Temperatures', variableName: 'DEACTIVATE_TEMP', description: 'deactivate temperature on the module', - type: 'boolean', + type: 'bool', default: true, }, { @@ -153,7 +148,24 @@ const runTimeParameters: RunTimeParameter[] = [ default: 'flex', }, ] -export const Default = Template.bind({}) -Default.args = { - runTimeParameters: runTimeParameters, + +const meta: Meta = { + title: 'Library/Molecules/ParametersTable', + component: ParametersTable, + decorators: [ + Story => ( + + + + ), + ], +} +export default meta + +type Story = StoryObj + +export const DefaultParameterTable: Story = { + args: { + runTimeParameters: runTimeParameters, + }, } diff --git a/components/src/molecules/ParametersTable/__tests__/InfoScreen.test.tsx b/components/src/molecules/ParametersTable/__tests__/InfoScreen.test.tsx new file mode 100644 index 00000000000..a6f3b78a358 --- /dev/null +++ b/components/src/molecules/ParametersTable/__tests__/InfoScreen.test.tsx @@ -0,0 +1,49 @@ +import * as React from 'react' +import { screen } from '@testing-library/react' +import { describe, it, expect, beforeEach } from 'vitest' + +import { renderWithProviders } from '../../../testing/utils' +import { BORDERS, COLORS } from '../../../helix-design-system' +import { InfoScreen } from '../InfoScreen' + +const render = (props: React.ComponentProps) => { + return renderWithProviders() +} + +describe('InfoScreen', () => { + let props: React.ComponentProps + + beforeEach(() => { + props = { + contentType: 'parameters', + } + }) + + it('should render text and icon with proper color - parameters', () => { + render(props) + screen.getByLabelText('alert') + screen.getByText('No parameters specified in this protocol') + }) + + it('should render text and icon with proper color - module controls', () => { + props = { + contentType: 'moduleControls', + } + render(props) + screen.getByLabelText('alert') + screen.getByText('Connect modules to see controls') + }) + + it('should have proper styles', () => { + render(props) + expect(screen.getByTestId('InfoScreen_parameters')).toHaveStyle( + `background-color: ${COLORS.grey30}` + ) + expect(screen.getByTestId('InfoScreen_parameters')).toHaveStyle( + `border-radius: ${BORDERS.borderRadius8}` + ) + expect(screen.getByLabelText('alert')).toHaveStyle( + `color: ${COLORS.grey60}` + ) + }) +}) diff --git a/components/src/molecules/ParametersTable/__tests__/NoParameters.test.tsx b/components/src/molecules/ParametersTable/__tests__/NoParameters.test.tsx deleted file mode 100644 index 5b2e7f2927d..00000000000 --- a/components/src/molecules/ParametersTable/__tests__/NoParameters.test.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import * as React from 'react' -import { screen } from '@testing-library/react' -import { describe, it, expect } from 'vitest' - -import { renderWithProviders } from '../../../testing/utils' -import { BORDERS, COLORS } from '../../../helix-design-system' -import { NoParameters } from '../NoParameters' - -const render = (props: React.ComponentProps) => { - return renderWithProviders() -} - -const tMock = (key: string) => key - -describe('NoParameters', () => { - it('should render text and icon with proper color', () => { - render({}) - screen.getByLabelText('alert') - screen.getByText('No parameters specified in this protocol') - }) - - it('should have proper styles', () => { - render({}) - expect(screen.getByTestId('NoRunTimeParameter')).toHaveStyle( - `background-color: ${COLORS.grey30}` - ) - expect(screen.getByTestId('NoRunTimeParameter')).toHaveStyle( - `border-radius: ${BORDERS.borderRadius8}` - ) - expect(screen.getByLabelText('alert')).toHaveStyle( - `color: ${COLORS.grey60}` - ) - }) - - it('should render the raw i18n value if a t is provided', () => { - render({ - t: tMock, - }) - screen.getByText('no_parameters') - }) -}) diff --git a/components/src/molecules/ParametersTable/__tests__/ParametersTable.test.tsx b/components/src/molecules/ParametersTable/__tests__/ParametersTable.test.tsx index 1c9cd2d571c..5cd4b59a59b 100644 --- a/components/src/molecules/ParametersTable/__tests__/ParametersTable.test.tsx +++ b/components/src/molecules/ParametersTable/__tests__/ParametersTable.test.tsx @@ -13,7 +13,7 @@ const mockRunTimeParameter: RunTimeParameter[] = [ variableName: 'TIP_TRASH', description: 'to throw tip into the trash or to not throw tip into the trash', - type: 'boolean', + type: 'bool', default: true, value: true, }, @@ -74,7 +74,7 @@ const render = (props: React.ComponentProps) => { return renderWithProviders() } -describe('ParametersTabl', () => { +describe('ParametersTable', () => { let props: React.ComponentProps beforeEach(() => { @@ -98,12 +98,14 @@ describe('ParametersTabl', () => { screen.getByText('EtoH Volume') screen.getByText('6.5 mL') - screen.getByText('1.5-10') + screen.getByText('1.5-10.0') + // more than 2 options screen.getByText('Default Module Offsets') screen.getByText('No offsets') - screen.getByText('3 choices') + screen.getByText('3 options') + // 2 options screen.getByText('pipette mount') screen.getByText('Left') screen.getByText('Left, Right') @@ -116,4 +118,9 @@ describe('ParametersTabl', () => { screen.getByText('default_value') screen.getByText('range') }) + + it('should render a description icon if description is provided', () => { + render(props) + screen.getByTestId('Icon_0') + }) }) diff --git a/components/src/molecules/ParametersTable/index.tsx b/components/src/molecules/ParametersTable/index.tsx index 4ff5cdeeb18..5ae0d36d550 100644 --- a/components/src/molecules/ParametersTable/index.tsx +++ b/components/src/molecules/ParametersTable/index.tsx @@ -1,9 +1,17 @@ import * as React from 'react' -import styled from 'styled-components' -import { formatRunTimeParameterValue } from '@opentrons/shared-data' -import { BORDERS } from '../../helix-design-system' +import styled, { css } from 'styled-components' +import { + formatRunTimeParameterDefaultValue, + formatRunTimeParameterMinMax, + orderRuntimeParameterRangeOptions, +} from '@opentrons/shared-data' +import { BORDERS, COLORS } from '../../helix-design-system' import { SPACING, TYPOGRAPHY } from '../../ui-style-constants/index' import { StyledText } from '../../atoms/StyledText' +import { Tooltip, useHoverTooltip } from '../../tooltips' +import { Icon } from '../../icons' +import { Flex } from '../../primitives' +import { DISPLAY_INLINE } from '../../styles' import type { RunTimeParameter } from '@opentrons/shared-data' @@ -19,29 +27,30 @@ export function ParametersTable({ runTimeParameters, t, }: ProtocolParameterItemsProps): JSX.Element { - const formatRange = ( - runTimeParameter: RunTimeParameter, - minMax: string - ): string => { + const formatRange = (runTimeParameter: RunTimeParameter): string => { const { type } = runTimeParameter + const minMax = formatRunTimeParameterMinMax(runTimeParameter) const choices = 'choices' in runTimeParameter ? runTimeParameter.choices : [] const count = choices.length + if (count > 0) { + return count > 2 + ? t != null + ? t('num_options', { num: count }) + : `${count} options` + : orderRuntimeParameterRangeOptions(choices) + } + switch (type) { case 'int': case 'float': return minMax - case 'boolean': + case 'bool': return t != null ? t('on_off') : 'On, off' - case 'str': - if (count > 2) { - return t != null ? t('choices', { count }) : `${count} choices` - } else { - return choices.map(choice => choice.displayName).join(', ') - } + default: + return '' } - return '' } return ( @@ -57,25 +66,27 @@ export function ParametersTable({ {runTimeParameters.map((parameter: RunTimeParameter, index: number) => { - const min = 'min' in parameter ? parameter.min : 0 - const max = 'max' in parameter ? parameter.max : 0 return ( - - {parameter.displayName} - + - {formatRunTimeParameterValue(parameter, t)} + {formatRunTimeParameterDefaultValue(parameter, t)} - - - {formatRange(parameter, `${min}-${max}`)} - + + {formatRange(parameter)} ) @@ -85,6 +96,52 @@ export function ParametersTable({ ) } +interface ParameterNameProps { + displayName: string + description: string | null + isLast: boolean + index: number +} + +const ParameterName = (props: ParameterNameProps): JSX.Element => { + const { displayName, description, isLast, index } = props + const [targetProps, tooltipProps] = useHoverTooltip() + + return ( + + + {displayName} + + {description != null ? ( + <> + + + + + {description} + + + ) : null} + + ) +} + const StyledTable = styled.table` width: 100%; border-collapse: collapse; @@ -93,7 +150,8 @@ const StyledTable = styled.table` const StyledTableHeader = styled.th` ${TYPOGRAPHY.labelSemiBold} - padding: ${SPACING.spacing8}; + grid-gap: ${SPACING.spacing16}; + padding-bottom: ${SPACING.spacing8}; border-bottom: ${BORDERS.lineBorder}; ` @@ -102,16 +160,21 @@ interface StyledTableRowProps { } const StyledTableRow = styled.tr` - padding: ${SPACING.spacing8}; + grid-gap: ${SPACING.spacing16}; border-bottom: ${props => (props.isLast ? 'none' : BORDERS.lineBorder)}; ` interface StyledTableCellProps { isLast: boolean + paddingRight?: string + display?: string } const StyledTableCell = styled.td` - padding-left: ${SPACING.spacing8}; + width: 33%; + display: ${props => (props.display != null ? props.display : 'table-cell')}; padding-top: ${SPACING.spacing12}; padding-bottom: ${props => (props.isLast ? 0 : SPACING.spacing12)}; + padding-right: ${props => + props.paddingRight != null ? props.paddingRight : SPACING.spacing16}; ` diff --git a/components/src/molecules/RoundTab.stories.tsx b/components/src/molecules/RoundTab.stories.tsx index be08c541743..fc0821c793d 100644 --- a/components/src/molecules/RoundTab.stories.tsx +++ b/components/src/molecules/RoundTab.stories.tsx @@ -1,55 +1,80 @@ import * as React from 'react' import { SPACING, TYPOGRAPHY } from '../ui-style-constants' -import { Flex, Text } from '../primitives' -import { DIRECTION_ROW } from '../styles' -import { RoundTab } from './RoundTab' -import type { Story, Meta } from '@storybook/react' +import { Flex } from '../primitives' +import { StyledText } from '../atoms/StyledText' +import { DIRECTION_COLUMN, DIRECTION_ROW } from '../styles' +import { RoundTab as RoundTabComponent } from './RoundTab' +import type { Meta, StoryObj } from '@storybook/react' -export default { +const meta: Meta = { title: 'Library/Molecules/RoundTab', - component: RoundTab, -} as Meta - -const Template: Story< - React.ComponentProps -> = (): JSX.Element => { - const [step, setStep] = React.useState<'details' | 'pipette' | 'module'>( - 'details' - ) + component: RoundTabComponent, + decorators: [Story => ], +} +export default meta + +const Tabs = (): JSX.Element => { + const [step, setStep] = React.useState< + 'setup' | 'parameters' | 'module controls' | 'run preview' + >('setup') return ( - setStep('details')} - > - - {'Protocol Name and Description'} - - - - setStep('pipette')} + + setStep('setup')} + tabName={'setup'} + > + + {'Setup'} + + + + setStep('parameters')} + > + + {'Parameters'} + + + + setStep('module controls')} + > + + {'Module Controls'} + + + + setStep('run preview')} + > + + {'Run Preview'} + + + + - - {'Pipette Selection'} - - - - setStep('module')}> - - {'Module Selection'} - - + {step} + ) } -export const Basic = Template.bind({}) -Basic.args = {} +type Story = StoryObj + +export const RoundTab: Story = { + args: {}, +} diff --git a/components/src/molecules/index.tsx b/components/src/molecules/index.ts similarity index 66% rename from components/src/molecules/index.tsx rename to components/src/molecules/index.ts index 3231c2f93a9..cc7a1eacdbd 100644 --- a/components/src/molecules/index.tsx +++ b/components/src/molecules/index.ts @@ -1,4 +1,4 @@ export * from './LocationIcon' export * from './RoundTab' export * from './ParametersTable' -export * from './ParametersTable/NoParameters' +export * from './ParametersTable/InfoScreen' diff --git a/components/src/primitives/Box.stories.tsx b/components/src/primitives/Box.stories.tsx index 3d322842a0a..54fd773d125 100644 --- a/components/src/primitives/Box.stories.tsx +++ b/components/src/primitives/Box.stories.tsx @@ -1,21 +1,25 @@ -import * as React from 'react' +import { COLORS, BORDERS } from '../helix-design-system' +import { SPACING } from '../ui-style-constants' import { Box as BoxComponent } from './Box' -import type { Story, Meta } from '@storybook/react' +import type { Meta, StoryObj } from '@storybook/react' -export default { +const meta: Meta = { title: 'Library/Atoms/Box', -} as Meta + component: BoxComponent, +} + +export default meta + +type Story = StoryObj -const Template: Story> = args => ( - -) -export const Box = Template.bind({}) -Box.args = { - children: - 'This is a simple box atom that accepts all primitive styling props.', - backgroundColor: 'grey', - border: '1px solid black', - padding: '1rem', - maxWidth: '20rem', +export const Box: Story = { + args: { + children: + 'This is a simple box atom that accepts all primitive styling props.', + backgroundColor: COLORS.grey60, + border: `1px ${BORDERS.styleSolid} black`, + padding: SPACING.spacing16, + maxWidth: '20rem', + }, } diff --git a/components/src/primitives/Flex.stories.tsx b/components/src/primitives/Flex.stories.tsx index f9773b5fc55..1335fa52919 100644 --- a/components/src/primitives/Flex.stories.tsx +++ b/components/src/primitives/Flex.stories.tsx @@ -1,35 +1,51 @@ import * as React from 'react' -import { Flex as FlexComponent } from './Flex' -import { - Box, - DIRECTION_COLUMN, - JUSTIFY_SPACE_AROUND, -} from '@opentrons/components' +import { BORDERS, COLORS } from '../helix-design-system' +import { SPACING } from '../ui-style-constants' +import { DIRECTION_COLUMN, JUSTIFY_SPACE_AROUND } from '../styles' +import { StyledText } from '../atoms/StyledText' +import { Box, Flex as FlexComponent } from '../primitives' -import type { Story, Meta } from '@storybook/react' +import type { Meta, StoryObj } from '@storybook/react' -export default { +const meta: Meta = { title: 'Library/Atoms/Flex', -} as Meta + component: FlexComponent, +} + +export default meta + +type Story = StoryObj -const Template: Story> = args => ( - -) -export const Flex = Template.bind({}) -Flex.args = { - children: [ - - This is a flex child - , - - This is a flex child - , - ], - flexDirection: DIRECTION_COLUMN, - justifyContent: JUSTIFY_SPACE_AROUND, - backgroundColor: 'grey', - border: '1px solid black', - padding: '1rem', - maxWidth: '20rem', - height: '10rem', +export const Flex: Story = { + args: { + children: [ + + + This is a flex child + + , + + + This is a flex child + + , + ], + flexDirection: DIRECTION_COLUMN, + justifyContent: JUSTIFY_SPACE_AROUND, + backgroundColor: 'grey', + border: '1px solid black', + padding: '1rem', + maxWidth: '20rem', + height: '10rem', + }, } diff --git a/components/src/primitives/Link.stories.tsx b/components/src/primitives/Link.stories.tsx index 2f54b472920..1aa3890d293 100644 --- a/components/src/primitives/Link.stories.tsx +++ b/components/src/primitives/Link.stories.tsx @@ -1,24 +1,27 @@ -import * as React from 'react' import { Link } from './Link' -import type { Story, Meta } from '@storybook/react' +import type { Meta, StoryObj } from '@storybook/react' -export default { +const meta: Meta = { title: 'Library/Atoms/Link', -} as Meta + component: Link, +} + +export default meta + +type Story = StoryObj -const Template: Story> = args => ( - -) -export const Basic = Template.bind({}) -Basic.args = { - children: 'hello anchor', - href: '#', +export const Basic: Story = { + args: { + children: 'hello anchor', + href: '#', + }, } -export const External = Template.bind({}) -External.args = { - children: 'hello opentrons', - external: true, - href: 'https://www.opentrons.com', +export const External: Story = { + args: { + children: 'hello opentrons', + external: true, + href: 'https://www.opentrons.com', + }, } diff --git a/components/src/styles/flexbox.ts b/components/src/styles/flexbox.ts index bc588372e96..2c36936b200 100644 --- a/components/src/styles/flexbox.ts +++ b/components/src/styles/flexbox.ts @@ -1,6 +1,7 @@ export const FLEX_NONE = 'none' export const FLEX_AUTO = 'auto' export const FLEX_MIN_CONTENT = 'min-content' +export const FLEX_MAX_CONTENT = 'max-content' export const ALIGN_NORMAL = 'normal' export const ALIGN_START = 'start' diff --git a/components/src/ui-style-constants/index.ts b/components/src/ui-style-constants/index.ts index 21a599f031c..e61234d0e96 100644 --- a/components/src/ui-style-constants/index.ts +++ b/components/src/ui-style-constants/index.ts @@ -1,3 +1,4 @@ export * as RESPONSIVENESS from './responsiveness' -export * as TYPOGRAPHY from './typography' export * as SPACING from './spacing' +export * as TYPOGRAPHY from './typography' +export * as VIEWPORT from './viewport' diff --git a/components/src/ui-style-constants/spacing.ts b/components/src/ui-style-constants/spacing.ts index bdd4dbcab26..2fd0e0c9ecd 100644 --- a/components/src/ui-style-constants/spacing.ts +++ b/components/src/ui-style-constants/spacing.ts @@ -9,6 +9,7 @@ export const spacing20 = '1.25rem' as const // 20px export const spacing24 = '1.5rem' as const // 24px export const spacing32 = '2rem' as const // 32px export const spacing40 = '2.5rem' as const // 40px +export const spacing44 = '2.75rem' as const // 44px export const spacing48 = '3rem' as const // 48px export const spacing60 = '3.75rem' as const // 60px export const spacing68 = '4.25rem' as const // 68px diff --git a/app/src/DesignTokens/constants.ts b/components/src/ui-style-constants/viewport.ts similarity index 100% rename from app/src/DesignTokens/constants.ts rename to components/src/ui-style-constants/viewport.ts diff --git a/components/webpack.config.js b/components/webpack.config.js deleted file mode 100644 index 648eaee3432..00000000000 --- a/components/webpack.config.js +++ /dev/null @@ -1,38 +0,0 @@ -'use strict' - -const path = require('path') -const { rules } = require('@opentrons/webpack-config') - -const ENTRY_INDEX = path.join(__dirname, 'src/barrel.ts') -const OUTPUT_PATH = path.join(__dirname, 'lib') - -module.exports = { - target: 'web', - entry: { index: ENTRY_INDEX }, - output: { - path: OUTPUT_PATH, - filename: 'opentrons-components.js', - library: '@opentrons/components', - libraryTarget: 'umd', - globalObject: 'this', - }, - mode: 'production', - module: { rules: [rules.js] }, - resolve: { - extensions: ['.wasm', '.mjs', '.js', '.ts', '.tsx', '.json'], - }, - externals: { - react: { - root: 'React', - commonjs2: 'react', - commonjs: 'react', - amd: 'react', - }, - 'react-dom': { - root: 'ReactDOM', - commonjs2: 'react-dom', - commonjs: 'react-dom', - amd: 'react-dom', - }, - }, -} diff --git a/discovery-client/vite.config.ts b/discovery-client/vite.config.ts index 7cbd9ae43c3..c67977a8359 100644 --- a/discovery-client/vite.config.ts +++ b/discovery-client/vite.config.ts @@ -1,13 +1,14 @@ -import { versionForProject } from '../scripts/git-version' +import { versionForProject } from '../scripts/git-version.mjs' import pkg from './package.json' import path from 'path' -import { UserConfig, defineConfig } from 'vite' +import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' import postCssImport from 'postcss-import' import postCssApply from 'postcss-apply' import postColorModFunction from 'postcss-color-mod-function' import postCssPresetEnv from 'postcss-preset-env' import lostCss from 'lost' +import type { UserConfig } from 'vite' export default defineConfig( async (): Promise => { diff --git a/discovery-client/webpack.config.js b/discovery-client/webpack.config.js deleted file mode 100644 index c15a3bae1c2..00000000000 --- a/discovery-client/webpack.config.js +++ /dev/null @@ -1,26 +0,0 @@ -'use strict' - -const path = require('path') -const webpackMerge = require('webpack-merge') -const { DefinePlugin } = require('webpack') -const { nodeBaseConfig } = require('@opentrons/webpack-config') -const { versionForProject } = require('../scripts/git-version') - -const ENTRY_INDEX = path.join(__dirname, 'src/index.ts') -const ENTRY_CLI = path.join(__dirname, 'src/cli.ts') -const OUTPUT_PATH = path.join(__dirname, 'lib') -const project = process.env.OPENTRONS_PROJECT ?? 'robot-stack' - -module.exports = async () => - webpackMerge(nodeBaseConfig, { - entry: { - index: ENTRY_INDEX, - cli: ENTRY_CLI, - }, - output: { path: OUTPUT_PATH }, - plugins: [ - new DefinePlugin({ - _PKG_VERSION_: JSON.stringify(await versionForProject(project)), - }), - ], - }) diff --git a/hardware-testing/Makefile b/hardware-testing/Makefile index 5e6d7264113..afe2a57c2ee 100755 --- a/hardware-testing/Makefile +++ b/hardware-testing/Makefile @@ -79,36 +79,26 @@ sdist: .PHONY: test test: - -$(MAKE) apply-patches-gravimetric $(pytest) $(tests) $(test_opts) - -$(MAKE) remove-patches-gravimetric .PHONY: test-cov test-cov: - -$(MAKE) apply-patches-gravimetric $(pytest) $(tests) $(test_opts) $(cov_opts) - -$(MAKE) remove-patches-gravimetric .PHONY: test-photometric-single test-photometric-single: - -$(MAKE) apply-patches-gravimetric $(python) -m hardware_testing.gravimetric --photometric --simulate --pipette 50 --channels 1 --tip 50 $(python) -m hardware_testing.gravimetric --photometric --simulate --pipette 50 --channels 1 --tip 50 --photoplate-col-offset 3 $(python) -m hardware_testing.gravimetric --photometric --simulate --pipette 50 --channels 1 --tip 50 --dye-well-col-offset 3 - -$(MAKE) remove-patches-gravimetric .PHONY: test-photometric-multi test-photometric-multi: - -$(MAKE) apply-patches-gravimetric $(python) -m hardware_testing.gravimetric --photometric --simulate --pipette 50 --channels 8 --tip 50 - -$(MAKE) remove-patches-gravimetric .PHONY: test-photometric test-photometric: - -$(MAKE) apply-patches-gravimetric $(python) -m hardware_testing.gravimetric --photometric --simulate --pipette 1000 --channels 96 --tip 50 --trials 1 $(python) -m hardware_testing.gravimetric --photometric --simulate --pipette 1000 --channels 96 --tip 200 --trials 1 - -$(MAKE) remove-patches-gravimetric .PHONY: test-gravimetric-single test-gravimetric-single: @@ -134,14 +124,12 @@ test-gravimetric-96: .PHONY: test-gravimetric test-gravimetric: - -$(MAKE) apply-patches-gravimetric $(python) -m hardware_testing.gravimetric.daily_setup --simulate $(python) -m hardware_testing.gravimetric.daily_setup --simulate --calibrate $(MAKE) test-gravimetric-single $(MAKE) test-gravimetric-multi $(MAKE) test-gravimetric-96 $(MAKE) test-photometric - -$(MAKE) remove-patches-gravimetric .PHONY: test-production-qc test-production-qc: @@ -167,16 +155,22 @@ test-examples: test-scripts: $(python) -m hardware_testing.scripts.bowtie_ot3 --simulate +.PHONY: test-liquid-sense +test-liquid-sense: + $(python) -m hardware_testing.liquid_sense --simulate --pipette 1000 --channels 1 + $(python) -m hardware_testing.liquid_sense --simulate --pipette 50 --channels 1 + $(python) -m hardware_testing.liquid_sense --simulate --pipette 1000 --channels 8 + $(python) -m hardware_testing.liquid_sense --simulate --pipette 50 --channels 8 + $(python) -m hardware_testing.liquid_sense --simulate --pipette 1000 --channels 96 + .PHONY: test-integration test-integration: test-production-qc test-examples test-scripts test-gravimetric .PHONY: lint lint: - -$(MAKE) apply-patches-gravimetric $(python) -m mypy hardware_testing tests $(python) -m black --check hardware_testing tests setup.py $(python) -m flake8 hardware_testing tests setup.py - -$(MAKE) remove-patches-gravimetric .PHONY: format format: @@ -263,9 +257,11 @@ scp $(ssh_helper_ot3) $(4) root@$(1):/tmp/ ssh $(ssh_helper_ot3) root@$(1) \ "function cleanup () { (rm -rf /tmp/$(4) || true) && mount -o remount,ro / ; } ;\ mount -o remount,rw / &&\ -(unzip -o /tmp/$(4) -d /usr/lib/firmware || cleanup) &&\ +(unzip -o /tmp/$(5) -d /usr/lib/firmware || cleanup) &&\ python3 -m json.tool /usr/lib/firmware/opentrons-firmware.json &&\ -cleanup" +cleanup &&\ +echo "Restarting robot server" &&\ +systemctl restart opentrons-robot-server" endef .PHONY: sync-sw-ot3 @@ -290,16 +286,18 @@ remove-patches-fixture: .PHONY: sync-fw-ot3 sync-fw-ot3: - $(call push-and-update-fw,$(host),$(ssh_key),$(ssh_opts),$(zip)) + $(call push-and-update-fw,$(host),$(ssh_key),$(ssh_opts),$(zip),$(notdir $(zip))) .PHONY: sync-ot3 sync-ot3: sync-sw-ot3 sync-fw-ot3 .PHONY: push-ot3-gravimetric push-ot3-gravimetric: + $(MAKE) push-ot3 + ssh $(ssh_helper_ot3) root@$(host) "mkdir -p /data/labware/v2/custom_definitions/custom_beta" + scp $(ssh_helper_ot3) -r hardware_testing/labware/* root@$(host):/data/labware/v2/custom_definitions/custom_beta/ $(MAKE) apply-patches-gravimetric - -$(MAKE) sync-sw-ot3 - scp $(ssh_helper_ot3) -r hardware_testing/labware root@$(host):/data/labware/v2/custom_definitions/custom_beta/ + cd ../ && $(MAKE) -C shared-data push-ot3 $(MAKE) remove-patches-gravimetric .PHONY: apply-patches-gravimetric diff --git a/hardware-testing/hardware_testing/drivers/asair_sensor.py b/hardware-testing/hardware_testing/drivers/asair_sensor.py index 350741ebc79..00b73893e6d 100644 --- a/hardware-testing/hardware_testing/drivers/asair_sensor.py +++ b/hardware-testing/hardware_testing/drivers/asair_sensor.py @@ -92,7 +92,7 @@ def BuildAsairSensor(simulate: bool, autosearch: bool = True) -> AsairSensorBase ui.print_info(f"Trying to connect to env sensor on port {port}") sensor = AsairSensor.connect(port) ser_id = sensor.get_serial() - if len(ser_id) != 0: + if ser_id == " ": ui.print_info(f"Found env sensor {ser_id} on port {port}") return sensor except: # noqa: E722 diff --git a/hardware-testing/hardware_testing/gravimetric/__main__.py b/hardware-testing/hardware_testing/gravimetric/__main__.py index 54a8278adef..0855345598b 100644 --- a/hardware-testing/hardware_testing/gravimetric/__main__.py +++ b/hardware-testing/hardware_testing/gravimetric/__main__.py @@ -2,10 +2,8 @@ from json import load as json_load from pathlib import Path import argparse -from time import time from typing import List, Union, Dict, Optional, Any, Tuple from dataclasses import dataclass -from opentrons.hardware_control.types import OT3Mount from opentrons.protocol_api import ProtocolContext from . import report import subprocess @@ -42,16 +40,15 @@ from .measurement.record import GravimetricRecorder from .measurement import DELAY_FOR_MEASUREMENT from .measurement.scale import Scale -from .measurement.environment import read_environment_data from .trial import TestResources, _change_pipettes from .tips import get_tips from hardware_testing.drivers import asair_sensor from opentrons.protocol_api import InstrumentContext +from opentrons.protocol_engine.types import LabwareOffset -# FIXME: bump to v2.15 to utilize protocol engine -API_LEVEL = "2.13" +API_LEVEL = "2.18" -LABWARE_OFFSETS: List[dict] = [] +LABWARE_OFFSETS: List[LabwareOffset] = [] # Keyed by pipette volume, channel count, and tip volume in that order GRAVIMETRIC_CFG = { @@ -90,6 +87,19 @@ }, } +PIPETTE_MODEL_NAME = { + 50: { + 1: "p50_single_flex", + 8: "p50_multi_flex", + }, + 1000: { + 1: "p1000_single_flex", + 8: "p1000_multi_flex", + 96: "p1000_96_flex", + }, +} + + PHOTOMETRIC_CFG = { 50: { 1: { @@ -148,22 +158,18 @@ def _get_protocol_context(cls, args: argparse.Namespace) -> ProtocolContext: ui.print_info( "Starting opentrons-robot-server, so we can http GET labware offsets" ) - offsets = workarounds.http_get_all_labware_offsets() - ui.print_info(f"found {len(offsets)} offsets:") - for offset in offsets: - ui.print_info(f"\t{offset['createdAt']}:") - ui.print_info(f"\t\t{offset['definitionUri']}") - ui.print_info(f"\t\t{offset['vector']}") - LABWARE_OFFSETS.append(offset) + LABWARE_OFFSETS.extend(workarounds.http_get_all_labware_offsets()) + ui.print_info(f"found {len(LABWARE_OFFSETS)} offsets:") + for offset in LABWARE_OFFSETS: + ui.print_info(f"\t{offset.createdAt}:") + ui.print_info(f"\t\t{offset.definitionUri}") + ui.print_info(f"\t\t{offset.vector}") # gather the custom labware (for simulation) custom_defs = {} if args.simulate: labware_dir = Path(__file__).parent.parent / "labware" custom_def_uris = [ "radwag_pipette_calibration_vial", - "opentrons_flex_96_tiprack_50ul_adp", - "opentrons_flex_96_tiprack_200ul_adp", - "opentrons_flex_96_tiprack_1000ul_adp", ] for def_uri in custom_def_uris: with open(labware_dir / def_uri / "1.json", "r") as f: @@ -172,9 +178,12 @@ def _get_protocol_context(cls, args: argparse.Namespace) -> ProtocolContext: _ctx = helpers.get_api_context( API_LEVEL, # type: ignore[attr-defined] is_simulating=args.simulate, - deck_version="2", + pipette_left=PIPETTE_MODEL_NAME[args.pipette][args.channels], extra_labware=custom_defs, ) + for offset in LABWARE_OFFSETS: + engine = _ctx._core._engine_client._transport._engine # type: ignore[attr-defined] + engine.state_view._labware_store._add_labware_offset(offset) return _ctx @classmethod @@ -301,7 +310,7 @@ def build_run_args(cls, args: argparse.Namespace) -> "RunArgs": # noqa: C901 trials=trials, name=name, robot_serial=robot_serial, - fw_version=_ctx._core.get_hardware().fw_version, + fw_version=workarounds.get_sync_hw_api(_ctx).fw_version, ) else: if args.increment: @@ -334,7 +343,7 @@ def build_run_args(cls, args: argparse.Namespace) -> "RunArgs": # noqa: C901 name=name, environment_sensor=environment_sensor, trials=trials, - fw_version=_ctx._core.get_hardware().fw_version, + fw_version=workarounds.get_sync_hw_api(_ctx).fw_version, ) return RunArgs( @@ -387,7 +396,6 @@ def build_gravimetric_cfg( pipette_channels=run_args.pipette_channels, tip_volume=tip_volume, trials=run_args.trials, - labware_offsets=LABWARE_OFFSETS, labware_on_scale=run_args.protocol_cfg.LABWARE_ON_SCALE, # type: ignore[attr-defined] slot_scale=run_args.protocol_cfg.SLOT_SCALE, # type: ignore[attr-defined] slots_tiprack=run_args.protocol_cfg.SLOTS_TIPRACK[tip_volume], # type: ignore[attr-defined] @@ -436,7 +444,6 @@ def build_photometric_cfg( increment=False, tip_volume=tip_volume, trials=run_args.trials, - labware_offsets=LABWARE_OFFSETS, photoplate=run_args.protocol_cfg.PHOTOPLATE_LABWARE, # type: ignore[attr-defined] photoplate_slot=run_args.protocol_cfg.SLOT_PLATE, # type: ignore[attr-defined] reservoir=run_args.protocol_cfg.RESERVOIR_LABWARE, # type: ignore[attr-defined] @@ -569,7 +576,6 @@ def _main( parser.add_argument( "--mode", type=str, choices=["", "default", "lowVolumeDefault"], default="" ) - parser.add_argument("--pre-heat", action="store_true") args = parser.parse_args() run_args = RunArgs.build_run_args(args) if not run_args.ctx.is_simulating(): @@ -580,48 +586,13 @@ def _main( shell=True, ) sleep(1) - hw = run_args.ctx._core.get_hardware() + hw = workarounds.get_sync_hw_api(run_args.ctx) try: if not run_args.ctx.is_simulating() and not args.photometric: ui.get_user_ready("CLOSE the door, and MOVE AWAY from machine") ui.print_info("homing...") run_args.ctx.home() - if args.pre_heat: - ui.print_header("PRE-HEAT") - mnt = OT3Mount.LEFT - hw.add_tip(mnt, 1) - hw.prepare_for_aspirate(mnt) - env_data = read_environment_data( - mnt.name.lower(), hw.is_simulator, run_args.environment_sensor - ) - start_temp = env_data.celsius_pipette - temp_limit = min(start_temp + 3.0, 28.0) - max_pre_heat_seconds = 60 * 10 - now = time() - start_time = now - while ( - now - start_time < max_pre_heat_seconds - and env_data.celsius_pipette < temp_limit - ): - ui.print_info( - f"pre-heat {int(now - start_time)} seconds " - f"({max_pre_heat_seconds} limit): " - f"{round(env_data.celsius_pipette, 2)} C " - f"({round(temp_limit, 2)} C limit)" - ) - # NOTE: moving slowly helps make sure full current is sent to coils - hw.aspirate(mnt, rate=0.1) - hw.dispense(mnt, rate=0.1, push_out=0) - env_data = read_environment_data( - mnt.name.lower(), hw.is_simulator, run_args.environment_sensor - ) - if run_args.ctx.is_simulating(): - now += 1 - else: - now = time() - hw.remove_tip(mnt) - for tip, volumes in run_args.volumes: if args.channels == 96 and not run_args.ctx.is_simulating(): ui.alert_user_ready(f"prepare the {tip}ul tipracks", hw) @@ -634,5 +605,5 @@ def _main( _change_pipettes(run_args.ctx, run_args.pipette) if not run_args.ctx.is_simulating(): serial_logger.terminate() - del hw._backend.eeprom_driver._gpio + del hw._backend.eeprom_driver._gpio # still need this? print("done\n\n") diff --git a/hardware-testing/hardware_testing/gravimetric/config.py b/hardware-testing/hardware_testing/gravimetric/config.py index 3af376a04cf..f80d87d7124 100644 --- a/hardware-testing/hardware_testing/gravimetric/config.py +++ b/hardware-testing/hardware_testing/gravimetric/config.py @@ -5,6 +5,7 @@ from enum import Enum from opentrons.config.types import LiquidProbeSettings, OutputOptions from opentrons.protocol_api.labware import Well +from opentrons.hardware_control.types import InstrumentProbeType class ConfigType(Enum): @@ -24,7 +25,6 @@ class VolumetricConfig: pipette_mount: str tip_volume: int trials: int - labware_offsets: List[dict] slots_tiprack: List[int] increment: bool return_tip: bool @@ -194,11 +194,11 @@ def _get_liquid_probe_settings( plunger_speed=lqid_cfg["plunger_speed"], sensor_threshold_pascals=lqid_cfg["sensor_threshold_pascals"], expected_liquid_height=110, - output_option=OutputOptions.stream_to_csv, + output_option=OutputOptions.sync_only, aspirate_while_sensing=False, auto_zero_sensor=True, num_baseline_reads=10, - data_file="/var/pressure_sensor_data.csv", + data_files={InstrumentProbeType.PRIMARY: "/data/testing_data/pressure.csv"}, ) diff --git a/hardware-testing/hardware_testing/gravimetric/daily_setup.py b/hardware-testing/hardware_testing/gravimetric/daily_setup.py index bc13dc9d0bf..77569b43c11 100644 --- a/hardware-testing/hardware_testing/gravimetric/daily_setup.py +++ b/hardware-testing/hardware_testing/gravimetric/daily_setup.py @@ -13,8 +13,9 @@ ) from hardware_testing.gravimetric.config import GANTRY_MAX_SPEED from hardware_testing.gravimetric.measurement.scale import Scale # type: ignore[import] -from hardware_testing.gravimetric import helpers, workarounds +from hardware_testing.gravimetric import helpers from hardware_testing.gravimetric.__main__ import API_LEVEL +from hardware_testing.gravimetric.workarounds import get_sync_hw_api TEST_NAME = "gravimetric-daily-setup" @@ -253,7 +254,7 @@ def _calibrate() -> None: API_LEVEL, # type: ignore[attr-defined] is_simulating=args.simulate, ) - _hw = workarounds.get_sync_hw_api(_ctx) + _hw = get_sync_hw_api(_ctx) _hw.set_status_bar_state(COLOR_STATES["idle"]) _rec = GravimetricRecorder( GravimetricRecorderConfig( diff --git a/hardware-testing/hardware_testing/gravimetric/execute.py b/hardware-testing/hardware_testing/gravimetric/execute.py index cf2b8fb1ecc..76b8ff037e2 100644 --- a/hardware-testing/hardware_testing/gravimetric/execute.py +++ b/hardware-testing/hardware_testing/gravimetric/execute.py @@ -18,7 +18,6 @@ _calculate_average, _jog_to_find_liquid_height, _sense_liquid_height, - _apply_labware_offsets, _pick_up_tip, _drop_tip, ) @@ -53,6 +52,7 @@ import glob from opentrons.hardware_control.types import StatusBarState +from hardware_testing.gravimetric.workarounds import get_sync_hw_api _MEASUREMENTS: List[Tuple[str, MeasurementData]] = list() @@ -89,7 +89,7 @@ def _generate_callbacks_for_trial( if blank_measurement: volume = None - hw_api = ctx._core.get_hardware() + hw_api = get_sync_hw_api(ctx) hw_mount = OT3Mount.LEFT if pipette.mount == "left" else OT3Mount.RIGHT pip_ax = Axis.of_main_tool_actuator(hw_mount) estimate_bottom: float = -1 @@ -179,7 +179,6 @@ def _load_labware(ctx: ProtocolContext, cfg: config.GravimetricConfig) -> Labwar labware_on_scale = ctx.load_labware( cfg.labware_on_scale, location=cfg.slot_scale, namespace=namespace ) - _apply_labware_offsets(cfg, [labware_on_scale]) return labware_on_scale @@ -283,9 +282,13 @@ def _record_measurement_and_store(m_type: MeasurementType) -> MeasurementData: m_tag = _tag(m_type) if trial.recorder.is_simulator and not trial.blank: if m_type == MeasurementType.ASPIRATE: - trial.recorder.add_simulation_mass(trial.volume * -0.001) + trial.recorder.add_simulation_mass( + trial.channel_count * trial.volume * -0.001 + ) elif m_type == MeasurementType.DISPENSE: - trial.recorder.add_simulation_mass(trial.volume * 0.001) + trial.recorder.add_simulation_mass( + trial.channel_count * trial.volume * 0.001 + ) m_data = record_measurement_data( trial.ctx, m_tag, @@ -327,8 +330,7 @@ def _record_measurement_and_store(m_type: MeasurementType) -> MeasurementData: else: # center channel over well trial.pipette.move_to(trial.well.top(50).move(trial.channel_offset)) - mnt = OT3Mount.RIGHT if trial.pipette.mount == "right" else OT3Mount.LEFT - trial.ctx._core.get_hardware().retract(mnt) # retract to top of gantry + trial.pipette._retract() # retract to top of gantry m_data_init = _record_measurement_and_store(MeasurementType.INIT) ui.print_info(f"\tinitial grams: {m_data_init.grams_average} g") # update the vials volumes, using the last-known weight @@ -357,7 +359,7 @@ def _record_measurement_and_store(m_type: MeasurementType) -> MeasurementData: mode=trial.mode, clear_accuracy_function=trial.cfg.increment, ) - trial.ctx._core.get_hardware().retract(mnt) # retract to top of gantry + trial.pipette._retract() # retract to top of gantry _take_photos(trial, "aspirate") m_data_aspirate = _record_measurement_and_store(MeasurementType.ASPIRATE) @@ -379,7 +381,7 @@ def _record_measurement_and_store(m_type: MeasurementType) -> MeasurementData: mode=trial.mode, clear_accuracy_function=trial.cfg.increment, ) - trial.ctx._core.get_hardware().retract(mnt) # retract to top of gantry + trial.pipette._retract() # retract to top of gantry _take_photos(trial, "dispense") m_data_dispense = _record_measurement_and_store(MeasurementType.DISPENSE) ui.print_info(f"\tgrams after dispense: {m_data_dispense.grams_average} g") @@ -500,8 +502,7 @@ def _calculate_evaporation( resources.env_sensor, ) ui.print_info(f"running {config.NUM_BLANK_TRIALS}x blank measurements") - mnt = OT3Mount.RIGHT if resources.pipette.mount == "right" else OT3Mount.LEFT - resources.ctx._core.get_hardware().retract(mnt) + resources.pipette._retract() for i in range(config.SCALE_SECONDS_TO_TRUE_STABILIZE): ui.print_info( f"wait for scale to stabilize " @@ -545,7 +546,7 @@ def _get_liquid_height( if not resources.ctx.is_simulating() and not cfg.same_tip: ui.alert_user_ready( f"Please replace the {cfg.tip_volume}ul tips in slot 2", - resources.ctx._core.get_hardware(), + get_sync_hw_api(resources.ctx), ) _tip_counter[0] = 0 if cfg.jog: @@ -595,7 +596,7 @@ def run(cfg: config.GravimetricConfig, resources: TestResources) -> None: # noq recorder._recording = GravimetricRecording() report.store_config_gm(resources.test_report, cfg) calibration_tip_in_use = True - hw_api = resources.ctx._core.get_hardware() + hw_api = get_sync_hw_api(resources.ctx) if resources.ctx.is_simulating(): _PREV_TRIAL_GRAMS = None _MEASUREMENTS = list() @@ -605,8 +606,6 @@ def run(cfg: config.GravimetricConfig, resources: TestResources) -> None: # noq setup_channel_offset = _get_channel_offset(cfg, channel=0) first_tip_location = first_tip.top().move(setup_channel_offset) _pick_up_tip(resources.ctx, resources.pipette, cfg, location=first_tip_location) - mnt = OT3Mount.LEFT if cfg.pipette_mount == "left" else OT3Mount.RIGHT - resources.ctx._core.get_hardware().retract(mnt) ui.print_info("moving to scale") well = labware_on_scale["A1"] _liquid_height = _get_liquid_height(resources, cfg, well) @@ -642,6 +641,7 @@ def run(cfg: config.GravimetricConfig, resources: TestResources) -> None: # noq resources.pipette, return_tip=False, minimum_z_height=_minimum_z_height(cfg), + offset=_get_channel_offset(cfg, 0), ) # always trash calibration tips calibration_tip_in_use = False trial_count = 0 @@ -662,7 +662,7 @@ def run(cfg: config.GravimetricConfig, resources: TestResources) -> None: # noq actual_asp_list_all = [] actual_disp_list_all = [] ui.print_title(f"{volume} uL") - + resources.pipette.configure_for_volume(volume) trial_asp_dict: Dict[int, List[float]] = { trial: [] for trial in range(cfg.trials) } @@ -694,12 +694,7 @@ def run(cfg: config.GravimetricConfig, resources: TestResources) -> None: # noq cfg, location=next_tip_location, ) - mnt = ( - OT3Mount.LEFT - if cfg.pipette_mount == "left" - else OT3Mount.RIGHT - ) - resources.ctx._core.get_hardware().retract(mnt) + resources.pipette._retract() # retract to top of gantry ( actual_aspirate, aspirate_data, @@ -742,14 +737,12 @@ def run(cfg: config.GravimetricConfig, resources: TestResources) -> None: # noq ) ui.print_info("dropping tip") if not cfg.same_tip: - mnt = ( - OT3Mount.LEFT - if cfg.pipette_mount == "left" - else OT3Mount.RIGHT - ) - resources.ctx._core.get_hardware().retract(mnt) + resources.pipette._retract() # retract to top of gantry _drop_tip( - resources.pipette, cfg.return_tip, _minimum_z_height(cfg) + resources.pipette, + cfg.return_tip, + _minimum_z_height(cfg), + _get_channel_offset(cfg, run_trial.channel), ) ui.print_header(f"{volume} uL channel {channel + 1} CALCULATIONS") @@ -809,7 +802,7 @@ def run(cfg: config.GravimetricConfig, resources: TestResources) -> None: # noq acceptable_d = trials[volume][channel][0].acceptable_d print(f"acceptable cv {acceptable_cv} acceptable_d {acceptable_d}") print(f"dispense cv {dispense_cv} aspirate_cv {aspirate_cv}") - print(f"dispense d {dispense_cv} aspirate_d {aspirate_d}") + print(f"dispense d {dispense_d} aspirate_d {aspirate_d}") if ( not cfg.ignore_fail and acceptable_cv is not None @@ -820,8 +813,8 @@ def run(cfg: config.GravimetricConfig, resources: TestResources) -> None: # noq if ( dispense_cv > acceptable_cv or aspirate_cv > acceptable_cv - or aspirate_d > acceptable_d - or dispense_d > acceptable_d + or abs(aspirate_d) > acceptable_d + or abs(dispense_d) > acceptable_d ): raise RuntimeError( f"Trial with volume {volume} on channel {channel} did not pass spec" diff --git a/hardware-testing/hardware_testing/gravimetric/execute_photometric.py b/hardware-testing/hardware_testing/gravimetric/execute_photometric.py index 5b36acc46f3..217109dd89d 100644 --- a/hardware-testing/hardware_testing/gravimetric/execute_photometric.py +++ b/hardware-testing/hardware_testing/gravimetric/execute_photometric.py @@ -5,7 +5,7 @@ from opentrons.protocol_api import ProtocolContext, Well, Labware from hardware_testing.data import ui -from hardware_testing.opentrons_api.types import Point, OT3Mount +from hardware_testing.opentrons_api.types import Point from .measurement import ( MeasurementType, create_measurement_tag, @@ -18,7 +18,6 @@ from .helpers import ( _jog_to_find_liquid_height, _sense_liquid_height, - _apply_labware_offsets, _pick_up_tip, _drop_tip, get_list_of_wells_affected, @@ -110,7 +109,6 @@ def _load_labware( photoplate = loaded_labwares[cfg.photoplate_slot] else: photoplate = ctx.load_labware(cfg.photoplate, location=cfg.photoplate_slot) - _apply_labware_offsets(cfg, [photoplate]) if ( cfg.reservoir_slot in loaded_labwares.keys() @@ -119,7 +117,6 @@ def _load_labware( reservoir = loaded_labwares[cfg.reservoir_slot] else: reservoir = ctx.load_labware(cfg.reservoir, location=cfg.reservoir_slot) - _apply_labware_offsets(cfg, [reservoir]) return photoplate, reservoir @@ -218,11 +215,10 @@ def _record_measurement_and_store(m_type: MeasurementType) -> EnvironmentData: touch_tip=trial.cfg.touch_tip, ) _record_measurement_and_store(MeasurementType.DISPENSE) - trial.ctx._core.get_hardware().retract(OT3Mount.LEFT) + trial.pipette._retract() # retract to top of gantry if (i + 1) == num_dispenses: if not trial.cfg.same_tip: _drop_tip(trial.pipette, trial.cfg.return_tip) - trial.ctx._core.get_hardware().retract(OT3Mount.LEFT) if not trial.ctx.is_simulating() and trial.channel_count == 96: ui.get_user_ready("add SEAL to plate and remove from DECK") return @@ -350,13 +346,13 @@ def _find_liquid_height( setup_tip = _next_tip(resources, cfg, cfg.pipette_channels == 1) volume_for_setup = max(resources.test_volumes) _pick_up_tip(resources.ctx, resources.pipette, cfg, location=setup_tip.top()) - mnt = OT3Mount.LEFT if cfg.pipette_mount == "left" else OT3Mount.RIGHT - resources.ctx._core.get_hardware().retract(mnt) if ( not resources.ctx.is_simulating() and not cfg.same_tip and cfg.pipette_channels == 96 ): + + resources.pipette._retract() ui.get_user_ready("REPLACE first tip with NEW TIP") required_ul_per_src = (volume_for_setup * channel_count * cfg.trials) / len( cfg.dye_well_column_offset @@ -411,10 +407,8 @@ def _find_liquid_height( raise RuntimeError( f"bad volume in reservoir: {round(reservoir_ul / 1000, 1)} ml" ) - resources.ctx._core.get_hardware().retract(OT3Mount.LEFT) if not cfg.same_tip: resources.pipette.drop_tip(home_after=False) # always trash setup tips - resources.ctx._core.get_hardware().retract(OT3Mount.LEFT) # NOTE: the first tip-rack should have already been replaced # with new tips by the operator diff --git a/hardware-testing/hardware_testing/gravimetric/helpers.py b/hardware-testing/hardware_testing/gravimetric/helpers.py index 179701e0d83..7844f8d8d5e 100644 --- a/hardware-testing/hardware_testing/gravimetric/helpers.py +++ b/hardware-testing/hardware_testing/gravimetric/helpers.py @@ -2,7 +2,7 @@ import asyncio from random import random, randint from types import MethodType -from typing import Any, List, Dict, Optional, Tuple +from typing import Any, List, Dict, Optional, Tuple, Union from statistics import stdev from . import config from .liquid_class.defaults import get_liquid_class @@ -15,21 +15,34 @@ guess_from_global_config as guess_deck_type_from_global_config, ) from opentrons.protocol_api.labware import Well, Labware +from opentrons.protocol_api._types import OffDeckType +from opentrons.protocol_api._nozzle_layout import NozzleLayout from opentrons.protocols.types import APIVersion from opentrons.hardware_control.thread_manager import ThreadManager from opentrons.hardware_control.types import OT3Mount, Axis from opentrons.hardware_control.ot3api import OT3API from opentrons.hardware_control.instruments.ot3.pipette import Pipette +from opentrons import execute, simulate from opentrons.types import Point, Location from opentrons_shared_data.labware.dev_types import LabwareDefinition from hardware_testing.opentrons_api import helpers_ot3 from opentrons.protocol_api import ProtocolContext, InstrumentContext -from .workarounds import get_sync_hw_api, get_latest_offset_for_labware +from .workarounds import get_sync_hw_api from hardware_testing.opentrons_api.helpers_ot3 import clear_pipette_ul_per_mm +import opentrons.protocol_engine.execution.pipetting as PE_pipetting +from opentrons.protocol_engine.notes import CommandNoteAdder + +from opentrons.protocol_engine import ( + StateView, + WellLocation, + DropTipWellLocation, +) +from opentrons.protocol_api.core.engine import deck_conflict as DeckConflit + def _add_fake_simulate( ctx: protocol_api.ProtocolContext, is_simulating: bool @@ -79,13 +92,21 @@ async def _thread_manager_build_hw_api( stall_detection_enable=stall_detection_enable, ) - return protocol_api.create_protocol_context( - api_version=APIVersion.from_string(api_level), - hardware_api=ThreadManager(_thread_manager_build_hw_api), # type: ignore[arg-type] - deck_type="ot3_standard", - extra_labware=extra_labware, - deck_version=2, - ) + papi: protocol_api.ProtocolContext + if is_simulating: + papi = simulate.get_protocol_api( + version=APIVersion.from_string(api_level), + extra_labware=extra_labware, + hardware_simulator=ThreadManager(_thread_manager_build_hw_api), + robot_type="Flex", + use_virtual_hardware=False, + ) + else: + papi = execute.get_protocol_api( + version=APIVersion.from_string(api_level), extra_labware=extra_labware + ) + + return papi def well_is_reservoir(well: protocol_api.labware.Well) -> bool: @@ -203,6 +224,50 @@ def _check_if_software_supports_high_volumes() -> bool: return modified_a and modified_b +def _override_set_current_volume(self, new_volume: float) -> None: # noqa: ANN001 + assert new_volume >= 0 + # assert new_volume <= self.working_volume + self._current_volume = new_volume + + +def _override_add_current_volume(self, volume_incr: float) -> None: # noqa: ANN001 + self._current_volume += volume_incr + + +def _override_ok_to_add_volume(self, volume_incr: float) -> bool: # noqa: ANN001 + return True + + +def _override_validate_asp_vol( + state_view: StateView, + pipette_id: str, + aspirate_volume: float, + command_note_adder: CommandNoteAdder, +) -> float: + return aspirate_volume + + +def _override_check_safe_for_pipette_movement( + engine_state: StateView, + pipette_id: str, + labware_id: str, + well_name: str, + well_location: Union[WellLocation, DropTipWellLocation], +) -> None: + pass + + +def _override_software_supports_high_volumes() -> None: + # yea so ok this is pretty ugly but this is super helpful for us + # with this we don't need to apply patches, and can run the testing scripts + # without pushing modified code to the robot + + Pipette.set_current_volume = _override_set_current_volume # type: ignore[assignment] + Pipette.ok_to_add_volume = _override_ok_to_add_volume # type: ignore[assignment] + Pipette.add_current_volume = _override_add_current_volume # type: ignore[assignment] + PE_pipetting._validate_aspirate_volume = _override_validate_asp_vol # type: ignore[assignment] + + def _get_channel_offset(cfg: config.VolumetricConfig, channel: int) -> Point: assert ( channel < cfg.pipette_channels @@ -252,23 +317,6 @@ def _get_tip_batch(is_simulating: bool, tip: int) -> str: return "simulation-tip-batch" -def _apply(labware: Labware, cfg: config.VolumetricConfig) -> None: - o = get_latest_offset_for_labware(cfg.labware_offsets, labware) - ui.print_info( - f'Apply labware offset to "{labware.name}" (slot={labware.parent}): ' - f"x={round(o.x, 2)}, y={round(o.y, 2)}, z={round(o.z, 2)}" - ) - labware.set_calibration(o) - - -def _apply_labware_offsets( - cfg: config.VolumetricConfig, - labwares: List[Labware], -) -> None: - for lw in labwares: - _apply(lw, cfg) - - def _pick_up_tip( ctx: ProtocolContext, pipette: InstrumentContext, @@ -280,8 +328,6 @@ def _pick_up_tip( f"from slot #{location.labware.parent.parent}" ) pipette.pick_up_tip(location) - if pipette.channels == 96: - get_sync_hw_api(ctx).retract(OT3Mount.LEFT) # NOTE: the accuracy-adjust function gets set on the Pipette # each time we pick-up a new tip. if cfg.increment: @@ -293,12 +339,27 @@ def _pick_up_tip( def _drop_tip( - pipette: InstrumentContext, return_tip: bool, minimum_z_height: int = 0 + pipette: InstrumentContext, + return_tip: bool, + minimum_z_height: int = 0, + offset: Optional[Point] = None, ) -> None: if return_tip: pipette.return_tip(home_after=False) else: - pipette.drop_tip(home_after=False) + if offset is not None: + # we don't actually need the offset, if this is an 8 channel we always center channel + # a1 over the back of the trash + trash_well = pipette.trash_container.well(0) # type: ignore[union-attr] + trash_container = trash_well.center().move( + Point(0, trash_well.width / 2, 0) # type: ignore[union-attr, operator] + ) + pipette.drop_tip( + trash_container, + home_after=False, + ) + else: + pipette.drop_tip(home_after=False) if minimum_z_height > 0: cur_location = pipette._get_last_location_by_api_version() if cur_location is not None: @@ -337,11 +398,8 @@ def _get_volumes( kind, channels, pipette_volume, tip_volume, extra ) if not _check_if_software_supports_high_volumes(): - if ctx.is_simulating(): - test_volumes = _reduce_volumes_to_not_exceed_software_limit( - test_volumes, pipette_volume, channels, tip_volume - ) - else: + _override_software_supports_high_volumes() + if not _check_if_software_supports_high_volumes(): raise RuntimeError("you are not the correct branch") return test_volumes @@ -363,7 +421,9 @@ def _load_pipette( if pipette_mount in loaded_pipettes.keys(): return loaded_pipettes[pipette_mount] + trash = ctx.load_labware("opentrons_1_trash_3200ml_fixed", "A3") pipette = ctx.load_instrument(pip_name, pipette_mount) + loaded_pipettes = ctx.loaded_instruments assert pipette.max_volume == pipette_volume, ( f"expected {pipette_volume} uL pipette, " f"but got a {pipette.max_volume} uL pipette" @@ -374,12 +434,12 @@ def _load_pipette( # NOTE: 8ch QC testing means testing 1 channel at a time, # so we need to decrease the pick-up current to work with 1 tip. if pipette.channels == 8 and not increment and not photometric: - hwapi = get_sync_hw_api(ctx) - mnt = OT3Mount.LEFT if pipette_mount == "left" else OT3Mount.RIGHT - hwpipette: Pipette = hwapi.hardware_pipettes[mnt.to_mount()] - hwpipette._config.pick_up_tip_configurations.press_fit.current_by_tip_count[ - 8 - ] = 0.2 + pipette.configure_nozzle_layout(NozzleLayout.SINGLE, "A1") + # override deck conflict checking cause we specially lay out our tipracks + DeckConflit.check_safe_for_pipette_movement = ( + _override_check_safe_for_pipette_movement + ) + pipette.trash_container = trash return pipette @@ -402,23 +462,22 @@ def _load_tipracks( cfg: config.VolumetricConfig, use_adapters: bool = False, ) -> List[Labware]: - adp_str = "_adp" if use_adapters else "" tiprack_load_settings: List[Tuple[int, str]] = [ ( slot, - f"opentrons_flex_96_tiprack_{cfg.tip_volume}ul{adp_str}", + f"opentrons_flex_96_tiprack_{cfg.tip_volume}ul", ) for slot in cfg.slots_tiprack ] for ls in tiprack_load_settings: ui.print_info(f'Loading tiprack "{ls[1]}" in slot #{ls[0]}') - if use_adapters: - tiprack_namespace = "custom_beta" - else: - tiprack_namespace = "opentrons" + adapter: Optional[str] = ( + "opentrons_flex_96_tiprack_adapter" if use_adapters else None + ) # If running multiple tests in one run, the labware may already be loaded loaded_labwares = ctx.loaded_labwares + print(f"Loaded labwares {loaded_labwares}") pre_loaded_tips: List[Labware] = [] for ls in tiprack_load_settings: if ls[0] in loaded_labwares.keys(): @@ -430,15 +489,25 @@ def _load_tipracks( ui.print_info( f"Removing {loaded_labwares[ls[0]].name} from slot {ls[0]}" ) - del ctx._core.get_deck()[ls[0]] # type: ignore[attr-defined] + ctx._core.move_labware( + loaded_labwares[ls[0]]._core, + new_location=OffDeckType.OFF_DECK, + use_gripper=False, + pause_for_manual_move=False, + pick_up_offset=None, + drop_offset=None, + ) if len(pre_loaded_tips) == len(tiprack_load_settings): return pre_loaded_tips - tipracks = [ - ctx.load_labware(ls[1], location=ls[0], namespace=tiprack_namespace) - for ls in tiprack_load_settings - ] - _apply_labware_offsets(cfg, tipracks) + tipracks: List[Labware] = [] + for ls in tiprack_load_settings: + if ctx.deck[ls[0]] is not None: + tipracks.append( + ctx.deck[ls[0]].load_labware(ls[1]) # type: ignore[union-attr] + ) + else: + tipracks.append(ctx.load_labware(ls[1], location=ls[0], adapter=adapter)) return tipracks diff --git a/hardware-testing/hardware_testing/gravimetric/liquid_class/defaults.py b/hardware-testing/hardware_testing/gravimetric/liquid_class/defaults.py index 1146d6bb432..a37f21b1b36 100644 --- a/hardware-testing/hardware_testing/gravimetric/liquid_class/defaults.py +++ b/hardware-testing/hardware_testing/gravimetric/liquid_class/defaults.py @@ -11,8 +11,6 @@ _default_submerge_aspirate_mm = 1.5 _p50_multi_submerge_aspirate_mm = 1.5 _default_submerge_dispense_mm = 1.5 -_96_default_submerge_aspirate_mm = 2.5 -_96_default_submerge_dispense_mm = 3.0 _default_retract_mm = 5.0 _default_retract_discontinuity = 20 @@ -273,7 +271,7 @@ 1000: { # P1000 50: { # T50 5: DispenseSettings( # 5uL - z_submerge_depth=_96_default_submerge_dispense_mm, + z_submerge_depth=_default_submerge_dispense_mm, plunger_acceleration=_default_accel_96ch_ul_sec_sec, plunger_flow_rate=80, # ul/sec delay=_default_dispense_delay_seconds, @@ -282,7 +280,7 @@ blow_out_submerged=5, ), 10: DispenseSettings( # 10uL - z_submerge_depth=_96_default_submerge_dispense_mm, + z_submerge_depth=_default_submerge_dispense_mm, plunger_acceleration=_default_accel_96ch_ul_sec_sec, plunger_flow_rate=80, # ul/sec delay=_default_dispense_delay_seconds, @@ -291,7 +289,7 @@ blow_out_submerged=5, ), 50: DispenseSettings( # 50uL - z_submerge_depth=_96_default_submerge_dispense_mm, + z_submerge_depth=_default_submerge_dispense_mm, plunger_acceleration=_default_accel_96ch_ul_sec_sec, plunger_flow_rate=80, # ul/sec delay=_default_dispense_delay_seconds, @@ -302,7 +300,7 @@ }, 200: { # T200 5: DispenseSettings( # 5uL - z_submerge_depth=_96_default_submerge_dispense_mm, + z_submerge_depth=_default_submerge_dispense_mm, plunger_acceleration=_default_accel_96ch_ul_sec_sec, plunger_flow_rate=80, # ul/sec delay=_default_dispense_delay_seconds, @@ -311,7 +309,7 @@ blow_out_submerged=5, ), 50: DispenseSettings( # 50uL - z_submerge_depth=_96_default_submerge_dispense_mm, + z_submerge_depth=_default_submerge_dispense_mm, plunger_acceleration=_default_accel_96ch_ul_sec_sec, plunger_flow_rate=80, # ul/sec delay=_default_dispense_delay_seconds, @@ -320,7 +318,7 @@ blow_out_submerged=5, ), 200: DispenseSettings( # 200uL - z_submerge_depth=_96_default_submerge_dispense_mm, + z_submerge_depth=_default_submerge_dispense_mm, plunger_acceleration=_default_accel_96ch_ul_sec_sec, plunger_flow_rate=80, # ul/sec delay=_default_dispense_delay_seconds, @@ -331,7 +329,7 @@ }, 1000: { # T1000 10: DispenseSettings( # 10uL - z_submerge_depth=_96_default_submerge_dispense_mm, + z_submerge_depth=_default_submerge_dispense_mm, plunger_acceleration=_default_accel_96ch_ul_sec_sec, plunger_flow_rate=80, # ul/sec delay=_default_dispense_delay_seconds, @@ -340,7 +338,7 @@ blow_out_submerged=20, ), 100: DispenseSettings( # 100uL - z_submerge_depth=_96_default_submerge_dispense_mm, + z_submerge_depth=_default_submerge_dispense_mm, plunger_acceleration=_default_accel_96ch_ul_sec_sec, plunger_flow_rate=80, # ul/sec delay=_default_dispense_delay_seconds, @@ -349,7 +347,7 @@ blow_out_submerged=20, ), 1000: DispenseSettings( # 1000uL - z_submerge_depth=_96_default_submerge_dispense_mm, + z_submerge_depth=_default_submerge_dispense_mm, plunger_acceleration=_default_accel_96ch_ul_sec_sec, plunger_flow_rate=80, # ul/sec delay=_default_dispense_delay_seconds, @@ -635,7 +633,7 @@ 1000: { # P1000 50: { # T50 5: AspirateSettings( # 5uL - z_submerge_depth=_96_default_submerge_aspirate_mm, + z_submerge_depth=_default_submerge_aspirate_mm, plunger_acceleration=_default_accel_96ch_ul_sec_sec, plunger_flow_rate=6.5, # ul/sec delay=_default_aspirate_delay_seconds, @@ -645,7 +643,7 @@ trailing_air_gap=0.1, ), 10: AspirateSettings( # 10uL - z_submerge_depth=_96_default_submerge_aspirate_mm, + z_submerge_depth=_default_submerge_aspirate_mm, plunger_acceleration=_default_accel_96ch_ul_sec_sec, plunger_flow_rate=6.5, # ul/sec delay=_default_aspirate_delay_seconds, @@ -655,7 +653,7 @@ trailing_air_gap=0.1, ), 50: AspirateSettings( # 50uL - z_submerge_depth=_96_default_submerge_aspirate_mm, + z_submerge_depth=_default_submerge_aspirate_mm, plunger_acceleration=_default_accel_96ch_ul_sec_sec, plunger_flow_rate=6.5, # ul/sec delay=_default_aspirate_delay_seconds, @@ -667,7 +665,7 @@ }, 200: { # T200 5: AspirateSettings( # 5uL - z_submerge_depth=_96_default_submerge_aspirate_mm, + z_submerge_depth=_default_submerge_aspirate_mm, plunger_acceleration=_default_accel_96ch_ul_sec_sec, plunger_flow_rate=80, # ul/sec delay=_default_aspirate_delay_seconds, @@ -677,7 +675,7 @@ trailing_air_gap=2, ), 50: AspirateSettings( # 50uL - z_submerge_depth=_96_default_submerge_aspirate_mm, + z_submerge_depth=_default_submerge_aspirate_mm, plunger_acceleration=_default_accel_96ch_ul_sec_sec, plunger_flow_rate=80, # ul/sec delay=_default_aspirate_delay_seconds, @@ -687,7 +685,7 @@ trailing_air_gap=3.5, ), 200: AspirateSettings( # 200uL - z_submerge_depth=_96_default_submerge_aspirate_mm, + z_submerge_depth=_default_submerge_aspirate_mm, plunger_acceleration=_default_accel_96ch_ul_sec_sec, plunger_flow_rate=80, # ul/sec delay=_default_aspirate_delay_seconds, @@ -699,7 +697,7 @@ }, 1000: { # T1000 10: AspirateSettings( # 10uL - z_submerge_depth=_96_default_submerge_aspirate_mm, + z_submerge_depth=_default_submerge_aspirate_mm, plunger_acceleration=_default_accel_96ch_ul_sec_sec, plunger_flow_rate=160, # ul/sec delay=_default_aspirate_delay_seconds, @@ -709,7 +707,7 @@ trailing_air_gap=10, ), 100: AspirateSettings( # 100uL - z_submerge_depth=_96_default_submerge_aspirate_mm, + z_submerge_depth=_default_submerge_aspirate_mm, plunger_acceleration=_default_accel_96ch_ul_sec_sec, plunger_flow_rate=160, # ul/sec delay=_default_aspirate_delay_seconds, @@ -719,7 +717,7 @@ trailing_air_gap=10, ), 1000: AspirateSettings( # 1000uL - z_submerge_depth=_96_default_submerge_aspirate_mm, + z_submerge_depth=_default_submerge_aspirate_mm, plunger_acceleration=_default_accel_96ch_ul_sec_sec, plunger_flow_rate=160, # ul/sec delay=_default_aspirate_delay_seconds, diff --git a/hardware-testing/hardware_testing/gravimetric/liquid_class/pipetting.py b/hardware-testing/hardware_testing/gravimetric/liquid_class/pipetting.py index 473877208ea..9f059559f13 100644 --- a/hardware-testing/hardware_testing/gravimetric/liquid_class/pipetting.py +++ b/hardware-testing/hardware_testing/gravimetric/liquid_class/pipetting.py @@ -8,6 +8,7 @@ from hardware_testing.opentrons_api.types import OT3AxisKind from hardware_testing.gravimetric import config +from hardware_testing.gravimetric.workarounds import get_sync_hw_api from hardware_testing.gravimetric.liquid_height.height import LiquidTracker from hardware_testing.opentrons_api.types import OT3Mount, Point from hardware_testing.opentrons_api.helpers_ot3 import clear_pipette_ul_per_mm @@ -177,7 +178,7 @@ def _pipette_with_liquid_settings( # noqa: C901 ) -> None: """Run a pipette given some Pipetting Liquid Settings.""" # FIXME: stop using hwapi, and get those functions into core software - hw_api = ctx._core.get_hardware() + hw_api = get_sync_hw_api(ctx) hw_mount = OT3Mount.LEFT if pipette.mount == "left" else OT3Mount.RIGHT hw_pipette = hw_api.hardware_pipettes[hw_mount.to_mount()] _check_aspirate_dispense_args(mix, aspirate, dispense) @@ -189,20 +190,6 @@ def _get_max_blow_out_ul() -> float: blow_out = hw_pipette.plunger_positions.blow_out return (blow_out - bottom) * blow_out_ul_per_mm - def _dispense_with_added_blow_out() -> None: - # dispense all liquid, plus some air - # FIXME: push-out is not supported in Legacy core, so here - # we again use the hardware controller - hw_api = ctx._core.get_hardware() - hw_mount = OT3Mount.LEFT if pipette.mount == "left" else OT3Mount.RIGHT - push_out = min(liquid_class.dispense.blow_out_submerged, _get_max_blow_out_ul()) - hw_api.dispense(hw_mount, push_out=push_out) - - def _blow_out_remaining_air() -> None: - # FIXME: using the HW-API to specify that we want to blow-out the full - # available blow-out volume - hw_api.blow_out(hw_mount, _get_max_blow_out_ul()) - # ASPIRATE/DISPENSE SEQUENCE HAS THREE PHASES: # 1. APPROACH # 2. SUBMERGE @@ -237,16 +224,17 @@ def _aspirate_on_approach() -> None: "WARNING: removing trailing air-gap from pipette, " "this should only happen during blank trials" ) - hw_api.dispense(hw_mount) + pipette.dispense(volume=pipette.current_volume) if mode: # NOTE: increment test requires the plunger's "bottom" position # does not change during the entire test run hw_api.set_liquid_class(hw_mount, mode) else: - hw_api.configure_for_volume(hw_mount, aspirate if aspirate else dispense) + cfg_volume: float = aspirate if aspirate else dispense # type: ignore[assignment] + pipette.configure_for_volume(cfg_volume) if clear_accuracy_function: clear_pipette_ul_per_mm(hw_api, hw_mount) # type: ignore[arg-type] - hw_api.prepare_for_aspirate(hw_mount) + pipette.prepare_to_aspirate() if liquid_class.aspirate.leading_air_gap > 0: pipette.aspirate(liquid_class.aspirate.leading_air_gap) @@ -260,14 +248,18 @@ def _aspirate_on_mix() -> None: if i < _num_mixes - 1: pipette.dispense(mix) else: - _dispense_with_added_blow_out() + if added_blow_out: + push_out = min( + liquid_class.dispense.blow_out_submerged, _get_max_blow_out_ul() + ) + pipette.dispense(dispense, push_out=push_out) ctx.delay(liquid_class.dispense.delay) # don't go all the way up to retract position, but instead just above liquid _retract( ctx, pipette, well, channel_offset, approach_mm, retract_speed, _z_disc ) - _blow_out_remaining_air() - hw_api.prepare_for_aspirate(hw_mount) + pipette.blow_out() + pipette.prepare_to_aspirate() assert pipette.current_volume == 0 def _aspirate_on_submerge() -> None: @@ -283,18 +275,22 @@ def _aspirate_on_submerge() -> None: def _aspirate_on_retract() -> None: # add trailing-air-gap - pipette.aspirate(liquid_class.aspirate.trailing_air_gap) + if not blank: + pipette.air_gap(liquid_class.aspirate.trailing_air_gap, height=0) def _dispense_on_approach() -> None: # remove trailing-air-gap - pipette.dispense(liquid_class.aspirate.trailing_air_gap) + if not blank: + pipette.dispense(liquid_class.aspirate.trailing_air_gap) def _dispense_on_submerge() -> None: callbacks.on_dispensing() + push_out = None if added_blow_out: - _dispense_with_added_blow_out() - else: - pipette.dispense(dispense) + push_out = min( + liquid_class.dispense.blow_out_submerged, _get_max_blow_out_ul() + ) + pipette.dispense(dispense, push_out=push_out) # update liquid-height tracker liquid_tracker.update_affected_wells( well, dispense=dispense, channels=channel_count @@ -306,13 +302,13 @@ def _dispense_on_retract() -> None: if pipette.current_volume <= 0 and added_blow_out: # blow-out any remaining air in pipette (any reason why not?) callbacks.on_blowing_out() - _blow_out_remaining_air() - hw_api.prepare_for_aspirate(hw_mount) + pipette.blow_out() + pipette.prepare_to_aspirate() if touch_tip: pipette.touch_tip(speed=config.TOUCH_TIP_SPEED) # NOTE: always do a trailing-air-gap, regardless of if tip is empty or not # to avoid droplets from forming and falling off the tip - pipette.aspirate(liquid_class.aspirate.trailing_air_gap) + pipette.air_gap(liquid_class.aspirate.trailing_air_gap, height=0) # PHASE 1: APPROACH pipette.flow_rate.aspirate = liquid_class.aspirate.plunger_flow_rate @@ -337,7 +333,7 @@ def _dispense_on_retract() -> None: # EXIT callbacks.on_exiting() - hw_api.retract(hw_mount) + pipette._retract() def mix_with_liquid_class( diff --git a/hardware-testing/hardware_testing/gravimetric/measurement/record.py b/hardware-testing/hardware_testing/gravimetric/measurement/record.py index d1e4ab7e4d4..86ef8b84903 100644 --- a/hardware-testing/hardware_testing/gravimetric/measurement/record.py +++ b/hardware-testing/hardware_testing/gravimetric/measurement/record.py @@ -280,7 +280,11 @@ class GravimetricRecorder: """Gravimetric Recorder.""" def __init__( - self, cfg: GravimetricRecorderConfig, scale: Scale, simulate: bool = False + self, + cfg: GravimetricRecorderConfig, + scale: Scale, + simulate: bool = False, + start_graph: bool = True, ) -> None: """Gravimetric Recorder.""" self._cfg = cfg @@ -294,7 +298,7 @@ def __init__( self._scale_serial: str = "" self._scale_max_capacity: float = 0.0 super().__init__() - self.activate() + self.activate(start_graph) def _start_graph_server_process(self) -> None: if self.is_simulator: @@ -350,9 +354,10 @@ def add_simulation_mass(self, mass: float) -> None: """Add simulation mass.""" self._scale.add_simulation_mass(mass) - def activate(self) -> None: + def activate(self, graph: bool = True) -> None: """Activate.""" - self._start_graph_server_process() + if graph: + self._start_graph_server_process() # Some Radwag settings cannot be controlled remotely. # Listed below are the things the must be done using the touchscreen: # 1) Set profile to USER diff --git a/hardware-testing/hardware_testing/gravimetric/overrides/api.patch b/hardware-testing/hardware_testing/gravimetric/overrides/api.patch index 4e2ab9b6c23..e69de29bb2d 100644 --- a/hardware-testing/hardware_testing/gravimetric/overrides/api.patch +++ b/hardware-testing/hardware_testing/gravimetric/overrides/api.patch @@ -1,111 +0,0 @@ -diff --git a/api/src/opentrons/hardware_control/instruments/ot3/pipette.py b/api/src/opentrons/hardware_control/instruments/ot3/pipette.py -index 2d36460ca6..8578768930 100644 ---- a/api/src/opentrons/hardware_control/instruments/ot3/pipette.py -+++ b/api/src/opentrons/hardware_control/instruments/ot3/pipette.py -@@ -427,11 +427,11 @@ class Pipette(AbstractInstrument[PipetteConfigurations]): - - def set_current_volume(self, new_volume: float) -> None: - assert new_volume >= 0 -- assert new_volume <= self.working_volume -+ # assert new_volume <= self.working_volume - self._current_volume = new_volume - - def add_current_volume(self, volume_incr: float) -> None: -- assert self.ok_to_add_volume(volume_incr) -+ # assert self.ok_to_add_volume(volume_incr) - self._current_volume += volume_incr - - def remove_current_volume(self, volume_incr: float) -> None: -@@ -439,7 +439,8 @@ class Pipette(AbstractInstrument[PipetteConfigurations]): - self._current_volume -= volume_incr - - def ok_to_add_volume(self, volume_incr: float) -> bool: -- return self.current_volume + volume_incr <= self.working_volume -+ # return self.current_volume + volume_incr <= self.working_volume -+ return True - - def ok_to_push_out(self, push_out_dist_mm: float) -> bool: - return push_out_dist_mm <= ( -diff --git a/api/src/opentrons/protocol_api/core/engine/deck_conflict.py b/api/src/opentrons/protocol_api/core/engine/deck_conflict.py -index 0ba7e17621..4d6682f5e4 100644 ---- a/api/src/opentrons/protocol_api/core/engine/deck_conflict.py -+++ b/api/src/opentrons/protocol_api/core/engine/deck_conflict.py -@@ -341,18 +341,12 @@ def check_safe_for_tip_pickup_and_return( - f" when picking up fewer than 96 tips." - ) - elif not is_partial_config and not is_96_ch_tiprack_adapter: -- raise UnsuitableTiprackForPipetteMotion( -- f"{tiprack_name} must be on an Opentrons Flex 96 Tip Rack Adapter" -- f" in order to pick up or return all 96 tips simultaneously." -- ) -+ pass - - elif ( - not is_partial_config - ): # tiprack is not on adapter and pipette is in full config -- raise UnsuitableTiprackForPipetteMotion( -- f"{tiprack_name} must be on an Opentrons Flex 96 Tip Rack Adapter" -- f" in order to pick up or return all 96 tips simultaneously." -- ) -+ pass - - - # TODO (spp, 2023-02-06): update the extents check to use all nozzle bounds instead of -diff --git a/api/src/opentrons/protocol_api/core/legacy/deck.py b/api/src/opentrons/protocol_api/core/legacy/deck.py -index 9a9092af5a..33aa5941ce 100644 ---- a/api/src/opentrons/protocol_api/core/legacy/deck.py -+++ b/api/src/opentrons/protocol_api/core/legacy/deck.py -@@ -55,11 +55,11 @@ class DeckItem(Protocol): - class Deck(UserDict): # type: ignore[type-arg] - data: Dict[int, Optional[DeckItem]] - -- def __init__(self, deck_type: str) -> None: -+ def __init__( -+ self, deck_type: str, version: int = DEFAULT_LEGACY_DECK_DEFINITION_VERSION -+ ) -> None: - super().__init__() -- self._definition = load_deck( -- name=deck_type, version=DEFAULT_LEGACY_DECK_DEFINITION_VERSION -- ) -+ self._definition = load_deck(name=deck_type, version=version) - self._positions = {} - for slot in self._definition["locations"]["orderedSlots"]: - self.data[int(slot["id"])] = None -diff --git a/api/src/opentrons/protocol_api/create_protocol_context.py b/api/src/opentrons/protocol_api/create_protocol_context.py -index 5a64e70cf9..7d5047cc4b 100644 ---- a/api/src/opentrons/protocol_api/create_protocol_context.py -+++ b/api/src/opentrons/protocol_api/create_protocol_context.py -@@ -22,6 +22,7 @@ from .deck import Deck - - from .core.common import ProtocolCore as AbstractProtocolCore - from .core.legacy.deck import Deck as LegacyDeck -+from opentrons_shared_data.deck import DEFAULT_DECK_DEFINITION_VERSION - from .core.legacy.legacy_protocol_core import LegacyProtocolCore - from .core.legacy.labware_offset_provider import ( - AbstractLabwareOffsetProvider, -@@ -52,6 +53,7 @@ def create_protocol_context( - extra_labware: Optional[Dict[str, LabwareDefinition]] = None, - bundled_labware: Optional[Dict[str, LabwareDefinition]] = None, - bundled_data: Optional[Dict[str, bytes]] = None, -+ deck_version: int = DEFAULT_DECK_DEFINITION_VERSION, - ) -> ProtocolContext: - """Create a ProtocolContext for use in a Python protocol. - -@@ -121,7 +123,7 @@ def create_protocol_context( - - # TODO(mc, 2022-8-22): remove `disable_fast_protocol_upload` - elif use_simulating_core and not feature_flags.disable_fast_protocol_upload(): -- legacy_deck = LegacyDeck(deck_type=deck_type) -+ legacy_deck = LegacyDeck(deck_type=deck_type, version=deck_version) - core = LegacyProtocolCoreSimulator( - sync_hardware=sync_hardware, - labware_offset_provider=labware_offset_provider, -@@ -133,7 +135,7 @@ def create_protocol_context( - ) - - else: -- legacy_deck = LegacyDeck(deck_type=deck_type) -+ legacy_deck = LegacyDeck(deck_type=deck_type, version=deck_version) - core = LegacyProtocolCore( - sync_hardware=sync_hardware, - labware_offset_provider=labware_offset_provider, diff --git a/hardware-testing/hardware_testing/gravimetric/overrides/shared-data.patch b/hardware-testing/hardware_testing/gravimetric/overrides/shared-data.patch index b2d08d109e9..5d688841b91 100644 --- a/hardware-testing/hardware_testing/gravimetric/overrides/shared-data.patch +++ b/hardware-testing/hardware_testing/gravimetric/overrides/shared-data.patch @@ -1,872 +1,180 @@ -diff --git a/shared-data/deck/definitions/2/ot3_standard.json b/shared-data/deck/definitions/2/ot3_standard.json -new file mode 100644 -index 0000000000..8ad4397cba ---- /dev/null -+++ b/shared-data/deck/definitions/2/ot3_standard.json -@@ -0,0 +1,866 @@ -+{ -+ "otId": "ot3_standard", -+ "schemaVersion": 3, -+ "cornerOffsetFromOrigin": [-204.31, -76.59, 0], -+ "dimensions": [854.995, 581.74, 0], -+ "metadata": { -+ "displayName": "OT-3 Standard Deck", -+ "tags": ["ot3", "12 slots", "standard"] -+ }, -+ "robot": { -+ "model": "OT-3 Standard" -+ }, -+ "locations": { -+ "orderedSlots": [ -+ { -+ "id": "1", -+ "position": [0.0, 0.0, 0.0], -+ "matingSurfaceUnitVector": [-1, 1, -1], -+ "boundingBox": { -+ "xDimension": 128.0, -+ "yDimension": 86.0, -+ "zDimension": 0 -+ }, -+ "displayName": "Slot D1", -+ "compatibleModuleTypes": [ -+ "magneticModuleType", -+ "temperatureModuleType", -+ "heaterShakerModuleType" -+ ] -+ }, -+ { -+ "id": "2", -+ "position": [164.0, 0.0, 0.0], -+ "matingSurfaceUnitVector": [-1, 1, -1], -+ "boundingBox": { -+ "xDimension": 128.0, -+ "yDimension": 86.0, -+ "zDimension": 0 -+ }, -+ "displayName": "Slot D2", -+ "compatibleModuleTypes": [ -+ "magneticModuleType", -+ "temperatureModuleType", -+ "heaterShakerModuleType" -+ ] -+ }, -+ { -+ "id": "3", -+ "position": [328.0, 0.0, 0.0], -+ "matingSurfaceUnitVector": [-1, 1, -1], -+ "boundingBox": { -+ "xDimension": 128.0, -+ "yDimension": 86.0, -+ "zDimension": 0 -+ }, -+ "displayName": "Slot D3", -+ "compatibleModuleTypes": [ -+ "magneticModuleType", -+ "temperatureModuleType", -+ "heaterShakerModuleType" -+ ] -+ }, -+ { -+ "id": "4", -+ "position": [0.0, 107, 0.0], -+ "matingSurfaceUnitVector": [-1, 1, -1], -+ "boundingBox": { -+ "xDimension": 128.0, -+ "yDimension": 86.0, -+ "zDimension": 0 -+ }, -+ "displayName": "Slot C1", -+ "compatibleModuleTypes": [ -+ "magneticModuleType", -+ "temperatureModuleType", -+ "heaterShakerModuleType" -+ ] -+ }, -+ { -+ "id": "5", -+ "position": [164.0, 107, 0.0], -+ "matingSurfaceUnitVector": [-1, 1, -1], -+ "boundingBox": { -+ "xDimension": 128.0, -+ "yDimension": 86.0, -+ "zDimension": 0 -+ }, -+ "displayName": "Slot C2", -+ "compatibleModuleTypes": [ -+ "magneticModuleType", -+ "temperatureModuleType", -+ "heaterShakerModuleType" -+ ] -+ }, -+ { -+ "id": "6", -+ "position": [328.0, 107, 0.0], -+ "matingSurfaceUnitVector": [-1, 1, -1], -+ "boundingBox": { -+ "xDimension": 128.0, -+ "yDimension": 86.0, -+ "zDimension": 0 -+ }, -+ "displayName": "Slot C3", -+ "compatibleModuleTypes": [ -+ "magneticModuleType", -+ "temperatureModuleType", -+ "heaterShakerModuleType" -+ ] -+ }, -+ { -+ "id": "7", -+ "position": [0.0, 214.0, 0.0], -+ "matingSurfaceUnitVector": [-1, 1, -1], -+ "boundingBox": { -+ "xDimension": 128.0, -+ "yDimension": 86.0, -+ "zDimension": 0 -+ }, -+ "displayName": "Slot B1", -+ "compatibleModuleTypes": [ -+ "magneticModuleType", -+ "temperatureModuleType", -+ "thermocyclerModuleType", -+ "heaterShakerModuleType" -+ ] -+ }, -+ { -+ "id": "8", -+ "position": [164.0, 214.0, 0.0], -+ "matingSurfaceUnitVector": [-1, 1, -1], -+ "boundingBox": { -+ "xDimension": 128.0, -+ "yDimension": 86.0, -+ "zDimension": 0 -+ }, -+ "displayName": "Slot B2", -+ "compatibleModuleTypes": [ -+ "magneticModuleType", -+ "temperatureModuleType", -+ "heaterShakerModuleType" -+ ] -+ }, -+ { -+ "id": "9", -+ "position": [328.0, 214.0, 0.0], -+ "matingSurfaceUnitVector": [-1, 1, -1], -+ "boundingBox": { -+ "xDimension": 128.0, -+ "yDimension": 86.0, -+ "zDimension": 0 -+ }, -+ "displayName": "Slot B3", -+ "compatibleModuleTypes": [ -+ "magneticModuleType", -+ "temperatureModuleType", -+ "heaterShakerModuleType" -+ ] -+ }, -+ { -+ "id": "10", -+ "position": [0.0, 321.0, 0.0], -+ "matingSurfaceUnitVector": [-1, 1, -1], -+ "boundingBox": { -+ "xDimension": 128.0, -+ "yDimension": 86.0, -+ "zDimension": 0 -+ }, -+ "displayName": "Slot A1", -+ "compatibleModuleTypes": [ -+ "magneticModuleType", -+ "temperatureModuleType", -+ "heaterShakerModuleType" -+ ] -+ }, -+ { -+ "id": "11", -+ "position": [164.0, 321.0, 0.0], -+ "matingSurfaceUnitVector": [-1, 1, -1], -+ "boundingBox": { -+ "xDimension": 128.0, -+ "yDimension": 86.0, -+ "zDimension": 0 -+ }, -+ "displayName": "Slot A2", -+ "compatibleModuleTypes": [ -+ "magneticModuleType", -+ "temperatureModuleType", -+ "heaterShakerModuleType" -+ ] -+ }, -+ { -+ "id": "12", -+ "position": [328.0, 321.0, 0.0], -+ "boundingBox": { -+ "xDimension": 128.0, -+ "yDimension": 86.0, -+ "zDimension": 0 -+ }, -+ "displayName": "Slot A3", -+ "compatibleModuleTypes": [] -+ } -+ ], -+ "calibrationPoints": [], -+ "fixtures": [ -+ { -+ "id": "fixedTrash", -+ "slot": "12", -+ "labware": "opentrons_1_trash_3200ml_fixed", -+ "displayName": "Fixed Trash" -+ } -+ ] -+ }, -+ "layers": [ -+ { -+ "name": "style", -+ "type": "element", -+ "value": "", -+ "attributes": { -+ "type": "text/css" -+ }, -+ "children": [ -+ { -+ "name": "", -+ "type": "text", -+ "value": "\n.st0{fill:#CCCCCC;}\n.st1{fill:none;stroke:#16212D;stroke-width:3.2047;stroke-opacity:0.7;}\n.st2{fill:none;stroke:#16212D;stroke-width:3.156;stroke-opacity:0.7;}\n", -+ "attributes": {}, -+ "children": [] -+ } -+ ] -+ }, -+ { -+ "name": "path", -+ "type": "element", -+ "value": "", -+ "attributes": { -+ "id": "SLOT_A1_EXPANSION", -+ "class": "st0", -+ "d": "M-97.8,496.6h239c2.3,0,4.2-1.9,4.2-4.2v-70c0-2.3-1.9-4.2-4.2-4.2h-239c-2.3,0-4.2,1.9-4.2,4.2v70\nC-102,494.7-100.1,496.6-97.8,496.6z" -+ }, -+ "children": [] -+ }, -+ { -+ "name": "path", -+ "type": "element", -+ "value": "", -+ "attributes": { -+ "id": "SLOT_BASE_A1", -+ "class": "st0", -+ "d": "M-97.7,417.1h238.8c2.4,0,4.3-1.9,4.3-4.3v-97.4c0-2.4-1.9-4.3-4.3-4.3H-97.7c-2.4,0-4.3,1.9-4.3,4.3v97.4\nC-102,415.1-100.1,417.1-97.7,417.1z" -+ }, -+ "children": [] -+ }, -+ { -+ "name": "path", -+ "type": "element", -+ "value": "", -+ "attributes": { -+ "id": "SLOT_BASE_A2", -+ "class": "st0", -+ "d": "M150.8,417.1h154.3c2.4,0,4.3-1.9,4.3-4.3v-97.4c0-2.4-1.9-4.3-4.3-4.3H150.8c-2.4,0-4.3,1.9-4.3,4.3v97.4\nC146.5,415.1,148.4,417.1,150.8,417.1z" -+ }, -+ "children": [] -+ }, -+ { -+ "name": "path", -+ "type": "element", -+ "value": "", -+ "attributes": { -+ "id": "SLOT_BASE_A3", -+ "class": "st0", -+ "d": "M314.8,417.1h238.9c2.4,0,4.3-1.9,4.3-4.3v-97.4c0-2.4-1.9-4.3-4.3-4.3H314.8c-2.4,0-4.3,1.9-4.3,4.3v97.4\nC310.5,415.1,312.4,417.1,314.8,417.1z" -+ }, -+ "children": [] -+ }, -+ { -+ "name": "path", -+ "type": "element", -+ "value": "", -+ "attributes": { -+ "id": "SLOT_BASE_B1", -+ "class": "st0", -+ "d": "M-97.7,310h238.8c2.4,0,4.3-1.9,4.3-4.3v-97.2c0-2.4-1.9-4.3-4.3-4.3H-97.7c-2.4,0-4.3,1.9-4.3,4.3v97.2\nC-102,308.1-100.1,310-97.7,310z" -+ }, -+ "children": [] -+ }, -+ { -+ "name": "path", -+ "type": "element", -+ "value": "", -+ "attributes": { -+ "id": "SLOT_BASE_B2", -+ "class": "st0", -+ "d": "M150.8,310h154.3c2.4,0,4.3-1.9,4.3-4.3v-97.2c0-2.4-1.9-4.3-4.3-4.3H150.8c-2.4,0-4.3,1.9-4.3,4.3v97.2\nC146.5,308.1,148.4,310,150.8,310z" -+ }, -+ "children": [] -+ }, -+ { -+ "name": "path", -+ "type": "element", -+ "value": "", -+ "attributes": { -+ "id": "SLOT_BASE_B3", -+ "class": "st0", -+ "d": "M314.8,310h238.9c2.4,0,4.3-1.9,4.3-4.3v-97.2c0-2.4-1.9-4.3-4.3-4.3H314.8c-2.4,0-4.3,1.9-4.3,4.3v97.2\nC310.5,308.1,312.4,310,314.8,310z" -+ }, -+ "children": [] -+ }, -+ { -+ "name": "path", -+ "type": "element", -+ "value": "", -+ "attributes": { -+ "id": "SLOT_BASE_C1", -+ "class": "st0", -+ "d": "M-97.7,203.1h238.8c2.4,0,4.3-1.9,4.3-4.3v-97.4c0-2.4-1.9-4.3-4.3-4.3H-97.7c-2.4,0-4.3,1.9-4.3,4.3v97.4\nC-102,201.2-100.1,203.1-97.7,203.1z" -+ }, -+ "children": [] -+ }, -+ { -+ "name": "path", -+ "type": "element", -+ "value": "", -+ "attributes": { -+ "id": "SLOT_BASE_C2", -+ "class": "st0", -+ "d": "M150.8,203.1h154.3c2.4,0,4.3-1.9,4.3-4.3v-97.4c0-2.4-1.9-4.3-4.3-4.3H150.8c-2.4,0-4.3,1.9-4.3,4.3v97.4\nC146.5,201.2,148.4,203.1,150.8,203.1z" -+ }, -+ "children": [] -+ }, -+ { -+ "name": "path", -+ "type": "element", -+ "value": "", -+ "attributes": { -+ "id": "SLOT_BASE_C3", -+ "class": "st0", -+ "d": "M314.8,203.1h238.9c2.4,0,4.3-1.9,4.3-4.3v-97.4c0-2.4-1.9-4.3-4.3-4.3H314.8c-2.4,0-4.3,1.9-4.3,4.3v97.4\nC310.5,201.2,312.4,203.1,314.8,203.1z" -+ }, -+ "children": [] -+ }, -+ { -+ "name": "path", -+ "type": "element", -+ "value": "", -+ "attributes": { -+ "id": "SLOT_BASE_D1", -+ "class": "st0", -+ "d": "M-97.7,96.1h238.8c2.4,0,4.3-1.9,4.3-4.3V-5.6c0-2.4-1.9-4.3-4.3-4.3H-97.7c-2.4,0-4.3,1.9-4.3,4.3v97.4\nC-102,94.2-100.1,96.1-97.7,96.1z" -+ }, -+ "children": [] -+ }, -+ { -+ "name": "path", -+ "type": "element", -+ "value": "", -+ "attributes": { -+ "id": "SLOT_BASE_D2", -+ "class": "st0", -+ "d": "M150.8,96.1h154.3c2.4,0,4.3-1.9,4.3-4.3V-5.6c0-2.4-1.9-4.3-4.3-4.3H150.8c-2.4,0-4.3,1.9-4.3,4.3v97.4\nC146.5,94.2,148.4,96.1,150.8,96.1z" -+ }, -+ "children": [] -+ }, -+ { -+ "name": "path", -+ "type": "element", -+ "value": "", -+ "attributes": { -+ "id": "SLOT_BASE_D3", -+ "class": "st0", -+ "d": "M314.8,96.1h238.9c2.4,0,4.3-1.9,4.3-4.3V-5.6c0-2.4-1.9-4.3-4.3-4.3H314.8c-2.4,0-4.3,1.9-4.3,4.3v97.4\nC310.5,94.2,312.4,96.1,314.8,96.1z" -+ }, -+ "children": [] -+ }, -+ { -+ "name": "g", -+ "type": "element", -+ "value": "", -+ "attributes": { -+ "id": "SLOT_CLIPS" -+ }, -+ "children": [ -+ { -+ "name": "path", -+ "type": "element", -+ "value": "", -+ "attributes": { -+ "class": "st1", -+ "d": "M-1.9,398.9V409H8.9" -+ }, -+ "children": [] -+ }, -+ { -+ "name": "path", -+ "type": "element", -+ "value": "", -+ "attributes": { -+ "class": "st2", -+ "d": "M-1.9,329.8v-10.5H8.7" -+ }, -+ "children": [] -+ }, -+ { -+ "name": "path", -+ "type": "element", -+ "value": "", -+ "attributes": { -+ "class": "st1", -+ "d": "M129.9,398.9V409h-10.8" -+ }, -+ "children": [] -+ }, -+ { -+ "name": "path", -+ "type": "element", -+ "value": "", -+ "attributes": { -+ "class": "st1", -+ "d": "M129.9,329.8v-10.7h-10.8" -+ }, -+ "children": [] -+ }, -+ { -+ "name": "path", -+ "type": "element", -+ "value": "", -+ "attributes": { -+ "class": "st1", -+ "d": "M162.1,398.9V409h10.8" -+ }, -+ "children": [] -+ }, -+ { -+ "name": "path", -+ "type": "element", -+ "value": "", -+ "attributes": { -+ "class": "st2", -+ "d": "M162.1,329.8v-10.5h10.6" -+ }, -+ "children": [] -+ }, -+ { -+ "name": "path", -+ "type": "element", -+ "value": "", -+ "attributes": { -+ "class": "st1", -+ "d": "M293.9,398.9V409h-10.8" -+ }, -+ "children": [] -+ }, -+ { -+ "name": "path", -+ "type": "element", -+ "value": "", -+ "attributes": { -+ "class": "st1", -+ "d": "M293.9,329.8v-10.7h-10.8" -+ }, -+ "children": [] -+ }, -+ { -+ "name": "path", -+ "type": "element", -+ "value": "", -+ "attributes": { -+ "class": "st1", -+ "d": "M326,398.9V409h10.8" -+ }, -+ "children": [] -+ }, -+ { -+ "name": "path", -+ "type": "element", -+ "value": "", -+ "attributes": { -+ "class": "st2", -+ "d": "M326,329.8v-10.5h10.6" -+ }, -+ "children": [] -+ }, -+ { -+ "name": "path", -+ "type": "element", -+ "value": "", -+ "attributes": { -+ "class": "st1", -+ "d": "M457.8,398.9V409H447" -+ }, -+ "children": [] -+ }, -+ { -+ "name": "path", -+ "type": "element", -+ "value": "", -+ "attributes": { -+ "class": "st1", -+ "d": "M457.8,329.8v-10.7H447" -+ }, -+ "children": [] -+ }, -+ { -+ "name": "path", -+ "type": "element", -+ "value": "", -+ "attributes": { -+ "class": "st1", -+ "d": "M-1.9,291.9V302H8.9" -+ }, -+ "children": [] -+ }, -+ { -+ "name": "path", -+ "type": "element", -+ "value": "", -+ "attributes": { -+ "class": "st2", -+ "d": "M-1.9,222.8v-10.5H8.7" -+ }, -+ "children": [] -+ }, -+ { -+ "name": "path", -+ "type": "element", -+ "value": "", -+ "attributes": { -+ "class": "st1", -+ "d": "M129.9,291.9V302h-10.8" -+ }, -+ "children": [] -+ }, -+ { -+ "name": "path", -+ "type": "element", -+ "value": "", -+ "attributes": { -+ "class": "st1", -+ "d": "M129.9,222.8v-10.7h-10.8" -+ }, -+ "children": [] -+ }, -+ { -+ "name": "path", -+ "type": "element", -+ "value": "", -+ "attributes": { -+ "class": "st1", -+ "d": "M162.1,291.9V302h10.8" -+ }, -+ "children": [] -+ }, -+ { -+ "name": "path", -+ "type": "element", -+ "value": "", -+ "attributes": { -+ "class": "st2", -+ "d": "M162.1,222.8v-10.5h10.6" -+ }, -+ "children": [] -+ }, -+ { -+ "name": "path", -+ "type": "element", -+ "value": "", -+ "attributes": { -+ "class": "st1", -+ "d": "M293.9,291.9V302h-10.8" -+ }, -+ "children": [] -+ }, -+ { -+ "name": "path", -+ "type": "element", -+ "value": "", -+ "attributes": { -+ "class": "st1", -+ "d": "M293.9,222.8v-10.7h-10.8" -+ }, -+ "children": [] -+ }, -+ { -+ "name": "path", -+ "type": "element", -+ "value": "", -+ "attributes": { -+ "class": "st1", -+ "d": "M326,291.9V302h10.8" -+ }, -+ "children": [] -+ }, -+ { -+ "name": "path", -+ "type": "element", -+ "value": "", -+ "attributes": { -+ "class": "st2", -+ "d": "M326,222.8v-10.5h10.6" -+ }, -+ "children": [] -+ }, -+ { -+ "name": "path", -+ "type": "element", -+ "value": "", -+ "attributes": { -+ "class": "st1", -+ "d": "M457.8,291.9V302H447" -+ }, -+ "children": [] -+ }, -+ { -+ "name": "path", -+ "type": "element", -+ "value": "", -+ "attributes": { -+ "class": "st1", -+ "d": "M457.8,222.8v-10.7H447" -+ }, -+ "children": [] -+ }, -+ { -+ "name": "path", -+ "type": "element", -+ "value": "", -+ "attributes": { -+ "class": "st1", -+ "d": "M-1.9,185v10.1H8.9" -+ }, -+ "children": [] -+ }, -+ { -+ "name": "path", -+ "type": "element", -+ "value": "", -+ "attributes": { -+ "class": "st2", -+ "d": "M-1.9,115.8v-10.5H8.7" -+ }, -+ "children": [] -+ }, -+ { -+ "name": "path", -+ "type": "element", -+ "value": "", -+ "attributes": { -+ "class": "st1", -+ "d": "M129.9,185v10.1h-10.8" -+ }, -+ "children": [] -+ }, -+ { -+ "name": "path", -+ "type": "element", -+ "value": "", -+ "attributes": { -+ "class": "st1", -+ "d": "M129.9,115.8v-10.7h-10.8" -+ }, -+ "children": [] -+ }, -+ { -+ "name": "path", -+ "type": "element", -+ "value": "", -+ "attributes": { -+ "class": "st1", -+ "d": "M162.1,185v10.1h10.8" -+ }, -+ "children": [] -+ }, -+ { -+ "name": "path", -+ "type": "element", -+ "value": "", -+ "attributes": { -+ "class": "st2", -+ "d": "M162.1,115.8v-10.5h10.6" -+ }, -+ "children": [] -+ }, -+ { -+ "name": "path", -+ "type": "element", -+ "value": "", -+ "attributes": { -+ "class": "st1", -+ "d": "M293.9,185v10.1h-10.8" -+ }, -+ "children": [] -+ }, -+ { -+ "name": "path", -+ "type": "element", -+ "value": "", -+ "attributes": { -+ "class": "st1", -+ "d": "M293.9,115.8v-10.7h-10.8" -+ }, -+ "children": [] -+ }, -+ { -+ "name": "path", -+ "type": "element", -+ "value": "", -+ "attributes": { -+ "class": "st1", -+ "d": "M326,185v10.1h10.8" -+ }, -+ "children": [] -+ }, -+ { -+ "name": "path", -+ "type": "element", -+ "value": "", -+ "attributes": { -+ "class": "st2", -+ "d": "M326,115.8v-10.5h10.6" -+ }, -+ "children": [] -+ }, -+ { -+ "name": "path", -+ "type": "element", -+ "value": "", -+ "attributes": { -+ "class": "st1", -+ "d": "M457.8,185v10.1H447" -+ }, -+ "children": [] -+ }, -+ { -+ "name": "path", -+ "type": "element", -+ "value": "", -+ "attributes": { -+ "class": "st1", -+ "d": "M457.8,115.8v-10.7H447" -+ }, -+ "children": [] -+ }, -+ { -+ "name": "path", -+ "type": "element", -+ "value": "", -+ "attributes": { -+ "class": "st1", -+ "d": "M-1.9,77.9V88H8.9" -+ }, -+ "children": [] -+ }, -+ { -+ "name": "path", -+ "type": "element", -+ "value": "", -+ "attributes": { -+ "class": "st2", -+ "d": "M-1.9,8.8V-1.7H8.7" -+ }, -+ "children": [] -+ }, -+ { -+ "name": "path", -+ "type": "element", -+ "value": "", -+ "attributes": { -+ "class": "st1", -+ "d": "M129.9,77.9V88h-10.8" -+ }, -+ "children": [] -+ }, -+ { -+ "name": "path", -+ "type": "element", -+ "value": "", -+ "attributes": { -+ "class": "st1", -+ "d": "M129.9,8.8V-1.9h-10.8" -+ }, -+ "children": [] -+ }, -+ { -+ "name": "path", -+ "type": "element", -+ "value": "", -+ "attributes": { -+ "class": "st1", -+ "d": "M162.1,77.9V88h10.8" -+ }, -+ "children": [] -+ }, -+ { -+ "name": "path", -+ "type": "element", -+ "value": "", -+ "attributes": { -+ "class": "st2", -+ "d": "M162.1,8.8V-1.7h10.6" -+ }, -+ "children": [] -+ }, -+ { -+ "name": "path", -+ "type": "element", -+ "value": "", -+ "attributes": { -+ "class": "st1", -+ "d": "M293.9,77.9V88h-10.8" -+ }, -+ "children": [] -+ }, -+ { -+ "name": "path", -+ "type": "element", -+ "value": "", -+ "attributes": { -+ "class": "st1", -+ "d": "M293.9,8.8V-1.9h-10.8" -+ }, -+ "children": [] -+ }, -+ { -+ "name": "path", -+ "type": "element", -+ "value": "", -+ "attributes": { -+ "class": "st1", -+ "d": "M326,77.9V88h10.8" -+ }, -+ "children": [] -+ }, -+ { -+ "name": "path", -+ "type": "element", -+ "value": "", -+ "attributes": { -+ "class": "st2", -+ "d": "M326,8.8V-1.7h10.6" -+ }, -+ "children": [] -+ }, -+ { -+ "name": "path", -+ "type": "element", -+ "value": "", -+ "attributes": { -+ "class": "st1", -+ "d": "M457.8,77.9V88H447" -+ }, -+ "children": [] -+ }, -+ { -+ "name": "path", -+ "type": "element", -+ "value": "", -+ "attributes": { -+ "class": "st1", -+ "d": "M457.8,8.8V-1.9H447" -+ }, -+ "children": [] -+ } -+ ] -+ } -+ ] -+} +diff --git a/shared-data/pipette/definitions/2/liquid/single_channel/p50/default/3_5.json b/shared-data/pipette/definitions/2/liquid/single_channel/p50/default/3_5.json +index c798ce421a..14fc4a5b67 100644 +--- a/shared-data/pipette/definitions/2/liquid/single_channel/p50/default/3_5.json ++++ b/shared-data/pipette/definitions/2/liquid/single_channel/p50/default/3_5.json +@@ -20,50 +20,50 @@ + "aspirate": { + "default": { + "1": [ +- [0.462, 0.5646, 0.0415], +- [0.648, 0.3716, 0.1307], +- [1.032, 0.2742, 0.1938], +- [1.37, 0.1499, 0.3221], +- [2.014, 0.1044, 0.3845], +- [2.772, 0.0432, 0.5076], +- [3.05, -0.0809, 0.8517], +- [3.4, 0.0256, 0.5268], +- [3.962, 0.0612, 0.4057], +- [4.438, 0.0572, 0.4217], +- [5.164, 0.018, 0.5955], +- [5.966, 0.0095, 0.6393], +- [7.38, 0.0075, 0.6514], +- [9.128, 0.0049, 0.6705], +- [10.16, 0.0033, 0.6854], +- [13.812, 0.0024, 0.6948], +- [27.204, 0.0008, 0.7165], +- [50.614, 0.0002, 0.7328], +- [53.046, -0.0005, 0.7676] ++ [0.31, 0.591, 0.0197], ++ [0.39, 0.2586, 0.1227], ++ [0.86, 0.3697, 0.0794], ++ [1.29, 0.231, 0.1987], ++ [1.93, 0.1144, 0.3491], ++ [2.7, 0.0536, 0.4664], ++ [2.95, -0.1041, 0.8923], ++ [3.28, 0.0216, 0.5214], ++ [3.76, 0.048, 0.4349], ++ [4.38, 0.083, 0.3032], ++ [5.08, 0.0153, 0.5996], ++ [5.9, 0.0136, 0.6083], ++ [7.29, 0.007, 0.6474], ++ [9.04, 0.0059, 0.6551], ++ [10.08, 0.0045, 0.6682], ++ [13.74, 0.0029, 0.6842], ++ [27.15, 0.001, 0.7104], ++ [50.48, 0.0002, 0.7319], ++ [52.89, -0.0006, 0.7703] + ] + } + }, + "dispense": { + "default": { + "1": [ +- [0.462, 0.5646, 0.0415], +- [0.648, 0.3716, 0.1307], +- [1.032, 0.2742, 0.1938], +- [1.37, 0.1499, 0.3221], +- [2.014, 0.1044, 0.3845], +- [2.772, 0.0432, 0.5076], +- [3.05, -0.0809, 0.8517], +- [3.4, 0.0256, 0.5268], +- [3.962, 0.0612, 0.4057], +- [4.438, 0.0572, 0.4217], +- [5.164, 0.018, 0.5955], +- [5.966, 0.0095, 0.6393], +- [7.38, 0.0075, 0.6514], +- [9.128, 0.0049, 0.6705], +- [10.16, 0.0033, 0.6854], +- [13.812, 0.0024, 0.6948], +- [27.204, 0.0008, 0.7165], +- [50.614, 0.0002, 0.7328], +- [53.046, -0.0005, 0.7676] ++ [0.31, 0.591, 0.0197], ++ [0.39, 0.2586, 0.1227], ++ [0.86, 0.3697, 0.0794], ++ [1.29, 0.231, 0.1987], ++ [1.93, 0.1144, 0.3491], ++ [2.7, 0.0536, 0.4664], ++ [2.95, -0.1041, 0.8923], ++ [3.28, 0.0216, 0.5214], ++ [3.76, 0.048, 0.4349], ++ [4.38, 0.083, 0.3032], ++ [5.08, 0.0153, 0.5996], ++ [5.9, 0.0136, 0.6083], ++ [7.29, 0.007, 0.6474], ++ [9.04, 0.0059, 0.6551], ++ [10.08, 0.0045, 0.6682], ++ [13.74, 0.0029, 0.6842], ++ [27.15, 0.001, 0.7104], ++ [50.48, 0.0002, 0.7319], ++ [52.89, -0.0006, 0.7703] + ] + } + }, +diff --git a/shared-data/pipette/definitions/2/liquid/single_channel/p50/lowVolumeDefault/3_5.json b/shared-data/pipette/definitions/2/liquid/single_channel/p50/lowVolumeDefault/3_5.json +index 644d93354e..4eba92a089 100644 +--- a/shared-data/pipette/definitions/2/liquid/single_channel/p50/lowVolumeDefault/3_5.json ++++ b/shared-data/pipette/definitions/2/liquid/single_channel/p50/lowVolumeDefault/3_5.json +@@ -20,46 +20,48 @@ + "aspirate": { + "default": { + "1": [ +- [0.11, 0.207815, 0.040201], +- [0.65, 0.43933, 0.014735], +- [1.04, 0.256666, 0.133466], +- [1.67, 0.147126, 0.247388], +- [2.45, 0.078774, 0.361536], +- [2.89, 0.042387, 0.450684], +- [3.2, 0.014781, 0.530464], +- [3.79, 0.071819, 0.347944], +- [4.22, 0.051592, 0.424605], +- [4.93, 0.021219, 0.552775], +- [5.81, 0.023461, 0.541725], +- [7.21, 0.008959, 0.625982], +- [8.93, 0.005456, 0.651235], +- [10.0, 0.007108, 0.636489], +- [13.61, 0.002591, 0.681656], +- [26.99, 0.001163, 0.701094], +- [45.25, 0.000207, 0.726887] ++ [0.3, 0.459, 0.0586], ++ [0.47, 0.43, 0.0674], ++ [0.9, 0.3404, 0.1095], ++ [1.26, 0.1925, 0.2425], ++ [1.95, 0.1314, 0.3195], ++ [2.76, 0.0604, 0.458], ++ [2.95, -0.2085, 1.2002], ++ [3.33, 0.0425, 0.4597], ++ [3.87, 0.0592, 0.404], ++ [4.31, 0.0518, 0.4327], ++ [5.07, 0.0264, 0.5424], ++ [5.93, 0.0186, 0.5818], ++ [7.34, 0.0078, 0.6458], ++ [9.08, 0.005, 0.6664], ++ [10.09, 0.0022, 0.6918], ++ [13.74, 0.0027, 0.6868], ++ [27.13, 0.0009, 0.7109], ++ [45.43, -0.0038, 0.8391] + ] + } + }, + "dispense": { + "default": { + "1": [ +- [0.11, 0.207815, 0.040201], +- [0.65, 0.43933, 0.014735], +- [1.04, 0.256666, 0.133466], +- [1.67, 0.147126, 0.247388], +- [2.45, 0.078774, 0.361536], +- [2.89, 0.042387, 0.450684], +- [3.2, 0.014781, 0.530464], +- [3.79, 0.071819, 0.347944], +- [4.22, 0.051592, 0.424605], +- [4.93, 0.021219, 0.552775], +- [5.81, 0.023461, 0.541725], +- [7.21, 0.008959, 0.625982], +- [8.93, 0.005456, 0.651235], +- [10.0, 0.007108, 0.636489], +- [13.61, 0.002591, 0.681656], +- [26.99, 0.001163, 0.701094], +- [45.25, 0.000207, 0.726887] ++ [0.3, 0.459, 0.0586], ++ [0.47, 0.43, 0.0674], ++ [0.9, 0.3404, 0.1095], ++ [1.26, 0.1925, 0.2425], ++ [1.95, 0.1314, 0.3195], ++ [2.76, 0.0604, 0.458], ++ [2.95, -0.2085, 1.2002], ++ [3.33, 0.0425, 0.4597], ++ [3.87, 0.0592, 0.404], ++ [4.31, 0.0518, 0.4327], ++ [5.07, 0.0264, 0.5424], ++ [5.93, 0.0186, 0.5818], ++ [7.34, 0.0078, 0.6458], ++ [9.08, 0.005, 0.6664], ++ [10.09, 0.0022, 0.6918], ++ [13.74, 0.0027, 0.6868], ++ [27.13, 0.0009, 0.7109], ++ [45.43, -0.0038, 0.8391] + ] + } + }, diff --git a/hardware-testing/hardware_testing/gravimetric/tips.py b/hardware-testing/hardware_testing/gravimetric/tips.py index 8edf66a5797..7e72c6884a2 100644 --- a/hardware-testing/hardware_testing/gravimetric/tips.py +++ b/hardware-testing/hardware_testing/gravimetric/tips.py @@ -60,18 +60,18 @@ 7: "A", } CHANNEL_TO_TIP_ROW_LOOKUP_BY_SLOT = { - "1": CHANNEL_TO_TIP_ROW_LOOKUP, - "2": CHANNEL_TO_TIP_ROW_LOOKUP, - "3": CHANNEL_TO_TIP_ROW_LOOKUP, - "4": CHANNEL_TO_TIP_ROW_LOOKUP, - "5": CHANNEL_TO_TIP_ROW_LOOKUP, - "6": CHANNEL_TO_TIP_ROW_LOOKUP, - "7": CHANNEL_TO_TIP_ROW_LOOKUP, - "8": CHANNEL_TO_TIP_ROW_LOOKUP, - "9": CHANNEL_TO_TIP_ROW_LOOKUP, - "10": CHANNEL_TO_TIP_ROW_LOOKUP_BACK, - "11": CHANNEL_TO_TIP_ROW_LOOKUP_BACK, - "12": CHANNEL_TO_TIP_ROW_LOOKUP_BACK, + "D1": CHANNEL_TO_TIP_ROW_LOOKUP, + "D2": CHANNEL_TO_TIP_ROW_LOOKUP, + "D3": CHANNEL_TO_TIP_ROW_LOOKUP, + "C1": CHANNEL_TO_TIP_ROW_LOOKUP, + "C2": CHANNEL_TO_TIP_ROW_LOOKUP, + "C3": CHANNEL_TO_TIP_ROW_LOOKUP, + "B1": CHANNEL_TO_TIP_ROW_LOOKUP, + "B2": CHANNEL_TO_TIP_ROW_LOOKUP, + "B3": CHANNEL_TO_TIP_ROW_LOOKUP, + "A1": CHANNEL_TO_TIP_ROW_LOOKUP_BACK, + "A2": CHANNEL_TO_TIP_ROW_LOOKUP_BACK, + "A3": CHANNEL_TO_TIP_ROW_LOOKUP_BACK, } REAR_CHANNELS = [0, 1, 2, 3] FRONT_CHANNELS = [4, 5, 6, 7] diff --git a/hardware-testing/hardware_testing/gravimetric/workarounds.py b/hardware-testing/hardware_testing/gravimetric/workarounds.py index 0d2c425d830..7c182ddd079 100644 --- a/hardware-testing/hardware_testing/gravimetric/workarounds.py +++ b/hardware-testing/hardware_testing/gravimetric/workarounds.py @@ -12,6 +12,8 @@ from hardware_testing.opentrons_api.helpers_ot3 import start_server_ot3, stop_server_ot3 from hardware_testing.opentrons_api.types import Point +from opentrons.protocol_engine.types import LabwareOffset + def is_running_in_app() -> bool: """Is running in App.""" @@ -33,7 +35,7 @@ def force_prepare_for_aspirate(pipette: InstrumentContext) -> None: pipette.dispense() -def http_get_all_labware_offsets() -> List[dict]: +def http_get_all_labware_offsets() -> List[LabwareOffset]: """Request (HTTP GET) from the local robot-server all runs information.""" req = Request("http://localhost:31950/runs") req.add_header("Opentrons-Version", "2") @@ -46,7 +48,18 @@ def http_get_all_labware_offsets() -> List[dict]: runs_json = json_loads(runs_response_data) protocols_list = runs_json["data"] - return [offset for p in protocols_list for offset in p["labwareOffsets"]] + offset_dict = [offset for p in protocols_list for offset in p["labwareOffsets"]] + offsets: List[LabwareOffset] = [] + for offset_data in offset_dict: + new_offset = LabwareOffset( + id=offset_data["id"], + createdAt=offset_data["createdAt"], + definitionUri=offset_data["definitionUri"], + location=offset_data["location"], + vector=offset_data["vector"], + ) + offsets.append(new_offset) + return offsets def _old_slot_to_ot3_slot(old_api_slot: str) -> str: diff --git a/hardware-testing/hardware_testing/labware/dial_indicator/1.json b/hardware-testing/hardware_testing/labware/dial_indicator/1.json new file mode 100644 index 00000000000..6c3ac9c3f24 --- /dev/null +++ b/hardware-testing/hardware_testing/labware/dial_indicator/1.json @@ -0,0 +1,57 @@ +{ + "schemaVersion": 2, + "version": 1, + "namespace": "custom_beta", + "ordering": [["A1"]], + "metadata": { + "displayName": "Mitutoyo Digimatic Indicator", + "displayCategory": "tubeRack", + "displayVolumeUnits": "µL", + "tags": [] + }, + "dimensions": { + "xDimension": 128, + "yDimension": 86, + "zDimension": 136 + }, + "parameters": { + "format": "irregular", + "quirks": [], + "isTiprack": false, + "isMagneticModuleCompatible": false, + "loadName": "dial_indicator" + }, + "wells": { + "A1": { + "depth": 14, + "totalLiquidVolume": 10, + "shape": "circular", + "diameter": 4, + "x": 60.8, + "y": 41.5, + "z": 135 + } + }, + "brand": { + "brand": "Mitutoyo", + "brandId": ["ID-S"] + }, + "groups": [ + { + "brand": { + "brand": "Mitutoyo", + "brandId": ["ID-S"] + }, + "metadata": { + "wellBottomShape": "flat", + "displayCategory": "tubeRack" + }, + "wells": ["A1"] + } + ], + "cornerOffsetFromSlot": { + "x": 0, + "y": 0, + "z": 0 + } +} diff --git a/hardware-testing/hardware_testing/labware/opentrons_flex_96_tiprack_1000ul_adp/1.json b/hardware-testing/hardware_testing/labware/opentrons_flex_96_tiprack_1000ul_adp/1.json deleted file mode 100644 index 2307f25d876..00000000000 --- a/hardware-testing/hardware_testing/labware/opentrons_flex_96_tiprack_1000ul_adp/1.json +++ /dev/null @@ -1,1017 +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": "ryantrons OT-3", - "brandId": [] - }, - "metadata": { - "displayName": "Opentrons Flex 96 Tip Rack 1000 µL with adapter", - "displayCategory": "tipRack", - "displayVolumeUnits": "µL", - "tags": [] - }, - "dimensions": { - "xDimension": 127.76, - "yDimension": 85.48, - "zDimension": 132 - }, - "wells": { - "A1": { - "depth": 95.6, - "totalLiquidVolume": 1000, - "shape": "circular", - "diameter": 5.47, - "x": 14.38, - "y": 74.1, - "z": 36.4 - }, - "B1": { - "depth": 95.6, - "totalLiquidVolume": 1000, - "shape": "circular", - "diameter": 5.47, - "x": 14.38, - "y": 65.1, - "z": 36.4 - }, - "C1": { - "depth": 95.6, - "totalLiquidVolume": 1000, - "shape": "circular", - "diameter": 5.47, - "x": 14.38, - "y": 56.1, - "z": 36.4 - }, - "D1": { - "depth": 95.6, - "totalLiquidVolume": 1000, - "shape": "circular", - "diameter": 5.47, - "x": 14.38, - "y": 47.1, - "z": 36.4 - }, - "E1": { - "depth": 95.6, - "totalLiquidVolume": 1000, - "shape": "circular", - "diameter": 5.47, - "x": 14.38, - "y": 38.1, - "z": 36.4 - }, - "F1": { - "depth": 95.6, - "totalLiquidVolume": 1000, - "shape": "circular", - "diameter": 5.47, - "x": 14.38, - "y": 29.1, - "z": 36.4 - }, - "G1": { - "depth": 95.6, - "totalLiquidVolume": 1000, - "shape": "circular", - "diameter": 5.47, - "x": 14.38, - "y": 20.1, - "z": 36.4 - }, - "H1": { - "depth": 95.6, - "totalLiquidVolume": 1000, - "shape": "circular", - "diameter": 5.47, - "x": 14.38, - "y": 11.1, - "z": 36.4 - }, - "A2": { - "depth": 95.6, - "totalLiquidVolume": 1000, - "shape": "circular", - "diameter": 5.47, - "x": 23.38, - "y": 74.1, - "z": 36.4 - }, - "B2": { - "depth": 95.6, - "totalLiquidVolume": 1000, - "shape": "circular", - "diameter": 5.47, - "x": 23.38, - "y": 65.1, - "z": 36.4 - }, - "C2": { - "depth": 95.6, - "totalLiquidVolume": 1000, - "shape": "circular", - "diameter": 5.47, - "x": 23.38, - "y": 56.1, - "z": 36.4 - }, - "D2": { - "depth": 95.6, - "totalLiquidVolume": 1000, - "shape": "circular", - "diameter": 5.47, - "x": 23.38, - "y": 47.1, - "z": 36.4 - }, - "E2": { - "depth": 95.6, - "totalLiquidVolume": 1000, - "shape": "circular", - "diameter": 5.47, - "x": 23.38, - "y": 38.1, - "z": 36.4 - }, - "F2": { - "depth": 95.6, - "totalLiquidVolume": 1000, - "shape": "circular", - "diameter": 5.47, - "x": 23.38, - "y": 29.1, - "z": 36.4 - }, - "G2": { - "depth": 95.6, - "totalLiquidVolume": 1000, - "shape": "circular", - "diameter": 5.47, - "x": 23.38, - "y": 20.1, - "z": 36.4 - }, - "H2": { - "depth": 95.6, - "totalLiquidVolume": 1000, - "shape": "circular", - "diameter": 5.47, - "x": 23.38, - "y": 11.1, - "z": 36.4 - }, - "A3": { - "depth": 95.6, - "totalLiquidVolume": 1000, - "shape": "circular", - "diameter": 5.47, - "x": 32.38, - "y": 74.1, - "z": 36.4 - }, - "B3": { - "depth": 95.6, - "totalLiquidVolume": 1000, - "shape": "circular", - "diameter": 5.47, - "x": 32.38, - "y": 65.1, - "z": 36.4 - }, - "C3": { - "depth": 95.6, - "totalLiquidVolume": 1000, - "shape": "circular", - "diameter": 5.47, - "x": 32.38, - "y": 56.1, - "z": 36.4 - }, - "D3": { - "depth": 95.6, - "totalLiquidVolume": 1000, - "shape": "circular", - "diameter": 5.47, - "x": 32.38, - "y": 47.1, - "z": 36.4 - }, - "E3": { - "depth": 95.6, - "totalLiquidVolume": 1000, - "shape": "circular", - "diameter": 5.47, - "x": 32.38, - "y": 38.1, - "z": 36.4 - }, - "F3": { - "depth": 95.6, - "totalLiquidVolume": 1000, - "shape": "circular", - "diameter": 5.47, - "x": 32.38, - "y": 29.1, - "z": 36.4 - }, - "G3": { - "depth": 95.6, - "totalLiquidVolume": 1000, - "shape": "circular", - "diameter": 5.47, - "x": 32.38, - "y": 20.1, - "z": 36.4 - }, - "H3": { - "depth": 95.6, - "totalLiquidVolume": 1000, - "shape": "circular", - "diameter": 5.47, - "x": 32.38, - "y": 11.1, - "z": 36.4 - }, - "A4": { - "depth": 95.6, - "totalLiquidVolume": 1000, - "shape": "circular", - "diameter": 5.47, - "x": 41.38, - "y": 74.1, - "z": 36.4 - }, - "B4": { - "depth": 95.6, - "totalLiquidVolume": 1000, - "shape": "circular", - "diameter": 5.47, - "x": 41.38, - "y": 65.1, - "z": 36.4 - }, - "C4": { - "depth": 95.6, - "totalLiquidVolume": 1000, - "shape": "circular", - "diameter": 5.47, - "x": 41.38, - "y": 56.1, - "z": 36.4 - }, - "D4": { - "depth": 95.6, - "totalLiquidVolume": 1000, - "shape": "circular", - "diameter": 5.47, - "x": 41.38, - "y": 47.1, - "z": 36.4 - }, - "E4": { - "depth": 95.6, - "totalLiquidVolume": 1000, - "shape": "circular", - "diameter": 5.47, - "x": 41.38, - "y": 38.1, - "z": 36.4 - }, - "F4": { - "depth": 95.6, - "totalLiquidVolume": 1000, - "shape": "circular", - "diameter": 5.47, - "x": 41.38, - "y": 29.1, - "z": 36.4 - }, - "G4": { - "depth": 95.6, - "totalLiquidVolume": 1000, - "shape": "circular", - "diameter": 5.47, - "x": 41.38, - "y": 20.1, - "z": 36.4 - }, - "H4": { - "depth": 95.6, - "totalLiquidVolume": 1000, - "shape": "circular", - "diameter": 5.47, - "x": 41.38, - "y": 11.1, - "z": 36.4 - }, - "A5": { - "depth": 95.6, - "totalLiquidVolume": 1000, - "shape": "circular", - "diameter": 5.47, - "x": 50.38, - "y": 74.1, - "z": 36.4 - }, - "B5": { - "depth": 95.6, - "totalLiquidVolume": 1000, - "shape": "circular", - "diameter": 5.47, - "x": 50.38, - "y": 65.1, - "z": 36.4 - }, - "C5": { - "depth": 95.6, - "totalLiquidVolume": 1000, - "shape": "circular", - "diameter": 5.47, - "x": 50.38, - "y": 56.1, - "z": 36.4 - }, - "D5": { - "depth": 95.6, - "totalLiquidVolume": 1000, - "shape": "circular", - "diameter": 5.47, - "x": 50.38, - "y": 47.1, - "z": 36.4 - }, - "E5": { - "depth": 95.6, - "totalLiquidVolume": 1000, - "shape": "circular", - "diameter": 5.47, - "x": 50.38, - "y": 38.1, - "z": 36.4 - }, - "F5": { - "depth": 95.6, - "totalLiquidVolume": 1000, - "shape": "circular", - "diameter": 5.47, - "x": 50.38, - "y": 29.1, - "z": 36.4 - }, - "G5": { - "depth": 95.6, - "totalLiquidVolume": 1000, - "shape": "circular", - "diameter": 5.47, - "x": 50.38, - "y": 20.1, - "z": 36.4 - }, - "H5": { - "depth": 95.6, - "totalLiquidVolume": 1000, - "shape": "circular", - "diameter": 5.47, - "x": 50.38, - "y": 11.1, - "z": 36.4 - }, - "A6": { - "depth": 95.6, - "totalLiquidVolume": 1000, - "shape": "circular", - "diameter": 5.47, - "x": 59.38, - "y": 74.1, - "z": 36.4 - }, - "B6": { - "depth": 95.6, - "totalLiquidVolume": 1000, - "shape": "circular", - "diameter": 5.47, - "x": 59.38, - "y": 65.1, - "z": 36.4 - }, - "C6": { - "depth": 95.6, - "totalLiquidVolume": 1000, - "shape": "circular", - "diameter": 5.47, - "x": 59.38, - "y": 56.1, - "z": 36.4 - }, - "D6": { - "depth": 95.6, - "totalLiquidVolume": 1000, - "shape": "circular", - "diameter": 5.47, - "x": 59.38, - "y": 47.1, - "z": 36.4 - }, - "E6": { - "depth": 95.6, - "totalLiquidVolume": 1000, - "shape": "circular", - "diameter": 5.47, - "x": 59.38, - "y": 38.1, - "z": 36.4 - }, - "F6": { - "depth": 95.6, - "totalLiquidVolume": 1000, - "shape": "circular", - "diameter": 5.47, - "x": 59.38, - "y": 29.1, - "z": 36.4 - }, - "G6": { - "depth": 95.6, - "totalLiquidVolume": 1000, - "shape": "circular", - "diameter": 5.47, - "x": 59.38, - "y": 20.1, - "z": 36.4 - }, - "H6": { - "depth": 95.6, - "totalLiquidVolume": 1000, - "shape": "circular", - "diameter": 5.47, - "x": 59.38, - "y": 11.1, - "z": 36.4 - }, - "A7": { - "depth": 95.6, - "totalLiquidVolume": 1000, - "shape": "circular", - "diameter": 5.47, - "x": 68.38, - "y": 74.1, - "z": 36.4 - }, - "B7": { - "depth": 95.6, - "totalLiquidVolume": 1000, - "shape": "circular", - "diameter": 5.47, - "x": 68.38, - "y": 65.1, - "z": 36.4 - }, - "C7": { - "depth": 95.6, - "totalLiquidVolume": 1000, - "shape": "circular", - "diameter": 5.47, - "x": 68.38, - "y": 56.1, - "z": 36.4 - }, - "D7": { - "depth": 95.6, - "totalLiquidVolume": 1000, - "shape": "circular", - "diameter": 5.47, - "x": 68.38, - "y": 47.1, - "z": 36.4 - }, - "E7": { - "depth": 95.6, - "totalLiquidVolume": 1000, - "shape": "circular", - "diameter": 5.47, - "x": 68.38, - "y": 38.1, - "z": 36.4 - }, - "F7": { - "depth": 95.6, - "totalLiquidVolume": 1000, - "shape": "circular", - "diameter": 5.47, - "x": 68.38, - "y": 29.1, - "z": 36.4 - }, - "G7": { - "depth": 95.6, - "totalLiquidVolume": 1000, - "shape": "circular", - "diameter": 5.47, - "x": 68.38, - "y": 20.1, - "z": 36.4 - }, - "H7": { - "depth": 95.6, - "totalLiquidVolume": 1000, - "shape": "circular", - "diameter": 5.47, - "x": 68.38, - "y": 11.1, - "z": 36.4 - }, - "A8": { - "depth": 95.6, - "totalLiquidVolume": 1000, - "shape": "circular", - "diameter": 5.47, - "x": 77.38, - "y": 74.1, - "z": 36.4 - }, - "B8": { - "depth": 95.6, - "totalLiquidVolume": 1000, - "shape": "circular", - "diameter": 5.47, - "x": 77.38, - "y": 65.1, - "z": 36.4 - }, - "C8": { - "depth": 95.6, - "totalLiquidVolume": 1000, - "shape": "circular", - "diameter": 5.47, - "x": 77.38, - "y": 56.1, - "z": 36.4 - }, - "D8": { - "depth": 95.6, - "totalLiquidVolume": 1000, - "shape": "circular", - "diameter": 5.47, - "x": 77.38, - "y": 47.1, - "z": 36.4 - }, - "E8": { - "depth": 95.6, - "totalLiquidVolume": 1000, - "shape": "circular", - "diameter": 5.47, - "x": 77.38, - "y": 38.1, - "z": 36.4 - }, - "F8": { - "depth": 95.6, - "totalLiquidVolume": 1000, - "shape": "circular", - "diameter": 5.47, - "x": 77.38, - "y": 29.1, - "z": 36.4 - }, - "G8": { - "depth": 95.6, - "totalLiquidVolume": 1000, - "shape": "circular", - "diameter": 5.47, - "x": 77.38, - "y": 20.1, - "z": 36.4 - }, - "H8": { - "depth": 95.6, - "totalLiquidVolume": 1000, - "shape": "circular", - "diameter": 5.47, - "x": 77.38, - "y": 11.1, - "z": 36.4 - }, - "A9": { - "depth": 95.6, - "totalLiquidVolume": 1000, - "shape": "circular", - "diameter": 5.47, - "x": 86.38, - "y": 74.1, - "z": 36.4 - }, - "B9": { - "depth": 95.6, - "totalLiquidVolume": 1000, - "shape": "circular", - "diameter": 5.47, - "x": 86.38, - "y": 65.1, - "z": 36.4 - }, - "C9": { - "depth": 95.6, - "totalLiquidVolume": 1000, - "shape": "circular", - "diameter": 5.47, - "x": 86.38, - "y": 56.1, - "z": 36.4 - }, - "D9": { - "depth": 95.6, - "totalLiquidVolume": 1000, - "shape": "circular", - "diameter": 5.47, - "x": 86.38, - "y": 47.1, - "z": 36.4 - }, - "E9": { - "depth": 95.6, - "totalLiquidVolume": 1000, - "shape": "circular", - "diameter": 5.47, - "x": 86.38, - "y": 38.1, - "z": 36.4 - }, - "F9": { - "depth": 95.6, - "totalLiquidVolume": 1000, - "shape": "circular", - "diameter": 5.47, - "x": 86.38, - "y": 29.1, - "z": 36.4 - }, - "G9": { - "depth": 95.6, - "totalLiquidVolume": 1000, - "shape": "circular", - "diameter": 5.47, - "x": 86.38, - "y": 20.1, - "z": 36.4 - }, - "H9": { - "depth": 95.6, - "totalLiquidVolume": 1000, - "shape": "circular", - "diameter": 5.47, - "x": 86.38, - "y": 11.1, - "z": 36.4 - }, - "A10": { - "depth": 95.6, - "totalLiquidVolume": 1000, - "shape": "circular", - "diameter": 5.47, - "x": 95.38, - "y": 74.1, - "z": 36.4 - }, - "B10": { - "depth": 95.6, - "totalLiquidVolume": 1000, - "shape": "circular", - "diameter": 5.47, - "x": 95.38, - "y": 65.1, - "z": 36.4 - }, - "C10": { - "depth": 95.6, - "totalLiquidVolume": 1000, - "shape": "circular", - "diameter": 5.47, - "x": 95.38, - "y": 56.1, - "z": 36.4 - }, - "D10": { - "depth": 95.6, - "totalLiquidVolume": 1000, - "shape": "circular", - "diameter": 5.47, - "x": 95.38, - "y": 47.1, - "z": 36.4 - }, - "E10": { - "depth": 95.6, - "totalLiquidVolume": 1000, - "shape": "circular", - "diameter": 5.47, - "x": 95.38, - "y": 38.1, - "z": 36.4 - }, - "F10": { - "depth": 95.6, - "totalLiquidVolume": 1000, - "shape": "circular", - "diameter": 5.47, - "x": 95.38, - "y": 29.1, - "z": 36.4 - }, - "G10": { - "depth": 95.6, - "totalLiquidVolume": 1000, - "shape": "circular", - "diameter": 5.47, - "x": 95.38, - "y": 20.1, - "z": 36.4 - }, - "H10": { - "depth": 95.6, - "totalLiquidVolume": 1000, - "shape": "circular", - "diameter": 5.47, - "x": 95.38, - "y": 11.1, - "z": 36.4 - }, - "A11": { - "depth": 95.6, - "totalLiquidVolume": 1000, - "shape": "circular", - "diameter": 5.47, - "x": 104.38, - "y": 74.1, - "z": 36.4 - }, - "B11": { - "depth": 95.6, - "totalLiquidVolume": 1000, - "shape": "circular", - "diameter": 5.47, - "x": 104.38, - "y": 65.1, - "z": 36.4 - }, - "C11": { - "depth": 95.6, - "totalLiquidVolume": 1000, - "shape": "circular", - "diameter": 5.47, - "x": 104.38, - "y": 56.1, - "z": 36.4 - }, - "D11": { - "depth": 95.6, - "totalLiquidVolume": 1000, - "shape": "circular", - "diameter": 5.47, - "x": 104.38, - "y": 47.1, - "z": 36.4 - }, - "E11": { - "depth": 95.6, - "totalLiquidVolume": 1000, - "shape": "circular", - "diameter": 5.47, - "x": 104.38, - "y": 38.1, - "z": 36.4 - }, - "F11": { - "depth": 95.6, - "totalLiquidVolume": 1000, - "shape": "circular", - "diameter": 5.47, - "x": 104.38, - "y": 29.1, - "z": 36.4 - }, - "G11": { - "depth": 95.6, - "totalLiquidVolume": 1000, - "shape": "circular", - "diameter": 5.47, - "x": 104.38, - "y": 20.1, - "z": 36.4 - }, - "H11": { - "depth": 95.6, - "totalLiquidVolume": 1000, - "shape": "circular", - "diameter": 5.47, - "x": 104.38, - "y": 11.1, - "z": 36.4 - }, - "A12": { - "depth": 95.6, - "totalLiquidVolume": 1000, - "shape": "circular", - "diameter": 5.47, - "x": 113.38, - "y": 74.1, - "z": 36.4 - }, - "B12": { - "depth": 95.6, - "totalLiquidVolume": 1000, - "shape": "circular", - "diameter": 5.47, - "x": 113.38, - "y": 65.1, - "z": 36.4 - }, - "C12": { - "depth": 95.6, - "totalLiquidVolume": 1000, - "shape": "circular", - "diameter": 5.47, - "x": 113.38, - "y": 56.1, - "z": 36.4 - }, - "D12": { - "depth": 95.6, - "totalLiquidVolume": 1000, - "shape": "circular", - "diameter": 5.47, - "x": 113.38, - "y": 47.1, - "z": 36.4 - }, - "E12": { - "depth": 95.6, - "totalLiquidVolume": 1000, - "shape": "circular", - "diameter": 5.47, - "x": 113.38, - "y": 38.1, - "z": 36.4 - }, - "F12": { - "depth": 95.6, - "totalLiquidVolume": 1000, - "shape": "circular", - "diameter": 5.47, - "x": 113.38, - "y": 29.1, - "z": 36.4 - }, - "G12": { - "depth": 95.6, - "totalLiquidVolume": 1000, - "shape": "circular", - "diameter": 5.47, - "x": 113.38, - "y": 20.1, - "z": 36.4 - }, - "H12": { - "depth": 95.6, - "totalLiquidVolume": 1000, - "shape": "circular", - "diameter": 5.47, - "x": 113.38, - "y": 11.1, - "z": 36.4 - } - }, - "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", - "quirks": [], - "isTiprack": true, - "tipLength": 95.6, - "tipOverlap": 10.5, - "isMagneticModuleCompatible": false, - "loadName": "opentrons_flex_96_tiprack_1000ul_adp" - }, - "namespace": "custom_beta", - "version": 1, - "schemaVersion": 2, - "cornerOffsetFromSlot": { - "x": 0, - "y": 0, - "z": 0 - } -} diff --git a/hardware-testing/hardware_testing/labware/opentrons_flex_96_tiprack_200ul_adp/1.json b/hardware-testing/hardware_testing/labware/opentrons_flex_96_tiprack_200ul_adp/1.json deleted file mode 100644 index 439479d5c76..00000000000 --- a/hardware-testing/hardware_testing/labware/opentrons_flex_96_tiprack_200ul_adp/1.json +++ /dev/null @@ -1,1017 +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": "ryantrons OT-3", - "brandId": [] - }, - "metadata": { - "displayName": "Opentrons Flex 96 Tip Rack 200 µL with adapter", - "displayCategory": "tipRack", - "displayVolumeUnits": "µL", - "tags": [] - }, - "dimensions": { - "xDimension": 127.76, - "yDimension": 85.48, - "zDimension": 132 - }, - "wells": { - "A1": { - "depth": 58.35, - "totalLiquidVolume": 200, - "shape": "circular", - "diameter": 5.59, - "x": 14.38, - "y": 74.1, - "z": 73.65 - }, - "B1": { - "depth": 58.35, - "totalLiquidVolume": 200, - "shape": "circular", - "diameter": 5.59, - "x": 14.38, - "y": 65.1, - "z": 73.65 - }, - "C1": { - "depth": 58.35, - "totalLiquidVolume": 200, - "shape": "circular", - "diameter": 5.59, - "x": 14.38, - "y": 56.1, - "z": 73.65 - }, - "D1": { - "depth": 58.35, - "totalLiquidVolume": 200, - "shape": "circular", - "diameter": 5.59, - "x": 14.38, - "y": 47.1, - "z": 73.65 - }, - "E1": { - "depth": 58.35, - "totalLiquidVolume": 200, - "shape": "circular", - "diameter": 5.59, - "x": 14.38, - "y": 38.1, - "z": 73.65 - }, - "F1": { - "depth": 58.35, - "totalLiquidVolume": 200, - "shape": "circular", - "diameter": 5.59, - "x": 14.38, - "y": 29.1, - "z": 73.65 - }, - "G1": { - "depth": 58.35, - "totalLiquidVolume": 200, - "shape": "circular", - "diameter": 5.59, - "x": 14.38, - "y": 20.1, - "z": 73.65 - }, - "H1": { - "depth": 58.35, - "totalLiquidVolume": 200, - "shape": "circular", - "diameter": 5.59, - "x": 14.38, - "y": 11.1, - "z": 73.65 - }, - "A2": { - "depth": 58.35, - "totalLiquidVolume": 200, - "shape": "circular", - "diameter": 5.59, - "x": 23.38, - "y": 74.1, - "z": 73.65 - }, - "B2": { - "depth": 58.35, - "totalLiquidVolume": 200, - "shape": "circular", - "diameter": 5.59, - "x": 23.38, - "y": 65.1, - "z": 73.65 - }, - "C2": { - "depth": 58.35, - "totalLiquidVolume": 200, - "shape": "circular", - "diameter": 5.59, - "x": 23.38, - "y": 56.1, - "z": 73.65 - }, - "D2": { - "depth": 58.35, - "totalLiquidVolume": 200, - "shape": "circular", - "diameter": 5.59, - "x": 23.38, - "y": 47.1, - "z": 73.65 - }, - "E2": { - "depth": 58.35, - "totalLiquidVolume": 200, - "shape": "circular", - "diameter": 5.59, - "x": 23.38, - "y": 38.1, - "z": 73.65 - }, - "F2": { - "depth": 58.35, - "totalLiquidVolume": 200, - "shape": "circular", - "diameter": 5.59, - "x": 23.38, - "y": 29.1, - "z": 73.65 - }, - "G2": { - "depth": 58.35, - "totalLiquidVolume": 200, - "shape": "circular", - "diameter": 5.59, - "x": 23.38, - "y": 20.1, - "z": 73.65 - }, - "H2": { - "depth": 58.35, - "totalLiquidVolume": 200, - "shape": "circular", - "diameter": 5.59, - "x": 23.38, - "y": 11.1, - "z": 73.65 - }, - "A3": { - "depth": 58.35, - "totalLiquidVolume": 200, - "shape": "circular", - "diameter": 5.59, - "x": 32.38, - "y": 74.1, - "z": 73.65 - }, - "B3": { - "depth": 58.35, - "totalLiquidVolume": 200, - "shape": "circular", - "diameter": 5.59, - "x": 32.38, - "y": 65.1, - "z": 73.65 - }, - "C3": { - "depth": 58.35, - "totalLiquidVolume": 200, - "shape": "circular", - "diameter": 5.59, - "x": 32.38, - "y": 56.1, - "z": 73.65 - }, - "D3": { - "depth": 58.35, - "totalLiquidVolume": 200, - "shape": "circular", - "diameter": 5.59, - "x": 32.38, - "y": 47.1, - "z": 73.65 - }, - "E3": { - "depth": 58.35, - "totalLiquidVolume": 200, - "shape": "circular", - "diameter": 5.59, - "x": 32.38, - "y": 38.1, - "z": 73.65 - }, - "F3": { - "depth": 58.35, - "totalLiquidVolume": 200, - "shape": "circular", - "diameter": 5.59, - "x": 32.38, - "y": 29.1, - "z": 73.65 - }, - "G3": { - "depth": 58.35, - "totalLiquidVolume": 200, - "shape": "circular", - "diameter": 5.59, - "x": 32.38, - "y": 20.1, - "z": 73.65 - }, - "H3": { - "depth": 58.35, - "totalLiquidVolume": 200, - "shape": "circular", - "diameter": 5.59, - "x": 32.38, - "y": 11.1, - "z": 73.65 - }, - "A4": { - "depth": 58.35, - "totalLiquidVolume": 200, - "shape": "circular", - "diameter": 5.59, - "x": 41.38, - "y": 74.1, - "z": 73.65 - }, - "B4": { - "depth": 58.35, - "totalLiquidVolume": 200, - "shape": "circular", - "diameter": 5.59, - "x": 41.38, - "y": 65.1, - "z": 73.65 - }, - "C4": { - "depth": 58.35, - "totalLiquidVolume": 200, - "shape": "circular", - "diameter": 5.59, - "x": 41.38, - "y": 56.1, - "z": 73.65 - }, - "D4": { - "depth": 58.35, - "totalLiquidVolume": 200, - "shape": "circular", - "diameter": 5.59, - "x": 41.38, - "y": 47.1, - "z": 73.65 - }, - "E4": { - "depth": 58.35, - "totalLiquidVolume": 200, - "shape": "circular", - "diameter": 5.59, - "x": 41.38, - "y": 38.1, - "z": 73.65 - }, - "F4": { - "depth": 58.35, - "totalLiquidVolume": 200, - "shape": "circular", - "diameter": 5.59, - "x": 41.38, - "y": 29.1, - "z": 73.65 - }, - "G4": { - "depth": 58.35, - "totalLiquidVolume": 200, - "shape": "circular", - "diameter": 5.59, - "x": 41.38, - "y": 20.1, - "z": 73.65 - }, - "H4": { - "depth": 58.35, - "totalLiquidVolume": 200, - "shape": "circular", - "diameter": 5.59, - "x": 41.38, - "y": 11.1, - "z": 73.65 - }, - "A5": { - "depth": 58.35, - "totalLiquidVolume": 200, - "shape": "circular", - "diameter": 5.59, - "x": 50.38, - "y": 74.1, - "z": 73.65 - }, - "B5": { - "depth": 58.35, - "totalLiquidVolume": 200, - "shape": "circular", - "diameter": 5.59, - "x": 50.38, - "y": 65.1, - "z": 73.65 - }, - "C5": { - "depth": 58.35, - "totalLiquidVolume": 200, - "shape": "circular", - "diameter": 5.59, - "x": 50.38, - "y": 56.1, - "z": 73.65 - }, - "D5": { - "depth": 58.35, - "totalLiquidVolume": 200, - "shape": "circular", - "diameter": 5.59, - "x": 50.38, - "y": 47.1, - "z": 73.65 - }, - "E5": { - "depth": 58.35, - "totalLiquidVolume": 200, - "shape": "circular", - "diameter": 5.59, - "x": 50.38, - "y": 38.1, - "z": 73.65 - }, - "F5": { - "depth": 58.35, - "totalLiquidVolume": 200, - "shape": "circular", - "diameter": 5.59, - "x": 50.38, - "y": 29.1, - "z": 73.65 - }, - "G5": { - "depth": 58.35, - "totalLiquidVolume": 200, - "shape": "circular", - "diameter": 5.59, - "x": 50.38, - "y": 20.1, - "z": 73.65 - }, - "H5": { - "depth": 58.35, - "totalLiquidVolume": 200, - "shape": "circular", - "diameter": 5.59, - "x": 50.38, - "y": 11.1, - "z": 73.65 - }, - "A6": { - "depth": 58.35, - "totalLiquidVolume": 200, - "shape": "circular", - "diameter": 5.59, - "x": 59.38, - "y": 74.1, - "z": 73.65 - }, - "B6": { - "depth": 58.35, - "totalLiquidVolume": 200, - "shape": "circular", - "diameter": 5.59, - "x": 59.38, - "y": 65.1, - "z": 73.65 - }, - "C6": { - "depth": 58.35, - "totalLiquidVolume": 200, - "shape": "circular", - "diameter": 5.59, - "x": 59.38, - "y": 56.1, - "z": 73.65 - }, - "D6": { - "depth": 58.35, - "totalLiquidVolume": 200, - "shape": "circular", - "diameter": 5.59, - "x": 59.38, - "y": 47.1, - "z": 73.65 - }, - "E6": { - "depth": 58.35, - "totalLiquidVolume": 200, - "shape": "circular", - "diameter": 5.59, - "x": 59.38, - "y": 38.1, - "z": 73.65 - }, - "F6": { - "depth": 58.35, - "totalLiquidVolume": 200, - "shape": "circular", - "diameter": 5.59, - "x": 59.38, - "y": 29.1, - "z": 73.65 - }, - "G6": { - "depth": 58.35, - "totalLiquidVolume": 200, - "shape": "circular", - "diameter": 5.59, - "x": 59.38, - "y": 20.1, - "z": 73.65 - }, - "H6": { - "depth": 58.35, - "totalLiquidVolume": 200, - "shape": "circular", - "diameter": 5.59, - "x": 59.38, - "y": 11.1, - "z": 73.65 - }, - "A7": { - "depth": 58.35, - "totalLiquidVolume": 200, - "shape": "circular", - "diameter": 5.59, - "x": 68.38, - "y": 74.1, - "z": 73.65 - }, - "B7": { - "depth": 58.35, - "totalLiquidVolume": 200, - "shape": "circular", - "diameter": 5.59, - "x": 68.38, - "y": 65.1, - "z": 73.65 - }, - "C7": { - "depth": 58.35, - "totalLiquidVolume": 200, - "shape": "circular", - "diameter": 5.59, - "x": 68.38, - "y": 56.1, - "z": 73.65 - }, - "D7": { - "depth": 58.35, - "totalLiquidVolume": 200, - "shape": "circular", - "diameter": 5.59, - "x": 68.38, - "y": 47.1, - "z": 73.65 - }, - "E7": { - "depth": 58.35, - "totalLiquidVolume": 200, - "shape": "circular", - "diameter": 5.59, - "x": 68.38, - "y": 38.1, - "z": 73.65 - }, - "F7": { - "depth": 58.35, - "totalLiquidVolume": 200, - "shape": "circular", - "diameter": 5.59, - "x": 68.38, - "y": 29.1, - "z": 73.65 - }, - "G7": { - "depth": 58.35, - "totalLiquidVolume": 200, - "shape": "circular", - "diameter": 5.59, - "x": 68.38, - "y": 20.1, - "z": 73.65 - }, - "H7": { - "depth": 58.35, - "totalLiquidVolume": 200, - "shape": "circular", - "diameter": 5.59, - "x": 68.38, - "y": 11.1, - "z": 73.65 - }, - "A8": { - "depth": 58.35, - "totalLiquidVolume": 200, - "shape": "circular", - "diameter": 5.59, - "x": 77.38, - "y": 74.1, - "z": 73.65 - }, - "B8": { - "depth": 58.35, - "totalLiquidVolume": 200, - "shape": "circular", - "diameter": 5.59, - "x": 77.38, - "y": 65.1, - "z": 73.65 - }, - "C8": { - "depth": 58.35, - "totalLiquidVolume": 200, - "shape": "circular", - "diameter": 5.59, - "x": 77.38, - "y": 56.1, - "z": 73.65 - }, - "D8": { - "depth": 58.35, - "totalLiquidVolume": 200, - "shape": "circular", - "diameter": 5.59, - "x": 77.38, - "y": 47.1, - "z": 73.65 - }, - "E8": { - "depth": 58.35, - "totalLiquidVolume": 200, - "shape": "circular", - "diameter": 5.59, - "x": 77.38, - "y": 38.1, - "z": 73.65 - }, - "F8": { - "depth": 58.35, - "totalLiquidVolume": 200, - "shape": "circular", - "diameter": 5.59, - "x": 77.38, - "y": 29.1, - "z": 73.65 - }, - "G8": { - "depth": 58.35, - "totalLiquidVolume": 200, - "shape": "circular", - "diameter": 5.59, - "x": 77.38, - "y": 20.1, - "z": 73.65 - }, - "H8": { - "depth": 58.35, - "totalLiquidVolume": 200, - "shape": "circular", - "diameter": 5.59, - "x": 77.38, - "y": 11.1, - "z": 73.65 - }, - "A9": { - "depth": 58.35, - "totalLiquidVolume": 200, - "shape": "circular", - "diameter": 5.59, - "x": 86.38, - "y": 74.1, - "z": 73.65 - }, - "B9": { - "depth": 58.35, - "totalLiquidVolume": 200, - "shape": "circular", - "diameter": 5.59, - "x": 86.38, - "y": 65.1, - "z": 73.65 - }, - "C9": { - "depth": 58.35, - "totalLiquidVolume": 200, - "shape": "circular", - "diameter": 5.59, - "x": 86.38, - "y": 56.1, - "z": 73.65 - }, - "D9": { - "depth": 58.35, - "totalLiquidVolume": 200, - "shape": "circular", - "diameter": 5.59, - "x": 86.38, - "y": 47.1, - "z": 73.65 - }, - "E9": { - "depth": 58.35, - "totalLiquidVolume": 200, - "shape": "circular", - "diameter": 5.59, - "x": 86.38, - "y": 38.1, - "z": 73.65 - }, - "F9": { - "depth": 58.35, - "totalLiquidVolume": 200, - "shape": "circular", - "diameter": 5.59, - "x": 86.38, - "y": 29.1, - "z": 73.65 - }, - "G9": { - "depth": 58.35, - "totalLiquidVolume": 200, - "shape": "circular", - "diameter": 5.59, - "x": 86.38, - "y": 20.1, - "z": 73.65 - }, - "H9": { - "depth": 58.35, - "totalLiquidVolume": 200, - "shape": "circular", - "diameter": 5.59, - "x": 86.38, - "y": 11.1, - "z": 73.65 - }, - "A10": { - "depth": 58.35, - "totalLiquidVolume": 200, - "shape": "circular", - "diameter": 5.59, - "x": 95.38, - "y": 74.1, - "z": 73.65 - }, - "B10": { - "depth": 58.35, - "totalLiquidVolume": 200, - "shape": "circular", - "diameter": 5.59, - "x": 95.38, - "y": 65.1, - "z": 73.65 - }, - "C10": { - "depth": 58.35, - "totalLiquidVolume": 200, - "shape": "circular", - "diameter": 5.59, - "x": 95.38, - "y": 56.1, - "z": 73.65 - }, - "D10": { - "depth": 58.35, - "totalLiquidVolume": 200, - "shape": "circular", - "diameter": 5.59, - "x": 95.38, - "y": 47.1, - "z": 73.65 - }, - "E10": { - "depth": 58.35, - "totalLiquidVolume": 200, - "shape": "circular", - "diameter": 5.59, - "x": 95.38, - "y": 38.1, - "z": 73.65 - }, - "F10": { - "depth": 58.35, - "totalLiquidVolume": 200, - "shape": "circular", - "diameter": 5.59, - "x": 95.38, - "y": 29.1, - "z": 73.65 - }, - "G10": { - "depth": 58.35, - "totalLiquidVolume": 200, - "shape": "circular", - "diameter": 5.59, - "x": 95.38, - "y": 20.1, - "z": 73.65 - }, - "H10": { - "depth": 58.35, - "totalLiquidVolume": 200, - "shape": "circular", - "diameter": 5.59, - "x": 95.38, - "y": 11.1, - "z": 73.65 - }, - "A11": { - "depth": 58.35, - "totalLiquidVolume": 200, - "shape": "circular", - "diameter": 5.59, - "x": 104.38, - "y": 74.1, - "z": 73.65 - }, - "B11": { - "depth": 58.35, - "totalLiquidVolume": 200, - "shape": "circular", - "diameter": 5.59, - "x": 104.38, - "y": 65.1, - "z": 73.65 - }, - "C11": { - "depth": 58.35, - "totalLiquidVolume": 200, - "shape": "circular", - "diameter": 5.59, - "x": 104.38, - "y": 56.1, - "z": 73.65 - }, - "D11": { - "depth": 58.35, - "totalLiquidVolume": 200, - "shape": "circular", - "diameter": 5.59, - "x": 104.38, - "y": 47.1, - "z": 73.65 - }, - "E11": { - "depth": 58.35, - "totalLiquidVolume": 200, - "shape": "circular", - "diameter": 5.59, - "x": 104.38, - "y": 38.1, - "z": 73.65 - }, - "F11": { - "depth": 58.35, - "totalLiquidVolume": 200, - "shape": "circular", - "diameter": 5.59, - "x": 104.38, - "y": 29.1, - "z": 73.65 - }, - "G11": { - "depth": 58.35, - "totalLiquidVolume": 200, - "shape": "circular", - "diameter": 5.59, - "x": 104.38, - "y": 20.1, - "z": 73.65 - }, - "H11": { - "depth": 58.35, - "totalLiquidVolume": 200, - "shape": "circular", - "diameter": 5.59, - "x": 104.38, - "y": 11.1, - "z": 73.65 - }, - "A12": { - "depth": 58.35, - "totalLiquidVolume": 200, - "shape": "circular", - "diameter": 5.59, - "x": 113.38, - "y": 74.1, - "z": 73.65 - }, - "B12": { - "depth": 58.35, - "totalLiquidVolume": 200, - "shape": "circular", - "diameter": 5.59, - "x": 113.38, - "y": 65.1, - "z": 73.65 - }, - "C12": { - "depth": 58.35, - "totalLiquidVolume": 200, - "shape": "circular", - "diameter": 5.59, - "x": 113.38, - "y": 56.1, - "z": 73.65 - }, - "D12": { - "depth": 58.35, - "totalLiquidVolume": 200, - "shape": "circular", - "diameter": 5.59, - "x": 113.38, - "y": 47.1, - "z": 73.65 - }, - "E12": { - "depth": 58.35, - "totalLiquidVolume": 200, - "shape": "circular", - "diameter": 5.59, - "x": 113.38, - "y": 38.1, - "z": 73.65 - }, - "F12": { - "depth": 58.35, - "totalLiquidVolume": 200, - "shape": "circular", - "diameter": 5.59, - "x": 113.38, - "y": 29.1, - "z": 73.65 - }, - "G12": { - "depth": 58.35, - "totalLiquidVolume": 200, - "shape": "circular", - "diameter": 5.59, - "x": 113.38, - "y": 20.1, - "z": 73.65 - }, - "H12": { - "depth": 58.35, - "totalLiquidVolume": 200, - "shape": "circular", - "diameter": 5.59, - "x": 113.38, - "y": 11.1, - "z": 73.65 - } - }, - "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", - "quirks": [], - "isTiprack": true, - "tipLength": 58.35, - "tipOverlap": 10.5, - "isMagneticModuleCompatible": false, - "loadName": "opentrons_flex_96_tiprack_200ul_adp" - }, - "namespace": "custom_beta", - "version": 1, - "schemaVersion": 2, - "cornerOffsetFromSlot": { - "x": 0, - "y": 0, - "z": 0 - } -} diff --git a/hardware-testing/hardware_testing/labware/opentrons_flex_96_tiprack_50ul_adp/1.json b/hardware-testing/hardware_testing/labware/opentrons_flex_96_tiprack_50ul_adp/1.json deleted file mode 100644 index a4d1b339097..00000000000 --- a/hardware-testing/hardware_testing/labware/opentrons_flex_96_tiprack_50ul_adp/1.json +++ /dev/null @@ -1,1017 +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": "ryantrons OT-3", - "brandId": [] - }, - "metadata": { - "displayName": "Opentrons Flex 96 Tip Rack 50 µL with adapter", - "displayCategory": "tipRack", - "displayVolumeUnits": "µL", - "tags": [] - }, - "dimensions": { - "xDimension": 127.76, - "yDimension": 85.48, - "zDimension": 132 - }, - "wells": { - "A1": { - "depth": 57.9, - "totalLiquidVolume": 50, - "shape": "circular", - "diameter": 5.58, - "x": 14.38, - "y": 74.1, - "z": 74.1 - }, - "B1": { - "depth": 57.9, - "totalLiquidVolume": 50, - "shape": "circular", - "diameter": 5.58, - "x": 14.38, - "y": 65.1, - "z": 74.1 - }, - "C1": { - "depth": 57.9, - "totalLiquidVolume": 50, - "shape": "circular", - "diameter": 5.58, - "x": 14.38, - "y": 56.1, - "z": 74.1 - }, - "D1": { - "depth": 57.9, - "totalLiquidVolume": 50, - "shape": "circular", - "diameter": 5.58, - "x": 14.38, - "y": 47.1, - "z": 74.1 - }, - "E1": { - "depth": 57.9, - "totalLiquidVolume": 50, - "shape": "circular", - "diameter": 5.58, - "x": 14.38, - "y": 38.1, - "z": 74.1 - }, - "F1": { - "depth": 57.9, - "totalLiquidVolume": 50, - "shape": "circular", - "diameter": 5.58, - "x": 14.38, - "y": 29.1, - "z": 74.1 - }, - "G1": { - "depth": 57.9, - "totalLiquidVolume": 50, - "shape": "circular", - "diameter": 5.58, - "x": 14.38, - "y": 20.1, - "z": 74.1 - }, - "H1": { - "depth": 57.9, - "totalLiquidVolume": 50, - "shape": "circular", - "diameter": 5.58, - "x": 14.38, - "y": 11.1, - "z": 74.1 - }, - "A2": { - "depth": 57.9, - "totalLiquidVolume": 50, - "shape": "circular", - "diameter": 5.58, - "x": 23.38, - "y": 74.1, - "z": 74.1 - }, - "B2": { - "depth": 57.9, - "totalLiquidVolume": 50, - "shape": "circular", - "diameter": 5.58, - "x": 23.38, - "y": 65.1, - "z": 74.1 - }, - "C2": { - "depth": 57.9, - "totalLiquidVolume": 50, - "shape": "circular", - "diameter": 5.58, - "x": 23.38, - "y": 56.1, - "z": 74.1 - }, - "D2": { - "depth": 57.9, - "totalLiquidVolume": 50, - "shape": "circular", - "diameter": 5.58, - "x": 23.38, - "y": 47.1, - "z": 74.1 - }, - "E2": { - "depth": 57.9, - "totalLiquidVolume": 50, - "shape": "circular", - "diameter": 5.58, - "x": 23.38, - "y": 38.1, - "z": 74.1 - }, - "F2": { - "depth": 57.9, - "totalLiquidVolume": 50, - "shape": "circular", - "diameter": 5.58, - "x": 23.38, - "y": 29.1, - "z": 74.1 - }, - "G2": { - "depth": 57.9, - "totalLiquidVolume": 50, - "shape": "circular", - "diameter": 5.58, - "x": 23.38, - "y": 20.1, - "z": 74.1 - }, - "H2": { - "depth": 57.9, - "totalLiquidVolume": 50, - "shape": "circular", - "diameter": 5.58, - "x": 23.38, - "y": 11.1, - "z": 74.1 - }, - "A3": { - "depth": 57.9, - "totalLiquidVolume": 50, - "shape": "circular", - "diameter": 5.58, - "x": 32.38, - "y": 74.1, - "z": 74.1 - }, - "B3": { - "depth": 57.9, - "totalLiquidVolume": 50, - "shape": "circular", - "diameter": 5.58, - "x": 32.38, - "y": 65.1, - "z": 74.1 - }, - "C3": { - "depth": 57.9, - "totalLiquidVolume": 50, - "shape": "circular", - "diameter": 5.58, - "x": 32.38, - "y": 56.1, - "z": 74.1 - }, - "D3": { - "depth": 57.9, - "totalLiquidVolume": 50, - "shape": "circular", - "diameter": 5.58, - "x": 32.38, - "y": 47.1, - "z": 74.1 - }, - "E3": { - "depth": 57.9, - "totalLiquidVolume": 50, - "shape": "circular", - "diameter": 5.58, - "x": 32.38, - "y": 38.1, - "z": 74.1 - }, - "F3": { - "depth": 57.9, - "totalLiquidVolume": 50, - "shape": "circular", - "diameter": 5.58, - "x": 32.38, - "y": 29.1, - "z": 74.1 - }, - "G3": { - "depth": 57.9, - "totalLiquidVolume": 50, - "shape": "circular", - "diameter": 5.58, - "x": 32.38, - "y": 20.1, - "z": 74.1 - }, - "H3": { - "depth": 57.9, - "totalLiquidVolume": 50, - "shape": "circular", - "diameter": 5.58, - "x": 32.38, - "y": 11.1, - "z": 74.1 - }, - "A4": { - "depth": 57.9, - "totalLiquidVolume": 50, - "shape": "circular", - "diameter": 5.58, - "x": 41.38, - "y": 74.1, - "z": 74.1 - }, - "B4": { - "depth": 57.9, - "totalLiquidVolume": 50, - "shape": "circular", - "diameter": 5.58, - "x": 41.38, - "y": 65.1, - "z": 74.1 - }, - "C4": { - "depth": 57.9, - "totalLiquidVolume": 50, - "shape": "circular", - "diameter": 5.58, - "x": 41.38, - "y": 56.1, - "z": 74.1 - }, - "D4": { - "depth": 57.9, - "totalLiquidVolume": 50, - "shape": "circular", - "diameter": 5.58, - "x": 41.38, - "y": 47.1, - "z": 74.1 - }, - "E4": { - "depth": 57.9, - "totalLiquidVolume": 50, - "shape": "circular", - "diameter": 5.58, - "x": 41.38, - "y": 38.1, - "z": 74.1 - }, - "F4": { - "depth": 57.9, - "totalLiquidVolume": 50, - "shape": "circular", - "diameter": 5.58, - "x": 41.38, - "y": 29.1, - "z": 74.1 - }, - "G4": { - "depth": 57.9, - "totalLiquidVolume": 50, - "shape": "circular", - "diameter": 5.58, - "x": 41.38, - "y": 20.1, - "z": 74.1 - }, - "H4": { - "depth": 57.9, - "totalLiquidVolume": 50, - "shape": "circular", - "diameter": 5.58, - "x": 41.38, - "y": 11.1, - "z": 74.1 - }, - "A5": { - "depth": 57.9, - "totalLiquidVolume": 50, - "shape": "circular", - "diameter": 5.58, - "x": 50.38, - "y": 74.1, - "z": 74.1 - }, - "B5": { - "depth": 57.9, - "totalLiquidVolume": 50, - "shape": "circular", - "diameter": 5.58, - "x": 50.38, - "y": 65.1, - "z": 74.1 - }, - "C5": { - "depth": 57.9, - "totalLiquidVolume": 50, - "shape": "circular", - "diameter": 5.58, - "x": 50.38, - "y": 56.1, - "z": 74.1 - }, - "D5": { - "depth": 57.9, - "totalLiquidVolume": 50, - "shape": "circular", - "diameter": 5.58, - "x": 50.38, - "y": 47.1, - "z": 74.1 - }, - "E5": { - "depth": 57.9, - "totalLiquidVolume": 50, - "shape": "circular", - "diameter": 5.58, - "x": 50.38, - "y": 38.1, - "z": 74.1 - }, - "F5": { - "depth": 57.9, - "totalLiquidVolume": 50, - "shape": "circular", - "diameter": 5.58, - "x": 50.38, - "y": 29.1, - "z": 74.1 - }, - "G5": { - "depth": 57.9, - "totalLiquidVolume": 50, - "shape": "circular", - "diameter": 5.58, - "x": 50.38, - "y": 20.1, - "z": 74.1 - }, - "H5": { - "depth": 57.9, - "totalLiquidVolume": 50, - "shape": "circular", - "diameter": 5.58, - "x": 50.38, - "y": 11.1, - "z": 74.1 - }, - "A6": { - "depth": 57.9, - "totalLiquidVolume": 50, - "shape": "circular", - "diameter": 5.58, - "x": 59.38, - "y": 74.1, - "z": 74.1 - }, - "B6": { - "depth": 57.9, - "totalLiquidVolume": 50, - "shape": "circular", - "diameter": 5.58, - "x": 59.38, - "y": 65.1, - "z": 74.1 - }, - "C6": { - "depth": 57.9, - "totalLiquidVolume": 50, - "shape": "circular", - "diameter": 5.58, - "x": 59.38, - "y": 56.1, - "z": 74.1 - }, - "D6": { - "depth": 57.9, - "totalLiquidVolume": 50, - "shape": "circular", - "diameter": 5.58, - "x": 59.38, - "y": 47.1, - "z": 74.1 - }, - "E6": { - "depth": 57.9, - "totalLiquidVolume": 50, - "shape": "circular", - "diameter": 5.58, - "x": 59.38, - "y": 38.1, - "z": 74.1 - }, - "F6": { - "depth": 57.9, - "totalLiquidVolume": 50, - "shape": "circular", - "diameter": 5.58, - "x": 59.38, - "y": 29.1, - "z": 74.1 - }, - "G6": { - "depth": 57.9, - "totalLiquidVolume": 50, - "shape": "circular", - "diameter": 5.58, - "x": 59.38, - "y": 20.1, - "z": 74.1 - }, - "H6": { - "depth": 57.9, - "totalLiquidVolume": 50, - "shape": "circular", - "diameter": 5.58, - "x": 59.38, - "y": 11.1, - "z": 74.1 - }, - "A7": { - "depth": 57.9, - "totalLiquidVolume": 50, - "shape": "circular", - "diameter": 5.58, - "x": 68.38, - "y": 74.1, - "z": 74.1 - }, - "B7": { - "depth": 57.9, - "totalLiquidVolume": 50, - "shape": "circular", - "diameter": 5.58, - "x": 68.38, - "y": 65.1, - "z": 74.1 - }, - "C7": { - "depth": 57.9, - "totalLiquidVolume": 50, - "shape": "circular", - "diameter": 5.58, - "x": 68.38, - "y": 56.1, - "z": 74.1 - }, - "D7": { - "depth": 57.9, - "totalLiquidVolume": 50, - "shape": "circular", - "diameter": 5.58, - "x": 68.38, - "y": 47.1, - "z": 74.1 - }, - "E7": { - "depth": 57.9, - "totalLiquidVolume": 50, - "shape": "circular", - "diameter": 5.58, - "x": 68.38, - "y": 38.1, - "z": 74.1 - }, - "F7": { - "depth": 57.9, - "totalLiquidVolume": 50, - "shape": "circular", - "diameter": 5.58, - "x": 68.38, - "y": 29.1, - "z": 74.1 - }, - "G7": { - "depth": 57.9, - "totalLiquidVolume": 50, - "shape": "circular", - "diameter": 5.58, - "x": 68.38, - "y": 20.1, - "z": 74.1 - }, - "H7": { - "depth": 57.9, - "totalLiquidVolume": 50, - "shape": "circular", - "diameter": 5.58, - "x": 68.38, - "y": 11.1, - "z": 74.1 - }, - "A8": { - "depth": 57.9, - "totalLiquidVolume": 50, - "shape": "circular", - "diameter": 5.58, - "x": 77.38, - "y": 74.1, - "z": 74.1 - }, - "B8": { - "depth": 57.9, - "totalLiquidVolume": 50, - "shape": "circular", - "diameter": 5.58, - "x": 77.38, - "y": 65.1, - "z": 74.1 - }, - "C8": { - "depth": 57.9, - "totalLiquidVolume": 50, - "shape": "circular", - "diameter": 5.58, - "x": 77.38, - "y": 56.1, - "z": 74.1 - }, - "D8": { - "depth": 57.9, - "totalLiquidVolume": 50, - "shape": "circular", - "diameter": 5.58, - "x": 77.38, - "y": 47.1, - "z": 74.1 - }, - "E8": { - "depth": 57.9, - "totalLiquidVolume": 50, - "shape": "circular", - "diameter": 5.58, - "x": 77.38, - "y": 38.1, - "z": 74.1 - }, - "F8": { - "depth": 57.9, - "totalLiquidVolume": 50, - "shape": "circular", - "diameter": 5.58, - "x": 77.38, - "y": 29.1, - "z": 74.1 - }, - "G8": { - "depth": 57.9, - "totalLiquidVolume": 50, - "shape": "circular", - "diameter": 5.58, - "x": 77.38, - "y": 20.1, - "z": 74.1 - }, - "H8": { - "depth": 57.9, - "totalLiquidVolume": 50, - "shape": "circular", - "diameter": 5.58, - "x": 77.38, - "y": 11.1, - "z": 74.1 - }, - "A9": { - "depth": 57.9, - "totalLiquidVolume": 50, - "shape": "circular", - "diameter": 5.58, - "x": 86.38, - "y": 74.1, - "z": 74.1 - }, - "B9": { - "depth": 57.9, - "totalLiquidVolume": 50, - "shape": "circular", - "diameter": 5.58, - "x": 86.38, - "y": 65.1, - "z": 74.1 - }, - "C9": { - "depth": 57.9, - "totalLiquidVolume": 50, - "shape": "circular", - "diameter": 5.58, - "x": 86.38, - "y": 56.1, - "z": 74.1 - }, - "D9": { - "depth": 57.9, - "totalLiquidVolume": 50, - "shape": "circular", - "diameter": 5.58, - "x": 86.38, - "y": 47.1, - "z": 74.1 - }, - "E9": { - "depth": 57.9, - "totalLiquidVolume": 50, - "shape": "circular", - "diameter": 5.58, - "x": 86.38, - "y": 38.1, - "z": 74.1 - }, - "F9": { - "depth": 57.9, - "totalLiquidVolume": 50, - "shape": "circular", - "diameter": 5.58, - "x": 86.38, - "y": 29.1, - "z": 74.1 - }, - "G9": { - "depth": 57.9, - "totalLiquidVolume": 50, - "shape": "circular", - "diameter": 5.58, - "x": 86.38, - "y": 20.1, - "z": 74.1 - }, - "H9": { - "depth": 57.9, - "totalLiquidVolume": 50, - "shape": "circular", - "diameter": 5.58, - "x": 86.38, - "y": 11.1, - "z": 74.1 - }, - "A10": { - "depth": 57.9, - "totalLiquidVolume": 50, - "shape": "circular", - "diameter": 5.58, - "x": 95.38, - "y": 74.1, - "z": 74.1 - }, - "B10": { - "depth": 57.9, - "totalLiquidVolume": 50, - "shape": "circular", - "diameter": 5.58, - "x": 95.38, - "y": 65.1, - "z": 74.1 - }, - "C10": { - "depth": 57.9, - "totalLiquidVolume": 50, - "shape": "circular", - "diameter": 5.58, - "x": 95.38, - "y": 56.1, - "z": 74.1 - }, - "D10": { - "depth": 57.9, - "totalLiquidVolume": 50, - "shape": "circular", - "diameter": 5.58, - "x": 95.38, - "y": 47.1, - "z": 74.1 - }, - "E10": { - "depth": 57.9, - "totalLiquidVolume": 50, - "shape": "circular", - "diameter": 5.58, - "x": 95.38, - "y": 38.1, - "z": 74.1 - }, - "F10": { - "depth": 57.9, - "totalLiquidVolume": 50, - "shape": "circular", - "diameter": 5.58, - "x": 95.38, - "y": 29.1, - "z": 74.1 - }, - "G10": { - "depth": 57.9, - "totalLiquidVolume": 50, - "shape": "circular", - "diameter": 5.58, - "x": 95.38, - "y": 20.1, - "z": 74.1 - }, - "H10": { - "depth": 57.9, - "totalLiquidVolume": 50, - "shape": "circular", - "diameter": 5.58, - "x": 95.38, - "y": 11.1, - "z": 74.1 - }, - "A11": { - "depth": 57.9, - "totalLiquidVolume": 50, - "shape": "circular", - "diameter": 5.58, - "x": 104.38, - "y": 74.1, - "z": 74.1 - }, - "B11": { - "depth": 57.9, - "totalLiquidVolume": 50, - "shape": "circular", - "diameter": 5.58, - "x": 104.38, - "y": 65.1, - "z": 74.1 - }, - "C11": { - "depth": 57.9, - "totalLiquidVolume": 50, - "shape": "circular", - "diameter": 5.58, - "x": 104.38, - "y": 56.1, - "z": 74.1 - }, - "D11": { - "depth": 57.9, - "totalLiquidVolume": 50, - "shape": "circular", - "diameter": 5.58, - "x": 104.38, - "y": 47.1, - "z": 74.1 - }, - "E11": { - "depth": 57.9, - "totalLiquidVolume": 50, - "shape": "circular", - "diameter": 5.58, - "x": 104.38, - "y": 38.1, - "z": 74.1 - }, - "F11": { - "depth": 57.9, - "totalLiquidVolume": 50, - "shape": "circular", - "diameter": 5.58, - "x": 104.38, - "y": 29.1, - "z": 74.1 - }, - "G11": { - "depth": 57.9, - "totalLiquidVolume": 50, - "shape": "circular", - "diameter": 5.58, - "x": 104.38, - "y": 20.1, - "z": 74.1 - }, - "H11": { - "depth": 57.9, - "totalLiquidVolume": 50, - "shape": "circular", - "diameter": 5.58, - "x": 104.38, - "y": 11.1, - "z": 74.1 - }, - "A12": { - "depth": 57.9, - "totalLiquidVolume": 50, - "shape": "circular", - "diameter": 5.58, - "x": 113.38, - "y": 74.1, - "z": 74.1 - }, - "B12": { - "depth": 57.9, - "totalLiquidVolume": 50, - "shape": "circular", - "diameter": 5.58, - "x": 113.38, - "y": 65.1, - "z": 74.1 - }, - "C12": { - "depth": 57.9, - "totalLiquidVolume": 50, - "shape": "circular", - "diameter": 5.58, - "x": 113.38, - "y": 56.1, - "z": 74.1 - }, - "D12": { - "depth": 57.9, - "totalLiquidVolume": 50, - "shape": "circular", - "diameter": 5.58, - "x": 113.38, - "y": 47.1, - "z": 74.1 - }, - "E12": { - "depth": 57.9, - "totalLiquidVolume": 50, - "shape": "circular", - "diameter": 5.58, - "x": 113.38, - "y": 38.1, - "z": 74.1 - }, - "F12": { - "depth": 57.9, - "totalLiquidVolume": 50, - "shape": "circular", - "diameter": 5.58, - "x": 113.38, - "y": 29.1, - "z": 74.1 - }, - "G12": { - "depth": 57.9, - "totalLiquidVolume": 50, - "shape": "circular", - "diameter": 5.58, - "x": 113.38, - "y": 20.1, - "z": 74.1 - }, - "H12": { - "depth": 57.9, - "totalLiquidVolume": 50, - "shape": "circular", - "diameter": 5.58, - "x": 113.38, - "y": 11.1, - "z": 74.1 - } - }, - "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", - "quirks": [], - "isTiprack": true, - "tipLength": 57.9, - "tipOverlap": 10.5, - "isMagneticModuleCompatible": false, - "loadName": "opentrons_flex_96_tiprack_50ul_adp" - }, - "namespace": "custom_beta", - "version": 1, - "schemaVersion": 2, - "cornerOffsetFromSlot": { - "x": 0, - "y": 0, - "z": 0 - } -} diff --git a/hardware-testing/hardware_testing/liquid_sense/__init__.py b/hardware-testing/hardware_testing/liquid_sense/__init__.py new file mode 100644 index 00000000000..e6b26332d7b --- /dev/null +++ b/hardware-testing/hardware_testing/liquid_sense/__init__.py @@ -0,0 +1 @@ +"""Liquid Sense.""" diff --git a/hardware-testing/hardware_testing/liquid_sense/__main__.py b/hardware-testing/hardware_testing/liquid_sense/__main__.py new file mode 100644 index 00000000000..fae4f502315 --- /dev/null +++ b/hardware-testing/hardware_testing/liquid_sense/__main__.py @@ -0,0 +1,319 @@ +"""Liquid sense testing.""" +import argparse +from dataclasses import dataclass +from json import load as json_load +from pathlib import Path +import subprocess +from time import sleep +import os +from typing import List, Any, Optional +import traceback + +from hardware_testing.opentrons_api import helpers_ot3 +from hardware_testing.gravimetric import helpers, workarounds +from hardware_testing.data.csv_report import CSVReport +from hardware_testing.gravimetric.measurement.record import GravimetricRecorder +from hardware_testing.gravimetric.measurement.scale import Scale +from hardware_testing.drivers import ( + asair_sensor, + mitutoyo_digimatic_indicator, + list_ports_and_select, +) +from hardware_testing.data import ( + ui, + create_run_id_and_start_time, + get_git_description, + get_testing_data_directory, +) + +from opentrons.protocol_api import InstrumentContext, ProtocolContext +from opentrons.protocol_engine.types import LabwareOffset + +from hardware_testing.liquid_sense import execute +from .report import build_ls_report, store_config, store_serial_numbers +from .post_process import process_csv_directory + +from hardware_testing.protocols.liquid_sense_lpc import ( + liquid_sense_ot3_p50_single, + liquid_sense_ot3_p50_multi, + liquid_sense_ot3_p1000_single, + liquid_sense_ot3_p1000_multi, + liquid_sense_ot3_p1000_96, +) + +API_LEVEL = "2.18" + +LABWARE_OFFSETS: List[LabwareOffset] = [] + + +LIQUID_SENSE_CFG = { + 50: { + 1: liquid_sense_ot3_p50_single, + 8: liquid_sense_ot3_p50_multi, + }, + 1000: { + 1: liquid_sense_ot3_p1000_single, + 8: liquid_sense_ot3_p1000_multi, + 96: liquid_sense_ot3_p1000_96, + }, +} + +PIPETTE_MODEL_NAME = { + 50: { + 1: "p50_single_flex", + 8: "p50_multi_flex", + }, + 1000: { + 1: "p1000_single_flex", + 8: "p1000_multi_flex", + 96: "p1000_96_flex", + }, +} + + +@dataclass +class RunArgs: + """Common resources across multiple runs.""" + + tip_volumes: List[int] + run_id: str + pipette: InstrumentContext + pipette_tag: str + git_description: str + robot_serial: str + recorder: GravimetricRecorder + pipette_volume: int + pipette_channels: int + name: str + environment_sensor: asair_sensor.AsairSensorBase + trials: int + z_speed: float + return_tip: bool + ctx: ProtocolContext + protocol_cfg: Any + test_report: CSVReport + start_height_offset: float + aspirate: bool + dial_indicator: Optional[mitutoyo_digimatic_indicator.Mitutoyo_Digimatic_Indicator] + plunger_speed: bool + trials_before_jog: int + + @classmethod + def _get_protocol_context(cls, args: argparse.Namespace) -> ProtocolContext: + if not args.simulate and not args.skip_labware_offsets: + # getting labware offsets must be done before creating the protocol context + # because it requires the robot-server to be running + ui.print_title("SETUP") + ui.print_info( + "Starting opentrons-robot-server, so we can http GET labware offsets" + ) + LABWARE_OFFSETS.extend(workarounds.http_get_all_labware_offsets()) + ui.print_info(f"found {len(LABWARE_OFFSETS)} offsets:") + for offset in LABWARE_OFFSETS: + ui.print_info(f"\t{offset.createdAt}:") + ui.print_info(f"\t\t{offset.definitionUri}") + ui.print_info(f"\t\t{offset.vector}") + # gather the custom labware (for simulation) + custom_defs = {} + if args.simulate: + labware_dir = Path(__file__).parent.parent / "labware" + custom_def_uris = [ + "radwag_pipette_calibration_vial", + "dial_indicator", + ] + for def_uri in custom_def_uris: + with open(labware_dir / def_uri / "1.json", "r") as f: + custom_def = json_load(f) + custom_defs[def_uri] = custom_def + _ctx = helpers.get_api_context( + API_LEVEL, # type: ignore[attr-defined] + is_simulating=args.simulate, + pipette_left=PIPETTE_MODEL_NAME[args.pipette][args.channels], + extra_labware=custom_defs, + ) + for offset in LABWARE_OFFSETS: + engine = _ctx._core._engine_client._transport._engine # type: ignore[attr-defined] + engine.state_view._labware_store._add_labware_offset(offset) + return _ctx + + @classmethod + def build_run_args(cls, args: argparse.Namespace) -> "RunArgs": + """Build.""" + _ctx = RunArgs._get_protocol_context(args) + robot_serial = helpers._get_robot_serial(_ctx.is_simulating()) + run_id, start_time = create_run_id_and_start_time() + environment_sensor = asair_sensor.BuildAsairSensor( + _ctx.is_simulating() or args.ignore_env + ) + git_description = get_git_description() + protocol_cfg = LIQUID_SENSE_CFG[args.pipette][args.channels] + name = protocol_cfg.metadata["protocolName"] # type: ignore[attr-defined] + ui.print_header("LOAD PIPETTE") + pipette = _ctx.load_instrument( + f"flex_{args.channels}channel_{args.pipette}", "left" + ) + loaded_labwares = _ctx.loaded_labwares + if 12 in loaded_labwares.keys(): + trash = loaded_labwares[12] + else: + trash = _ctx.load_labware("opentrons_1_trash_3200ml_fixed", "A3") + pipette.trash_container = trash + pipette_tag = helpers._get_tag_from_pipette(pipette, False, False) + + if args.trials == 0: + trials = 10 + else: + trials = args.trials + + if args.tip == 0: + if args.pipette == 1000: + tip_volumes: List[int] = [50, 200, 1000] + else: + tip_volumes = [50] + else: + tip_volumes = [args.tip] + + scale = Scale.build(simulate=_ctx.is_simulating() or args.ignore_scale) + recorder: GravimetricRecorder = execute._load_scale( + name, + scale, + run_id, + pipette_tag, + start_time, + _ctx.is_simulating() or args.ignore_scale, + ) + dial: Optional[mitutoyo_digimatic_indicator.Mitutoyo_Digimatic_Indicator] = None + if not _ctx.is_simulating() and not args.ignore_dial: + dial_port = list_ports_and_select("Dial Indicator") + dial = mitutoyo_digimatic_indicator.Mitutoyo_Digimatic_Indicator( + port=dial_port + ) + dial.connect() + ui.print_info(f"pipette_tag {pipette_tag}") + report = build_ls_report(name, run_id, trials, tip_volumes) + report.set_tag(name) + # go ahead and store the meta data now + store_serial_numbers( + report, + robot_serial, + pipette_tag, + scale.read_serial_number(), + environment_sensor.get_serial(), + git_description, + ) + + store_config( + report, + name, + args.pipette, + tip_volumes, + trials, + args.plunger_direction, + args.liquid, + protocol_cfg.LABWARE_ON_SCALE, # type: ignore[attr-defined] + args.z_speed, + args.start_height_offset, + ) + return RunArgs( + tip_volumes=tip_volumes, + run_id=run_id, + pipette=pipette, + pipette_tag=pipette_tag, + git_description=git_description, + robot_serial=robot_serial, + recorder=recorder, + pipette_volume=args.pipette, + pipette_channels=args.channels, + name=name, + environment_sensor=environment_sensor, + trials=trials, + z_speed=args.z_speed, + return_tip=args.return_tip, + ctx=_ctx, + protocol_cfg=protocol_cfg, + test_report=report, + start_height_offset=args.start_height_offset, + aspirate=args.plunger_direction == "aspirate", + dial_indicator=dial, + plunger_speed=args.plunger_speed, + trials_before_jog=args.trials_before_jog, + ) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser("Pipette Testing") + parser.add_argument("--simulate", action="store_true") + parser.add_argument("--pipette", type=int, choices=[50, 1000], required=True) + parser.add_argument("--channels", type=int, choices=[1, 8, 96], default=1) + parser.add_argument("--tip", type=int, choices=[0, 50, 200, 1000], default=0) + parser.add_argument("--trials", type=int, default=0) + parser.add_argument("--return-tip", action="store_true") + parser.add_argument("--skip-labware-offsets", action="store_true") + parser.add_argument( + "--liquid", type=str, choices=["water", "glycerol", "alchohol"], default="water" + ) + parser.add_argument("--z-speed", type=float, default=5) + parser.add_argument( + "--plunger-direction", + type=str, + choices=["aspirate", "dispense"], + default="aspirate", + ) + parser.add_argument("--labware-type", type=str, default="nest_1_reservoir_195ml") + parser.add_argument("--plunger-speed", type=float, default=-1.0) + parser.add_argument("--isolate-plungers", action="store_true") + parser.add_argument("--start-height-offset", type=float, default=0) + parser.add_argument("--ignore-scale", action="store_true") + parser.add_argument("--ignore-env", action="store_true") + parser.add_argument("--ignore-dial", action="store_true") + parser.add_argument("--trials-before-jog", type=int, default=10) + + args = parser.parse_args() + run_args = RunArgs.build_run_args(args) + exit_error = os.EX_OK + try: + if not run_args.ctx.is_simulating(): + data_dir = get_testing_data_directory() + data_file = f"/{data_dir}/{run_args.name}/{run_args.run_id}/serial.log" + ui.print_info(f"logging can data to {data_file}") + serial_logger = subprocess.Popen( + [f"python3 -m opentrons_hardware.scripts.can_mon > {data_file}"], + shell=True, + ) + sleep(1) + hw = run_args.ctx._core.get_hardware() + if not run_args.ctx.is_simulating(): + ui.get_user_ready("CLOSE the door, and MOVE AWAY from machine") + ui.print_info("homing...") + run_args.ctx.home() + for tip in run_args.tip_volumes: + if args.channels == 96 and not run_args.ctx.is_simulating(): + ui.alert_user_ready(f"prepare the {tip}ul tipracks", hw) + execute.run(tip, run_args) + except Exception as e: + ui.print_info(f"got error {e}") + ui.print_info(traceback.format_exc()) + exit_error = 1 + finally: + if run_args.recorder is not None: + ui.print_info("ending recording") + run_args.recorder.stop() + run_args.recorder.deactivate() + if not run_args.ctx.is_simulating(): + ui.print_info("killing serial log") + serial_logger.terminate() + if run_args.dial_indicator is not None: + run_args.dial_indicator.disconnect() + run_args.test_report.save_to_disk() + run_args.test_report.print_results() + ui.print_info("done\n\n") + if not run_args.ctx.is_simulating(): + process_csv_directory( + f"{data_dir}/{run_args.name}/{run_args.run_id}", + run_args.tip_volumes, + run_args.trials, + ) + run_args.ctx.cleanup() + if not args.simulate: + helpers_ot3.restart_server_ot3() + os._exit(exit_error) diff --git a/hardware-testing/hardware_testing/liquid_sense/execute.py b/hardware-testing/hardware_testing/liquid_sense/execute.py new file mode 100644 index 00000000000..9ce6f71b2a8 --- /dev/null +++ b/hardware-testing/hardware_testing/liquid_sense/execute.py @@ -0,0 +1,317 @@ +"""Logic for running a single liquid probe test.""" +from typing import Dict, Any, List, Tuple, Optional +from .report import store_tip_results, store_trial, store_baseline_trial +from opentrons.config.types import LiquidProbeSettings, OutputOptions +from .__main__ import RunArgs +from hardware_testing.gravimetric.workarounds import get_sync_hw_api +from hardware_testing.gravimetric.helpers import ( + _jog_to_find_liquid_height, +) +from hardware_testing.gravimetric.config import LIQUID_PROBE_SETTINGS +from hardware_testing.gravimetric.tips import get_unused_tips +from hardware_testing.data import ui, get_testing_data_directory +from opentrons.hardware_control.types import ( + InstrumentProbeType, + OT3Mount, + Axis, + top_types, +) + +from hardware_testing.gravimetric.measurement.scale import Scale +from hardware_testing.gravimetric.measurement.record import ( + GravimetricRecorder, + GravimetricRecorderConfig, +) +from opentrons.protocol_api._types import OffDeckType + +from opentrons.protocol_api import ProtocolContext, Well, Labware + + +def _load_tipracks( + ctx: ProtocolContext, pipette_channels: int, protocol_cfg: Any, tip: int +) -> List[Labware]: + # TODO add logic here for partial tip using 96 + use_adapters: bool = pipette_channels == 96 + tiprack_load_settings: List[Tuple[int, str]] = [ + ( + slot, + f"opentrons_flex_96_tiprack_{tip}ul", + ) + for slot in protocol_cfg.SLOTS_TIPRACK[tip] # type: ignore[attr-defined] + ] + for ls in tiprack_load_settings: + ui.print_info(f'Loading tiprack "{ls[1]}" in slot #{ls[0]}') + + adapter: Optional[str] = ( + "opentrons_flex_96_tiprack_adapter" if use_adapters else None + ) + # If running multiple tests in one run, the labware may already be loaded + loaded_labwares = ctx.loaded_labwares + ui.print_info(f"Loaded labwares {loaded_labwares}") + pre_loaded_tips: List[Labware] = [] + for ls in tiprack_load_settings: + if ls[0] in loaded_labwares.keys(): + if loaded_labwares[ls[0]].name == ls[1]: + pre_loaded_tips.append(loaded_labwares[ls[0]]) + else: + # If something is in the slot that's not what we want, remove it + # we use this only for the 96 channel + ui.print_info( + f"Removing {loaded_labwares[ls[0]].name} from slot {ls[0]}" + ) + ctx._core.move_labware( + loaded_labwares[ls[0]]._core, + new_location=OffDeckType.OFF_DECK, + use_gripper=False, + pause_for_manual_move=False, + pick_up_offset=None, + drop_offset=None, + ) + if len(pre_loaded_tips) == len(tiprack_load_settings): + return pre_loaded_tips + + tipracks: List[Labware] = [] + for ls in tiprack_load_settings: + if ctx.deck[ls[0]] is not None: + tipracks.append( + ctx.deck[ls[0]].load_labware(ls[1]) # type: ignore[union-attr] + ) + else: + tipracks.append(ctx.load_labware(ls[1], location=ls[0], adapter=adapter)) + return tipracks + + +def _load_dial_indicator(run_args: RunArgs) -> Labware: + slot_dial = run_args.protocol_cfg.SLOT_DIAL # type: ignore[union-attr] + dial_labware_name = "dial_indicator" + loaded_labwares = run_args.ctx.loaded_labwares + if ( + slot_dial in loaded_labwares.keys() + and loaded_labwares[slot_dial].name == dial_labware_name + ): + return loaded_labwares[slot_dial] + + dial_labware = run_args.ctx.load_labware( + dial_labware_name, location=slot_dial, namespace="custom_beta" + ) + return dial_labware + + +def _load_test_well(run_args: RunArgs) -> Labware: + slot_scale = run_args.protocol_cfg.SLOT_SCALE # type: ignore[union-attr] + labware_on_scale = run_args.protocol_cfg.LABWARE_ON_SCALE # type: ignore[union-attr] + ui.print_info(f'Loading labware on scale: "{labware_on_scale}"') + if labware_on_scale == "radwag_pipette_calibration_vial": + namespace = "custom_beta" + else: + namespace = "opentrons" + # If running multiple tests in one run, the labware may already be loaded + loaded_labwares = run_args.ctx.loaded_labwares + if ( + slot_scale in loaded_labwares.keys() + and loaded_labwares[slot_scale].name == labware_on_scale + ): + return loaded_labwares[slot_scale] + + labware_on_scale = run_args.ctx.load_labware( + labware_on_scale, location=slot_scale, namespace=namespace + ) + return labware_on_scale + + +def _load_scale( + name: str, + scale: Scale, + run_id: str, + pipette_tag: str, + start_time: float, + simulating: bool, +) -> GravimetricRecorder: + ui.print_header("LOAD SCALE") + ui.print_info( + "Some Radwag settings cannot be controlled remotely.\n" + "Listed below are the things the must be done using the touchscreen:\n" + " 1) Set profile to USER\n" + " 2) Set screensaver to NONE\n" + ) + recorder = GravimetricRecorder( + GravimetricRecorderConfig( + test_name=name, + run_id=run_id, + tag=pipette_tag, + start_time=start_time, + duration=0, + frequency=1000 if simulating else 60, + stable=False, + ), + scale, + simulate=simulating, + start_graph=False, + ) + ui.print_info(f'found scale "{recorder.serial_number}"') + if simulating: + recorder.set_simulation_mass(0) + recorder.record(in_thread=True) + ui.print_info(f'scale is recording to "{recorder.file_name}"') + return recorder + + +def run(tip: int, run_args: RunArgs) -> None: + """Run a liquid probe test.""" + test_labware: Labware = _load_test_well(run_args) + dial_indicator: Labware = _load_dial_indicator(run_args) + dial_well: Well = dial_indicator["A1"] + hw_api = get_sync_hw_api(run_args.ctx) + test_well: Well = test_labware["A1"] + _load_tipracks(run_args.ctx, run_args.pipette_channels, run_args.protocol_cfg, tip) + tips: List[Well] = get_unused_tips( + ctx=run_args.ctx, tip_volume=tip, pipette_mount="" + ) + assert len(tips) >= run_args.trials + results: List[float] = [] + adjusted_results: List[float] = [] + lpc_offset = 0.0 + if run_args.dial_indicator is not None: + run_args.pipette.move_to(dial_well.top()) + lpc_offset = run_args.dial_indicator.read_stable() + run_args.pipette._retract() + + def _get_baseline() -> float: + run_args.pipette.pick_up_tip(tips[0]) + del tips[: run_args.pipette_channels] + liquid_height = _jog_to_find_liquid_height( + run_args.ctx, run_args.pipette, test_well + ) + target_height = test_well.bottom(liquid_height).point.z + + run_args.pipette._retract() + tip_offset = 0.0 + if run_args.dial_indicator is not None: + run_args.pipette.move_to(dial_well.top()) + tip_offset = run_args.dial_indicator.read_stable() + run_args.pipette._retract() + if run_args.return_tip: + run_args.pipette.return_tip() + else: + run_args.pipette.drop_tip() + + env_data = run_args.environment_sensor.get_reading() + + store_baseline_trial( + run_args.test_report, + tip, + target_height, + env_data.relative_humidity, + env_data.temperature, + test_well.top().point.z - target_height, + tip_offset - lpc_offset, + ) + return target_height + + trials_before_jog = run_args.trials_before_jog + tip_offset = 0.0 + for trial in range(run_args.trials): + if trial % trials_before_jog == 0: + tip_offset = _get_baseline() + + ui.print_info(f"Picking up {tip}ul tip") + run_args.pipette.pick_up_tip(tips[0]) + del tips[: run_args.pipette_channels] + run_args.pipette.move_to(test_well.top()) + + start_pos = hw_api.current_position_ot3(OT3Mount.LEFT) + height = _run_trial(run_args, tip, test_well, trial) + end_pos = hw_api.current_position_ot3(OT3Mount.LEFT) + run_args.pipette.blow_out() + tip_length_offset = 0.0 + if run_args.dial_indicator is not None: + + run_args.pipette._retract() + run_args.pipette.move_to(dial_well.top()) + tip_length_offset = tip_offset - run_args.dial_indicator.read_stable() + run_args.pipette._retract() + ui.print_info(f"Tip Offset {tip_length_offset}") + + ui.print_info("Droping tip") + if run_args.return_tip: + run_args.pipette.return_tip() + else: + run_args.pipette.drop_tip() + results.append(height) + adjusted_results.append(height + tip_length_offset) + env_data = run_args.environment_sensor.get_reading() + hw_pipette = hw_api.hardware_pipettes[top_types.Mount.LEFT] + plunger_start = ( + hw_pipette.plunger_positions.bottom + if run_args.aspirate + else hw_pipette.plunger_positions.top + ) + store_trial( + run_args.test_report, + trial, + tip, + height, + end_pos[Axis.P_L], + env_data.relative_humidity, + env_data.temperature, + start_pos[Axis.Z_L] - end_pos[Axis.Z_L], + plunger_start - end_pos[Axis.P_L], + tip_length_offset, + ) + ui.print_info( + f"\n\n Z axis start pos {start_pos[Axis.Z_L]} end pos {end_pos[Axis.Z_L]}" + ) + ui.print_info( + f"plunger start pos {plunger_start} end pos {end_pos[Axis.P_L]}\n\n" + ) + + ui.print_info(f"RESULTS: \n{results}") + ui.print_info(f"Adjusted RESULTS: \n{adjusted_results}") + store_tip_results(run_args.test_report, tip, results, adjusted_results) + + +def _run_trial(run_args: RunArgs, tip: int, well: Well, trial: int) -> float: + hw_api = get_sync_hw_api(run_args.ctx) + lqid_cfg: Dict[str, int] = LIQUID_PROBE_SETTINGS[run_args.pipette_volume][ + run_args.pipette_channels + ][tip] + data_dir = get_testing_data_directory() + probes: List[InstrumentProbeType] = [InstrumentProbeType.PRIMARY] + probe_target: InstrumentProbeType = InstrumentProbeType.PRIMARY + if run_args.pipette_channels > 1: + probes.append(InstrumentProbeType.SECONDARY) + probe_target = InstrumentProbeType.BOTH + data_files: Dict[InstrumentProbeType, str] = {} + for probe in probes: + data_filename = f"pressure_sensor_data-trial{trial}-tip{tip}-{probe.name}.csv" + data_file = f"{data_dir}/{run_args.name}/{run_args.run_id}/{data_filename}" + ui.print_info(f"logging pressure data to {data_file}") + data_files[probe] = data_file + + plunger_speed = ( + lqid_cfg["plunger_speed"] + if run_args.plunger_speed == -1 + else run_args.plunger_speed + ) + lps = LiquidProbeSettings( + starting_mount_height=well.top().point.z + run_args.start_height_offset, + max_z_distance=min(well.depth, lqid_cfg["max_z_distance"]), + min_z_distance=lqid_cfg["min_z_distance"], + mount_speed=run_args.z_speed, + plunger_speed=plunger_speed, + sensor_threshold_pascals=lqid_cfg["sensor_threshold_pascals"], + expected_liquid_height=110, + output_option=OutputOptions.sync_buffer_to_csv, + aspirate_while_sensing=run_args.aspirate, + auto_zero_sensor=True, + num_baseline_reads=10, + data_files=data_files, + ) + + hw_mount = OT3Mount.LEFT if run_args.pipette.mount == "left" else OT3Mount.RIGHT + run_args.recorder.set_sample_tag(f"trial-{trial}-{tip}ul") + # TODO add in stuff for secondary probe + height = hw_api.liquid_probe(hw_mount, lps, probe_target) + ui.print_info(f"Trial {trial} complete") + run_args.recorder.clear_sample_tag() + return height diff --git a/hardware-testing/hardware_testing/liquid_sense/post_process.py b/hardware-testing/hardware_testing/liquid_sense/post_process.py new file mode 100644 index 00000000000..20e46ed746a --- /dev/null +++ b/hardware-testing/hardware_testing/liquid_sense/post_process.py @@ -0,0 +1,170 @@ +"""Post process script csvs.""" +import csv +import os +from typing import List, Dict, Tuple +from math import isclose + +COL_TRIAL_CONVERSION = { + 1: "E", + 2: "H", + 3: "K", + 4: "N", + 5: "Q", + 6: "T", + 7: "W", + 8: "Z", + 9: "AC", + 10: "AF", + 11: "AI", + 12: "AL", + 13: "AO", +} + + +def process_csv_directory( # noqa: C901 + data_directory: str, tips: List[int], trials: int, make_graph: bool = False +) -> None: + """Post process script csvs.""" + csv_files: List[str] = os.listdir(data_directory) + summary: str = [f for f in csv_files if "CSVReport" in f][0] + final_report_file: str = f"{data_directory}/final_report.csv" + # initialize our data structs + pressure_csvs = [f for f in csv_files if "pressure_sensor_data" in f] + pressure_results_files: Dict[int, List[str]] = {} + pressure_results: Dict[int, Dict[int, List[float]]] = {} + results_settings: Dict[int, Dict[int, Tuple[float, float, float]]] = {} + tip_offsets: Dict[int, List[float]] = {} + p_offsets: Dict[int, List[float]] = {} + meniscus_travel: float = 0 + for tip in tips: + pressure_results_files[tip] = [f for f in pressure_csvs if f"tip{tip}" in f] + pressure_results[tip] = {} + results_settings[tip] = {} + tip_offsets[tip] = [] + p_offsets[tip] = [i * 0 for i in range(trials)] + for trial in range(trials): + pressure_results[tip][trial] = [] + results_settings[tip][trial] = (0.0, 0.0, 0.0) + max_results_len = 0 + + # read in all of the pressure csvs into one big struct so we can process them + for tip in tips: + for trial in range(trials): + with open( + f"{data_directory}/{pressure_results_files[tip][trial]}", newline="" + ) as trial_csv: + trial_reader = csv.reader(trial_csv) + i = 0 + for row in trial_reader: + if i == 1: + results_settings[tip][trial] = ( + float(row[2]), + float(row[3]), + float(row[4]), + ) + if i > 1: + pressure_results[tip][trial].append(float(row[1])) + i += 1 + max_results_len = max([i - 2, max_results_len]) + # start writing the final report csv + with open(f"{data_directory}/{summary}", newline="") as summary_csv: + summary_reader = csv.reader(summary_csv) + with open(final_report_file, "w", newline="") as final_report: + # copy over the results summary + final_report_writer = csv.writer(final_report) + s = 0 + for row in summary_reader: + final_report_writer.writerow(row) + s += 1 + if s == 45: + meniscus_travel = float(row[6]) + if s >= 46 and s < 46 + (trials * len(tips)): + # while processing this grab the tip offsets from the summary + tip_offsets[tips[int((s - 46) / trials)]].append(float(row[8])) + # summary_reader.line_num is the last line in the summary that has text + pressures_start_line = summary_reader.line_num + 3 + # calculate where the start and end of each block of data we want to graph + final_report_writer.writerow( + [ + "50ul", + f"A{pressures_start_line-1}", + f"{COL_TRIAL_CONVERSION[trials]}{pressures_start_line + max_results_len -1}", + "200ul", + f"A{pressures_start_line+max_results_len-1}", + f"{COL_TRIAL_CONVERSION[trials]}{pressures_start_line +(2*max_results_len)-1}", + "10000ul", + f"A{pressures_start_line+(2*max_results_len-1)}", + f"{COL_TRIAL_CONVERSION[trials]}{pressures_start_line + (3*max_results_len)-1}", + ] + ) + + # build a header row + pressure_header_row = ["time", ""] + for i in range(trials): + pressure_header_row.extend( + [f"pressure T{i+1}", f"z_travel T{i+1}", f"p_travel T{i+1}"] + ) + + # we want to line up the z height's of each trial at time==0 + # to do this we drop the results at the beginning of each of the trials + # except for one with the longest tip (lower tip offset are longer tips) + min_tip_offset = 0.0 + if make_graph: + for tip in tips: + min_tip_offset = min(tip_offsets[tip]) + for trial in range(trials): + for i in range(max_results_len): + if tip_offsets[tip][trial] > min_tip_offset: + # drop this pressure result + pressure_results[tip][trial].pop(0) + # we don't want to change the length of this array so just + # stretch out the last value + pressure_results[tip][trial].append( + pressure_results[tip][trial][-1] + ) + # decrement the offset while this is true + # so we can account for it later + tip_offsets[tip][trial] -= ( + 0.001 * results_settings[tip][0][0] + ) + # keep track of how this effects the plunger start position + p_offsets[tip][trial] = ( + (i + 1) * 0.001 * results_settings[tip][0][1] * -1 + ) + else: + # we've lined up this trial so move to the next + break + # write the processed test data + for tip in tips: + time = 0.0 + final_report_writer.writerow(pressure_header_row) + meniscus_time = (meniscus_travel + min_tip_offset) / results_settings[ + tip + ][0][0] + for i in range(max_results_len): + pressure_row: List[str] = [f"{time}"] + if isclose( + time, + meniscus_time, + rel_tol=0.001, + ): + pressure_row.append("Meniscus") + else: + pressure_row.append("") + for trial in range(trials): + if i < len(pressure_results[tip][trial]): + pressure_row.append(f"{pressure_results[tip][trial][i]}") + else: + pressure_row.append("") + pressure_row.append( + f"{results_settings[tip][trial][0] * time - tip_offsets[tip][trial]}" + ) + pressure_row.append( + f"{abs(results_settings[tip][trial][1]) * time + p_offsets[tip][trial]}" + ) + final_report_writer.writerow(pressure_row) + time += 0.001 + + +if __name__ == "__main__": + process_csv_directory("/home/ryan/testdata", [50], 10) diff --git a/hardware-testing/hardware_testing/liquid_sense/report.py b/hardware-testing/hardware_testing/liquid_sense/report.py new file mode 100644 index 00000000000..bca898e79c7 --- /dev/null +++ b/hardware-testing/hardware_testing/liquid_sense/report.py @@ -0,0 +1,263 @@ +"""Format the csv report for a liquid-sense run.""" + +import statistics +from hardware_testing.data.csv_report import ( + CSVReport, + CSVSection, + CSVLine, + CSVLineRepeating, +) +from typing import List, Union + +""" +CSV Test Report: + - Serial numbers: + - Robot + - Pipette + - Scale + - Environment sensor + - Config: + - protocol name + - pipette_volume + - pipette_mount + - tip_volume + - trials + - plunger direction + - liquid + - labware type + - speed + - start height offset + - Trials + trial-x-{tipsize}ul + - Results + {tipsize}ul-average + {tipsize}ul-cv + {tipsize}ul-d +""" + + +def build_serial_number_section() -> CSVSection: + """Build section.""" + return CSVSection( + title="SERIAL-NUMBERS", + lines=[ + CSVLine("robot", [str]), + CSVLine("git_description", [str]), + CSVLine("pipette", [str]), + CSVLine("scale", [str]), + CSVLine("environment", [str]), + ], + ) + + +def build_config_section() -> CSVSection: + """Build section.""" + return CSVSection( + title="CONFIG", + lines=[ + CSVLine("protocol_name", [str]), + CSVLine("pipette_volume", [str]), + CSVLine("tip_volume", [bool, bool, bool]), + CSVLine("trials", [str]), + CSVLine("plunger_direction", [str]), + CSVLine("liquid", [str]), + CSVLine("labware_type", [str]), + CSVLine("speed", [str]), + CSVLine("start_height_offset", [str]), + ], + ) + + +def build_trials_section(trials: int, tips: List[int]) -> CSVSection: + """Build section.""" + lines: List[Union[CSVLine, CSVLineRepeating]] = [ + CSVLine("trial_number", [str, str, str, str, str, str, str, str]) + ] + lines.extend( + [ + CSVLine( + f"trial-baseline-{tip}ul", + [float, float, float, float, float, float, float, float], + ) + for tip in tips + ] + ) + lines.extend( + [ + CSVLine( + f"trial-{t + 1}-{tip}ul", + [float, float, float, float, float, float, float, float], + ) + for tip in tips + for t in range(trials) + ] + ) + + return CSVSection( + title="TRIALS", + lines=lines, + ) + + +def build_results_section(tips: List[int]) -> CSVSection: + """Build section.""" + lines: List[CSVLine] = [] + for tip in tips: + lines.append(CSVLine(f"{tip}ul-average", [float])) + lines.append(CSVLine(f"{tip}ul-minumum", [float])) + lines.append(CSVLine(f"{tip}ul-maximum", [float])) + lines.append(CSVLine(f"{tip}ul-stdev", [float])) + lines.append(CSVLine(f"{tip}ul-adjusted-average", [float])) + lines.append(CSVLine(f"{tip}ul-adjusted-minumum", [float])) + lines.append(CSVLine(f"{tip}ul-adjusted-maximum", [float])) + lines.append(CSVLine(f"{tip}ul-adjusted-stdev", [float])) + return CSVSection(title="RESULTS", lines=lines) # type: ignore[arg-type] + + +def store_serial_numbers( + report: CSVReport, + robot: str, + pipette: str, + scale: str, + environment: str, + git_description: str, +) -> None: + """Report serial numbers.""" + report("SERIAL-NUMBERS", "robot", [robot]) + report("SERIAL-NUMBERS", "git_description", [git_description]) + report("SERIAL-NUMBERS", "pipette", [pipette]) + report("SERIAL-NUMBERS", "scale", [scale]) + report("SERIAL-NUMBERS", "environment", [environment]) + + +def store_config( + report: CSVReport, + protocol_name: str, + pipette_volume: str, + tip_volumes: List[int], + trials: int, + plunger_direction: str, + liquid: str, + labware_type: str, + speed: str, + start_height_offset: str, +) -> None: + """Report config.""" + report("CONFIG", "protocol_name", [protocol_name]) + report("CONFIG", "pipette_volume", [pipette_volume]) + report( + "CONFIG", + "tip_volume", + [50 in tip_volumes, 200 in tip_volumes, 1000 in tip_volumes], + ) + report("CONFIG", "trials", [trials]) + report("CONFIG", "plunger_direction", [plunger_direction]) + report("CONFIG", "liquid", [liquid]) + report("CONFIG", "labware_type", [labware_type]) + report("CONFIG", "speed", [speed]) + report("CONFIG", "start_height_offset", [start_height_offset]) + + +def store_baseline_trial( + report: CSVReport, + tip: float, + height: float, + humidity: float, + temp: float, + z_travel: float, + measured_error: float, +) -> None: + """Report Trial.""" + report( + "TRIALS", + f"trial-baseline-{tip}ul", + [ + height, + 0, + humidity, + temp, + z_travel, + 0, + 0, + measured_error, + ], + ) + + +def store_trial( + report: CSVReport, + trial: int, + tip: float, + height: float, + plunger_pos: float, + humidity: float, + temp: float, + z_travel: float, + plunger_travel: float, + tip_length_offset: float, +) -> None: + """Report Trial.""" + report( + "TRIALS", + f"trial-{trial + 1}-{tip}ul", + [ + height, + plunger_pos, + humidity, + temp, + z_travel, + plunger_travel, + tip_length_offset, + height + tip_length_offset, + ], + ) + + +def store_tip_results( + report: CSVReport, tip: float, results: List[float], adjusted_results: List[float] +) -> None: + """Store final results.""" + report("RESULTS", f"{tip}ul-average", [sum(results) / len(results)]) + report("RESULTS", f"{tip}ul-minumum", [min(results)]) + report("RESULTS", f"{tip}ul-maximum", [max(results)]) + report("RESULTS", f"{tip}ul-stdev", [statistics.stdev(results)]) + report( + "RESULTS", + f"{tip}ul-adjusted-average", + [sum(adjusted_results) / len(adjusted_results)], + ) + report("RESULTS", f"{tip}ul-adjusted-minumum", [min(adjusted_results)]) + report("RESULTS", f"{tip}ul-adjusted-maximum", [max(adjusted_results)]) + report("RESULTS", f"{tip}ul-adjusted-stdev", [statistics.stdev(adjusted_results)]) + + +def build_ls_report( + test_name: str, run_id: str, trials: int, tips: List[int] +) -> CSVReport: + """Generate a CSV Report.""" + report = CSVReport( + test_name=test_name, + sections=[ + build_serial_number_section(), + build_config_section(), + build_trials_section(trials, tips), + build_results_section(tips), + ], + run_id=run_id, + start_time=0.0, + ) + report( + "TRIALS", + "trial_number", + [ + "height", + "plunger_pos", + "humidity", + "temp", + "z_travel", + "plunger_travel", + "tip_length_offset", + "adjusted_height", + ], + ) + return report diff --git a/hardware-testing/hardware_testing/opentrons_api/helpers_ot3.py b/hardware-testing/hardware_testing/opentrons_api/helpers_ot3.py index 4beae74bdd9..d1ff8f91d53 100644 --- a/hardware-testing/hardware_testing/opentrons_api/helpers_ot3.py +++ b/hardware-testing/hardware_testing/opentrons_api/helpers_ot3.py @@ -84,9 +84,7 @@ def stop_server_ot3() -> None: def restart_server_ot3() -> None: """Start opentrons-robot-server on the OT3.""" print('Starting "opentrons-robot-server"...') - Popen( - ["systemctl", "restart", "opentrons-robot-server", "&"], - ) + Popen(["systemctl restart opentrons-robot-server &"], shell=True) def start_server_ot3() -> None: @@ -113,7 +111,7 @@ def _create_fake_pipette_id(mount: OT3Mount, model: Optional[str]) -> Optional[s assert len(items) == 3 size = "P1K" if items[0] == "p1000" else "P50" channels = "S" if items[1] == "single" else "M" - version = items[2].upper().replace(".", "") + version = 35 # model names don't have a version so just fake a 3.5 version date = datetime.now().strftime("%y%m%d") unique_number = 1 if mount == OT3Mount.LEFT else 2 return f"{size}{channels}{version}{date}A0{unique_number}" diff --git a/hardware-testing/hardware_testing/production_qc/pipette_assembly_qc_ot3/__main__.py b/hardware-testing/hardware_testing/production_qc/pipette_assembly_qc_ot3/__main__.py index 1ec595974b4..5e482afa6e7 100644 --- a/hardware-testing/hardware_testing/production_qc/pipette_assembly_qc_ot3/__main__.py +++ b/hardware-testing/hardware_testing/production_qc/pipette_assembly_qc_ot3/__main__.py @@ -1386,7 +1386,7 @@ async def _test_liquid_probe( aspirate_while_sensing=False, # FIXME: I heard this doesn't work auto_zero_sensor=True, # TODO: when would we want to adjust this? num_baseline_reads=10, # TODO: when would we want to adjust this? - data_file="", # FIXME: remove + data_files=None, ) end_z = await api.liquid_probe(mount, probe_settings, probe=probe) if probe == InstrumentProbeType.PRIMARY: diff --git a/hardware-testing/hardware_testing/protocols/gravimetric_lpc/gravimetric/gravimetric_ot3_p1000_96.py b/hardware-testing/hardware_testing/protocols/gravimetric_lpc/gravimetric/gravimetric_ot3_p1000_96.py index e4901928a34..6fe882f5370 100644 --- a/hardware-testing/hardware_testing/protocols/gravimetric_lpc/gravimetric/gravimetric_ot3_p1000_96.py +++ b/hardware-testing/hardware_testing/protocols/gravimetric_lpc/gravimetric/gravimetric_ot3_p1000_96.py @@ -1,5 +1,6 @@ """Photometric OT3 P1000.""" from opentrons.protocol_api import ProtocolContext +from opentrons.protocol_api._types import OffDeckType metadata = {"protocolName": "gravimetric-ot3-p1000-96"} requirements = {"robotType": "Flex", "apiLevel": "2.15"} @@ -8,24 +9,34 @@ SLOTS_TIPRACK = { # TODO: add slot 12 when tipracks are disposable 50: [2, 3, 5, 6, 7, 8, 9, 10, 11], - 200: [2, 3, 5, 6, 7, 8, 9, 10, 11], # NOTE: ignored during calibration - 1000: [2, 3, 5, 6, 7, 8, 9, 10, 11], # NOTE: ignored during calibration + 200: [2, 3, 5, 6, 7, 8, 9, 10, 11], + 1000: [2, 3, 5, 6, 7, 8, 9, 10, 11], } LABWARE_ON_SCALE = "nest_1_reservoir_195ml" def run(ctx: ProtocolContext) -> None: """Run.""" - tipracks = [ - ctx.load_labware(f"opentrons_flex_96_tiprack_{size}uL_adp", slot) - for size, slots in SLOTS_TIPRACK.items() - for slot in slots - if size == 50 # only calibrate 50ul tip-racks - ] scale_labware = ctx.load_labware(LABWARE_ON_SCALE, SLOT_SCALE) pipette = ctx.load_instrument("flex_96channel_1000", "left") - for rack in tipracks: - pipette.pick_up_tip(rack["A1"]) - pipette.aspirate(10, scale_labware["A1"].top()) - pipette.dispense(10, scale_labware["A1"].top()) - pipette.drop_tip(home_after=False) + adapters = [ + ctx.load_adapter("opentrons_flex_96_tiprack_adapter", slot) + for slot in SLOTS_TIPRACK[50] + ] + for tip_size in SLOTS_TIPRACK.keys(): + tipracks = [ + adapter.load_labware(f"opentrons_flex_96_tiprack_{tip_size}uL") + for adapter in adapters + ] + for rack in tipracks: + pipette.pick_up_tip(rack) + pipette.aspirate(10, scale_labware["A1"].top()) + pipette.dispense(10, scale_labware["A1"].top()) + pipette.drop_tip(home_after=False) + + for rack in tipracks: + ctx.move_labware( + rack, + new_location=OffDeckType.OFF_DECK, + use_gripper=False, + ) diff --git a/hardware-testing/hardware_testing/protocols/gravimetric_lpc/photometric/photometric_ot3_p1000_96.py b/hardware-testing/hardware_testing/protocols/gravimetric_lpc/photometric/photometric_ot3_p1000_96.py index 4be97d86289..2cb4dcc1daf 100644 --- a/hardware-testing/hardware_testing/protocols/gravimetric_lpc/photometric/photometric_ot3_p1000_96.py +++ b/hardware-testing/hardware_testing/protocols/gravimetric_lpc/photometric/photometric_ot3_p1000_96.py @@ -1,12 +1,13 @@ """Photometric OT3 P1000.""" from opentrons.protocol_api import ProtocolContext +from opentrons.protocol_api._types import OffDeckType metadata = {"protocolName": "photometric-ot3-p1000-96"} requirements = {"robotType": "Flex", "apiLevel": "2.15"} SLOTS_TIPRACK = { 50: [5, 6, 8, 9, 11], - 200: [5, 6, 8, 9, 11], # NOTE: ignoring this tip-rack during run() method + 200: [5, 6, 8, 9, 11], } SLOT_PLATE = 3 SLOT_RESERVOIR = 2 @@ -17,20 +18,27 @@ def run(ctx: ProtocolContext) -> None: """Run.""" - tipracks = [ - # FIXME: use official tip-racks once available - ctx.load_labware( - f"opentrons_flex_96_tiprack_{size}uL_adp", slot, namespace="custom_beta" - ) - for size, slots in SLOTS_TIPRACK.items() - for slot in slots - if size == 50 # only calibrate 50ul tips for 96ch test - ] reservoir = ctx.load_labware(RESERVOIR_LABWARE, SLOT_RESERVOIR) plate = ctx.load_labware(PHOTOPLATE_LABWARE, SLOT_PLATE) pipette = ctx.load_instrument("flex_96channel_1000", "left") - for rack in tipracks: - pipette.pick_up_tip(rack["A1"]) - pipette.aspirate(10, reservoir["A1"].top()) - pipette.dispense(10, plate["A1"].top()) - pipette.drop_tip(home_after=False) + adapters = [ + ctx.load_adapter("opentrons_flex_96_tiprack_adapter", slot) + for slot in SLOTS_TIPRACK[50] + ] + for tip_size in SLOTS_TIPRACK.keys(): + tipracks = [ + adapter.load_labware(f"opentrons_flex_96_tiprack_{tip_size}uL") + for adapter in adapters + ] + for rack in tipracks: + pipette.pick_up_tip(rack) + pipette.aspirate(10, reservoir["A1"].top()) + pipette.dispense(10, plate["A1"].top()) + pipette.drop_tip(home_after=False) + + for rack in tipracks: + ctx.move_labware( + rack, + new_location=OffDeckType.OFF_DECK, + use_gripper=False, + ) diff --git a/hardware-testing/hardware_testing/protocols/liquid_sense_lpc/__init__.py b/hardware-testing/hardware_testing/protocols/liquid_sense_lpc/__init__.py new file mode 100644 index 00000000000..6ec34e45de0 --- /dev/null +++ b/hardware-testing/hardware_testing/protocols/liquid_sense_lpc/__init__.py @@ -0,0 +1 @@ +"""Liquid Sense LPC.""" diff --git a/hardware-testing/hardware_testing/protocols/liquid_sense_lpc/liquid_sense_ot3_p1000_96.py b/hardware-testing/hardware_testing/protocols/liquid_sense_lpc/liquid_sense_ot3_p1000_96.py new file mode 100644 index 00000000000..02644b314a4 --- /dev/null +++ b/hardware-testing/hardware_testing/protocols/liquid_sense_lpc/liquid_sense_ot3_p1000_96.py @@ -0,0 +1,33 @@ +"""Liquid sense OT3 P1000.""" +from opentrons.protocol_api import ProtocolContext + +metadata = {"protocolName": "liquid-sense-ot3-p1000-96"} +requirements = {"robotType": "Flex", "apiLevel": "2.15"} + +SLOT_SCALE = 4 +SLOT_DIAL = 5 +SLOTS_TIPRACK = { + # TODO: add slot 12 when tipracks are disposable + 50: [2, 3, 6, 7, 8, 9, 10, 11], + 200: [2, 3, 6, 7, 8, 9, 10, 11], # NOTE: ignored during calibration + 1000: [2, 3, 6, 7, 8, 9, 10, 11], # NOTE: ignored during calibration +} + +LABWARE_ON_SCALE = "nest_1_reservoir_195ml" + + +def run(ctx: ProtocolContext) -> None: + """Run.""" + tipracks = [ + ctx.load_labware(f"opentrons_flex_96_tiprack_{size}uL_adp", slot) + for size, slots in SLOTS_TIPRACK.items() + for slot in slots + if size == 50 # only calibrate 50ul tip-racks + ] + scale_labware = ctx.load_labware(LABWARE_ON_SCALE, SLOT_SCALE) + pipette = ctx.load_instrument("p1000_96", "left") + for rack in tipracks: + pipette.pick_up_tip(rack["A1"]) + pipette.aspirate(10, scale_labware["A1"].top()) + pipette.dispense(10, scale_labware["A1"].top()) + pipette.drop_tip(home_after=False) diff --git a/hardware-testing/hardware_testing/protocols/liquid_sense_lpc/liquid_sense_ot3_p1000_multi.py b/hardware-testing/hardware_testing/protocols/liquid_sense_lpc/liquid_sense_ot3_p1000_multi.py new file mode 100644 index 00000000000..d2b806d1229 --- /dev/null +++ b/hardware-testing/hardware_testing/protocols/liquid_sense_lpc/liquid_sense_ot3_p1000_multi.py @@ -0,0 +1,26 @@ +"""LiquidSense OT3 P1000.""" +from opentrons.protocol_api import ProtocolContext + +metadata = {"protocolName": "liquid-sense-ot3-p1000-multi"} +requirements = {"robotType": "Flex", "apiLevel": "2.15"} + +SLOT_SCALE = 4 +SLOT_DIAL = 5 +SLOTS_TIPRACK = {50: [2], 200: [3], 1000: [6]} +LABWARE_ON_SCALE = "nest_1_reservoir_195ml" + + +def run(ctx: ProtocolContext) -> None: + """Run.""" + tipracks = [ + ctx.load_labware(f"opentrons_flex_96_tiprack_{size}uL", slot) + for size, slots in SLOTS_TIPRACK.items() + for slot in slots + ] + vial = ctx.load_labware(LABWARE_ON_SCALE, SLOT_SCALE) + pipette = ctx.load_instrument("flex_8channel_1000", "left") + for rack in tipracks: + pipette.pick_up_tip(rack["A1"]) + pipette.aspirate(10, vial["A1"].top()) + pipette.dispense(10, vial["A1"].top()) + pipette.drop_tip(home_after=False) diff --git a/hardware-testing/hardware_testing/protocols/liquid_sense_lpc/liquid_sense_ot3_p1000_single.py b/hardware-testing/hardware_testing/protocols/liquid_sense_lpc/liquid_sense_ot3_p1000_single.py new file mode 100644 index 00000000000..4e8fcc177f4 --- /dev/null +++ b/hardware-testing/hardware_testing/protocols/liquid_sense_lpc/liquid_sense_ot3_p1000_single.py @@ -0,0 +1,33 @@ +"""Liquid Sense OT3 P1000.""" +from opentrons.protocol_api import ProtocolContext + +metadata = {"protocolName": "liquid-sense-ot3-p1000-single"} +requirements = {"robotType": "Flex", "apiLevel": "2.15"} + +SLOT_SCALE = 4 +SLOT_DIAL = 5 +SLOTS_TIPRACK = { + 50: [3], + 200: [6], + 1000: [9], +} +LABWARE_ON_SCALE = "nest_1_reservoir_195ml" + + +def run(ctx: ProtocolContext) -> None: + """Run.""" + tipracks = [ + ctx.load_labware(f"opentrons_flex_96_tiprack_{size}uL", slot) + for size, slots in SLOTS_TIPRACK.items() + for slot in slots + ] + vial = ctx.load_labware(LABWARE_ON_SCALE, SLOT_SCALE) + dial = ctx.load_labware("dial_indicator", SLOT_DIAL) + pipette = ctx.load_instrument("flex_1channel_1000", "left") + for rack in tipracks: + pipette.pick_up_tip(rack["A1"]) + pipette.aspirate(10, vial["A1"].top()) + pipette.dispense(10, vial["A1"].top()) + pipette.aspirate(1, dial["A1"].top()) + pipette.dispense(1, dial["A1"].top()) + pipette.drop_tip(home_after=False) diff --git a/hardware-testing/hardware_testing/protocols/liquid_sense_lpc/liquid_sense_ot3_p50_multi.py b/hardware-testing/hardware_testing/protocols/liquid_sense_lpc/liquid_sense_ot3_p50_multi.py new file mode 100644 index 00000000000..34f83cd4cf7 --- /dev/null +++ b/hardware-testing/hardware_testing/protocols/liquid_sense_lpc/liquid_sense_ot3_p50_multi.py @@ -0,0 +1,28 @@ +"""Liquid Sense OT3.""" +from opentrons.protocol_api import ProtocolContext + +metadata = {"protocolName": "liquid_sense-ot3-p50-multi-50ul-tip"} +requirements = {"robotType": "Flex", "apiLevel": "2.15"} + +SLOT_SCALE = 4 +SLOT_DIAL = 5 +SLOTS_TIPRACK = { + 50: [3], +} +LABWARE_ON_SCALE = "nest_1_reservoir_195ml" + + +def run(ctx: ProtocolContext) -> None: + """Run.""" + tipracks = [ + ctx.load_labware(f"opentrons_flex_96_tiprack_{size}uL", slot) + for size, slots in SLOTS_TIPRACK.items() + for slot in slots + ] + vial = ctx.load_labware(LABWARE_ON_SCALE, SLOT_SCALE) + pipette = ctx.load_instrument("flex_8channel_50", "left") + for rack in tipracks: + pipette.pick_up_tip(rack["A1"]) + pipette.aspirate(pipette.min_volume, vial["A1"].top()) + pipette.dispense(pipette.min_volume, vial["A1"].top()) + pipette.drop_tip(home_after=False) diff --git a/hardware-testing/hardware_testing/protocols/liquid_sense_lpc/liquid_sense_ot3_p50_single.py b/hardware-testing/hardware_testing/protocols/liquid_sense_lpc/liquid_sense_ot3_p50_single.py new file mode 100644 index 00000000000..8e9d65a72e2 --- /dev/null +++ b/hardware-testing/hardware_testing/protocols/liquid_sense_lpc/liquid_sense_ot3_p50_single.py @@ -0,0 +1,31 @@ +"""Liquid Sense OT3.""" +from opentrons.protocol_api import ProtocolContext + +metadata = {"protocolName": "liquid-sense-ot3-p50-single"} +requirements = {"robotType": "Flex", "apiLevel": "2.15"} + +SLOT_SCALE = 4 +SLOT_DIAL = 5 +SLOTS_TIPRACK = { + 50: [3], +} +LABWARE_ON_SCALE = "radwag_pipette_calibration_vial" + + +def run(ctx: ProtocolContext) -> None: + """Run.""" + tipracks = [ + ctx.load_labware(f"opentrons_flex_96_tiprack_{size}uL", slot) + for size, slots in SLOTS_TIPRACK.items() + for slot in slots + ] + vial = ctx.load_labware(LABWARE_ON_SCALE, SLOT_SCALE) + dial = ctx.load_labware("dial_indicator", SLOT_DIAL) + pipette = ctx.load_instrument("flex_1channel_50", "left") + for rack in tipracks: + pipette.pick_up_tip(rack["A1"]) + pipette.aspirate(10, vial["A1"].top()) + pipette.dispense(10, vial["A1"].top()) + pipette.aspirate(1, dial["A1"].top()) + pipette.dispense(1, dial["A1"].top()) + pipette.drop_tip(home_after=False) diff --git a/hardware-testing/tests/hardware_testing/liquid/test_heights.py b/hardware-testing/tests/hardware_testing/liquid/test_heights.py index ab73b54618c..39efb419e65 100644 --- a/hardware-testing/tests/hardware_testing/liquid/test_heights.py +++ b/hardware-testing/tests/hardware_testing/liquid/test_heights.py @@ -17,7 +17,7 @@ def _create_context() -> ProtocolContext: - return get_api_context(api_level="2.13", is_simulating=True) + return get_api_context(api_level="2.16", is_simulating=True) def _load_labware(ctx: ProtocolContext) -> Tuple[Labware, Labware, Labware, Labware]: diff --git a/hardware/opentrons_hardware/drivers/binary_usb/binary_messenger.py b/hardware/opentrons_hardware/drivers/binary_usb/binary_messenger.py index 49c1584526d..4c54e5a8632 100644 --- a/hardware/opentrons_hardware/drivers/binary_usb/binary_messenger.py +++ b/hardware/opentrons_hardware/drivers/binary_usb/binary_messenger.py @@ -196,7 +196,6 @@ async def _read_task(self) -> None: if filter and not filter( BinaryMessageId(message_definition.message_id.value) ): - log.debug("message ignored by filter") continue listener(message_definition) if ( diff --git a/hardware/opentrons_hardware/drivers/can_bus/can_messenger.py b/hardware/opentrons_hardware/drivers/can_bus/can_messenger.py index 4446b3b0683..c0b49e376bb 100644 --- a/hardware/opentrons_hardware/drivers/can_bus/can_messenger.py +++ b/hardware/opentrons_hardware/drivers/can_bus/can_messenger.py @@ -379,7 +379,6 @@ async def _read_task(self) -> None: handled = False for listener, filter in self._listeners.values(): if filter and not filter(message.arbitration_id): - log.debug("message ignored by filter") continue listener(message_definition(payload=build), message.arbitration_id) # type: ignore[arg-type] handled = True diff --git a/hardware/opentrons_hardware/firmware_bindings/constants.py b/hardware/opentrons_hardware/firmware_bindings/constants.py index 5c9ec46d806..cd91ced91b7 100644 --- a/hardware/opentrons_hardware/firmware_bindings/constants.py +++ b/hardware/opentrons_hardware/firmware_bindings/constants.py @@ -338,6 +338,8 @@ class SensorId(int, Enum): S0 = 0x0 S1 = 0x1 + UNUSED = 0x2 + BOTH = 0x3 @unique diff --git a/hardware/opentrons_hardware/firmware_bindings/messages/messages.py b/hardware/opentrons_hardware/firmware_bindings/messages/messages.py index 6611edecfe4..9906aa8dc07 100644 --- a/hardware/opentrons_hardware/firmware_bindings/messages/messages.py +++ b/hardware/opentrons_hardware/firmware_bindings/messages/messages.py @@ -111,6 +111,7 @@ defs.GetHepaUVStateResponse, defs.SendAccumulatedPressureDataRequest, defs.AddSensorLinearMoveRequest, + defs.SendAccumulatedPressureDataRequest, ] diff --git a/hardware/opentrons_hardware/firmware_bindings/messages/payloads.py b/hardware/opentrons_hardware/firmware_bindings/messages/payloads.py index f4bca8cb881..c351495ba5b 100644 --- a/hardware/opentrons_hardware/firmware_bindings/messages/payloads.py +++ b/hardware/opentrons_hardware/firmware_bindings/messages/payloads.py @@ -665,6 +665,7 @@ class GetHepaFanStatePayloadResponse(EmptyPayload): duty_cycle: utils.UInt32Field fan_on: utils.UInt8Field + fan_rpm: utils.UInt16Field @dataclass(eq=False) diff --git a/hardware/opentrons_hardware/hardware_control/hepa_uv_settings.py b/hardware/opentrons_hardware/hardware_control/hepa_uv_settings.py index 0716a4f4c90..2812cdf3f7d 100644 --- a/hardware/opentrons_hardware/hardware_control/hepa_uv_settings.py +++ b/hardware/opentrons_hardware/hardware_control/hepa_uv_settings.py @@ -35,6 +35,7 @@ class HepaFanState: fan_on: bool duty_cycle: int + fan_rpm: int @dataclass(frozen=True) @@ -80,6 +81,7 @@ def _listener(message: MessageDefinition, arb_id: ArbitrationId) -> None: fan_state = HepaFanState( fan_on=bool(message.payload.fan_on.value), duty_cycle=int(message.payload.duty_cycle.value), + fan_rpm=int(message.payload.fan_rpm.value), ) def _filter(arb_id: ArbitrationId) -> bool: diff --git a/hardware/opentrons_hardware/hardware_control/motion.py b/hardware/opentrons_hardware/hardware_control/motion.py index 5d38a763ca1..4b482cf01a3 100644 --- a/hardware/opentrons_hardware/hardware_control/motion.py +++ b/hardware/opentrons_hardware/hardware_control/motion.py @@ -1,5 +1,5 @@ """A collection of motions that define a single move.""" -from typing import List, Dict, Iterable, Union +from typing import List, Dict, Iterable, Union, Optional from dataclasses import dataclass import numpy as np from logging import getLogger @@ -8,6 +8,7 @@ NodeId, PipetteTipActionType, MoveStopCondition as MoveStopCondition, + SensorId, ) LOG = getLogger(__name__) @@ -52,6 +53,7 @@ class MoveGroupSingleAxisStep: acceleration_mm_sec_sq: np.float64 = np.float64(0) stop_condition: MoveStopCondition = MoveStopCondition.none move_type: MoveType = MoveType.linear + sensor_id: Optional[SensorId] = None def is_moving_step(self) -> bool: """Check if this step involves any actual movement.""" @@ -131,6 +133,7 @@ def create_step( duration: np.float64, present_nodes: Iterable[NodeId], stop_condition: MoveStopCondition = MoveStopCondition.none, + sensor_to_use: Optional[SensorId] = None, ) -> MoveGroupStep: """Create a move from a block. @@ -157,6 +160,7 @@ def create_step( duration_sec=duration, stop_condition=stop_condition, move_type=MoveType.get_move_type(stop_condition), + sensor_id=sensor_to_use, ) return step diff --git a/hardware/opentrons_hardware/hardware_control/motor_enable_disable.py b/hardware/opentrons_hardware/hardware_control/motor_enable_disable.py index 9928b841da9..32897d16679 100644 --- a/hardware/opentrons_hardware/hardware_control/motor_enable_disable.py +++ b/hardware/opentrons_hardware/hardware_control/motor_enable_disable.py @@ -122,7 +122,9 @@ def _listener(message: MessageDefinition, arb_id: ArbitrationId) -> None: ) else: log.debug("Read motor status terminated, no missing nodes.") - return reported + finally: + can_messenger.remove_listener(_listener) + return reported async def get_tip_motor_enabled( diff --git a/hardware/opentrons_hardware/hardware_control/move_group_runner.py b/hardware/opentrons_hardware/hardware_control/move_group_runner.py index b5ab03db8fc..4b7f409b38b 100644 --- a/hardware/opentrons_hardware/hardware_control/move_group_runner.py +++ b/hardware/opentrons_hardware/hardware_control/move_group_runner.py @@ -24,7 +24,6 @@ GearMotorId, MoveAckId, MotorDriverErrorCode, - SensorId, ) from opentrons_hardware.drivers.can_bus.can_messenger import CanMessenger from opentrons_hardware.firmware_bindings.messages import MessageDefinition @@ -308,6 +307,7 @@ def _get_stepper_motor_message( return HomeRequest(payload=home_payload) elif step.move_type == MoveType.sensor: # stop_condition = step.stop_condition.value + assert step.sensor_id is not None stop_condition = MoveStopCondition.sync_line sensor_move_payload = AddSensorLinearMoveBasePayload( request_stop_condition=MoveStopConditionField(stop_condition), @@ -328,7 +328,7 @@ def _get_stepper_motor_message( velocity_mm=Int32Field( int((step.velocity_mm_sec / interrupts_per_sec) * (2**31)) ), - sensor_id=SensorIdField(SensorId.S0), + sensor_id=SensorIdField(step.sensor_id), ) return AddSensorLinearMoveRequest(payload=sensor_move_payload) else: diff --git a/hardware/opentrons_hardware/hardware_control/tool_sensors.py b/hardware/opentrons_hardware/hardware_control/tool_sensors.py index 94301464f22..ee1bc46c676 100644 --- a/hardware/opentrons_hardware/hardware_control/tool_sensors.py +++ b/hardware/opentrons_hardware/hardware_control/tool_sensors.py @@ -77,6 +77,7 @@ def _build_pass_step( distance: Dict[NodeId, float], speed: Dict[NodeId, float], stop_condition: MoveStopCondition = MoveStopCondition.sync_line, + sensor_to_use: Optional[SensorId] = None, ) -> MoveGroupStep: pipette_nodes = [ i for i in movers if i in [NodeId.pipette_left, NodeId.pipette_right] @@ -105,6 +106,7 @@ def _build_pass_step( duration=float64(abs(distance[movers[0]] / speed[movers[0]])), present_nodes=pipette_nodes, stop_condition=MoveStopCondition.sensor_report, + sensor_to_use=sensor_to_use, ) for node in pipette_nodes: move_group[node] = pipette_move[node] @@ -114,82 +116,176 @@ def _build_pass_step( async def run_sync_buffer_to_csv( messenger: CanMessenger, sensor_driver: SensorDriver, - pressure_sensor: PressureSensor, mount_speed: float, plunger_speed: float, threshold_pascals: float, head_node: NodeId, move_group: MoveGroupRunner, - log_file: str, + log_files: Dict[SensorId, str], tool: PipetteProbeTarget, - sensor_id: SensorId, ) -> Dict[NodeId, MotorPositionStatus]: """Runs the sensor pass move group and creates a csv file with the results.""" sensor_metadata = [0, 0, mount_speed, plunger_speed, threshold_pascals] - sensor_capturer = LogListener( - mount=head_node, - data_file=log_file, - file_heading=pressure_output_file_heading, - sensor_metadata=sensor_metadata, - ) - async with sensor_capturer: - print("starting move group runner") - positions = await move_group.run(can_messenger=messenger) - messenger.add_listener(sensor_capturer, None) + positions = await move_group.run(can_messenger=messenger) + for sensor_id in log_files.keys(): + sensor_capturer = LogListener( + mount=head_node, + data_file=log_files[sensor_id], + file_heading=pressure_output_file_heading, + sensor_metadata=sensor_metadata, + ) + async with sensor_capturer: + messenger.add_listener(sensor_capturer, None) + await messenger.send( + node_id=tool, + message=SendAccumulatedPressureDataRequest( + payload=SendAccumulatedPressureDataPayload( + sensor_id=SensorIdField(sensor_id) + ) + ), + ) + await asyncio.sleep(10) + messenger.remove_listener(sensor_capturer) await messenger.send( node_id=tool, - message=SendAccumulatedPressureDataRequest( - payload=SendAccumulatedPressureDataPayload( - sensor_id=SensorIdField(sensor_id) + message=BindSensorOutputRequest( + payload=BindSensorOutputRequestPayload( + sensor=SensorTypeField(SensorType.pressure), + sensor_id=SensorIdField(sensor_id), + binding=SensorOutputBindingField(SensorOutputBinding.none), ) ), ) - await asyncio.sleep(10) - messenger.remove_listener(sensor_capturer) - await messenger.send( - node_id=tool, - message=BindSensorOutputRequest( - payload=BindSensorOutputRequestPayload( - sensor=SensorTypeField(SensorType.pressure), - sensor_id=SensorIdField(sensor_id), - binding=SensorOutputBindingField(SensorOutputBinding.none), - ) - ), - ) return positions async def run_stream_output_to_csv( messenger: CanMessenger, sensor_driver: SensorDriver, - pressure_sensor: PressureSensor, + pressure_sensors: Dict[SensorId, PressureSensor], mount_speed: float, plunger_speed: float, threshold_pascals: float, head_node: NodeId, move_group: MoveGroupRunner, - log_file: str, + log_files: Dict[SensorId, str], ) -> Dict[NodeId, MotorPositionStatus]: """Runs the sensor pass move group and creates a csv file with the results.""" sensor_metadata = [0, 0, mount_speed, plunger_speed, threshold_pascals] sensor_capturer = LogListener( mount=head_node, - data_file=log_file, + data_file=log_files[ + next(iter(log_files)) + ], # hardcode to the first file, need to think more on this file_heading=pressure_output_file_heading, sensor_metadata=sensor_metadata, ) binding = [SensorOutputBinding.sync, SensorOutputBinding.report] + binding_field = SensorOutputBindingField.from_flags(binding) + for sensor_id in pressure_sensors.keys(): + sensor_info = pressure_sensors[sensor_id].sensor + await messenger.send( + node_id=sensor_info.node_id, + message=BindSensorOutputRequest( + payload=BindSensorOutputRequestPayload( + sensor=SensorTypeField(sensor_info.sensor_type), + sensor_id=SensorIdField(sensor_info.sensor_id), + binding=binding_field, + ) + ), + ) - async with sensor_driver.bind_output(messenger, pressure_sensor, binding): - messenger.add_listener(sensor_capturer, None) - - async with sensor_capturer: - positions = await move_group.run(can_messenger=messenger) - messenger.remove_listener(sensor_capturer) + messenger.add_listener(sensor_capturer, None) + async with sensor_capturer: + positions = await move_group.run(can_messenger=messenger) + messenger.remove_listener(sensor_capturer) + for sensor_id in pressure_sensors.keys(): + sensor_info = pressure_sensors[sensor_id].sensor + await messenger.send( + node_id=sensor_info.node_id, + message=BindSensorOutputRequest( + payload=BindSensorOutputRequestPayload( + sensor=SensorTypeField(sensor_info.sensor_type), + sensor_id=SensorIdField(sensor_info.sensor_id), + binding=SensorOutputBindingField(SensorOutputBinding.none), + ) + ), + ) return positions +async def _setup_pressure_sensors( + messenger: CanMessenger, + sensor_id: SensorId, + tool: PipetteProbeTarget, + num_baseline_reads: int, + threshold_fixed_point: float, + sensor_driver: SensorDriver, + auto_zero_sensor: bool, +) -> Dict[SensorId, PressureSensor]: + sensors: List[SensorId] = [] + result: Dict[SensorId, PressureSensor] = {} + if sensor_id == SensorId.BOTH: + sensors.append(SensorId.S0) + sensors.append(SensorId.S1) + else: + sensors.append(sensor_id) + + for sensor in sensors: + pressure_sensor = PressureSensor.build( + sensor_id=sensor_id, + node_id=tool, + stop_threshold=threshold_fixed_point, + ) + + if auto_zero_sensor: + pressure_baseline = await sensor_driver.get_baseline( + messenger, pressure_sensor, num_baseline_reads + ) + LOG.debug(f"found baseline pressure: {pressure_baseline} pascals") + + await sensor_driver.send_stop_threshold(messenger, pressure_sensor) + result[sensor] = pressure_sensor + return result + + +async def _run_with_binding( + messenger: CanMessenger, + pressure_sensors: Dict[SensorId, PressureSensor], + sensor_runner: MoveGroupRunner, + binding: List[SensorOutputBinding], +) -> Dict[NodeId, MotorPositionStatus]: + binding_field = SensorOutputBindingField.from_flags(binding) + for sensor_id in pressure_sensors.keys(): + sensor_info = pressure_sensors[sensor_id].sensor + await messenger.send( + node_id=sensor_info.node_id, + message=BindSensorOutputRequest( + payload=BindSensorOutputRequestPayload( + sensor=SensorTypeField(sensor_info.sensor_type), + sensor_id=SensorIdField(sensor_info.sensor_id), + binding=binding_field, + ) + ), + ) + + result = await sensor_runner.run(can_messenger=messenger) + for sensor_id in pressure_sensors.keys(): + sensor_info = pressure_sensors[sensor_id].sensor + await messenger.send( + node_id=sensor_info.node_id, + message=BindSensorOutputRequest( + payload=BindSensorOutputRequestPayload( + sensor=SensorTypeField(sensor_info.sensor_type), + sensor_id=SensorIdField(sensor_info.sensor_id), + binding=SensorOutputBindingField(SensorOutputBinding.none), + ) + ), + ) + return result + + async def liquid_probe( messenger: CanMessenger, tool: PipetteProbeTarget, @@ -201,83 +297,68 @@ async def liquid_probe( csv_output: bool = False, sync_buffer_output: bool = False, can_bus_only_output: bool = False, - # output_option: OutputOptions, - data_file: Optional[str] = None, + data_files: Optional[Dict[SensorId, str]] = None, auto_zero_sensor: bool = True, num_baseline_reads: int = 10, sensor_id: SensorId = SensorId.S0, ) -> Dict[NodeId, MotorPositionStatus]: """Move the mount and pipette simultaneously while reading from the pressure sensor.""" + log_files: Dict[SensorId, str] = {} if not data_files else data_files sensor_driver = SensorDriver() threshold_fixed_point = threshold_pascals * sensor_fixed_point_conversion - pressure_sensor = PressureSensor.build( - sensor_id=sensor_id, - node_id=tool, - stop_threshold=threshold_fixed_point, + pressure_sensors = await _setup_pressure_sensors( + messenger, + sensor_id, + tool, + num_baseline_reads, + threshold_fixed_point, + sensor_driver, + auto_zero_sensor, ) - if auto_zero_sensor: - pressure_baseline = await sensor_driver.get_baseline( - messenger, pressure_sensor, num_baseline_reads - ) - LOG.debug(f"found baseline pressure: {pressure_baseline} pascals") - - await sensor_driver.send_stop_threshold(messenger, pressure_sensor) - sensor_group = _build_pass_step( movers=[head_node, tool], distance={head_node: max_z_distance, tool: max_z_distance}, speed={head_node: mount_speed, tool: plunger_speed}, stop_condition=MoveStopCondition.sync_line, + sensor_to_use=sensor_id, ) sensor_runner = MoveGroupRunner(move_groups=[[sensor_group]]) - log_file: str = "/var/pressure_sensor_data.csv" if not data_file else data_file if csv_output: return await run_stream_output_to_csv( messenger, sensor_driver, - pressure_sensor, + pressure_sensors, mount_speed, plunger_speed, threshold_pascals, head_node, sensor_runner, - log_file, + log_files, ) elif sync_buffer_output: return await run_sync_buffer_to_csv( messenger, sensor_driver, - pressure_sensor, mount_speed, plunger_speed, threshold_pascals, head_node, sensor_runner, - log_file, - tool=tool, - sensor_id=sensor_id, + log_files, + tool, ) elif can_bus_only_output: - async with sensor_driver.bind_output( - messenger, - pressure_sensor, - [ - SensorOutputBinding.sync, - SensorOutputBinding.report, - ], - ): - return await sensor_runner.run(can_messenger=messenger) + binding = [SensorOutputBinding.sync, SensorOutputBinding.report] + return await _run_with_binding( + messenger, pressure_sensors, sensor_runner, binding + ) else: # none - async with sensor_driver.bind_output( - messenger, - pressure_sensor, - [ - SensorOutputBinding.sync, - ], - ): - return await sensor_runner.run(can_messenger=messenger) + binding = [SensorOutputBinding.sync] + return await _run_with_binding( + messenger, pressure_sensors, sensor_runner, binding + ) async def check_overpressure( diff --git a/hardware/tests/opentrons_hardware/hardware_control/test_hepauv_settings.py b/hardware/tests/opentrons_hardware/hardware_control/test_hepauv_settings.py index 2401aee34b4..dcaf85a8653 100644 --- a/hardware/tests/opentrons_hardware/hardware_control/test_hepauv_settings.py +++ b/hardware/tests/opentrons_hardware/hardware_control/test_hepauv_settings.py @@ -37,12 +37,15 @@ def mock_can_messenger() -> AsyncMock: return AsyncMock() -def create_hepa_fan_state_response(fan_on: bool, duty_cycle: int) -> MessageDefinition: +def create_hepa_fan_state_response( + fan_on: bool, duty_cycle: int, fan_rpm: int +) -> MessageDefinition: """Create a GetHepaFanStateResponse.""" return md.GetHepaFanStateResponse( payload=GetHepaFanStatePayloadResponse( fan_on=UInt8Field(fan_on), duty_cycle=UInt32Field(duty_cycle), + fan_rpm=UInt16Field(fan_rpm), ) ) @@ -111,10 +114,11 @@ async def test_set_hepa_uv_state( @pytest.mark.parametrize( "response", [ - (NodeId.host, create_hepa_fan_state_response(True, 75), NodeId.hepa_uv), - (NodeId.host, create_hepa_fan_state_response(True, 0), NodeId.hepa_uv), - (NodeId.host, create_hepa_fan_state_response(False, 75), NodeId.hepa_uv), - (NodeId.host, create_hepa_fan_state_response(False, 100), NodeId.hepa_uv), + (NodeId.host, create_hepa_fan_state_response(True, 50, 4540), NodeId.hepa_uv), + (NodeId.host, create_hepa_fan_state_response(True, 75, 6790), NodeId.hepa_uv), + (NodeId.host, create_hepa_fan_state_response(True, 0, 0), NodeId.hepa_uv), + (NodeId.host, create_hepa_fan_state_response(False, 75, 0), NodeId.hepa_uv), + (NodeId.host, create_hepa_fan_state_response(False, 100, 0), NodeId.hepa_uv), ], ) async def test_get_hepa_fan_state( @@ -147,6 +151,7 @@ def responder( HepaFanState( bool(payload.fan_on.value), int(payload.duty_cycle.value), + int(payload.fan_rpm.value), ) == res ) diff --git a/hardware/tests/opentrons_hardware/hardware_control/test_tool_sensors.py b/hardware/tests/opentrons_hardware/hardware_control/test_tool_sensors.py index 5db17d16cb4..ba391da2c14 100644 --- a/hardware/tests/opentrons_hardware/hardware_control/test_tool_sensors.py +++ b/hardware/tests/opentrons_hardware/hardware_control/test_tool_sensors.py @@ -50,7 +50,7 @@ SensorOutputBinding, ) from opentrons_hardware.sensors.scheduler import SensorScheduler -from opentrons_hardware.sensors.sensor_driver import LogListener, SensorDriver +from opentrons_hardware.sensors.sensor_driver import SensorDriver from opentrons_hardware.sensors.types import SensorDataType from opentrons_hardware.sensors.sensor_types import SensorInformation from opentrons_hardware.sensors.utils import SensorThresholdInformation @@ -193,35 +193,6 @@ def move_responder( data=SensorDataType.build(threshold_pascals * 65536, sensor_info.sensor_type), mode=SensorThresholdMode.absolute, ) - mock_bind_output.assert_called_once() - assert mock_bind_output.call_args_list[0][0][3] == [SensorOutputBinding.sync] - - with patch( - "opentrons_hardware.hardware_control.tool_sensors", LogListener - ) as mock_log: - - mock_log.__aenter__ = AsyncMock(return_value=mock_log) # type: ignore - mock_log.__aexit__ = AsyncMock(return_value=None) # type: ignore - - await liquid_probe( - messenger=mock_messenger, - tool=target_node, - head_node=motor_node, - max_z_distance=40, - mount_speed=10, - plunger_speed=8, - threshold_pascals=threshold_pascals, - csv_output=False, - sync_buffer_output=False, - can_bus_only_output=False, - auto_zero_sensor=True, - num_baseline_reads=8, - sensor_id=SensorId.S0, - ) - mock_bind_output.assert_called() - assert mock_bind_output.call_args_list[1][0][3] == [ - SensorOutputBinding.sync, - ] @pytest.mark.parametrize( diff --git a/labware-designer/webpack.config.js b/labware-designer/webpack.config.js deleted file mode 100644 index aec3b7cc0cb..00000000000 --- a/labware-designer/webpack.config.js +++ /dev/null @@ -1,26 +0,0 @@ -'use strict' - -const path = require('path') -const webpackMerge = require('webpack-merge') -const HtmlWebpackPlugin = require('html-webpack-plugin') -const ScriptExtHtmlWebpackPlugin = require('script-ext-html-webpack-plugin') - -const { baseConfig } = require('@opentrons/webpack-config') -const { productName: title, description, author } = require('./package.json') - -const JS_ENTRY = path.join(__dirname, 'src/index.tsx') -const HTML_ENTRY = path.join(__dirname, 'src/index.hbs') -const OUTPUT_PATH = path.join(__dirname, 'dist') - -module.exports = webpackMerge(baseConfig, { - entry: [JS_ENTRY], - - output: { - path: OUTPUT_PATH, - }, - - plugins: [ - new HtmlWebpackPlugin({ title, description, author, template: HTML_ENTRY }), - new ScriptExtHtmlWebpackPlugin({ defaultAttribute: 'defer' }), - ], -}) diff --git a/labware-library/webpack.config.js b/labware-library/webpack.config.js deleted file mode 100644 index c5fb0d8c7e8..00000000000 --- a/labware-library/webpack.config.js +++ /dev/null @@ -1,67 +0,0 @@ -'use strict' -const path = require('path') -const webpack = require('webpack') -const merge = require('webpack-merge') -const HtmlWebpackPlugin = require('html-webpack-plugin') -// const glob = require('glob') - -const { baseConfig } = require('@opentrons/webpack-config') -// const {baseConfig, DEV_MODE} = require('@opentrons/webpack-config') -const pkg = require('./package.json') - -const { versionForProject } = require('../scripts/git-version') - -const JS_ENTRY = path.join(__dirname, 'src/index.tsx') -const HTML_ENTRY = path.join(__dirname, 'src/index.hbs') -const OUT_PATH = path.join(__dirname, 'dist') - -const LABWARE_LIBRARY_ENV_VAR_PREFIX = 'OT_LL' - -const passThruEnvVars = Object.keys(process.env) - .filter(v => v.startsWith(LABWARE_LIBRARY_ENV_VAR_PREFIX)) - .concat(['NODE_ENV', 'CYPRESS']) - -const testAliases = - process.env.CYPRESS === '1' - ? { - 'file-saver': path.resolve(__dirname, 'cypress/mocks/file-saver.js'), - } - : {} - -module.exports = async () => { - const envVarsWithDefaults = { - OT_LL_VERSION: await versionForProject('labware-library'), - OT_LL_BUILD_DATE: new Date().toUTCString(), - } - - const envVars = passThruEnvVars.reduce( - (acc, envVar) => ({ [envVar]: '', ...acc }), - { ...envVarsWithDefaults } - ) - - return merge(baseConfig, { - entry: JS_ENTRY, - - output: { - path: OUT_PATH, - publicPath: '/', - }, - - plugins: [ - new webpack.EnvironmentPlugin(envVars), - - new HtmlWebpackPlugin({ - template: HTML_ENTRY, - title: pkg.productName, - description: pkg.description, - author: pkg.author.name, - gtmId: process.env.GTM_ID, - favicon: './src/images/favicon.ico', - }), - ], - - resolve: { - alias: testAliases, - }, - }) -} diff --git a/opentrons-ai-client/Makefile b/opentrons-ai-client/Makefile new file mode 100644 index 00000000000..9c15fa32e41 --- /dev/null +++ b/opentrons-ai-client/Makefile @@ -0,0 +1,59 @@ +# opentrons ai client makefile + +# using bash instead of /bin/bash in SHELL prevents macOS optimizing away our PATH update +SHELL := bash + +# add node_modules/.bin to PATH +PATH := $(shell cd .. && yarn bin):$(PATH) + +benchmark_output := $(shell node -e 'console.log(new Date());') + +# These variables can be overriden when make is invoked to customize the +# behavior of jest +tests ?= +cov_opts ?= --coverage=true +test_opts ?= + +# standard targets +##################################################################### + +.PHONY: all +all: clean build + +.PHONY: setup +setup: + yarn + +.PHONY: clean +clean: + shx rm -rf dist + +# artifacts +##################################################################### + +.PHONY: build +build: export NODE_ENV := production +build: + vite build + git rev-parse HEAD > dist/.commit + +# development +##################################################################### + +.PHONY: dev +dev: export NODE_ENV := development +dev: + vite serve + +# production assets server +.PHONY: serve +serve: all + node ../scripts/serve-static dist + +.PHONY: test +test: + $(MAKE) -C .. test-js-ai-client tests="$(tests)" test_opts="$(test_opts)" + +.PHONY: test-cov +test-cov: + make -C .. test-js-ai-client tests=$(tests) test_opts="$(test_opts)" cov_opts="$(cov_opts)" diff --git a/opentrons-ai-client/README.md b/opentrons-ai-client/README.md new file mode 100644 index 00000000000..d4c80c2bb23 --- /dev/null +++ b/opentrons-ai-client/README.md @@ -0,0 +1,63 @@ +# Opentrons AI Frontend + +[![JavaScript Style Guide][style-guide-badge]][style-guide] + +## Overview + +The Opentrons AI application helps you to create a protocol with natural language. + +## Developing + +To get started: clone the `Opentrons/opentrons` repository, set up your computer for development as specified in the [contributing guide][contributing-guide-setup], and then: + +```shell +# change into the cloned directory +cd opentrons +# prerequisite: install dependencies as specified in project setup +make setup +# launch the dev server +make -C opentrons-ai-client dev +``` + +## Stack and structure + +The UI stack is built using: + +- [React][] +- [Babel][] +- [Vite][] + +Some important directories: + +- [opentrons-ai-server][] — Opentrons AI application's server + +## Copy management + +We use [i18next](https://www.i18next.com) for copy management and internationalization. + +## Testing + +Tests for the Opentrons App are run from the top level along with all other JS project tests. + +- `make test-js` - Run all JavaScript tests + +Test tasks can also be run with the following arguments: + +| Argument | Default | Description | Example | +| -------- | -------- | ----------------------- | --------------------------------- | +| watch | `false` | Run tests in watch mode | `make test-unit watch=true` | +| cover | `!watch` | Calculate code coverage | `make test watch=true cover=true` | + +## Building + +TBD + +[style-guide]: https://standardjs.com +[style-guide-badge]: https://img.shields.io/badge/code_style-standard-brightgreen.svg?style=flat-square&maxAge=3600 +[contributing-guide-setup]: ../CONTRIBUTING.md#development-setup +[contributing-guide-running-the-api]: ../CONTRIBUTING.md#opentrons-api +[react]: https://react.dev/ +[babel]: https://babeljs.io/ +[vite]: https://vitejs.dev/ +[bundle-analyzer]: https://github.com/webpack-contrib/webpack-bundle-analyzer +[opentrons-ai-server]: https://github.com/Opentrons/opentrons/tree/edge/opentrons-ai-server diff --git a/opentrons-ai-client/babel.config.cjs b/opentrons-ai-client/babel.config.cjs new file mode 100644 index 00000000000..11739e6bf00 --- /dev/null +++ b/opentrons-ai-client/babel.config.cjs @@ -0,0 +1,21 @@ +'use strict' + +module.exports = { + env: { + // Must have babel-plugin-styled-components in each env, + // see here for further details: s https://styled-components.com/docs/tooling#babel-plugin + production: { + plugins: ['babel-plugin-styled-components', 'babel-plugin-unassert'], + }, + development: { + plugins: ['babel-plugin-styled-components'], + }, + test: { + plugins: [ + // disable ssr, displayName to fix toHaveStyleRule + // https://github.com/styled-components/jest-styled-components/issues/294 + ['babel-plugin-styled-components', { ssr: false, displayName: false }], + ], + }, + }, +} diff --git a/opentrons-ai-client/index.html b/opentrons-ai-client/index.html new file mode 100644 index 00000000000..57e7f83f591 --- /dev/null +++ b/opentrons-ai-client/index.html @@ -0,0 +1,13 @@ + + + + + + + Opentrons AI + + +
+ + + diff --git a/opentrons-ai-client/package.json b/opentrons-ai-client/package.json new file mode 100644 index 00000000000..d8ea50136ff --- /dev/null +++ b/opentrons-ai-client/package.json @@ -0,0 +1,39 @@ +{ + "name": "opentrons-ai-client", + "type": "module", + "version": "0.0.0-dev", + "description": "Opentrons AI application UI", + "source": "src/index.tsx", + "types": "lib/index.d.ts", + "repository": { + "type": "git", + "url": "https://github.com/Opentrons/opentrons.git" + }, + "author": { + "name": "Opentrons Labworks", + "email": "engineering@opentrons.com" + }, + "license": "Apache-2.0", + "bugs": { + "url": "https://github.com/Opentrons/opentrons/issues" + }, + "homepage": "https://github.com/Opentrons/opentrons", + "dependencies": { + "@fontsource/public-sans": "5.0.3", + "@opentrons/components": "link:../components", + "i18next": "^19.8.3", + "react": "18.2.0", + "react-dom": "18.2.0", + "react-error-boundary": "^4.0.10", + "react-hook-form": "7.50.1", + "react-i18next": "13.5.0", + "react-markdown": "9.0.1", + "styled-components": "5.3.6" + }, + "engines": { + "node": ">=18.19.0" + }, + "devDependencies": { + "@types/styled-components": "^5.1.26" + } +} diff --git a/opentrons-ai-client/src/App.test.tsx b/opentrons-ai-client/src/App.test.tsx new file mode 100644 index 00000000000..4ae3494a53c --- /dev/null +++ b/opentrons-ai-client/src/App.test.tsx @@ -0,0 +1,29 @@ +import React from 'react' +import { screen } from '@testing-library/react' +import { describe, it, vi, beforeEach } from 'vitest' + +import { renderWithProviders } from './__testing-utils__' +import { SidePanel } from './molecules/SidePanel' +import { ChatContainer } from './organisms/ChatContainer' + +import { App } from './App' + +vi.mock('./molecules/SidePanel') +vi.mock('./organisms/ChatContainer') + +const render = (): ReturnType => { + return renderWithProviders() +} + +describe('App', () => { + beforeEach(() => { + vi.mocked(SidePanel).mockReturnValue(
mock SidePanel
) + vi.mocked(ChatContainer).mockReturnValue(
mock ChatContainer
) + }) + + it('should render text', () => { + render() + screen.getByText('mock SidePanel') + screen.getByText('mock ChatContainer') + }) +}) diff --git a/opentrons-ai-client/src/App.tsx b/opentrons-ai-client/src/App.tsx new file mode 100644 index 00000000000..268a61b2e7f --- /dev/null +++ b/opentrons-ai-client/src/App.tsx @@ -0,0 +1,14 @@ +import React from 'react' +import { DIRECTION_ROW, Flex } from '@opentrons/components' + +import { SidePanel } from './molecules/SidePanel' +import { ChatContainer } from './organisms/ChatContainer' + +export function App(): JSX.Element { + return ( + + + + + ) +} diff --git a/opentrons-ai-client/src/__testing-utils__/index.ts b/opentrons-ai-client/src/__testing-utils__/index.ts new file mode 100644 index 00000000000..e17c0ffbc31 --- /dev/null +++ b/opentrons-ai-client/src/__testing-utils__/index.ts @@ -0,0 +1,2 @@ +export * from './renderWithProviders' +export * from './matchers' diff --git a/opentrons-ai-client/src/__testing-utils__/matchers.ts b/opentrons-ai-client/src/__testing-utils__/matchers.ts new file mode 100644 index 00000000000..66234dbc915 --- /dev/null +++ b/opentrons-ai-client/src/__testing-utils__/matchers.ts @@ -0,0 +1,24 @@ +import type { Matcher } from '@testing-library/react' + +// Match things like

Some nested text

+// Use with either string match: getByText(nestedTextMatcher("Some nested text")) +// or regexp: getByText(nestedTextMatcher(/Some nested text/)) +export const nestedTextMatcher = (textMatch: string | RegExp): Matcher => ( + content, + node +) => { + const hasText = (n: typeof node): boolean => { + if (n == null || n.textContent === null) return false + return typeof textMatch === 'string' + ? Boolean(n?.textContent.match(textMatch)) + : textMatch.test(n.textContent) + } + const nodeHasText = hasText(node) + const childrenDontHaveText = + node != null && Array.from(node.children).every(child => !hasText(child)) + + return nodeHasText && childrenDontHaveText +} + +// need componentPropsMatcher +// need partialComponentPropsMatcher diff --git a/opentrons-ai-client/src/__testing-utils__/renderWithProviders.tsx b/opentrons-ai-client/src/__testing-utils__/renderWithProviders.tsx new file mode 100644 index 00000000000..65a2e01855e --- /dev/null +++ b/opentrons-ai-client/src/__testing-utils__/renderWithProviders.tsx @@ -0,0 +1,53 @@ +// render using targetted component using @testing-library/react +// with wrapping providers for i18next and redux +import * as React from 'react' +import { QueryClient, QueryClientProvider } from 'react-query' +import { I18nextProvider } from 'react-i18next' +import { Provider } from 'react-redux' +import { vi } from 'vitest' +import { render } from '@testing-library/react' +import { createStore } from 'redux' + +import type { PreloadedState, Store } from 'redux' +import type { RenderOptions, RenderResult } from '@testing-library/react' + +export interface RenderWithProvidersOptions extends RenderOptions { + initialState?: State + i18nInstance: React.ComponentProps['i18n'] +} + +export function renderWithProviders( + Component: React.ReactElement, + options?: RenderWithProvidersOptions +): [RenderResult, Store] { + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + const { initialState = {}, i18nInstance = null } = options || {} + + const store: Store = createStore( + vi.fn(), + initialState as PreloadedState + ) + store.dispatch = vi.fn() + store.getState = vi.fn(() => initialState) as () => State + + const queryClient = new QueryClient() + + const ProviderWrapper: React.ComponentType> = ({ + children, + }) => { + const BaseWrapper = ( + + {children} + + ) + if (i18nInstance != null) { + return ( + {BaseWrapper} + ) + } else { + return BaseWrapper + } + } + + return [render(Component, { wrapper: ProviderWrapper }), store] +} diff --git a/opentrons-ai-client/src/assets/images/opentrons_logo.svg b/opentrons-ai-client/src/assets/images/opentrons_logo.svg new file mode 100644 index 00000000000..b183d161e81 --- /dev/null +++ b/opentrons-ai-client/src/assets/images/opentrons_logo.svg @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/opentrons-ai-client/src/assets/localization/en/index.ts b/opentrons-ai-client/src/assets/localization/en/index.ts new file mode 100644 index 00000000000..b5aa26621dd --- /dev/null +++ b/opentrons-ai-client/src/assets/localization/en/index.ts @@ -0,0 +1,7 @@ +import shared from './shared.json' +import protocol_generator from './protocol_generator.json' + +export const en = { + shared, + protocol_generator, +} diff --git a/opentrons-ai-client/src/assets/localization/en/protocol_generator.json b/opentrons-ai-client/src/assets/localization/en/protocol_generator.json new file mode 100644 index 00000000000..7911774f748 --- /dev/null +++ b/opentrons-ai-client/src/assets/localization/en/protocol_generator.json @@ -0,0 +1,28 @@ +{ + "api": "API: An API level is 2.15", + "application": "Application: Your protocol's name, describing what it does.", + "commands": "Commands: List the protocol's steps, specifying quantities in microliters and giving exact source and destination locations.", + "disclaimer": "OpentronsAI can make mistakes. Review your protocol before running it on an Opentrons robot.", + "got_feedback": "Got feedback? We love to hear it.", + "make_sure_your_prompt": "Make sure your prompt includes the following:", + "metadata": "Metadata: Three pieces of information.", + "modules": "Modules: Thermocycler or Temperature Module.", + "opentronsai_asks": "OpentronsAI asks you to provide it!", + "opentronsai": "OpentronsAI", + "ot2_pipettes": "OT-2 pipettes: Include volume, number of channels, and generation.", + "prc_flex": "PCR (Flex)", + "prc": "PCR", + "reagent_transfer_flex": "Reagent Transfer (Flex)", + "reagent_transfer": "Reagent Transfer", + "robot": "Robot: OT-2.", + "share_your_thoughts": "Share your thoughts here", + "side_panel_body": "Write a prompt in natural language to generate a Reagent Transfer or a PCR protocol for the OT-2 or Opentrons Flex using the Opentrons Python Protocol API.", + "side_panel_header": "Use natural language to generate protocols with OpentronsAI powered by OpenAI", + "tipracks_and_labware": "Tip racks and labware: Use names from the Opentrons Labware Library.", + "try_example_prompts": "Stuck? Try these example prompts to get started.", + "type_your_prompt": "Type your prompt...", + "well_allocations": "Well allocations: Describe where liquids should go in labware.", + "what_if_you": "What if you don’t provide all of those pieces of information? OpentronsAI asks you to provide it!", + "what_typeof_protocol": "What type of protocol do you need?", + "you": "You" +} diff --git a/opentrons-ai-client/src/assets/localization/en/shared.json b/opentrons-ai-client/src/assets/localization/en/shared.json new file mode 100644 index 00000000000..46cb365873f --- /dev/null +++ b/opentrons-ai-client/src/assets/localization/en/shared.json @@ -0,0 +1,3 @@ +{ + "send": "Send" +} diff --git a/opentrons-ai-client/src/assets/localization/index.ts b/opentrons-ai-client/src/assets/localization/index.ts new file mode 100644 index 00000000000..e92a7077ed9 --- /dev/null +++ b/opentrons-ai-client/src/assets/localization/index.ts @@ -0,0 +1,5 @@ +import { en } from './en' + +export const resources = { + en, +} diff --git a/opentrons-ai-client/src/atoms/GlobalStyle/index.ts b/opentrons-ai-client/src/atoms/GlobalStyle/index.ts new file mode 100644 index 00000000000..782a2a0b91b --- /dev/null +++ b/opentrons-ai-client/src/atoms/GlobalStyle/index.ts @@ -0,0 +1,34 @@ +import { createGlobalStyle } from 'styled-components' +import { COLORS } from '@opentrons/components' +import '@fontsource/public-sans' +import '@fontsource/public-sans/600.css' +import '@fontsource/public-sans/700.css' + +export const GlobalStyle = createGlobalStyle` + * { + box-sizing: border-box; + margin: 0; + padding: 0; + font-family: 'Public Sans', 'sans-serif'; + } + + html, + body { + width: 100%; + height: 100%; + color: ${COLORS.black90}; + } + + a { + text-decoration: none; + } + + button { + border: none; + + &:focus, + &:active { + outline: 0; + } + } +` diff --git a/opentrons-ai-client/src/i18n.ts b/opentrons-ai-client/src/i18n.ts new file mode 100644 index 00000000000..0f7ef3bf6df --- /dev/null +++ b/opentrons-ai-client/src/i18n.ts @@ -0,0 +1,45 @@ +import i18n from 'i18next' +import capitalize from 'lodash/capitalize' +import startCase from 'lodash/startCase' +import { initReactI18next } from 'react-i18next' +import { resources } from './assets/localization' +import { titleCase } from '@opentrons/shared-data' + +i18n.use(initReactI18next).init( + { + resources, + lng: 'en', + fallbackLng: 'en', + debug: process.env.NODE_ENV === 'development', + ns: ['shared'], + defaultNS: 'shared', + interpolation: { + escapeValue: false, // not needed for react as it escapes by default + format: function (value, format, lng) { + if (format === 'upperCase') return value.toUpperCase() + if (format === 'lowerCase') return value.toLowerCase() + if (format === 'capitalize') return capitalize(value) + if (format === 'sentenceCase') return startCase(value) + if (format === 'titleCase') return titleCase(value) + return value + }, + }, + keySeparator: false, // use namespaces and context instead + saveMissing: true, + missingKeyHandler: (lng, ns, key) => { + process.env.NODE_ENV === 'test' + ? console.error(`Missing ${lng} Translation: key={${key}} ns={${ns}}`) + : console.warn(`Missing ${lng} Translation: key={${key}} ns={${ns}}`) + }, + }, + err => { + if (err) { + console.error( + 'Internationalization was not initialized properly. error: ', + err + ) + } + } +) + +export { i18n } diff --git a/opentrons-ai-client/src/main.tsx b/opentrons-ai-client/src/main.tsx new file mode 100644 index 00000000000..a2f1338bd7b --- /dev/null +++ b/opentrons-ai-client/src/main.tsx @@ -0,0 +1,21 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import { I18nextProvider } from 'react-i18next' +import { GlobalStyle } from './atoms/GlobalStyle' + +import { i18n } from './i18n' +import { App } from './App' + +const rootElement = document.getElementById('root') +if (rootElement != null) { + ReactDOM.createRoot(rootElement).render( + + + + + + + ) +} else { + console.error('Root element not found') +} diff --git a/opentrons-ai-client/src/molecules/ChatDisplay/ChatDisplay.stories.tsx b/opentrons-ai-client/src/molecules/ChatDisplay/ChatDisplay.stories.tsx new file mode 100644 index 00000000000..ae03a25f754 --- /dev/null +++ b/opentrons-ai-client/src/molecules/ChatDisplay/ChatDisplay.stories.tsx @@ -0,0 +1,81 @@ +import React from 'react' +import { I18nextProvider } from 'react-i18next' +import { COLORS, Flex, SPACING } from '@opentrons/components' +import { i18n } from '../../i18n' +import { ChatDisplay } from './index' + +import type { Meta, StoryObj } from '@storybook/react' + +const meta: Meta = { + title: 'AI/molecules/ChatDisplay', + component: ChatDisplay, + decorators: [ + Story => ( + + + + + + ), + ], +} +export default meta +type Story = StoryObj + +export const OpentronsAI: Story = { + args: { + content: ` +## sample output from OpentronsAI + +\`\`\`py +from opentrons import protocol_api +# Metadata +metadata = { + 'protocolName': 'ThermoPrime Taq DNA Polymerase PCR Amplification', + 'author': 'Name ', + 'description': 'PCR amplification using ThermoPrime Taq DNA Polymerase kit', + 'apiLevel': '2.11' +} + +# Protocol run function +def run(protocol: protocol_api.ProtocolContext): + + # Constants + NO_OF_SAMPLES = 41 + SAMPLE_VOL = 3 # uL + MASTERMIX_VOL = 10 # uL + TC_SAMPLE_MASTERMIX_MIXES = 4 + TC_SAMPLE_MASTERMIX_MIX_VOLUME = SAMPLE_VOL + MASTERMIX_VOL + MASTERMIX_BLOCK_TEMP = 10 # degree C + TEMP_DECK_WAIT_TIME = 50 # seconds +\`\`\` +`, + isUserInput: false, + }, +} + +export const User: Story = { + args: { + content: ` + - Application: Reagent transfer + - Robot: OT-2 + - API: 2.13 + + Pipette mount: + - P1000 Single-Channel GEN2 is mounted on left + - P300 Single-Channel GEN2 is mounted on right + + Labware: + - Source Labware: Thermo Scientific Nunc 96 Well Plate 2000 µL on slot 7 + - Destination Labware: Opentrons 24 Well Aluminum Block with NEST 0.5 mL Screwcap on slot 3 + - Tiprack: Opentrons 96 Filter Tip Rack 1000 µL on slot 4 + + Commands: + - Using P1000 Single-Channel GEN2 pipette on left mount, transfer 195.0 uL of reagent + from H10, F12, D7, B1, C8 wells in source labware + to first well in the destination labware. + Use new tip for each transfer. + `, + isUserInput: true, + }, +} diff --git a/opentrons-ai-client/src/molecules/ChatDisplay/__tests__/ChatDisplay.test.tsx b/opentrons-ai-client/src/molecules/ChatDisplay/__tests__/ChatDisplay.test.tsx new file mode 100644 index 00000000000..75b99717abb --- /dev/null +++ b/opentrons-ai-client/src/molecules/ChatDisplay/__tests__/ChatDisplay.test.tsx @@ -0,0 +1,42 @@ +import React from 'react' +import { screen } from '@testing-library/react' +import { describe, it, beforeEach } from 'vitest' +import { renderWithProviders } from '../../../__testing-utils__' +import { i18n } from '../../../i18n' + +import { ChatDisplay } from '../index' + +const render = (props: React.ComponentProps) => { + return renderWithProviders(, { i18nInstance: i18n }) +} + +describe('ChatDisplay', () => { + let props: React.ComponentProps + + beforeEach(() => { + props = { + content: 'mock text from the backend', + isUserInput: false, + } + }) + it('should display response from the backend and label', () => { + render(props) + screen.getByText('OpentronsAI') + screen.getByText('mock text from the backend') + // ToDO (kk:04/16/2024) activate the following when jsdom's issue is solved + // const display = screen.getByTextId('ChatDisplay_from_backend') + // expect(display).toHaveStyle(`background-color: ${COLORS.grey30}`) + }) + it('should display input from use and label', () => { + props = { + content: 'mock text from user input', + isUserInput: true, + } + render(props) + screen.getByText('You') + screen.getByText('mock text from user input') + // ToDO (kk:04/16/2024) activate the following when jsdom's issue is solved + // const display = screen.getByTextId('ChatDisplay_from_user') + // expect(display).toHaveStyle(`background-color: ${COLORS.blue}`) + }) +}) diff --git a/opentrons-ai-client/src/molecules/ChatDisplay/index.tsx b/opentrons-ai-client/src/molecules/ChatDisplay/index.tsx new file mode 100644 index 00000000000..c2d52e6a593 --- /dev/null +++ b/opentrons-ai-client/src/molecules/ChatDisplay/index.tsx @@ -0,0 +1,46 @@ +import React from 'react' +import { useTranslation } from 'react-i18next' +import Markdown from 'react-markdown' +import { + BORDERS, + COLORS, + DIRECTION_COLUMN, + Flex, + SPACING, + StyledText, +} from '@opentrons/components' + +interface ChatDisplayProps { + content: string + isUserInput: boolean +} + +export function ChatDisplay({ + content, + isUserInput, +}: ChatDisplayProps): JSX.Element { + const { t } = useTranslation('protocol_generator') + return ( + + {isUserInput ? t('you') : t('opentronsai')} + {/* text should be markdown so this component will have a package or function to parse markdown */} + + {/* ToDo (kk:04/19/2024) I will get feedback for additional styling from the design team. */} + {content} + + + ) +} diff --git a/opentrons-ai-client/src/molecules/InputPrompt/__tests__/InputPrompt.test.tsx b/opentrons-ai-client/src/molecules/InputPrompt/__tests__/InputPrompt.test.tsx new file mode 100644 index 00000000000..f46d0722119 --- /dev/null +++ b/opentrons-ai-client/src/molecules/InputPrompt/__tests__/InputPrompt.test.tsx @@ -0,0 +1,29 @@ +import React from 'react' +import { describe, it, expect } from 'vitest' +import { fireEvent, screen } from '@testing-library/react' +import { renderWithProviders } from '../../../__testing-utils__' +import { i18n } from '../../../i18n' +import { InputPrompt } from '../index' + +const render = () => { + return renderWithProviders(, { i18nInstance: i18n }) +} + +describe('InputPrompt', () => { + it('should render textarea and disabled button', () => { + render() + screen.getByRole('textbox') + screen.queryByPlaceholderText('Type your prompt...') + screen.getByRole('button') + expect(screen.getByRole('button')).toBeDisabled() + }) + + it('should make send button not disabled when a user inputs something in textarea', () => { + render() + const textbox = screen.getByRole('textbox') + fireEvent.change(textbox, { target: { value: ['test'] } }) + expect(screen.getByRole('button')).not.toBeDisabled() + }) + + // ToDo (kk:04/19/2024) add more test cases +}) diff --git a/opentrons-ai-client/src/molecules/InputPrompt/index.tsx b/opentrons-ai-client/src/molecules/InputPrompt/index.tsx new file mode 100644 index 00000000000..c9702b7773d --- /dev/null +++ b/opentrons-ai-client/src/molecules/InputPrompt/index.tsx @@ -0,0 +1,149 @@ +import React from 'react' +import { useTranslation } from 'react-i18next' +import styled, { css } from 'styled-components' +import { useForm } from 'react-hook-form' + +import { + ALIGN_CENTER, + BORDERS, + Btn, + COLORS, + DIRECTION_ROW, + DISPLAY_FLEX, + Flex, + Icon, + JUSTIFY_CENTER, + SPACING, + TYPOGRAPHY, +} from '@opentrons/components' + +import type { SubmitHandler } from 'react-hook-form' + +// ToDo (kk:04/19/2024) Note this interface will be used by prompt buttons in SidePanel +// interface InputPromptProps {} + +interface InputType { + userPrompt: string +} + +export function InputPrompt(/* props: InputPromptProps */): JSX.Element { + const { t } = useTranslation('protocol_generator') + const { register, handleSubmit, watch } = useForm({ + defaultValues: { + userPrompt: '', + }, + }) + const userPrompt = watch('userPrompt') ?? '' + + const onSubmit: SubmitHandler = async data => { + // ToDo (kk: 04/19/2024) call api + const { userPrompt } = data + console.log('user prompt', userPrompt) + } + + return ( + handleSubmit(onSubmit)}> + + + + + + ) +} + +const StyledForm = styled.form` + width: 100%; +` + +const StyledTextarea = styled.textarea` + resize: none; + min-height: 3.75rem; + background-color: ${COLORS.white}; + border: none; + outline: none; + padding: 0; + box-shadow: none; + color: ${COLORS.black90}; + width: 100%; + font-size: ${TYPOGRAPHY.fontSize20}; + line-height: ${TYPOGRAPHY.lineHeight24}; + ::placeholder { + position: absolute; + top: 50%; + transform: translateY(-50%); + } +` + +interface PlayButtonProps { + onPlay?: () => void + disabled?: boolean + isLoading?: boolean +} + +function PlayButton({ + onPlay, + disabled = false, + isLoading = false, +}: PlayButtonProps): JSX.Element { + const playButtonStyle = css` + -webkit-tap-highlight-color: transparent; + &:focus { + background-color: ${COLORS.blue60}; + color: ${COLORS.white}; + } + + &:hover { + background-color: ${COLORS.blue50}; + color: ${COLORS.white}; + } + + &:focus-visible { + background-color: ${COLORS.blue50}; + } + + &:active { + background-color: ${COLORS.blue60}; + color: ${COLORS.white}; + } + + &:disabled { + background-color: ${COLORS.grey35}; + color: ${COLORS.grey50}; + } + ` + return ( + + + + ) +} diff --git a/opentrons-ai-client/src/molecules/PromptGuide/PromptGuide.stories.tsx b/opentrons-ai-client/src/molecules/PromptGuide/PromptGuide.stories.tsx new file mode 100644 index 00000000000..1a29b80c709 --- /dev/null +++ b/opentrons-ai-client/src/molecules/PromptGuide/PromptGuide.stories.tsx @@ -0,0 +1,21 @@ +import React from 'react' +import { I18nextProvider } from 'react-i18next' +import { i18n } from '../../i18n' +import { PromptGuide as PromptGuideComponent } from './index' + +import type { Meta, StoryObj } from '@storybook/react' + +const meta: Meta = { + title: 'AI/molecules/PromptGuide', + component: PromptGuideComponent, + decorators: [ + Story => ( + + + + ), + ], +} +export default meta +type Story = StoryObj +export const PromptGuide: Story = {} diff --git a/opentrons-ai-client/src/molecules/PromptGuide/__tests__/PromptGuide.test.tsx b/opentrons-ai-client/src/molecules/PromptGuide/__tests__/PromptGuide.test.tsx new file mode 100644 index 00000000000..babe9f271f8 --- /dev/null +++ b/opentrons-ai-client/src/molecules/PromptGuide/__tests__/PromptGuide.test.tsx @@ -0,0 +1,46 @@ +import React from 'react' +import { describe, it, expect } from 'vitest' +import { screen } from '@testing-library/react' +import { renderWithProviders } from '../../../__testing-utils__' +import { i18n } from '../../../i18n' + +import { PromptGuide } from '../index' + +const LABWARE_LIBRARY_URL = 'https://labware.opentrons.com/' + +const render = () => { + return renderWithProviders(, { i18nInstance: i18n }) +} + +describe('PromptGuide', () => { + it('should render text', () => { + render() + screen.getByText('What type of protocol do you need?') + screen.getByText('Make sure your prompt includes the following:') + screen.getByText('Metadata: Three pieces of information.') + screen.getByText( + "Application: Your protocol's name, describing what it does." + ) + screen.getByText('Robot: OT-2.') + screen.getByText('API: An API level is 2.15') + screen.getByText( + 'OT-2 pipettes: Include volume, number of channels, and generation.' + ) + screen.getByText('Modules: Thermocycler or Temperature Module.') + screen.getByText( + 'Well allocations: Describe where liquids should go in labware.' + ) + screen.getByText( + "Commands: List the protocol's steps, specifying quantities in microliters and giving exact source and destination locations." + ) + screen.getByText( + 'What if you don’t provide all of those pieces of information?' + ) + screen.getByText('OpentronsAI asks you to provide it!') + }) + it('should have the right url', () => { + render() + const link = screen.getByRole('link', { name: 'Opentrons Labware Library' }) + expect(link).toHaveAttribute('href', LABWARE_LIBRARY_URL) + }) +}) diff --git a/opentrons-ai-client/src/molecules/PromptGuide/index.tsx b/opentrons-ai-client/src/molecules/PromptGuide/index.tsx new file mode 100644 index 00000000000..3cb4c69cc51 --- /dev/null +++ b/opentrons-ai-client/src/molecules/PromptGuide/index.tsx @@ -0,0 +1,112 @@ +import React from 'react' +import { Trans, useTranslation } from 'react-i18next' +import styled, { css } from 'styled-components' +import { + BORDERS, + COLORS, + DIRECTION_COLUMN, + Flex, + Link, + SPACING, + StyledText, + TYPOGRAPHY, +} from '@opentrons/components' + +const LABWARE_LIBRARY_URL = 'https://labware.opentrons.com/' + +export function PromptGuide(): JSX.Element { + const { t } = useTranslation('protocol_generator') + + return ( + + + {t('what_typeof_protocol')} + + + + + {t('make_sure_your_prompt')} + + +
    +
  • + {t('metadata')} + +
  • + {t('application')} +
  • +
  • + {t('robot')} +
  • +
  • + {t('api')} +
  • + + +
  • + {t('ot2_pipettes')} +
  • +
  • + {t('modules')} +
  • +
  • + {t('well_allocations')} +
  • +
  • + , + span: , + }} + /> +
  • +
  • + {t('commands')} +
  • +
+
+
+ , + span: , + }} + /> +
+ ) +} + +const HEADER_TEXT_STYLE = css` + font-size: ${TYPOGRAPHY.fontSize28}; + line-height: ${TYPOGRAPHY.lineHeight36}; + font-weight: ${TYPOGRAPHY.fontWeightSemiBold}; +` +const BODY_TEXT_STYLE = css` + font-size: ${TYPOGRAPHY.fontSize20}; + line-height: ${TYPOGRAPHY.lineHeight24}; +` +const StyledUl = styled.ul` + padding-left: ${SPACING.spacing16}; + list-style-type: disc; +` + +const ExternalLink = styled(Link)` + font-size: ${TYPOGRAPHY.fontSize20}; + line-height: ${TYPOGRAPHY.lineHeight24}; + color: ${COLORS.black90}; + text-decoration: ${TYPOGRAPHY.textDecorationUnderline}; +` diff --git a/opentrons-ai-client/src/molecules/SidePanel/SidePanel.stories.tsx b/opentrons-ai-client/src/molecules/SidePanel/SidePanel.stories.tsx new file mode 100644 index 00000000000..1c1d30b7548 --- /dev/null +++ b/opentrons-ai-client/src/molecules/SidePanel/SidePanel.stories.tsx @@ -0,0 +1,21 @@ +import React from 'react' +import { I18nextProvider } from 'react-i18next' +import { i18n } from '../../i18n' +import { SidePanel as SidePanelComponent } from './index' + +import type { Meta, StoryObj } from '@storybook/react' + +const meta: Meta = { + title: 'AI/molecules/SidePanel', + component: SidePanelComponent, + decorators: [ + Story => ( + + + + ), + ], +} +export default meta +type Story = StoryObj +export const SidePanel: Story = {} diff --git a/opentrons-ai-client/src/molecules/SidePanel/__tests__/SidePanel.test.tsx b/opentrons-ai-client/src/molecules/SidePanel/__tests__/SidePanel.test.tsx new file mode 100644 index 00000000000..56cb50f73fc --- /dev/null +++ b/opentrons-ai-client/src/molecules/SidePanel/__tests__/SidePanel.test.tsx @@ -0,0 +1,48 @@ +import React from 'react' +import { screen } from '@testing-library/react' +import { describe, it, expect } from 'vitest' + +import { renderWithProviders } from '../../../__testing-utils__' +import { i18n } from '../../../i18n' + +import { SidePanel } from '../index' + +const LOGO_FILE_NAME = + '/opentrons-ai-client/src/assets/images/opentrons_logo.svg' + +const FEEDBACK_FORM_LINK = 'https://opentrons-ai-beta.paperform.co/' + +const render = (): ReturnType => { + return renderWithProviders(, { + i18nInstance: i18n, + }) +} + +describe('SidePanel', () => { + it('should render logo and text', () => { + render() + const image = screen.getByRole('img') + expect(image.getAttribute('src')).toEqual(LOGO_FILE_NAME) + screen.getByText( + 'Use natural language to generate protocols with OpentronsAI powered by OpenAI' + ) + screen.getByText( + 'Write a prompt in natural language to generate a Reagent Transfer or a PCR protocol for the OT-2 or Opentrons Flex using the Opentrons Python Protocol API.' + ) + screen.getByText('Stuck? Try these example prompts to get started.') + screen.getByText('Got feedback? We love to hear it.') + const link = screen.getByRole('link', { + name: 'Share your thoughts here', + }) + expect(link).toHaveAttribute('href', FEEDBACK_FORM_LINK) + }) + + it('should render buttons', () => { + render() + screen.getByRole('button', { name: 'PCR' }) + screen.getByRole('button', { name: 'PCR (Flex)' }) + screen.getByRole('button', { name: 'Reagent Transfer' }) + screen.getByRole('button', { name: 'Reagent Transfer (Flex)' }) + }) + it.todo('should call a mock function when clicking a button') +}) diff --git a/opentrons-ai-client/src/molecules/SidePanel/index.tsx b/opentrons-ai-client/src/molecules/SidePanel/index.tsx new file mode 100644 index 00000000000..9a408e2a732 --- /dev/null +++ b/opentrons-ai-client/src/molecules/SidePanel/index.tsx @@ -0,0 +1,103 @@ +import React from 'react' +import styled, { css } from 'styled-components' +import { useTranslation } from 'react-i18next' +import { + BORDERS, + COLORS, + DIRECTION_COLUMN, + Flex, + Link, + PrimaryButton, + SPACING, + StyledText, + TYPOGRAPHY, + WRAP, +} from '@opentrons/components' +import LOGO_PATH from '../../assets/images/opentrons_logo.svg' + +const IMAGE_ALT = 'Opentrons logo' +const FEEDBACK_FORM_LINK = 'https://opentrons-ai-beta.paperform.co/' +export function SidePanel(): JSX.Element { + const { t } = useTranslation('protocol_generator') + return ( + + {/* logo */} + + {IMAGE_ALT} + + + {/* body text */} + + + {t('side_panel_header')} + + {t('side_panel_body')} + + + {/* buttons */} + + + {t('try_example_prompts')} + + + + {/* ToDo(kk:04/11/2024) add a button component */} + {t('reagent_transfer')} + {t('reagent_transfer_flex')} + {t('prc')} + {t('prc_flex')} + + + + + {t('got_feedback')} + + + {t('share_your_thoughts')} + + + + ) +} + +const HEADER_TEXT_STYLE = css` + font-size: ${TYPOGRAPHY.fontSize32}; + line-height: ${TYPOGRAPHY.lineHeight42}; + font-weight: ${TYPOGRAPHY.fontWeightBold}; + color: ${COLORS.white}; +` +const BODY_TEXT_STYLE = css` + font-size: ${TYPOGRAPHY.fontSize20}; + line-height: ${TYPOGRAPHY.lineHeight24}; + font-weight: ${TYPOGRAPHY.fontWeightRegular}; + color: ${COLORS.white}; +` +const BUTTON_GUIDE_TEXT_STYLE = css` + font-size: ${TYPOGRAPHY.fontSize20}; + line-height: ${TYPOGRAPHY.lineHeight24}; + font-weight: ${TYPOGRAPHY.fontWeightSemiBold}; + color: ${COLORS.white}; +` + +const PromptButton = styled(PrimaryButton)` + border-radius: ${BORDERS.borderRadiusFull}; + white-space: nowrap; +` + +const FeedbackLink = styled(Link)` + font-size: ${TYPOGRAPHY.fontSize20}; + line-height: ${TYPOGRAPHY.lineHeight24}; + font-weight: ${TYPOGRAPHY.fontWeightBold}; + color: ${COLORS.white}; + text-decoration: ${TYPOGRAPHY.textDecorationUnderline}; +` diff --git a/opentrons-ai-client/src/molecules/index.ts b/opentrons-ai-client/src/molecules/index.ts new file mode 100644 index 00000000000..80fcd68f91a --- /dev/null +++ b/opentrons-ai-client/src/molecules/index.ts @@ -0,0 +1 @@ +export * from './SidePanel' diff --git a/opentrons-ai-client/src/organisms/ChatContainer/ChatContainer.stories.tsx b/opentrons-ai-client/src/organisms/ChatContainer/ChatContainer.stories.tsx new file mode 100644 index 00000000000..de3ba584302 --- /dev/null +++ b/opentrons-ai-client/src/organisms/ChatContainer/ChatContainer.stories.tsx @@ -0,0 +1,21 @@ +import React from 'react' +import { I18nextProvider } from 'react-i18next' +import { i18n } from '../../i18n' +import { ChatContainer as ChatContainerComponent } from './index' + +import type { Meta, StoryObj } from '@storybook/react' + +const meta: Meta = { + title: 'AI/organisms/ChatContainer', + component: ChatContainerComponent, + decorators: [ + Story => ( + + + + ), + ], +} +export default meta +type Story = StoryObj +export const ChatContainer: Story = {} diff --git a/opentrons-ai-client/src/organisms/ChatContainer/__tests__/ChatContainer.test.tsx b/opentrons-ai-client/src/organisms/ChatContainer/__tests__/ChatContainer.test.tsx new file mode 100644 index 00000000000..406e7889878 --- /dev/null +++ b/opentrons-ai-client/src/organisms/ChatContainer/__tests__/ChatContainer.test.tsx @@ -0,0 +1,35 @@ +import React from 'react' +import { screen } from '@testing-library/react' +import { describe, it, vi, beforeEach } from 'vitest' +import { renderWithProviders } from '../../../__testing-utils__' +import { i18n } from '../../../i18n' +import { PromptGuide } from '../../../molecules/PromptGuide' +import { InputPrompt } from '../../../molecules/InputPrompt' +import { ChatContainer } from '../index' + +vi.mock('../../../molecules/PromptGuide') +vi.mock('../../../molecules/InputPrompt') + +const render = (): ReturnType => { + return renderWithProviders(, { + i18nInstance: i18n, + }) +} + +describe('ChatContainer', () => { + beforeEach(() => { + vi.mocked(PromptGuide).mockReturnValue(
mock PromptGuide
) + vi.mocked(InputPrompt).mockReturnValue(
mock InputPrompt
) + }) + it('should render prompt guide and text', () => { + render() + screen.getByText('OpentronsAI') + screen.getByText('mock PromptGuide') + screen.getByText('mock InputPrompt') + screen.getByText( + 'OpentronsAI can make mistakes. Review your protocol before running it on an Opentrons robot.' + ) + }) + + // ToDo (kk:04/16/2024) Add more test cases +}) diff --git a/opentrons-ai-client/src/organisms/ChatContainer/index.tsx b/opentrons-ai-client/src/organisms/ChatContainer/index.tsx new file mode 100644 index 00000000000..be6c4d619da --- /dev/null +++ b/opentrons-ai-client/src/organisms/ChatContainer/index.tsx @@ -0,0 +1,64 @@ +import React from 'react' +import { useTranslation } from 'react-i18next' +import { css } from 'styled-components' +import { + COLORS, + DIRECTION_COLUMN, + Flex, + POSITION_ABSOLUTE, + POSITION_RELATIVE, + SPACING, + StyledText, + TYPOGRAPHY, +} from '@opentrons/components' +import { PromptGuide } from '../../molecules/PromptGuide' +import { InputPrompt } from '../../molecules/InputPrompt' + +export function ChatContainer(): JSX.Element { + const { t } = useTranslation('protocol_generator') + const isDummyInitial = true + return ( + + {/* This will be updated when input textbox and function are implemented */} + {isDummyInitial ? ( + + + {t('opentronsai')} + + + + + + {t('disclaimer')} + + + + ) : null} + + ) +} + +const DISCLAIMER_TEXT_STYLE = css` + color: ${COLORS.grey55}; + font-size: ${TYPOGRAPHY.fontSize20}; + line-height: ${TYPOGRAPHY.lineHeight24}; + text-align: ${TYPOGRAPHY.textAlignCenter}; +` diff --git a/opentrons-ai-client/tsconfig-data.json b/opentrons-ai-client/tsconfig-data.json new file mode 100644 index 00000000000..79a9673faa9 --- /dev/null +++ b/opentrons-ai-client/tsconfig-data.json @@ -0,0 +1,12 @@ +{ + "extends": "../tsconfig-base.json", + "references": [], + "compilerOptions": { + "composite": true, + "emitDeclarationOnly": false, + "rootDir": ".", + "outDir": "lib" + }, + "include": ["src/**/*.json", "fixtures/**/*.json", "vite.config.ts"], + "exclude": ["**/*.ts", "**/*.tsx"] +} diff --git a/opentrons-ai-client/tsconfig.json b/opentrons-ai-client/tsconfig.json new file mode 100644 index 00000000000..b3c6dc275a8 --- /dev/null +++ b/opentrons-ai-client/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../tsconfig-base.json", + "references": [ + { + "path": "./tsconfig-data.json" + }, + { + "path": "../components" + } + ], + "compilerOptions": { + "rootDir": "src", + "outDir": "lib" + }, + "include": ["typings", "src"] +} diff --git a/opentrons-ai-client/typings/images.d.ts b/opentrons-ai-client/typings/images.d.ts new file mode 100644 index 00000000000..9dcd2f68792 --- /dev/null +++ b/opentrons-ai-client/typings/images.d.ts @@ -0,0 +1,15 @@ +declare module '*.png' { + const image: string + // eslint-disable-next-line import/no-default-export + export default image +} +declare module '*.svg' { + const image: string + // eslint-disable-next-line import/no-default-export + export default image +} +declare module '*.webm' { + const image: string + // eslint-disable-next-line import/no-default-export + export default image +} diff --git a/opentrons-ai-client/typings/styled-components.d.ts b/opentrons-ai-client/typings/styled-components.d.ts new file mode 100644 index 00000000000..5d6296f94be --- /dev/null +++ b/opentrons-ai-client/typings/styled-components.d.ts @@ -0,0 +1 @@ +import 'styled-components/cssprop' diff --git a/opentrons-ai-client/vite.config.ts b/opentrons-ai-client/vite.config.ts new file mode 100644 index 00000000000..ee557f68d62 --- /dev/null +++ b/opentrons-ai-client/vite.config.ts @@ -0,0 +1,43 @@ +import path from 'path' +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + // this makes imports relative rather than absolute + base: '', + build: { + // Relative to the root + outDir: 'dist', + }, + plugins: [ + react({ + include: '**/*.tsx', + babel: { + // Use babel.config.js files + configFile: true, + }, + }), + ], + optimizeDeps: { + esbuildOptions: { + target: 'es2020', + }, + }, + css: { + postcss: { + plugins: [], + }, + }, + define: { + 'process.env': process.env, + global: 'globalThis', + }, + resolve: { + alias: { + '@opentrons/components/styles': path.resolve( + '../components/src/index.module.css' + ), + '@opentrons/components': path.resolve('../components/src/index.ts'), + }, + }, +}) diff --git a/opentrons-ai-server/Makefile b/opentrons-ai-server/Makefile new file mode 100644 index 00000000000..9de2141f6a0 --- /dev/null +++ b/opentrons-ai-server/Makefile @@ -0,0 +1,2 @@ +# opentrons ai server makefile +# TBD \ No newline at end of file diff --git a/opentrons-ai-server/README.md b/opentrons-ai-server/README.md new file mode 100644 index 00000000000..e00cdc1af3d --- /dev/null +++ b/opentrons-ai-server/README.md @@ -0,0 +1,39 @@ +# Opentrons AI Backend + +## Overview + +The Opentrons AI application's server. + +## Developing + +To get started: clone the `Opentrons/opentrons` repository, set up your computer for development as specified in the [contributing guide][contributing-guide-setup], and then: + +```shell +# change into the cloned directory +cd opentrons +# prerequisite: install dependencies as specified in project setup +make setup +# launch the dev server +make -C opentrons-ai-server dev +``` + +## Stack and structure + +The UI stack is built using: + +- [OpenAI Python API library][] + +Some important directories: + +- `opentrons-ai-client` — Opentrons AI application's client-side + +## Testing + +TBD + +## Building + +TBD + +[pytest]: https://docs.pytest.org/en/ +[openai python api library]: https://pypi.org/project/openai/ diff --git a/package.json b/package.json index 67e9f909547..a38a11bdcd3 100755 --- a/package.json +++ b/package.json @@ -133,7 +133,7 @@ "shx": "^0.3.3", "simple-git": "^3.15.1", "storybook": "^7.6.16", - "storybook-addon-pseudo-states": "^1.15.5", + "storybook-addon-pseudo-states": "2.0.0", "style-loader": "^1.1.3", "stylelint": "^11.0.0", "stylelint-config-standard": "^19.0.0", diff --git a/performance-metrics/.flake8 b/performance-metrics/.flake8 new file mode 100644 index 00000000000..4aa1c02d7aa --- /dev/null +++ b/performance-metrics/.flake8 @@ -0,0 +1,25 @@ +[flake8] + +# max cyclomatic complexity +max-complexity = 9 + +extend-ignore = + # defer formatting concerns to black + # E203: space around `:` operator + # E501: maximum line length + E203, + E501, + # do not require type annotations for self nor cls + ANN101, + ANN102 + # do not require docstring for __init__, put them on the class + D107, + +# configure flake8-docstrings +# https://pypi.org/project/flake8-docstrings/ +docstring-convention = google + +noqa-require-code = true + +per-file-ignores = + setup.py:ANN,D \ No newline at end of file diff --git a/performance-metrics/.gitignore b/performance-metrics/.gitignore new file mode 100644 index 00000000000..8fb3d9a4ea5 --- /dev/null +++ b/performance-metrics/.gitignore @@ -0,0 +1 @@ +.ruff_cache/ \ No newline at end of file diff --git a/performance-metrics/Makefile b/performance-metrics/Makefile new file mode 100644 index 00000000000..fd4dd421ad2 --- /dev/null +++ b/performance-metrics/Makefile @@ -0,0 +1,32 @@ +include ../scripts/python.mk + +.PHONY: lint +lint: + $(python) -m black --check . + $(python) -m flake8 . + $(python) -m mypy . + +.PHONY: format +format: + $(python) -m black . + +.PHONY: setup +setup: + $(pipenv) sync --dev + +.PHONY: teardown +teardown: + $(pipenv) --rm + +.PHONY: clean +clean: + rm -rf build dist *.egg-info .mypy_cache .pytest_cache src/performance_metrics.egg-info + +.PHONY: wheel +wheel: + $(python) setup.py $(wheel_opts) bdist_wheel + rm -rf build + +.PHONY: test +test: + $(pytest) tests \ No newline at end of file diff --git a/performance-metrics/Pipfile b/performance-metrics/Pipfile new file mode 100644 index 00000000000..a71db703e33 --- /dev/null +++ b/performance-metrics/Pipfile @@ -0,0 +1,21 @@ +[[source]] +url = "https://pypi.org/simple" +verify_ssl = true +name = "pypi" + +[packages] +opentrons-shared-data = {file = "../shared-data/python", editable = true} +performance-metrics = {file = ".", editable = true} + +[dev-packages] +pytest = "==7.4.4" +mypy = "==1.8.0" +flake8 = "==7.0.0" +flake8-annotations = "~=3.0.1" +flake8-docstrings = "~=1.7.0" +flake8-noqa = "~=1.4.0" +black = "==22.3.0" +pytest-asyncio = "~=0.23.0" + +[requires] +python_version = "3.10" diff --git a/performance-metrics/Pipfile.lock b/performance-metrics/Pipfile.lock new file mode 100644 index 00000000000..5c836231b7e --- /dev/null +++ b/performance-metrics/Pipfile.lock @@ -0,0 +1,380 @@ +{ + "_meta": { + "hash": { + "sha256": "d811fa2b7dca8a5be8b2dba79ab7200243b2e10fb65f9ee221623f2710b24372" + }, + "pipfile-spec": 6, + "requires": { + "python_version": "3.10" + }, + "sources": [ + { + "name": "pypi", + "url": "https://pypi.org/simple", + "verify_ssl": true + } + ] + }, + "default": { + "attrs": { + "hashes": [ + "sha256:935dc3b529c262f6cf76e50877d35a4bd3c1de194fd41f47a2b7ae8f19971f30", + "sha256:99b87a485a5820b23b879f04c2305b44b951b502fd64be915879d77a7e8fc6f1" + ], + "markers": "python_version >= '3.7'", + "version": "==23.2.0" + }, + "jsonschema": { + "hashes": [ + "sha256:0f864437ab8b6076ba6707453ef8f98a6a0d512a80e93f8abdb676f737ecb60d", + "sha256:a870ad254da1a8ca84b6a2905cac29d265f805acc57af304784962a2aa6508f6" + ], + "markers": "python_version >= '3.7'", + "version": "==4.17.3" + }, + "opentrons-shared-data": { + "editable": true, + "file": "../shared-data/python", + "markers": "python_version >= '3.8'" + }, + "performance-metrics": { + "editable": true, + "file": "." + }, + "pydantic": { + "hashes": [ + "sha256:005655cabc29081de8243126e036f2065bd7ea5b9dff95fde6d2c642d39755de", + "sha256:0d142fa1b8f2f0ae11ddd5e3e317dcac060b951d605fda26ca9b234b92214986", + "sha256:22ed12ee588b1df028a2aa5d66f07bf8f8b4c8579c2e96d5a9c1f96b77f3bb55", + "sha256:2746189100c646682eff0bce95efa7d2e203420d8e1c613dc0c6b4c1d9c1fde4", + "sha256:28e552a060ba2740d0d2aabe35162652c1459a0b9069fe0db7f4ee0e18e74d58", + "sha256:3287e1614393119c67bd4404f46e33ae3be3ed4cd10360b48d0a4459f420c6a3", + "sha256:3350f527bb04138f8aff932dc828f154847fbdc7a1a44c240fbfff1b57f49a12", + "sha256:3453685ccd7140715e05f2193d64030101eaad26076fad4e246c1cc97e1bb30d", + "sha256:394f08750bd8eaad714718812e7fab615f873b3cdd0b9d84e76e51ef3b50b6b7", + "sha256:4e316e54b5775d1eb59187f9290aeb38acf620e10f7fd2f776d97bb788199e53", + "sha256:50f1666a9940d3d68683c9d96e39640f709d7a72ff8702987dab1761036206bb", + "sha256:51d405b42f1b86703555797270e4970a9f9bd7953f3990142e69d1037f9d9e51", + "sha256:584f2d4c98ffec420e02305cf675857bae03c9d617fcfdc34946b1160213a948", + "sha256:5e09c19df304b8123938dc3c53d3d3be6ec74b9d7d0d80f4f4b5432ae16c2022", + "sha256:676ed48f2c5bbad835f1a8ed8a6d44c1cd5a21121116d2ac40bd1cd3619746ed", + "sha256:67f1a1fb467d3f49e1708a3f632b11c69fccb4e748a325d5a491ddc7b5d22383", + "sha256:6a51a1dd4aa7b3f1317f65493a182d3cff708385327c1c82c81e4a9d6d65b2e4", + "sha256:6bd7030c9abc80134087d8b6e7aa957e43d35714daa116aced57269a445b8f7b", + "sha256:75279d3cac98186b6ebc2597b06bcbc7244744f6b0b44a23e4ef01e5683cc0d2", + "sha256:7ac9237cd62947db00a0d16acf2f3e00d1ae9d3bd602b9c415f93e7a9fc10528", + "sha256:7ea210336b891f5ea334f8fc9f8f862b87acd5d4a0cbc9e3e208e7aa1775dabf", + "sha256:82790d4753ee5d00739d6cb5cf56bceb186d9d6ce134aca3ba7befb1eedbc2c8", + "sha256:92229f73400b80c13afcd050687f4d7e88de9234d74b27e6728aa689abcf58cc", + "sha256:9bea1f03b8d4e8e86702c918ccfd5d947ac268f0f0cc6ed71782e4b09353b26f", + "sha256:a980a77c52723b0dc56640ced396b73a024d4b74f02bcb2d21dbbac1debbe9d0", + "sha256:af9850d98fc21e5bc24ea9e35dd80a29faf6462c608728a110c0a30b595e58b7", + "sha256:bbc6989fad0c030bd70a0b6f626f98a862224bc2b1e36bfc531ea2facc0a340c", + "sha256:be51dd2c8596b25fe43c0a4a59c2bee4f18d88efb8031188f9e7ddc6b469cf44", + "sha256:c365ad9c394f9eeffcb30a82f4246c0006417f03a7c0f8315d6211f25f7cb654", + "sha256:c3d5731a120752248844676bf92f25a12f6e45425e63ce22e0849297a093b5b0", + "sha256:ca832e124eda231a60a041da4f013e3ff24949d94a01154b137fc2f2a43c3ffb", + "sha256:d207d5b87f6cbefbdb1198154292faee8017d7495a54ae58db06762004500d00", + "sha256:d31ee5b14a82c9afe2bd26aaa405293d4237d0591527d9129ce36e58f19f95c1", + "sha256:d3b5c4cbd0c9cb61bbbb19ce335e1f8ab87a811f6d589ed52b0254cf585d709c", + "sha256:d573082c6ef99336f2cb5b667b781d2f776d4af311574fb53d908517ba523c22", + "sha256:e49db944fad339b2ccb80128ffd3f8af076f9f287197a480bf1e4ca053a866f0" + ], + "markers": "python_version >= '3.7'", + "version": "==1.10.15" + }, + "pyrsistent": { + "hashes": [ + "sha256:0724c506cd8b63c69c7f883cc233aac948c1ea946ea95996ad8b1380c25e1d3f", + "sha256:09848306523a3aba463c4b49493a760e7a6ca52e4826aa100ee99d8d39b7ad1e", + "sha256:0f3b1bcaa1f0629c978b355a7c37acd58907390149b7311b5db1b37648eb6958", + "sha256:21cc459636983764e692b9eba7144cdd54fdec23ccdb1e8ba392a63666c60c34", + "sha256:2e14c95c16211d166f59c6611533d0dacce2e25de0f76e4c140fde250997b3ca", + "sha256:2e2c116cc804d9b09ce9814d17df5edf1df0c624aba3b43bc1ad90411487036d", + "sha256:4021a7f963d88ccd15b523787d18ed5e5269ce57aa4037146a2377ff607ae87d", + "sha256:4c48f78f62ab596c679086084d0dd13254ae4f3d6c72a83ffdf5ebdef8f265a4", + "sha256:4f5c2d012671b7391803263419e31b5c7c21e7c95c8760d7fc35602353dee714", + "sha256:58b8f6366e152092194ae68fefe18b9f0b4f89227dfd86a07770c3d86097aebf", + "sha256:59a89bccd615551391f3237e00006a26bcf98a4d18623a19909a2c48b8e986ee", + "sha256:5cdd7ef1ea7a491ae70d826b6cc64868de09a1d5ff9ef8d574250d0940e275b8", + "sha256:6288b3fa6622ad8a91e6eb759cfc48ff3089e7c17fb1d4c59a919769314af224", + "sha256:6d270ec9dd33cdb13f4d62c95c1a5a50e6b7cdd86302b494217137f760495b9d", + "sha256:79ed12ba79935adaac1664fd7e0e585a22caa539dfc9b7c7c6d5ebf91fb89054", + "sha256:7d29c23bdf6e5438c755b941cef867ec2a4a172ceb9f50553b6ed70d50dfd656", + "sha256:8441cf9616d642c475684d6cf2520dd24812e996ba9af15e606df5f6fd9d04a7", + "sha256:881bbea27bbd32d37eb24dd320a5e745a2a5b092a17f6debc1349252fac85423", + "sha256:8c3aba3e01235221e5b229a6c05f585f344734bd1ad42a8ac51493d74722bbce", + "sha256:a14798c3005ec892bbada26485c2eea3b54109cb2533713e355c806891f63c5e", + "sha256:b14decb628fac50db5e02ee5a35a9c0772d20277824cfe845c8a8b717c15daa3", + "sha256:b318ca24db0f0518630e8b6f3831e9cba78f099ed5c1d65ffe3e023003043ba0", + "sha256:c1beb78af5423b879edaf23c5591ff292cf7c33979734c99aa66d5914ead880f", + "sha256:c55acc4733aad6560a7f5f818466631f07efc001fd023f34a6c203f8b6df0f0b", + "sha256:ca52d1ceae015859d16aded12584c59eb3825f7b50c6cfd621d4231a6cc624ce", + "sha256:cae40a9e3ce178415040a0383f00e8d68b569e97f31928a3a8ad37e3fde6df6a", + "sha256:e78d0c7c1e99a4a45c99143900ea0546025e41bb59ebc10182e947cf1ece9174", + "sha256:ef3992833fbd686ee783590639f4b8343a57f1f75de8633749d984dc0eb16c86", + "sha256:f058a615031eea4ef94ead6456f5ec2026c19fb5bd6bfe86e9665c4158cf802f", + "sha256:f5ac696f02b3fc01a710427585c855f65cd9c640e14f52abe52020722bb4906b", + "sha256:f920385a11207dc372a028b3f1e1038bb244b3ec38d448e6d8e43c6b3ba20e98", + "sha256:fed2c3216a605dc9a6ea50c7e84c82906e3684c4e80d2908208f662a6cbf9022" + ], + "markers": "python_version >= '3.8'", + "version": "==0.20.0" + }, + "typing-extensions": { + "hashes": [ + "sha256:83f085bd5ca59c80295fc2a82ab5dac679cbe02b9f33f7d83af68e241bea51b0", + "sha256:c1f94d72897edaf4ce775bb7558d5b79d8126906a14ea5ed1635921406c0387a" + ], + "markers": "python_version >= '3.8'", + "version": "==4.11.0" + } + }, + "develop": { + "attrs": { + "hashes": [ + "sha256:935dc3b529c262f6cf76e50877d35a4bd3c1de194fd41f47a2b7ae8f19971f30", + "sha256:99b87a485a5820b23b879f04c2305b44b951b502fd64be915879d77a7e8fc6f1" + ], + "markers": "python_version >= '3.7'", + "version": "==23.2.0" + }, + "black": { + "hashes": [ + "sha256:06f9d8846f2340dfac80ceb20200ea5d1b3f181dd0556b47af4e8e0b24fa0a6b", + "sha256:10dbe6e6d2988049b4655b2b739f98785a884d4d6b85bc35133a8fb9a2233176", + "sha256:2497f9c2386572e28921fa8bec7be3e51de6801f7459dffd6e62492531c47e09", + "sha256:30d78ba6bf080eeaf0b7b875d924b15cd46fec5fd044ddfbad38c8ea9171043a", + "sha256:328efc0cc70ccb23429d6be184a15ce613f676bdfc85e5fe8ea2a9354b4e9015", + "sha256:35020b8886c022ced9282b51b5a875b6d1ab0c387b31a065b84db7c33085ca79", + "sha256:5795a0375eb87bfe902e80e0c8cfaedf8af4d49694d69161e5bd3206c18618bb", + "sha256:5891ef8abc06576985de8fa88e95ab70641de6c1fca97e2a15820a9b69e51b20", + "sha256:637a4014c63fbf42a692d22b55d8ad6968a946b4a6ebc385c5505d9625b6a464", + "sha256:67c8301ec94e3bcc8906740fe071391bce40a862b7be0b86fb5382beefecd968", + "sha256:6d2fc92002d44746d3e7db7cf9313cf4452f43e9ea77a2c939defce3b10b5c82", + "sha256:6ee227b696ca60dd1c507be80a6bc849a5a6ab57ac7352aad1ffec9e8b805f21", + "sha256:863714200ada56cbc366dc9ae5291ceb936573155f8bf8e9de92aef51f3ad0f0", + "sha256:9b542ced1ec0ceeff5b37d69838106a6348e60db7b8fdd245294dc1d26136265", + "sha256:a6342964b43a99dbc72f72812bf88cad8f0217ae9acb47c0d4f141a6416d2d7b", + "sha256:ad4efa5fad66b903b4a5f96d91461d90b9507a812b3c5de657d544215bb7877a", + "sha256:bc58025940a896d7e5356952228b68f793cf5fcb342be703c3a2669a1488cb72", + "sha256:cc1e1de68c8e5444e8f94c3670bb48a2beef0e91dddfd4fcc29595ebd90bb9ce", + "sha256:cee3e11161dde1b2a33a904b850b0899e0424cc331b7295f2a9698e79f9a69a0", + "sha256:e3556168e2e5c49629f7b0f377070240bd5511e45e25a4497bb0073d9dda776a", + "sha256:e8477ec6bbfe0312c128e74644ac8a02ca06bcdb8982d4ee06f209be28cdf163", + "sha256:ee8f1f7228cce7dffc2b464f07ce769f478968bfb3dd1254a4c2eeed84928aad", + "sha256:fd57160949179ec517d32ac2ac898b5f20d68ed1a9c977346efbac9c2f1e779d" + ], + "index": "pypi", + "markers": "python_full_version >= '3.6.2'", + "version": "==22.3.0" + }, + "click": { + "hashes": [ + "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28", + "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de" + ], + "markers": "python_version >= '3.7'", + "version": "==8.1.7" + }, + "exceptiongroup": { + "hashes": [ + "sha256:4bfd3996ac73b41e9b9628b04e079f193850720ea5945fc96a08633c66912f14", + "sha256:91f5c769735f051a4290d52edd0858999b57e5876e9f85937691bd4c9fa3ed68" + ], + "markers": "python_version < '3.11'", + "version": "==1.2.0" + }, + "flake8": { + "hashes": [ + "sha256:33f96621059e65eec474169085dc92bf26e7b2d47366b70be2f67ab80dc25132", + "sha256:a6dfbb75e03252917f2473ea9653f7cd799c3064e54d4c8140044c5c065f53c3" + ], + "index": "pypi", + "markers": "python_full_version >= '3.8.1'", + "version": "==7.0.0" + }, + "flake8-annotations": { + "hashes": [ + "sha256:af78e3216ad800d7e144745ece6df706c81b3255290cbf870e54879d495e8ade", + "sha256:ff37375e71e3b83f2a5a04d443c41e2c407de557a884f3300a7fa32f3c41cb0a" + ], + "index": "pypi", + "markers": "python_full_version >= '3.8.1'", + "version": "==3.0.1" + }, + "flake8-docstrings": { + "hashes": [ + "sha256:4c8cc748dc16e6869728699e5d0d685da9a10b0ea718e090b1ba088e67a941af", + "sha256:51f2344026da083fc084166a9353f5082b01f72901df422f74b4d953ae88ac75" + ], + "index": "pypi", + "markers": "python_version >= '3.7'", + "version": "==1.7.0" + }, + "flake8-noqa": { + "hashes": [ + "sha256:4465e16a19be433980f6f563d05540e2e54797eb11facb9feb50fed60624dc45", + "sha256:771765ab27d1efd157528379acd15131147f9ae578a72d17fb432ca197881243" + ], + "index": "pypi", + "markers": "python_version >= '3.7'", + "version": "==1.4.0" + }, + "iniconfig": { + "hashes": [ + "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", + "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374" + ], + "markers": "python_version >= '3.7'", + "version": "==2.0.0" + }, + "mccabe": { + "hashes": [ + "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325", + "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e" + ], + "markers": "python_version >= '3.6'", + "version": "==0.7.0" + }, + "mypy": { + "hashes": [ + "sha256:028cf9f2cae89e202d7b6593cd98db6759379f17a319b5faf4f9978d7084cdc6", + "sha256:2afecd6354bbfb6e0160f4e4ad9ba6e4e003b767dd80d85516e71f2e955ab50d", + "sha256:2b5b6c721bd4aabaadead3a5e6fa85c11c6c795e0c81a7215776ef8afc66de02", + "sha256:42419861b43e6962a649068a61f4a4839205a3ef525b858377a960b9e2de6e0d", + "sha256:42c6680d256ab35637ef88891c6bd02514ccb7e1122133ac96055ff458f93fc3", + "sha256:485a8942f671120f76afffff70f259e1cd0f0cfe08f81c05d8816d958d4577d3", + "sha256:4c886c6cce2d070bd7df4ec4a05a13ee20c0aa60cb587e8d1265b6c03cf91da3", + "sha256:4e6d97288757e1ddba10dd9549ac27982e3e74a49d8d0179fc14d4365c7add66", + "sha256:4ef4be7baf08a203170f29e89d79064463b7fc7a0908b9d0d5114e8009c3a259", + "sha256:51720c776d148bad2372ca21ca29256ed483aa9a4cdefefcef49006dff2a6835", + "sha256:52825b01f5c4c1c4eb0db253ec09c7aa17e1a7304d247c48b6f3599ef40db8bd", + "sha256:538fd81bb5e430cc1381a443971c0475582ff9f434c16cd46d2c66763ce85d9d", + "sha256:5c1538c38584029352878a0466f03a8ee7547d7bd9f641f57a0f3017a7c905b8", + "sha256:6ff8b244d7085a0b425b56d327b480c3b29cafbd2eff27316a004f9a7391ae07", + "sha256:7178def594014aa6c35a8ff411cf37d682f428b3b5617ca79029d8ae72f5402b", + "sha256:720a5ca70e136b675af3af63db533c1c8c9181314d207568bbe79051f122669e", + "sha256:7f1478736fcebb90f97e40aff11a5f253af890c845ee0c850fe80aa060a267c6", + "sha256:855fe27b80375e5c5878492f0729540db47b186509c98dae341254c8f45f42ae", + "sha256:8963b83d53ee733a6e4196954502b33567ad07dfd74851f32be18eb932fb1cb9", + "sha256:9261ed810972061388918c83c3f5cd46079d875026ba97380f3e3978a72f503d", + "sha256:99b00bc72855812a60d253420d8a2eae839b0afa4938f09f4d2aa9bb4654263a", + "sha256:ab3c84fa13c04aeeeabb2a7f67a25ef5d77ac9d6486ff33ded762ef353aa5592", + "sha256:afe3fe972c645b4632c563d3f3eff1cdca2fa058f730df2b93a35e3b0c538218", + "sha256:d19c413b3c07cbecf1f991e2221746b0d2a9410b59cb3f4fb9557f0365a1a817", + "sha256:df9824ac11deaf007443e7ed2a4a26bebff98d2bc43c6da21b2b64185da011c4", + "sha256:e46f44b54ebddbeedbd3d5b289a893219065ef805d95094d16a0af6630f5d410", + "sha256:f5ac9a4eeb1ec0f1ccdc6f326bcdb464de5f80eb07fb38b5ddd7b0de6bc61e55" + ], + "index": "pypi", + "markers": "python_version >= '3.8'", + "version": "==1.8.0" + }, + "mypy-extensions": { + "hashes": [ + "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d", + "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782" + ], + "markers": "python_version >= '3.5'", + "version": "==1.0.0" + }, + "packaging": { + "hashes": [ + "sha256:2ddfb553fdf02fb784c234c7ba6ccc288296ceabec964ad2eae3777778130bc5", + "sha256:eb82c5e3e56209074766e6885bb04b8c38a0c015d0a30036ebe7ece34c9989e9" + ], + "markers": "python_version >= '3.7'", + "version": "==24.0" + }, + "pathspec": { + "hashes": [ + "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", + "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712" + ], + "markers": "python_version >= '3.8'", + "version": "==0.12.1" + }, + "platformdirs": { + "hashes": [ + "sha256:0614df2a2f37e1a662acbd8e2b25b92ccf8632929bc6d43467e17fe89c75e068", + "sha256:ef0cc731df711022c174543cb70a9b5bd22e5a9337c8624ef2c2ceb8ddad8768" + ], + "markers": "python_version >= '3.8'", + "version": "==4.2.0" + }, + "pluggy": { + "hashes": [ + "sha256:7db9f7b503d67d1c5b95f59773ebb58a8c1c288129a88665838012cfb07b8981", + "sha256:8c85c2876142a764e5b7548e7d9a0e0ddb46f5185161049a79b7e974454223be" + ], + "markers": "python_version >= '3.8'", + "version": "==1.4.0" + }, + "pycodestyle": { + "hashes": [ + "sha256:41ba0e7afc9752dfb53ced5489e89f8186be00e599e712660695b7a75ff2663f", + "sha256:44fe31000b2d866f2e41841b18528a505fbd7fef9017b04eff4e2648a0fadc67" + ], + "markers": "python_version >= '3.8'", + "version": "==2.11.1" + }, + "pydocstyle": { + "hashes": [ + "sha256:118762d452a49d6b05e194ef344a55822987a462831ade91ec5c06fd2169d019", + "sha256:7ce43f0c0ac87b07494eb9c0b462c0b73e6ff276807f204d6b53edc72b7e44e1" + ], + "markers": "python_version >= '3.6'", + "version": "==6.3.0" + }, + "pyflakes": { + "hashes": [ + "sha256:1c61603ff154621fb2a9172037d84dca3500def8c8b630657d1701f026f8af3f", + "sha256:84b5be138a2dfbb40689ca07e2152deb896a65c3a3e24c251c5c62489568074a" + ], + "markers": "python_version >= '3.8'", + "version": "==3.2.0" + }, + "pytest": { + "hashes": [ + "sha256:2cf0005922c6ace4a3e2ec8b4080eb0d9753fdc93107415332f50ce9e7994280", + "sha256:b090cdf5ed60bf4c45261be03239c2c1c22df034fbffe691abe93cd80cea01d8" + ], + "index": "pypi", + "markers": "python_version >= '3.7'", + "version": "==7.4.4" + }, + "pytest-asyncio": { + "hashes": [ + "sha256:68516fdd1018ac57b846c9846b954f0393b26f094764a28c955eabb0536a4e8a", + "sha256:ffe523a89c1c222598c76856e76852b787504ddb72dd5d9b6617ffa8aa2cde5f" + ], + "index": "pypi", + "markers": "python_version >= '3.8'", + "version": "==0.23.6" + }, + "snowballstemmer": { + "hashes": [ + "sha256:09b16deb8547d3412ad7b590689584cd0fe25ec8db3be37788be3810cbf19cb1", + "sha256:c8e1716e83cc398ae16824e5572ae04e0d9fc2c6b985fb0f900f5f0c96ecba1a" + ], + "version": "==2.2.0" + }, + "tomli": { + "hashes": [ + "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc", + "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f" + ], + "markers": "python_version < '3.11'", + "version": "==2.0.1" + }, + "typing-extensions": { + "hashes": [ + "sha256:83f085bd5ca59c80295fc2a82ab5dac679cbe02b9f33f7d83af68e241bea51b0", + "sha256:c1f94d72897edaf4ce775bb7558d5b79d8126906a14ea5ed1635921406c0387a" + ], + "markers": "python_version >= '3.8'", + "version": "==4.11.0" + } + } +} diff --git a/performance-metrics/README.md b/performance-metrics/README.md new file mode 100644 index 00000000000..7fb20445e36 --- /dev/null +++ b/performance-metrics/README.md @@ -0,0 +1,3 @@ +# Performance Metrics + +Project to gather various performance metrics for the Opentrons Flex. diff --git a/performance-metrics/mypy.ini b/performance-metrics/mypy.ini new file mode 100644 index 00000000000..b94476cbcaa --- /dev/null +++ b/performance-metrics/mypy.ini @@ -0,0 +1,5 @@ +[mypy] +show_error_codes = True +warn_unused_configs = True +strict = True +exclude = setup.py \ No newline at end of file diff --git a/performance-metrics/pytest.ini b/performance-metrics/pytest.ini new file mode 100644 index 00000000000..49f04412746 --- /dev/null +++ b/performance-metrics/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +addopts = --color=yes --strict-markers +asyncio_mode = auto diff --git a/performance-metrics/setup.py b/performance-metrics/setup.py new file mode 100755 index 00000000000..eced9a55ab9 --- /dev/null +++ b/performance-metrics/setup.py @@ -0,0 +1,91 @@ +# Inspired by: +# https://hynek.me/articles/sharing-your-labor-of-love-pypi-quick-and-dirty/ +import sys +import codecs +import os +import os.path +from setuptools import setup, find_packages + +# make stdout blocking since Travis sets it to nonblocking +if os.name == "posix": + import fcntl + + flags = fcntl.fcntl(sys.stdout, fcntl.F_GETFL) + fcntl.fcntl(sys.stdout, fcntl.F_SETFL, flags & ~os.O_NONBLOCK) + +HERE = os.path.abspath(os.path.dirname(__file__)) +sys.path.append(os.path.join(HERE, "..", "scripts")) + +from python_build_utils import normalize_version # noqa: E402 + + +def get_version(): + buildno = os.getenv("BUILD_NUMBER") + project = os.getenv("OPENTRONS_PROJECT", "robot-stack") + git_dir = os.getenv("OPENTRONS_GIT_DIR", None) + if buildno: + normalize_opts = {"extra_tag": buildno} + else: + normalize_opts = {} + return normalize_version( + "performance-metrics", project, git_dir=git_dir, **normalize_opts + ) + + +VERSION = get_version() + +DISTNAME = "performance_metrics" +LICENSE = "Apache 2.0" +AUTHOR = "Opentrons" +EMAIL = "engineering@opentrons.com" +URL = "https://github.com/Opentrons/opentrons" +DOWNLOAD_URL = "" +CLASSIFIERS = [ + "Development Status :: 5 - Production/Stable", + "Environment :: Console", + "Operating System :: OS Independent", + "Intended Audience :: Science/Research", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Topic :: Scientific/Engineering", +] +KEYWORDS = ["robots", "protocols", "synbio", "pcr", "automation", "lab"] +DESCRIPTION = "Library for working with performance metrics on the Opentrons robots" +PACKAGES = find_packages(where="src", exclude=["tests.*", "tests"]) +INSTALL_REQUIRES = [ + f"opentrons-shared-data=={VERSION}", +] + + +def read(*parts): + """ + Build an absolute path from *parts* and and return the contents of the + resulting file. Assume UTF-8 encoding. + """ + with codecs.open(os.path.join(HERE, *parts), "rb", "utf-8") as f: + return f.read() + + +if __name__ == "__main__": + setup( + python_requires="~=3.10", + name=DISTNAME, + description=DESCRIPTION, + license=LICENSE, + url=URL, + version=VERSION, + author=AUTHOR, + author_email=EMAIL, + maintainer=AUTHOR, + maintainer_email=EMAIL, + keywords=KEYWORDS, + long_description=__doc__, + packages=PACKAGES, + zip_safe=False, + classifiers=CLASSIFIERS, + install_requires=INSTALL_REQUIRES, + include_package_data=True, + package_dir={"": "src"}, + package_data={"performance-metrics": ["py.typed"]}, + ) diff --git a/performance-metrics/src/performance_metrics/__init__.py b/performance-metrics/src/performance_metrics/__init__.py new file mode 100644 index 00000000000..b5f2e760c19 --- /dev/null +++ b/performance-metrics/src/performance_metrics/__init__.py @@ -0,0 +1,5 @@ +"""Opentrons performance metrics library.""" + +from .robot_context_tracker import RobotContextTracker + +__all__ = ["RobotContextTracker"] diff --git a/performance-metrics/src/performance_metrics/datashapes.py b/performance-metrics/src/performance_metrics/datashapes.py new file mode 100644 index 00000000000..7743ed1723d --- /dev/null +++ b/performance-metrics/src/performance_metrics/datashapes.py @@ -0,0 +1,35 @@ +"""Defines data classes and enums used in the performance metrics module.""" + +import dataclasses +from typing import Tuple +from opentrons_shared_data.performance.dev_types import RobotContextState + + +@dataclasses.dataclass(frozen=True) +class RawContextData: + """Represents raw duration data with context state information. + + Attributes: + - function_start_time (int): The start time of the function. + - duration_measurement_start_time (int): The start time for duration measurement. + - duration_measurement_end_time (int): The end time for duration measurement. + - state (RobotContextStates): The current state of the context. + """ + + func_start: int + duration_start: int + duration_end: int + state: RobotContextState + + @classmethod + def headers(self) -> Tuple[str, str, str]: + """Returns the headers for the raw context data.""" + return ("state_id", "function_start_time", "duration") + + def csv_row(self) -> Tuple[int, int, int]: + """Returns the raw context data as a string.""" + return ( + self.state.state_id, + self.func_start, + self.duration_end - self.duration_start, + ) diff --git a/api/tests/opentrons/commands/__init__.py b/performance-metrics/src/performance_metrics/py.typed similarity index 100% rename from api/tests/opentrons/commands/__init__.py rename to performance-metrics/src/performance_metrics/py.typed diff --git a/performance-metrics/src/performance_metrics/robot_context_tracker.py b/performance-metrics/src/performance_metrics/robot_context_tracker.py new file mode 100644 index 00000000000..99dc502c9ad --- /dev/null +++ b/performance-metrics/src/performance_metrics/robot_context_tracker.py @@ -0,0 +1,103 @@ +"""Module for tracking robot context and execution duration for different operations.""" + +import csv +from pathlib import Path +import platform + +from functools import wraps, partial +from time import perf_counter_ns +import os +from typing import Callable, TypeVar, cast + + +from typing_extensions import ParamSpec +from collections import deque +from performance_metrics.datashapes import ( + RawContextData, +) +from opentrons_shared_data.performance.dev_types import ( + RobotContextState, + SupportsTracking, +) + +P = ParamSpec("P") +R = TypeVar("R") + + +def _get_timing_function() -> Callable[[], int]: + """Returns a timing function for the current platform.""" + time_function: Callable[[], int] + if platform.system() == "Linux": + from time import clock_gettime_ns, CLOCK_REALTIME + + time_function = cast( + Callable[[], int], partial(clock_gettime_ns, CLOCK_REALTIME) + ) + else: + from time import time_ns + + time_function = time_ns + + return time_function + + +timing_function = _get_timing_function() + + +class RobotContextTracker(SupportsTracking): + """Tracks and stores robot context and execution duration for different operations.""" + + FILE_NAME = "context_data.csv" + + def __init__(self, storage_location: Path, should_track: bool = False) -> None: + """Initializes the RobotContextTracker with an empty storage list.""" + self._storage: deque[RawContextData] = deque() + self._storage_file_path = storage_location / self.FILE_NAME + self._should_track = should_track + + def track(self, state: RobotContextState) -> Callable: # type: ignore + """Decorator factory for tracking the execution duration and state of robot operations. + + Args: + state: The state to track for the decorated function. + + Returns: + Callable: A decorator that wraps a function to track its execution duration and state. + """ + + def inner_decorator(func: Callable[P, R]) -> Callable[P, R]: + if not self._should_track: + return func + + @wraps(func) + def wrapper(*args: P.args, **kwargs: P.kwargs) -> R: + function_start_time = timing_function() + duration_start_time = perf_counter_ns() + try: + result = func(*args, **kwargs) + finally: + duration_end_time = perf_counter_ns() + self._storage.append( + RawContextData( + function_start_time, + duration_start_time, + duration_end_time, + state, + ) + ) + return result + + return wrapper + + return inner_decorator + + def store(self) -> None: + """Returns the stored context data and clears the storage list.""" + stored_data = self._storage.copy() + self._storage.clear() + rows_to_write = [context_data.csv_row() for context_data in stored_data] + os.makedirs(self._storage_file_path.parent, exist_ok=True) + with open(self._storage_file_path, "a") as storage_file: + writer = csv.writer(storage_file) + writer.writerow(RawContextData.headers()) + writer.writerows(rows_to_write) diff --git a/performance-metrics/tests/performance_metrics/test_robot_context_tracker.py b/performance-metrics/tests/performance_metrics/test_robot_context_tracker.py new file mode 100644 index 00000000000..2c112410063 --- /dev/null +++ b/performance-metrics/tests/performance_metrics/test_robot_context_tracker.py @@ -0,0 +1,305 @@ +"""Tests for the RobotContextTracker class in performance_metrics.robot_context_tracker.""" + +import asyncio +from pathlib import Path +import pytest +from performance_metrics.robot_context_tracker import RobotContextTracker +from opentrons_shared_data.performance.dev_types import RobotContextState +from time import sleep, time_ns +from unittest.mock import patch + +# Corrected times in seconds +STARTING_TIME = 0.001 +CALIBRATING_TIME = 0.002 +ANALYZING_TIME = 0.003 +RUNNING_TIME = 0.004 +SHUTTING_DOWN_TIME = 0.005 + + +@pytest.fixture +def robot_context_tracker(tmp_path: Path) -> RobotContextTracker: + """Fixture to provide a fresh instance of RobotContextTracker for each test.""" + return RobotContextTracker(storage_location=tmp_path, should_track=True) + + +def test_robot_context_tracker(robot_context_tracker: RobotContextTracker) -> None: + """Tests the tracking of various robot context states through RobotContextTracker.""" + + @robot_context_tracker.track(state=RobotContextState.STARTING_UP) + def starting_robot() -> None: + sleep(STARTING_TIME) + + @robot_context_tracker.track(state=RobotContextState.CALIBRATING) + def calibrating_robot() -> None: + sleep(CALIBRATING_TIME) + + @robot_context_tracker.track(state=RobotContextState.ANALYZING_PROTOCOL) + def analyzing_protocol() -> None: + sleep(ANALYZING_TIME) + + @robot_context_tracker.track(state=RobotContextState.RUNNING_PROTOCOL) + def running_protocol() -> None: + sleep(RUNNING_TIME) + + @robot_context_tracker.track(state=RobotContextState.SHUTTING_DOWN) + def shutting_down_robot() -> None: + sleep(SHUTTING_DOWN_TIME) + + # Ensure storage is initially empty + assert ( + len(robot_context_tracker._storage) == 0 + ), "Storage should be initially empty." + + starting_robot() + calibrating_robot() + analyzing_protocol() + running_protocol() + shutting_down_robot() + + # Verify that all states were tracked + assert len(robot_context_tracker._storage) == 5, "All states should be tracked." + + # Validate the sequence and accuracy of tracked states + expected_states = [ + RobotContextState.STARTING_UP, + RobotContextState.CALIBRATING, + RobotContextState.ANALYZING_PROTOCOL, + RobotContextState.RUNNING_PROTOCOL, + RobotContextState.SHUTTING_DOWN, + ] + for i, state in enumerate(expected_states): + assert ( + RobotContextState.from_id(robot_context_tracker._storage[i].state.state_id) + == state + ), f"State at index {i} should be {state}." + + +def test_multiple_operations_single_state( + robot_context_tracker: RobotContextTracker, +) -> None: + """Tests tracking multiple operations within a single robot context state.""" + + @robot_context_tracker.track(state=RobotContextState.RUNNING_PROTOCOL) + def first_operation() -> None: + sleep(RUNNING_TIME) + + @robot_context_tracker.track(state=RobotContextState.RUNNING_PROTOCOL) + def second_operation() -> None: + sleep(RUNNING_TIME) + + first_operation() + second_operation() + + assert ( + len(robot_context_tracker._storage) == 2 + ), "Both operations should be tracked." + assert ( + robot_context_tracker._storage[0].state + == robot_context_tracker._storage[1].state + == RobotContextState.RUNNING_PROTOCOL + ), "Both operations should have the same state." + + +def test_exception_handling_in_tracked_function( + robot_context_tracker: RobotContextTracker, +) -> None: + """Ensures exceptions in tracked operations are handled correctly.""" + + @robot_context_tracker.track(state=RobotContextState.SHUTTING_DOWN) + def error_prone_operation() -> None: + sleep(SHUTTING_DOWN_TIME) + raise RuntimeError("Simulated operation failure") + + with pytest.raises(RuntimeError): + error_prone_operation() + + assert ( + len(robot_context_tracker._storage) == 1 + ), "Failed operation should still be tracked." + assert ( + robot_context_tracker._storage[0].state == RobotContextState.SHUTTING_DOWN + ), "State should be correctly logged despite the exception." + + +@pytest.mark.asyncio +async def test_async_operation_tracking( + robot_context_tracker: RobotContextTracker, +) -> None: + """Tests tracking of an asynchronous operation.""" + + @robot_context_tracker.track(state=RobotContextState.ANALYZING_PROTOCOL) + async def async_analyzing_operation() -> None: + await asyncio.sleep(ANALYZING_TIME) + + await async_analyzing_operation() + + assert ( + len(robot_context_tracker._storage) == 1 + ), "Async operation should be tracked." + assert ( + robot_context_tracker._storage[0].state == RobotContextState.ANALYZING_PROTOCOL + ), "State should be ANALYZING_PROTOCOL." + + +def test_sync_operation_timing_accuracy( + robot_context_tracker: RobotContextTracker, +) -> None: + """Tests the timing accuracy of a synchronous operation tracking.""" + + @robot_context_tracker.track(state=RobotContextState.RUNNING_PROTOCOL) + def running_operation() -> None: + sleep(RUNNING_TIME) + + running_operation() + + duration_data = robot_context_tracker._storage[0] + measured_duration = duration_data.duration_end - duration_data.duration_start + assert ( + abs(measured_duration - RUNNING_TIME * 1e9) < 1e7 + ), "Measured duration for sync operation should closely match the expected duration." + + +@pytest.mark.asyncio +async def test_async_operation_timing_accuracy( + robot_context_tracker: RobotContextTracker, +) -> None: + """Tests the timing accuracy of an async operation tracking.""" + + @robot_context_tracker.track(state=RobotContextState.RUNNING_PROTOCOL) + async def async_running_operation() -> None: + await asyncio.sleep(RUNNING_TIME) + + await async_running_operation() + + duration_data = robot_context_tracker._storage[0] + measured_duration = duration_data.duration_end - duration_data.duration_start + assert ( + abs(measured_duration - RUNNING_TIME * 1e9) < 1e7 + ), "Measured duration for async operation should closely match the expected duration." + + +@pytest.mark.asyncio +async def test_exception_in_async_operation( + robot_context_tracker: RobotContextTracker, +) -> None: + """Ensures exceptions in tracked async operations are correctly handled.""" + + @robot_context_tracker.track(state=RobotContextState.SHUTTING_DOWN) + async def async_error_prone_operation() -> None: + await asyncio.sleep(SHUTTING_DOWN_TIME) + raise RuntimeError("Simulated async operation failure") + + with pytest.raises(RuntimeError): + await async_error_prone_operation() + + assert ( + len(robot_context_tracker._storage) == 1 + ), "Failed async operation should still be tracked." + assert ( + robot_context_tracker._storage[0].state == RobotContextState.SHUTTING_DOWN + ), "State should be SHUTTING_DOWN despite the exception." + + +@pytest.mark.asyncio +async def test_concurrent_async_operations( + robot_context_tracker: RobotContextTracker, +) -> None: + """Tests tracking of concurrent async operations.""" + + @robot_context_tracker.track(state=RobotContextState.CALIBRATING) + async def first_async_calibrating() -> None: + await asyncio.sleep(CALIBRATING_TIME) + + @robot_context_tracker.track(state=RobotContextState.CALIBRATING) + async def second_async_calibrating() -> None: + await asyncio.sleep(CALIBRATING_TIME) + + await asyncio.gather(first_async_calibrating(), second_async_calibrating()) + + assert ( + len(robot_context_tracker._storage) == 2 + ), "Both concurrent async operations should be tracked." + assert all( + data.state == RobotContextState.CALIBRATING + for data in robot_context_tracker._storage + ), "All tracked operations should be in CALIBRATING state." + + +def test_no_tracking(tmp_path: Path) -> None: + """Tests that operations are not tracked when tracking is disabled.""" + robot_context_tracker = RobotContextTracker(tmp_path, should_track=False) + + @robot_context_tracker.track(state=RobotContextState.STARTING_UP) + def operation_without_tracking() -> None: + sleep(STARTING_TIME) + + operation_without_tracking() + + assert ( + len(robot_context_tracker._storage) == 0 + ), "Operation should not be tracked when tracking is disabled." + + +async def test_storing_to_file(tmp_path: Path) -> None: + """Tests storing the tracked data to a file.""" + robot_context_tracker = RobotContextTracker(tmp_path, should_track=True) + + @robot_context_tracker.track(state=RobotContextState.STARTING_UP) + def starting_robot() -> None: + sleep(STARTING_TIME) + + @robot_context_tracker.track(state=RobotContextState.CALIBRATING) + def calibrating_robot() -> None: + sleep(CALIBRATING_TIME) + + @robot_context_tracker.track(state=RobotContextState.ANALYZING_PROTOCOL) + def analyzing_protocol() -> None: + sleep(ANALYZING_TIME) + + starting_robot() + calibrating_robot() + analyzing_protocol() + + robot_context_tracker.store() + + with open(robot_context_tracker._storage_file_path, "r") as file: + lines = file.readlines() + assert ( + len(lines) == 4 + ), "All stored data + header should be written to the file." + + +@patch( + "performance_metrics.robot_context_tracker._get_timing_function", + return_value=time_ns, +) +def test_using_non_linux_time_functions(tmp_path: Path) -> None: + """Tests tracking operations using non-Linux time functions.""" + file_path = tmp_path / "test_file.csv" + robot_context_tracker = RobotContextTracker(file_path, should_track=True) + + @robot_context_tracker.track(state=RobotContextState.STARTING_UP) + def starting_robot() -> None: + sleep(STARTING_TIME) + + @robot_context_tracker.track(state=RobotContextState.CALIBRATING) + def calibrating_robot() -> None: + sleep(CALIBRATING_TIME) + + starting_robot() + calibrating_robot() + + storage = robot_context_tracker._storage + assert all( + data.func_start > 0 for data in storage + ), "All function start times should be greater than 0." + assert all( + data.duration_start > 0 for data in storage + ), "All duration start times should be greater than 0." + assert all( + data.duration_end > 0 for data in storage + ), "All duration end times should be greater than 0." + assert all( + data.duration_end > data.duration_start for data in storage + ), "Duration end times should be greater than duration start times." + assert len(storage) == 2, "Both operations should be tracked." diff --git a/protocol-designer/cypress/integration/migrations.spec.js b/protocol-designer/cypress/integration/migrations.spec.js index 6c1d01a0ee7..4339f40be5f 100644 --- a/protocol-designer/cypress/integration/migrations.spec.js +++ b/protocol-designer/cypress/integration/migrations.spec.js @@ -127,7 +127,7 @@ describe('Protocol fixtures migrate and match snapshots', () => { cy.get('div') .contains( - 'This protocol can only run on app and robot server version 7.1 or higher' + 'This protocol can only run on app and robot server version 7.3.0 or higher' ) .should('exist') cy.get('button').contains('continue', { matchCase: false }).click() diff --git a/protocol-designer/cypress/integration/mixSettings.spec.js b/protocol-designer/cypress/integration/mixSettings.spec.js index 809c92237b3..60fabb65d78 100644 --- a/protocol-designer/cypress/integration/mixSettings.spec.js +++ b/protocol-designer/cypress/integration/mixSettings.spec.js @@ -59,7 +59,7 @@ describe('Advanced Settings for Mix Form', () => { cy.get('input[name="aspirate_flowRate"]').should('be.disabled') // TipPosition Aspirate should be disabled - cy.get('[id=TipPositionField_mix_mmFromBottom]').should('be.disabled') + cy.get('[id=TipPositionIcon_mix_mmFromBottom]').should('not.be.enabled') // Dispense Flowrate disbled cy.get('input[name="dispense_flowRate"]').should('be.disabled') @@ -91,7 +91,7 @@ describe('Advanced Settings for Mix Form', () => { cy.get('input[name="dispense_flowRate"]').should('be.enabled') // TipPosition Aspirate should be enabled - cy.get('[id=TipPositionField_mix_mmFromBottom]').should('be.enabled') + cy.get('[id=TipPositionIcon_mix_mmFromBottom]').should('not.be.disabled') // Delay in aspirate and Dispense settings is enabled cy.get('input[name="aspirate_delay_checkbox"]').should('be.enabled') diff --git a/protocol-designer/cypress/integration/transferSettings.spec.js b/protocol-designer/cypress/integration/transferSettings.spec.js index a4c831fddd4..82fa26f8dae 100644 --- a/protocol-designer/cypress/integration/transferSettings.spec.js +++ b/protocol-designer/cypress/integration/transferSettings.spec.js @@ -53,7 +53,7 @@ describe('Advanced Settings for Transfer Form', () => { it('Verify functionality of advanced settings with different pipette and labware', () => { enterBatchEdit() - // Different Pipette disbales aspirate and dispense Flowrate and Mix settings + // Different Pipette disables aspirate and dispense Flowrate and Mix settings // step 6 has different pipette than step 1 cy.get('[data-test="StepItem_6"]').click(batchEditClickOptions) @@ -68,10 +68,14 @@ describe('Advanced Settings for Transfer Form', () => { cy.get('input[name="aspirate_mix_checkbox"]').should('be.disabled') // TipPosition Aspirate and Dispense should be disabled - cy.get('[id=TipPositionField_aspirate_mmFromBottom]').should('be.disabled') - cy.get('[id=TipPositionField_dispense_mmFromBottom]').should('be.disabled') + cy.get('[id=TipPositionIcon_aspirate_mmFromBottom]').should( + 'not.be.enabled' + ) + cy.get('[id=TipPositionIcon_dispense_mmFromBottom]').should( + 'not.be.enabled' + ) - // Dispense Flowrate and mix diabled + // Dispense Flowrate and mix disabled cy.get('input[name="dispense_flowRate"]').should('be.disabled') cy.get('input[name="dispense_mix_checkbox"]').should('be.disabled') @@ -108,8 +112,12 @@ describe('Advanced Settings for Transfer Form', () => { .should('be.empty') // TipPosition Aspirate and Dispense should be enabled - cy.get('[id=TipPositionField_aspirate_mmFromBottom]').should('be.enabled') - cy.get('[id=TipPositionField_dispense_mmFromBottom]').should('be.enabled') + cy.get('[id=TipPositionIcon_aspirate_mmFromBottom]').should( + 'not.be.disabled' + ) + cy.get('[id=TipPositionIcon_dispense_mmFromBottom]').should( + 'not.be.disabled' + ) // Delay in aspirate and Dispense settings is enabled cy.get('input[name="aspirate_delay_checkbox"]').should('be.enabled') diff --git a/protocol-designer/fixtures/protocol/8/doItAllV3MigratedToV8.json b/protocol-designer/fixtures/protocol/8/doItAllV3MigratedToV8.json index 9bc7b9e44ed..e448368f932 100644 --- a/protocol-designer/fixtures/protocol/8/doItAllV3MigratedToV8.json +++ b/protocol-designer/fixtures/protocol/8/doItAllV3MigratedToV8.json @@ -6,7 +6,7 @@ "author": "Fixture", "description": "Test all v3 commands", "created": 1585930833548, - "lastModified": 1709303240330, + "lastModified": 1711742442671, "category": null, "subcategory": null, "tags": [] @@ -15,7 +15,7 @@ "name": "opentrons/protocol-designer", "version": "8.1.0", "data": { - "_internalAppBuildDate": "Fri, 01 Mar 2024 14:22:27 GMT", + "_internalAppBuildDate": "Fri, 29 Mar 2024 20:00:04 GMT", "defaultValues": { "aspirate_mmFromBottom": 1, "dispense_mmFromBottom": 0.5, @@ -101,6 +101,7 @@ "dispense_touchTip_mmFromBottom": 40, "disposalVolume_checkbox": true, "disposalVolume_volume": "20", + "blowout_z_offset": 0, "blowout_checkbox": false, "blowout_location": "8053a205-f2dc-4b1d-8d05-bf8233949e2e:trashBin", "preWetTip": false, @@ -116,6 +117,10 @@ "dispense_delay_mmFromBottom": "0.5", "dropTip_location": "8053a205-f2dc-4b1d-8d05-bf8233949e2e:trashBin", "nozzles": null, + "dispense_x_position": 0, + "dispense_y_position": 0, + "aspirate_x_position": 0, + "aspirate_y_position": 0, "id": "3961e4c0-75c7-11ea-b42f-4b64e50f43e5", "stepType": "moveLiquid", "stepName": "transfer", @@ -153,6 +158,7 @@ "labware": "1e610d40-75c7-11ea-b42f-4b64e50f43e5:opentrons/nest_96_wellplate_100ul_pcr_full_skirt/1", "mix_wellOrder_first": "t2b", "mix_wellOrder_second": "l2r", + "blowout_z_offset": 0, "blowout_checkbox": true, "blowout_location": "8053a205-f2dc-4b1d-8d05-bf8233949e2e:trashBin", "mix_mmFromBottom": 0.5, @@ -170,6 +176,8 @@ "dropTip_location": "8053a205-f2dc-4b1d-8d05-bf8233949e2e:trashBin", "nozzles": null, "tipRack": "0b44c760-75c7-11ea-b42f-4b64e50f43e5:opentrons/opentrons_96_tiprack_300ul/1", + "mix_x_position": 0, + "mix_y_position": 0, "id": "a4cee9a0-75dc-11ea-b42f-4b64e50f43e5", "stepType": "mix", "stepName": "mix", @@ -2518,7 +2526,7 @@ "commandSchemaId": "opentronsCommandSchemaV8", "commands": [ { - "key": "db2d2973-9059-41a8-a6f7-3b70b747cb2d", + "key": "d371b7e2-71a8-4a60-90bc-7e865d9881b9", "commandType": "loadPipette", "params": { "pipetteName": "p300_single_gen2", @@ -2527,7 +2535,7 @@ } }, { - "key": "d9bb5f59-77e8-4794-af52-5ac18181a1c9", + "key": "424963b7-59f8-434a-bedc-9597e7b72c9f", "commandType": "loadLabware", "params": { "displayName": "Opentrons 96 Tip Rack 300 µL", @@ -2539,7 +2547,7 @@ } }, { - "key": "e375681d-7284-4f0c-9921-d16e4ce0649e", + "key": "05ef86f7-dec0-4134-a15d-5e38ef81cf8e", "commandType": "loadLabware", "params": { "displayName": "NEST 96 Well Plate 100 µL PCR Full Skirt", @@ -2551,7 +2559,7 @@ } }, { - "key": "9455cd08-4d3a-45e4-8614-7485193e824e", + "key": "ddefc5ef-b69a-4172-921b-959ba5e8d8d2", "commandType": "loadLabware", "params": { "displayName": "Opentrons 24 Well Aluminum Block with Generic 2 mL Screwcap", @@ -2564,7 +2572,7 @@ }, { "commandType": "loadLiquid", - "key": "27c67940-a745-41b9-b4d8-01a8dba8b4e9", + "key": "2a2084d5-67d8-4806-b919-5962a6258c1f", "params": { "liquidId": "0", "labwareId": "1e610d40-75c7-11ea-b42f-4b64e50f43e5:opentrons/nest_96_wellplate_100ul_pcr_full_skirt/1", @@ -2590,12 +2598,12 @@ }, { "commandType": "waitForDuration", - "key": "b7c8d36b-c9d6-4fa3-a696-da35a3cc5981", + "key": "c1a1eff4-7ef7-46be-aee7-ebca5924ace8", "params": { "seconds": 62, "message": "" } }, { "commandType": "pickUpTip", - "key": "d97988e7-e386-4965-a915-f4776a0d7720", + "key": "63ca0ab5-4cb6-4531-b912-1ba22e1b1a03", "params": { "pipetteId": "0b3f2210-75c7-11ea-b42f-4b64e50f43e5", "labwareId": "0b44c760-75c7-11ea-b42f-4b64e50f43e5:opentrons/opentrons_96_tiprack_300ul/1", @@ -2604,67 +2612,82 @@ }, { "commandType": "aspirate", - "key": "a60e86c1-8bf0-477f-8748-24ce798eb1de", + "key": "5ead7532-0eb2-4ad9-b704-856422fc9408", "params": { "pipetteId": "0b3f2210-75c7-11ea-b42f-4b64e50f43e5", "volume": 30, "labwareId": "1e610d40-75c7-11ea-b42f-4b64e50f43e5:opentrons/nest_96_wellplate_100ul_pcr_full_skirt/1", "wellName": "A1", - "wellLocation": { "origin": "bottom", "offset": { "z": 1 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 1, "x": 0, "y": 0 } + }, "flowRate": 46.43 } }, { "commandType": "dispense", - "key": "61afaad0-0566-4435-b03a-94498d2fc2aa", + "key": "3838f7d1-3450-49cc-a222-c8113eecf108", "params": { "pipetteId": "0b3f2210-75c7-11ea-b42f-4b64e50f43e5", "volume": 30, "labwareId": "1e610d40-75c7-11ea-b42f-4b64e50f43e5:opentrons/nest_96_wellplate_100ul_pcr_full_skirt/1", "wellName": "A1", - "wellLocation": { "origin": "bottom", "offset": { "z": 1 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 1, "x": 0, "y": 0 } + }, "flowRate": 46.43 } }, { "commandType": "aspirate", - "key": "4ccf6427-d404-4b4b-9974-935a6676d8d2", + "key": "25697ae7-169d-447a-906c-4e7f02950fe9", "params": { "pipetteId": "0b3f2210-75c7-11ea-b42f-4b64e50f43e5", "volume": 30, "labwareId": "1e610d40-75c7-11ea-b42f-4b64e50f43e5:opentrons/nest_96_wellplate_100ul_pcr_full_skirt/1", "wellName": "A1", - "wellLocation": { "origin": "bottom", "offset": { "z": 1 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 1, "x": 0, "y": 0 } + }, "flowRate": 46.43 } }, { "commandType": "dispense", - "key": "bcdd9d53-ff68-4264-bd61-e11422149144", + "key": "49a139f4-87ba-421d-9ef4-4ebe13beb987", "params": { "pipetteId": "0b3f2210-75c7-11ea-b42f-4b64e50f43e5", "volume": 30, "labwareId": "1e610d40-75c7-11ea-b42f-4b64e50f43e5:opentrons/nest_96_wellplate_100ul_pcr_full_skirt/1", "wellName": "A1", - "wellLocation": { "origin": "bottom", "offset": { "z": 1 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 1, "x": 0, "y": 0 } + }, "flowRate": 46.43 } }, { "commandType": "aspirate", - "key": "ce51dbac-b2ed-4edd-9657-33c106288844", + "key": "4e96faa5-c669-4b60-b15c-9d2f01c9c3fe", "params": { "pipetteId": "0b3f2210-75c7-11ea-b42f-4b64e50f43e5", "volume": 100, "labwareId": "1e610d40-75c7-11ea-b42f-4b64e50f43e5:opentrons/nest_96_wellplate_100ul_pcr_full_skirt/1", "wellName": "A1", - "wellLocation": { "origin": "bottom", "offset": { "z": 1 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 1, "x": 0, "y": 0 } + }, "flowRate": 46.43 } }, { "commandType": "touchTip", - "key": "a1993756-c789-4804-8ff0-f3f9577d68f4", + "key": "8eff88a1-fec9-46d7-b292-f6ce378e5ad9", "params": { "pipetteId": "0b3f2210-75c7-11ea-b42f-4b64e50f43e5", "labwareId": "1e610d40-75c7-11ea-b42f-4b64e50f43e5:opentrons/nest_96_wellplate_100ul_pcr_full_skirt/1", @@ -2674,19 +2697,22 @@ }, { "commandType": "dispense", - "key": "270e73e1-719a-4338-9a8d-7ef8cdab558e", + "key": "c95e323c-be69-4460-8acf-d1d4b74384bd", "params": { "pipetteId": "0b3f2210-75c7-11ea-b42f-4b64e50f43e5", "volume": 40, "labwareId": "21ed8f60-75c7-11ea-b42f-4b64e50f43e5:opentrons/opentrons_24_aluminumblock_generic_2ml_screwcap/1", "wellName": "A1", - "wellLocation": { "origin": "bottom", "offset": { "z": 0.5 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 0.5, "x": 0, "y": 0 } + }, "flowRate": 46.43 } }, { "commandType": "touchTip", - "key": "45fd9725-1bef-4333-bce3-e4e81fc94fd4", + "key": "0da25745-5e25-4138-b67c-dfc4c89c8949", "params": { "pipetteId": "0b3f2210-75c7-11ea-b42f-4b64e50f43e5", "labwareId": "21ed8f60-75c7-11ea-b42f-4b64e50f43e5:opentrons/opentrons_24_aluminumblock_generic_2ml_screwcap/1", @@ -2696,19 +2722,22 @@ }, { "commandType": "dispense", - "key": "ede9c8e0-ced4-4a60-841e-c28476d28ab8", + "key": "28eeb3d1-6e83-4414-8c0d-e8761ca2f75a", "params": { "pipetteId": "0b3f2210-75c7-11ea-b42f-4b64e50f43e5", "volume": 40, "labwareId": "21ed8f60-75c7-11ea-b42f-4b64e50f43e5:opentrons/opentrons_24_aluminumblock_generic_2ml_screwcap/1", "wellName": "A2", - "wellLocation": { "origin": "bottom", "offset": { "z": 0.5 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 0.5, "x": 0, "y": 0 } + }, "flowRate": 46.43 } }, { "commandType": "touchTip", - "key": "d7fb1df9-ee04-4a6d-98e0-1ded591260bc", + "key": "8cd5d90d-df0b-4c3b-8cb3-cea6f1849fef", "params": { "pipetteId": "0b3f2210-75c7-11ea-b42f-4b64e50f43e5", "labwareId": "21ed8f60-75c7-11ea-b42f-4b64e50f43e5:opentrons/opentrons_24_aluminumblock_generic_2ml_screwcap/1", @@ -2718,7 +2747,7 @@ }, { "commandType": "moveToAddressableArea", - "key": "35f1f9b9-78f2-4a1a-9b7b-3c488881db2b", + "key": "35643d1f-ae0b-4a90-9de4-c9eb3c9b775e", "params": { "pipetteId": "0b3f2210-75c7-11ea-b42f-4b64e50f43e5", "addressableAreaName": "fixedTrash", @@ -2727,7 +2756,7 @@ }, { "commandType": "blowOutInPlace", - "key": "7dbec34d-5da6-41aa-9ff9-9368efa23407", + "key": "d540a57a-6968-44a0-8645-b221a9b7bfd7", "params": { "pipetteId": "0b3f2210-75c7-11ea-b42f-4b64e50f43e5", "flowRate": 46.43 @@ -2735,7 +2764,7 @@ }, { "commandType": "moveToAddressableAreaForDropTip", - "key": "7c10381b-bee1-4908-94c5-11d76a966a12", + "key": "c721cfd7-fef8-4fcb-9d6f-1d78f2317729", "params": { "pipetteId": "0b3f2210-75c7-11ea-b42f-4b64e50f43e5", "addressableAreaName": "fixedTrash", @@ -2745,17 +2774,17 @@ }, { "commandType": "dropTipInPlace", - "key": "fb84a594-b8b9-4950-81f0-cc2be260346e", + "key": "a18788f3-cd5f-4470-8831-455d14883d1c", "params": { "pipetteId": "0b3f2210-75c7-11ea-b42f-4b64e50f43e5" } }, { "commandType": "waitForResume", - "key": "340df2fa-adf0-4b43-90d7-2e1d8f09ba71", + "key": "a54eb58b-ce5c-4a59-ba85-ed75438146a7", "params": { "message": "Wait until user intervention" } }, { "commandType": "pickUpTip", - "key": "9ff49b09-2860-4955-bd6f-a68ab3797208", + "key": "c1bddcd0-d5cf-4d7c-b830-a5b27a5a71cb", "params": { "pipetteId": "0b3f2210-75c7-11ea-b42f-4b64e50f43e5", "labwareId": "0b44c760-75c7-11ea-b42f-4b64e50f43e5:opentrons/opentrons_96_tiprack_300ul/1", @@ -2764,79 +2793,97 @@ }, { "commandType": "aspirate", - "key": "249a56b1-e68b-449d-aad1-9a4bd9113a34", + "key": "1660f6c2-9072-4348-b034-cb45712f8cd7", "params": { "pipetteId": "0b3f2210-75c7-11ea-b42f-4b64e50f43e5", "volume": 35, "labwareId": "1e610d40-75c7-11ea-b42f-4b64e50f43e5:opentrons/nest_96_wellplate_100ul_pcr_full_skirt/1", "wellName": "D2", - "wellLocation": { "origin": "bottom", "offset": { "z": 0.5 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 0.5, "x": 0, "y": 0 } + }, "flowRate": 40 } }, { "commandType": "dispense", - "key": "5503460e-fc28-4ebf-b476-88d2517ec4c5", + "key": "c3683fde-b4e0-4432-ad96-932292f2ebcd", "params": { "pipetteId": "0b3f2210-75c7-11ea-b42f-4b64e50f43e5", "volume": 35, "labwareId": "1e610d40-75c7-11ea-b42f-4b64e50f43e5:opentrons/nest_96_wellplate_100ul_pcr_full_skirt/1", "wellName": "D2", - "wellLocation": { "origin": "bottom", "offset": { "z": 0.5 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 0.5, "x": 0, "y": 0 } + }, "flowRate": 35 } }, { "commandType": "aspirate", - "key": "75c377d3-a93f-4310-9c06-1ee6e1d2fdb1", + "key": "59251222-f64d-400b-98a6-71f95f24bec7", "params": { "pipetteId": "0b3f2210-75c7-11ea-b42f-4b64e50f43e5", "volume": 35, "labwareId": "1e610d40-75c7-11ea-b42f-4b64e50f43e5:opentrons/nest_96_wellplate_100ul_pcr_full_skirt/1", "wellName": "D2", - "wellLocation": { "origin": "bottom", "offset": { "z": 0.5 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 0.5, "x": 0, "y": 0 } + }, "flowRate": 40 } }, { "commandType": "dispense", - "key": "c0ad7408-1f69-4092-8de0-524a0c3991e4", + "key": "a370936b-c12f-4039-88d0-97bb262cb80e", "params": { "pipetteId": "0b3f2210-75c7-11ea-b42f-4b64e50f43e5", "volume": 35, "labwareId": "1e610d40-75c7-11ea-b42f-4b64e50f43e5:opentrons/nest_96_wellplate_100ul_pcr_full_skirt/1", "wellName": "D2", - "wellLocation": { "origin": "bottom", "offset": { "z": 0.5 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 0.5, "x": 0, "y": 0 } + }, "flowRate": 35 } }, { "commandType": "aspirate", - "key": "2c04b095-4f0c-4cd7-a1bc-8daee4e05f38", + "key": "8f428646-3bd6-4a90-9674-23d3e3be8a63", "params": { "pipetteId": "0b3f2210-75c7-11ea-b42f-4b64e50f43e5", "volume": 35, "labwareId": "1e610d40-75c7-11ea-b42f-4b64e50f43e5:opentrons/nest_96_wellplate_100ul_pcr_full_skirt/1", "wellName": "D2", - "wellLocation": { "origin": "bottom", "offset": { "z": 0.5 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 0.5, "x": 0, "y": 0 } + }, "flowRate": 40 } }, { "commandType": "dispense", - "key": "8ead30e3-b057-4994-b00c-c18a838d86ad", + "key": "445797f5-5799-486a-b0e2-299e2f23ca2a", "params": { "pipetteId": "0b3f2210-75c7-11ea-b42f-4b64e50f43e5", "volume": 35, "labwareId": "1e610d40-75c7-11ea-b42f-4b64e50f43e5:opentrons/nest_96_wellplate_100ul_pcr_full_skirt/1", "wellName": "D2", - "wellLocation": { "origin": "bottom", "offset": { "z": 0.5 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 0.5, "x": 0, "y": 0 } + }, "flowRate": 35 } }, { "commandType": "moveToAddressableArea", - "key": "41dc50be-94fb-49f6-9ac6-9c8948622640", + "key": "d740d713-a3cb-4bdb-81a5-798059db8be7", "params": { "pipetteId": "0b3f2210-75c7-11ea-b42f-4b64e50f43e5", "addressableAreaName": "fixedTrash", @@ -2845,7 +2892,7 @@ }, { "commandType": "blowOutInPlace", - "key": "4147917f-bca1-4ef4-b055-0610002a3572", + "key": "ba227a58-a0b1-4d83-93f8-4a3566cbedf1", "params": { "pipetteId": "0b3f2210-75c7-11ea-b42f-4b64e50f43e5", "flowRate": 35 @@ -2853,7 +2900,7 @@ }, { "commandType": "touchTip", - "key": "5692383d-d3c3-4969-9b76-c5dfd265e4c5", + "key": "68b765bb-a232-49ec-b6be-fc6b375b0a15", "params": { "pipetteId": "0b3f2210-75c7-11ea-b42f-4b64e50f43e5", "labwareId": "1e610d40-75c7-11ea-b42f-4b64e50f43e5:opentrons/nest_96_wellplate_100ul_pcr_full_skirt/1", @@ -2863,7 +2910,7 @@ }, { "commandType": "moveToAddressableAreaForDropTip", - "key": "e389e5ae-8109-4a68-a8e5-58d96f453a85", + "key": "1464952c-cb00-48eb-a9db-8a4367d3ce0b", "params": { "pipetteId": "0b3f2210-75c7-11ea-b42f-4b64e50f43e5", "addressableAreaName": "fixedTrash", @@ -2873,12 +2920,12 @@ }, { "commandType": "dropTipInPlace", - "key": "82048ca4-6ad6-4de9-ad94-4fc698e3aaff", + "key": "2d96c742-46d0-4efa-8e94-3118e975bdd4", "params": { "pipetteId": "0b3f2210-75c7-11ea-b42f-4b64e50f43e5" } }, { "commandType": "pickUpTip", - "key": "b868a416-8074-4e21-8483-39b0bbc89ba2", + "key": "75a6817c-7f41-4a8c-a184-5e6e7aad51e9", "params": { "pipetteId": "0b3f2210-75c7-11ea-b42f-4b64e50f43e5", "labwareId": "0b44c760-75c7-11ea-b42f-4b64e50f43e5:opentrons/opentrons_96_tiprack_300ul/1", @@ -2887,79 +2934,97 @@ }, { "commandType": "aspirate", - "key": "677f3413-0fb0-428d-875a-32d3c8971ca1", + "key": "3e1db7e3-a5eb-473c-a98b-1c91e9b70c3d", "params": { "pipetteId": "0b3f2210-75c7-11ea-b42f-4b64e50f43e5", "volume": 35, "labwareId": "1e610d40-75c7-11ea-b42f-4b64e50f43e5:opentrons/nest_96_wellplate_100ul_pcr_full_skirt/1", "wellName": "E2", - "wellLocation": { "origin": "bottom", "offset": { "z": 0.5 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 0.5, "x": 0, "y": 0 } + }, "flowRate": 40 } }, { "commandType": "dispense", - "key": "6cafa525-b6f6-4ab4-8919-6398ecdcad50", + "key": "d37facff-0753-4d92-9599-93141c97a90f", "params": { "pipetteId": "0b3f2210-75c7-11ea-b42f-4b64e50f43e5", "volume": 35, "labwareId": "1e610d40-75c7-11ea-b42f-4b64e50f43e5:opentrons/nest_96_wellplate_100ul_pcr_full_skirt/1", "wellName": "E2", - "wellLocation": { "origin": "bottom", "offset": { "z": 0.5 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 0.5, "x": 0, "y": 0 } + }, "flowRate": 35 } }, { "commandType": "aspirate", - "key": "ef66610b-0d69-405b-91e9-9d46ef6f9e49", + "key": "df03e618-352a-44e8-8890-859f53229f10", "params": { "pipetteId": "0b3f2210-75c7-11ea-b42f-4b64e50f43e5", "volume": 35, "labwareId": "1e610d40-75c7-11ea-b42f-4b64e50f43e5:opentrons/nest_96_wellplate_100ul_pcr_full_skirt/1", "wellName": "E2", - "wellLocation": { "origin": "bottom", "offset": { "z": 0.5 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 0.5, "x": 0, "y": 0 } + }, "flowRate": 40 } }, { "commandType": "dispense", - "key": "88fbf912-ebaf-4148-9339-2b8fe5d8381d", + "key": "0b93f43f-b456-47fa-b9d7-89086cd9c20b", "params": { "pipetteId": "0b3f2210-75c7-11ea-b42f-4b64e50f43e5", "volume": 35, "labwareId": "1e610d40-75c7-11ea-b42f-4b64e50f43e5:opentrons/nest_96_wellplate_100ul_pcr_full_skirt/1", "wellName": "E2", - "wellLocation": { "origin": "bottom", "offset": { "z": 0.5 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 0.5, "x": 0, "y": 0 } + }, "flowRate": 35 } }, { "commandType": "aspirate", - "key": "86107fa6-c935-4123-bf58-76643dc888d5", + "key": "310303b6-76e3-4765-bd82-042eac727669", "params": { "pipetteId": "0b3f2210-75c7-11ea-b42f-4b64e50f43e5", "volume": 35, "labwareId": "1e610d40-75c7-11ea-b42f-4b64e50f43e5:opentrons/nest_96_wellplate_100ul_pcr_full_skirt/1", "wellName": "E2", - "wellLocation": { "origin": "bottom", "offset": { "z": 0.5 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 0.5, "x": 0, "y": 0 } + }, "flowRate": 40 } }, { "commandType": "dispense", - "key": "2c8d07b1-3f65-4554-99aa-3bc8899a5bd6", + "key": "9881ac40-2932-4197-a03b-77c936651a3b", "params": { "pipetteId": "0b3f2210-75c7-11ea-b42f-4b64e50f43e5", "volume": 35, "labwareId": "1e610d40-75c7-11ea-b42f-4b64e50f43e5:opentrons/nest_96_wellplate_100ul_pcr_full_skirt/1", "wellName": "E2", - "wellLocation": { "origin": "bottom", "offset": { "z": 0.5 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 0.5, "x": 0, "y": 0 } + }, "flowRate": 35 } }, { "commandType": "moveToAddressableArea", - "key": "52740457-0f28-44b5-a053-80a7b8be7932", + "key": "f521a11f-1676-4dc2-a022-f5eba1c5d22e", "params": { "pipetteId": "0b3f2210-75c7-11ea-b42f-4b64e50f43e5", "addressableAreaName": "fixedTrash", @@ -2968,7 +3033,7 @@ }, { "commandType": "blowOutInPlace", - "key": "cff38f29-4334-4fe3-a361-465f2ce46be5", + "key": "daede461-9d74-4259-91e6-ecf7ddaa4897", "params": { "pipetteId": "0b3f2210-75c7-11ea-b42f-4b64e50f43e5", "flowRate": 35 @@ -2976,7 +3041,7 @@ }, { "commandType": "touchTip", - "key": "e9c841a0-f8e2-4f07-9eb6-6d03764259a6", + "key": "0cde152c-2aeb-4e86-9745-3732e0074ba7", "params": { "pipetteId": "0b3f2210-75c7-11ea-b42f-4b64e50f43e5", "labwareId": "1e610d40-75c7-11ea-b42f-4b64e50f43e5:opentrons/nest_96_wellplate_100ul_pcr_full_skirt/1", @@ -2986,7 +3051,7 @@ }, { "commandType": "moveToAddressableAreaForDropTip", - "key": "f1dc8237-78ef-4116-88f5-42d426086e63", + "key": "cb24aade-655e-4f6f-83d7-1b60457b56e6", "params": { "pipetteId": "0b3f2210-75c7-11ea-b42f-4b64e50f43e5", "addressableAreaName": "fixedTrash", @@ -2996,7 +3061,7 @@ }, { "commandType": "dropTipInPlace", - "key": "c7ebd1ef-9d28-43dc-9fdd-6142a1b22c70", + "key": "6970ad16-6e47-4f5c-afba-3704abe0eabb", "params": { "pipetteId": "0b3f2210-75c7-11ea-b42f-4b64e50f43e5" } } ], diff --git a/protocol-designer/fixtures/protocol/8/doItAllV4MigratedToV8.json b/protocol-designer/fixtures/protocol/8/doItAllV4MigratedToV8.json index 6a3d3888cba..f8fec2171af 100644 --- a/protocol-designer/fixtures/protocol/8/doItAllV4MigratedToV8.json +++ b/protocol-designer/fixtures/protocol/8/doItAllV4MigratedToV8.json @@ -6,7 +6,7 @@ "author": "Fixture", "description": "Test all v4 commands", "created": 1585930833548, - "lastModified": 1709303209919, + "lastModified": 1711742493128, "category": null, "subcategory": null, "tags": [] @@ -15,7 +15,7 @@ "name": "opentrons/protocol-designer", "version": "8.1.0", "data": { - "_internalAppBuildDate": "Fri, 01 Mar 2024 14:22:27 GMT", + "_internalAppBuildDate": "Fri, 29 Mar 2024 20:00:04 GMT", "defaultValues": { "aspirate_mmFromBottom": 1, "dispense_mmFromBottom": 0.5, @@ -135,6 +135,7 @@ "dispense_touchTip_mmFromBottom": null, "disposalVolume_checkbox": true, "disposalVolume_volume": "20", + "blowout_z_offset": 0, "blowout_checkbox": false, "blowout_location": "84882326-9cd3-428e-8352-89f133a1fe5d:trashBin", "preWetTip": false, @@ -150,6 +151,10 @@ "dispense_delay_mmFromBottom": "0.5", "dropTip_location": "84882326-9cd3-428e-8352-89f133a1fe5d:trashBin", "nozzles": null, + "dispense_x_position": 0, + "dispense_y_position": 0, + "aspirate_x_position": 0, + "aspirate_y_position": 0, "id": "3961e4c0-75c7-11ea-b42f-4b64e50f43e5", "stepType": "moveLiquid", "stepName": "transfer", @@ -2546,7 +2551,7 @@ "commandSchemaId": "opentronsCommandSchemaV8", "commands": [ { - "key": "ee3dbe0a-f7b1-4995-8449-dea339f61737", + "key": "b7185c84-9b15-4b6e-a315-e331249569fa", "commandType": "loadPipette", "params": { "pipetteName": "p300_single_gen2", @@ -2555,7 +2560,7 @@ } }, { - "key": "248415e4-9ae5-4741-9799-9184775c2d31", + "key": "0d1f6599-70d5-4e99-9608-7d249135b5a9", "commandType": "loadModule", "params": { "model": "magneticModuleV2", @@ -2564,7 +2569,7 @@ } }, { - "key": "94f5969a-7e98-47bc-aa0b-eea46b0271a8", + "key": "2ee81ffe-c8fa-4cac-be56-62a902e301f7", "commandType": "loadModule", "params": { "model": "temperatureModuleV2", @@ -2573,7 +2578,7 @@ } }, { - "key": "2ee5efc8-5c75-4cc6-8bea-0f258478f0af", + "key": "e1da2e62-ac25-405f-b896-99384ab081d8", "commandType": "loadLabware", "params": { "displayName": "Opentrons 96 Tip Rack 300 µL", @@ -2585,7 +2590,7 @@ } }, { - "key": "352f2e8e-87e1-4658-a86e-153e5307f35c", + "key": "2895d8a7-239c-4d6b-afc8-69defe261790", "commandType": "loadLabware", "params": { "displayName": "NEST 96 Well Plate 100 µL PCR Full Skirt", @@ -2599,7 +2604,7 @@ } }, { - "key": "95ee1321-124a-4e78-8b9a-517455c40ab0", + "key": "46b84345-0c06-41f8-860d-1dfafa424e80", "commandType": "loadLabware", "params": { "displayName": "Opentrons 24 Well Aluminum Block with Generic 2 mL Screwcap", @@ -2614,7 +2619,7 @@ }, { "commandType": "loadLiquid", - "key": "44de4f93-8550-465d-b26b-6a2f95d411c1", + "key": "25dd8768-7731-4dee-9f5a-d54b9eb0983c", "params": { "liquidId": "0", "labwareId": "1e610d40-75c7-11ea-b42f-4b64e50f43e5:opentrons/nest_96_wellplate_100ul_pcr_full_skirt/1", @@ -2640,7 +2645,7 @@ }, { "commandType": "magneticModule/engage", - "key": "eb54de80-449c-4287-ae26-5fe7cae3fa3a", + "key": "3471fe25-a3a8-4be0-b6d8-545819c4aea0", "params": { "moduleId": "0b419310-75c7-11ea-b42f-4b64e50f43e5:magneticModuleType", "height": 6 @@ -2648,7 +2653,7 @@ }, { "commandType": "temperatureModule/setTargetTemperature", - "key": "a0123190-8242-4c09-bb02-6f78d8c5e493", + "key": "610ae127-200b-48ae-8cbc-7ba4b5ca7b30", "params": { "moduleId": "0b4319b0-75c7-11ea-b42f-4b64e50f43e5:temperatureModuleType", "celsius": 25 @@ -2656,12 +2661,12 @@ }, { "commandType": "waitForDuration", - "key": "6eb18da1-b4ae-4adc-8384-a06b4c21d898", + "key": "94aa4488-7792-49bc-ac3d-6a260bad0f86", "params": { "seconds": 62, "message": "" } }, { "commandType": "pickUpTip", - "key": "ff0fb666-871c-43b8-87d9-9c71fdc0efc9", + "key": "1a838ef5-ea1a-4680-bac0-6eaf473465a4", "params": { "pipetteId": "0b3f2210-75c7-11ea-b42f-4b64e50f43e5", "labwareId": "0b44c760-75c7-11ea-b42f-4b64e50f43e5:opentrons/opentrons_96_tiprack_300ul/1", @@ -2670,31 +2675,37 @@ }, { "commandType": "aspirate", - "key": "398bbf30-90e7-4e50-b630-fb02ddd00160", + "key": "f74c2687-f02c-4034-aa03-9a73c1ee47af", "params": { "pipetteId": "0b3f2210-75c7-11ea-b42f-4b64e50f43e5", "volume": 30, "labwareId": "1e610d40-75c7-11ea-b42f-4b64e50f43e5:opentrons/nest_96_wellplate_100ul_pcr_full_skirt/1", "wellName": "A1", - "wellLocation": { "origin": "bottom", "offset": { "z": 1 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 1, "x": 0, "y": 0 } + }, "flowRate": 46.43 } }, { "commandType": "dispense", - "key": "0b0ff1c4-0167-4980-b710-794df0799956", + "key": "507c7fff-1193-4c14-a0b1-e4bb9fe9d96e", "params": { "pipetteId": "0b3f2210-75c7-11ea-b42f-4b64e50f43e5", "volume": 30, "labwareId": "21ed8f60-75c7-11ea-b42f-4b64e50f43e5:opentrons/opentrons_24_aluminumblock_generic_2ml_screwcap/1", "wellName": "A1", - "wellLocation": { "origin": "bottom", "offset": { "z": 0.5 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 0.5, "x": 0, "y": 0 } + }, "flowRate": 46.43 } }, { "commandType": "moveToAddressableAreaForDropTip", - "key": "7c8d4e34-5282-4ee8-bcae-36604b949bde", + "key": "5a050ced-d1a9-4031-bf16-ed49cb561e60", "params": { "pipetteId": "0b3f2210-75c7-11ea-b42f-4b64e50f43e5", "addressableAreaName": "fixedTrash", @@ -2704,12 +2715,12 @@ }, { "commandType": "dropTipInPlace", - "key": "7ba94010-e87e-448b-8535-70ad404a5f19", + "key": "8083dcbe-8c00-4178-90c0-4d4a921bca9c", "params": { "pipetteId": "0b3f2210-75c7-11ea-b42f-4b64e50f43e5" } }, { "commandType": "pickUpTip", - "key": "ceab71fc-ea60-4cbe-8302-7e38a8d27847", + "key": "e6db98b2-7239-4f6b-9e41-02e1dd108ad6", "params": { "pipetteId": "0b3f2210-75c7-11ea-b42f-4b64e50f43e5", "labwareId": "0b44c760-75c7-11ea-b42f-4b64e50f43e5:opentrons/opentrons_96_tiprack_300ul/1", @@ -2718,31 +2729,37 @@ }, { "commandType": "aspirate", - "key": "eda46364-da03-4582-9998-dd91945f08fc", + "key": "47cf3011-68e2-40cd-8563-145e460f93aa", "params": { "pipetteId": "0b3f2210-75c7-11ea-b42f-4b64e50f43e5", "volume": 30, "labwareId": "1e610d40-75c7-11ea-b42f-4b64e50f43e5:opentrons/nest_96_wellplate_100ul_pcr_full_skirt/1", "wellName": "B1", - "wellLocation": { "origin": "bottom", "offset": { "z": 1 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 1, "x": 0, "y": 0 } + }, "flowRate": 46.43 } }, { "commandType": "dispense", - "key": "42d98996-7605-4d70-b3be-e6a802022a32", + "key": "1f1d966a-9095-4857-9137-36131c91bfd2", "params": { "pipetteId": "0b3f2210-75c7-11ea-b42f-4b64e50f43e5", "volume": 30, "labwareId": "21ed8f60-75c7-11ea-b42f-4b64e50f43e5:opentrons/opentrons_24_aluminumblock_generic_2ml_screwcap/1", "wellName": "A1", - "wellLocation": { "origin": "bottom", "offset": { "z": 0.5 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 0.5, "x": 0, "y": 0 } + }, "flowRate": 46.43 } }, { "commandType": "moveToAddressableAreaForDropTip", - "key": "97c7e6ee-b6c5-4708-bc85-e5cba1c93a1b", + "key": "ac6074f6-2f28-4012-914b-d3b28eb8453d", "params": { "pipetteId": "0b3f2210-75c7-11ea-b42f-4b64e50f43e5", "addressableAreaName": "fixedTrash", @@ -2752,12 +2769,12 @@ }, { "commandType": "dropTipInPlace", - "key": "11b30838-4205-4141-9d81-7e2bbde8c7aa", + "key": "074050d3-0c4c-4fc0-8036-a5dc9afe99ef", "params": { "pipetteId": "0b3f2210-75c7-11ea-b42f-4b64e50f43e5" } }, { "commandType": "temperatureModule/waitForTemperature", - "key": "3748a664-b9d8-49fa-9f6b-3ad35eec5c2b", + "key": "89672a34-bd2f-4e2a-bacc-407bb5f563a1", "params": { "moduleId": "0b4319b0-75c7-11ea-b42f-4b64e50f43e5:temperatureModuleType", "celsius": 25 @@ -2765,19 +2782,19 @@ }, { "commandType": "magneticModule/disengage", - "key": "a1c763ef-3712-495f-998b-651566f3e759", + "key": "26603c88-f0a7-49b3-a65c-37e9e23ac2ff", "params": { "moduleId": "0b419310-75c7-11ea-b42f-4b64e50f43e5:magneticModuleType" } }, { "commandType": "waitForResume", - "key": "f4c1a79c-d774-4a04-9858-2c58f77c93fd", + "key": "f0e0a8c0-01df-47d7-92e5-c3c16e962f4f", "params": { "message": "Wait until user intervention" } }, { "commandType": "temperatureModule/deactivate", - "key": "bb2a6fad-2767-45ad-bc5f-bac249004c00", + "key": "bde12c91-d991-4d57-8d7b-172706f3aa2a", "params": { "moduleId": "0b4319b0-75c7-11ea-b42f-4b64e50f43e5:temperatureModuleType" } diff --git a/protocol-designer/fixtures/protocol/8/doItAllV7MigratedToV8.json b/protocol-designer/fixtures/protocol/8/doItAllV7MigratedToV8.json index bddc1313927..5519ec4f502 100644 --- a/protocol-designer/fixtures/protocol/8/doItAllV7MigratedToV8.json +++ b/protocol-designer/fixtures/protocol/8/doItAllV7MigratedToV8.json @@ -6,7 +6,7 @@ "author": "", "description": "", "created": 1689346890165, - "lastModified": 1711047167434, + "lastModified": 1711742514037, "category": null, "subcategory": null, "tags": [] @@ -15,7 +15,7 @@ "name": "opentrons/protocol-designer", "version": "8.1.0", "data": { - "_internalAppBuildDate": "Fri, 01 Mar 2024 14:22:27 GMT", + "_internalAppBuildDate": "Fri, 29 Mar 2024 20:00:04 GMT", "defaultValues": { "aspirate_mmFromBottom": 1, "dispense_mmFromBottom": 0.5, @@ -179,6 +179,7 @@ "dispense_touchTip_mmFromBottom": null, "disposalVolume_checkbox": true, "disposalVolume_volume": "100", + "blowout_z_offset": 0, "blowout_checkbox": false, "blowout_location": "4824b094-5999-4549-9e6b-7098a9b30a8b:trashBin", "preWetTip": false, @@ -194,6 +195,10 @@ "dispense_delay_mmFromBottom": null, "dropTip_location": "4824b094-5999-4549-9e6b-7098a9b30a8b:trashBin", "nozzles": null, + "dispense_x_position": 0, + "dispense_y_position": 0, + "aspirate_x_position": 0, + "aspirate_y_position": 0, "id": "f9a294f1-f42b-4cae-893a-592405349d56", "stepType": "moveLiquid", "stepName": "transfer", @@ -205,6 +210,7 @@ "labware": "fcba73e7-b88e-438e-963e-f8b9a5de0983:opentrons/nest_96_wellplate_100ul_pcr_full_skirt/2", "mix_wellOrder_first": "t2b", "mix_wellOrder_second": "l2r", + "blowout_z_offset": 0, "blowout_checkbox": false, "blowout_location": "4824b094-5999-4549-9e6b-7098a9b30a8b:trashBin", "mix_mmFromBottom": 0.5, @@ -222,6 +228,8 @@ "dropTip_location": "4824b094-5999-4549-9e6b-7098a9b30a8b:trashBin", "nozzles": null, "tipRack": "23ed35de-5bfd-4bb0-8f54-da99a2804ed9:opentrons/opentrons_flex_96_filtertiprack_50ul/1", + "mix_x_position": 0, + "mix_y_position": 0, "id": "5fdb9a12-fab4-42fd-886f-40af107b15d6", "stepType": "mix", "stepName": "mix", @@ -3753,7 +3761,7 @@ "commandSchemaId": "opentronsCommandSchemaV8", "commands": [ { - "key": "6e489e69-6adb-4874-ad9f-4da035825829", + "key": "17a2f6e6-dc06-4c3a-8e97-52728d96dbd5", "commandType": "loadPipette", "params": { "pipetteName": "p1000_single_flex", @@ -3762,7 +3770,7 @@ } }, { - "key": "7536b20c-1416-4e5a-9e0a-2ac13f805fcd", + "key": "23762a87-4d05-4ce1-adaf-b2e7288bfef9", "commandType": "loadPipette", "params": { "pipetteName": "p50_multi_flex", @@ -3771,7 +3779,7 @@ } }, { - "key": "67136a9e-c10f-40ce-80de-920e33d78d44", + "key": "74ed5557-4813-4892-a2e3-4f7710b70d1c", "commandType": "loadModule", "params": { "model": "magneticBlockV1", @@ -3780,7 +3788,7 @@ } }, { - "key": "469e8246-7e19-4654-acdd-7c29a79ce67b", + "key": "00beb9a8-59c7-4c99-b386-0f4214d61350", "commandType": "loadModule", "params": { "model": "heaterShakerModuleV1", @@ -3789,7 +3797,7 @@ } }, { - "key": "29533de7-bd35-458c-9f60-6b9be67bd64b", + "key": "347f3697-2728-4c24-9067-8e9b7d9bd1d6", "commandType": "loadModule", "params": { "model": "temperatureModuleV2", @@ -3798,7 +3806,7 @@ } }, { - "key": "85e3ebf2-4d2f-49e2-8335-cf8c69d58372", + "key": "89c6d0b5-71ed-4bf9-9d94-15375788b86a", "commandType": "loadModule", "params": { "model": "thermocyclerModuleV2", @@ -3807,7 +3815,7 @@ } }, { - "key": "d05c0cc2-d6c2-4fd3-9918-33f7d07bd2fd", + "key": "07ba1a3a-9161-47ee-bf63-501e847bc84d", "commandType": "loadLabware", "params": { "displayName": "Opentrons 96 Flat Bottom Heater-Shaker Adapter", @@ -3821,7 +3829,7 @@ } }, { - "key": "7b515b7c-9d35-4d4e-a19c-1a73de8fdc65", + "key": "c9aafdba-c777-4609-b99f-87405a76a7ec", "commandType": "loadLabware", "params": { "displayName": "Opentrons Flex 96 Filter Tip Rack 50 µL", @@ -3833,7 +3841,7 @@ } }, { - "key": "583e9796-64e7-411e-b3e4-ce3c5f18a39a", + "key": "008af3b3-4557-4755-af65-4e263bcd4d52", "commandType": "loadLabware", "params": { "displayName": "NEST 96 Well Plate 100 µL PCR Full Skirt", @@ -3847,7 +3855,7 @@ } }, { - "key": "bb089d2b-b8f8-4306-b0b8-e5d38d81aba6", + "key": "df64c3d8-c74b-468e-b663-f88c59ed927c", "commandType": "loadLabware", "params": { "displayName": "Opentrons 24 Well Aluminum Block with NEST 1.5 mL Snapcap", @@ -3861,7 +3869,7 @@ } }, { - "key": "aa80f4db-d94f-407c-9ea1-6df86119d200", + "key": "23249708-2910-493b-aa56-a05e687f13ee", "commandType": "loadLabware", "params": { "displayName": "NEST 96 Well Plate 200 µL Flat", @@ -3876,7 +3884,7 @@ }, { "commandType": "loadLiquid", - "key": "52dfe64f-29b5-4d3e-838d-aecf6c0df8e0", + "key": "46b4c996-8800-432b-824a-9f9fb2ae033e", "params": { "liquidId": "1", "labwareId": "a793a135-06aa-4ed6-a1d3-c176c7810afa:opentrons/opentrons_24_aluminumblock_nest_1.5ml_snapcap/1", @@ -3885,7 +3893,7 @@ }, { "commandType": "loadLiquid", - "key": "68e4b018-1e5b-48d4-b858-93b3154e63a5", + "key": "b8e21e25-5da0-426b-a1da-8d87751e48cc", "params": { "liquidId": "0", "labwareId": "fcba73e7-b88e-438e-963e-f8b9a5de0983:opentrons/nest_96_wellplate_100ul_pcr_full_skirt/2", @@ -3903,7 +3911,7 @@ }, { "commandType": "temperatureModule/setTargetTemperature", - "key": "18dcba87-324d-4483-a9be-e561c9b47bf0", + "key": "0b60938b-1bd4-4ffb-89f6-dac42a87ac0e", "params": { "moduleId": "ef44ad7f-0fd9-46d6-8bc0-c70785644cc8:temperatureModuleType", "celsius": 4 @@ -3911,7 +3919,7 @@ }, { "commandType": "heaterShaker/waitForTemperature", - "key": "84ae2f28-38f6-4314-9d34-3ff9af5a875c", + "key": "7d5fd109-43cd-4dea-b0fb-2efa3f727e38", "params": { "moduleId": "c19dffa3-cb34-4702-bcf6-dcea786257d1:heaterShakerModuleType", "celsius": 4 @@ -3919,14 +3927,14 @@ }, { "commandType": "thermocycler/closeLid", - "key": "0ec55f7b-4f82-46ad-a450-aac71d8ca198", + "key": "31bb9bbe-9c53-407a-ac73-e789b800466d", "params": { "moduleId": "627b7a27-5bb7-46de-a530-67af45652e3b:thermocyclerModuleType" } }, { "commandType": "thermocycler/setTargetLidTemperature", - "key": "883d4fba-7b4c-410d-aeff-79ce4c3d106e", + "key": "0d83be22-5cec-4603-b42c-03ffb6e6d8ba", "params": { "moduleId": "627b7a27-5bb7-46de-a530-67af45652e3b:thermocyclerModuleType", "celsius": 40 @@ -3934,14 +3942,14 @@ }, { "commandType": "thermocycler/waitForLidTemperature", - "key": "8da2d0c4-c9b2-4acb-8230-6b68f33b92b7", + "key": "1ac36b4e-b0df-4d43-9cfc-a10cc64ccda3", "params": { "moduleId": "627b7a27-5bb7-46de-a530-67af45652e3b:thermocyclerModuleType" } }, { "commandType": "thermocycler/runProfile", - "key": "6df23192-439c-429c-89ab-c932751096f0", + "key": "0917c6de-9fd8-4afa-b496-f62ae18fa290", "params": { "moduleId": "627b7a27-5bb7-46de-a530-67af45652e3b:thermocyclerModuleType", "profile": [ @@ -3953,28 +3961,28 @@ }, { "commandType": "thermocycler/deactivateBlock", - "key": "c99151db-add8-41e4-9e5b-0516198f06b4", + "key": "4e5e9302-fac9-438d-83c9-fabd4c65791f", "params": { "moduleId": "627b7a27-5bb7-46de-a530-67af45652e3b:thermocyclerModuleType" } }, { "commandType": "thermocycler/deactivateLid", - "key": "2cf60177-6b03-4706-a2fb-7a211eb974e1", + "key": "a0fe06fa-e4cc-4de2-97a9-388a3df08111", "params": { "moduleId": "627b7a27-5bb7-46de-a530-67af45652e3b:thermocyclerModuleType" } }, { "commandType": "thermocycler/openLid", - "key": "968e4a04-1cda-4730-a26d-810c0af827ad", + "key": "8706cf32-b7c8-41ee-901a-6e62ef7b6824", "params": { "moduleId": "627b7a27-5bb7-46de-a530-67af45652e3b:thermocyclerModuleType" } }, { "commandType": "pickUpTip", - "key": "39f9b118-55f7-4b32-b28a-689255ecf69a", + "key": "90d3558e-e3ef-4e11-8e18-9e1312b212b0", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe", "labwareId": "23ed35de-5bfd-4bb0-8f54-da99a2804ed9:opentrons/opentrons_flex_96_filtertiprack_50ul/1", @@ -3983,31 +3991,37 @@ }, { "commandType": "aspirate", - "key": "cbd4de4c-8de3-407e-aace-9d0993c48214", + "key": "c7ac4218-4698-48f4-b00d-8eeb1ffddb3a", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe", "volume": 50, "labwareId": "a793a135-06aa-4ed6-a1d3-c176c7810afa:opentrons/opentrons_24_aluminumblock_nest_1.5ml_snapcap/1", "wellName": "A1", - "wellLocation": { "origin": "bottom", "offset": { "z": 1 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 1, "x": 0, "y": 0 } + }, "flowRate": 6 } }, { "commandType": "dispense", - "key": "df9dcd8b-54a2-4ea3-9014-8d1e5a8769c7", + "key": "604c9a1d-1ada-4159-850f-3bc9e4f802bc", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe", "volume": 50, "labwareId": "fcba73e7-b88e-438e-963e-f8b9a5de0983:opentrons/nest_96_wellplate_100ul_pcr_full_skirt/2", "wellName": "A1", - "wellLocation": { "origin": "bottom", "offset": { "z": 0.5 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 0.5, "x": 0, "y": 0 } + }, "flowRate": 6 } }, { "commandType": "moveToAddressableAreaForDropTip", - "key": "41586872-d7ba-47a4-b545-aca0f924e1e5", + "key": "c120780c-b4f4-4b11-a7f6-ab3b2621106f", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe", "addressableAreaName": "movableTrashA3", @@ -4017,12 +4031,12 @@ }, { "commandType": "dropTipInPlace", - "key": "1a253e2c-f44c-414b-929e-fb071233caa5", + "key": "2b9bb184-749e-4652-a2cb-31e427ae0472", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe" } }, { "commandType": "pickUpTip", - "key": "903f0cbd-36fc-46ad-9629-af0e713a2551", + "key": "24425f50-40ff-453a-9c3e-ba35f07a4b93", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe", "labwareId": "23ed35de-5bfd-4bb0-8f54-da99a2804ed9:opentrons/opentrons_flex_96_filtertiprack_50ul/1", @@ -4031,31 +4045,37 @@ }, { "commandType": "aspirate", - "key": "15e1819f-a75f-4a99-b248-ef9c44f742bb", + "key": "3eacc9b8-99bf-448b-b178-1638c2217d4f", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe", "volume": 50, "labwareId": "a793a135-06aa-4ed6-a1d3-c176c7810afa:opentrons/opentrons_24_aluminumblock_nest_1.5ml_snapcap/1", "wellName": "A1", - "wellLocation": { "origin": "bottom", "offset": { "z": 1 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 1, "x": 0, "y": 0 } + }, "flowRate": 6 } }, { "commandType": "dispense", - "key": "1746f838-436c-4524-99ca-79a89807e7c6", + "key": "b2f71d3b-13b3-4ba5-9672-3a5ae85b402e", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe", "volume": 50, "labwareId": "fcba73e7-b88e-438e-963e-f8b9a5de0983:opentrons/nest_96_wellplate_100ul_pcr_full_skirt/2", "wellName": "A1", - "wellLocation": { "origin": "bottom", "offset": { "z": 0.5 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 0.5, "x": 0, "y": 0 } + }, "flowRate": 6 } }, { "commandType": "moveToAddressableAreaForDropTip", - "key": "139ac8eb-77bc-4dd5-8358-5cd3352b2841", + "key": "982eb315-0f07-4db4-804d-3650a7ef3371", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe", "addressableAreaName": "movableTrashA3", @@ -4065,12 +4085,12 @@ }, { "commandType": "dropTipInPlace", - "key": "7d3f53de-e3e9-4cce-aca3-a1c84ec8e4f5", + "key": "fd1e4fcb-3f57-4e0e-9a07-f5710d713b2b", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe" } }, { "commandType": "pickUpTip", - "key": "519b1444-568a-49af-87cc-d251a28d5c74", + "key": "d1aa96b8-8218-497f-92d1-9d145d65cacd", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe", "labwareId": "23ed35de-5bfd-4bb0-8f54-da99a2804ed9:opentrons/opentrons_flex_96_filtertiprack_50ul/1", @@ -4079,31 +4099,37 @@ }, { "commandType": "aspirate", - "key": "eef96aa3-ed34-4a6c-a121-9bb1556016aa", + "key": "49b8562e-7d04-409e-b96e-60c04d82f890", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe", "volume": 50, "labwareId": "a793a135-06aa-4ed6-a1d3-c176c7810afa:opentrons/opentrons_24_aluminumblock_nest_1.5ml_snapcap/1", "wellName": "A1", - "wellLocation": { "origin": "bottom", "offset": { "z": 1 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 1, "x": 0, "y": 0 } + }, "flowRate": 6 } }, { "commandType": "dispense", - "key": "ee44942b-3da5-43c1-ad0b-17fc2ea46b68", + "key": "16da2628-d7fa-45e9-9911-cb06a61e488e", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe", "volume": 50, "labwareId": "fcba73e7-b88e-438e-963e-f8b9a5de0983:opentrons/nest_96_wellplate_100ul_pcr_full_skirt/2", "wellName": "B1", - "wellLocation": { "origin": "bottom", "offset": { "z": 0.5 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 0.5, "x": 0, "y": 0 } + }, "flowRate": 6 } }, { "commandType": "moveToAddressableAreaForDropTip", - "key": "1cad272e-a562-4abe-975a-209d5b29f6b8", + "key": "a7a1c2f8-6fdf-4322-a216-ca06fe064299", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe", "addressableAreaName": "movableTrashA3", @@ -4113,12 +4139,12 @@ }, { "commandType": "dropTipInPlace", - "key": "b25eeb63-22a3-4a45-9a34-2588f6e28034", + "key": "dcfb2a3c-fec6-467e-8ea4-0655e070857c", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe" } }, { "commandType": "pickUpTip", - "key": "3b4257ba-7e37-4dc7-8630-628695a993b0", + "key": "c54b1b14-a78e-4b3b-a7fd-df600c143996", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe", "labwareId": "23ed35de-5bfd-4bb0-8f54-da99a2804ed9:opentrons/opentrons_flex_96_filtertiprack_50ul/1", @@ -4127,31 +4153,37 @@ }, { "commandType": "aspirate", - "key": "9ee178b8-6918-477a-ac4a-3b94b3257ded", + "key": "1f586aaa-a2c3-4f35-98d4-514f30f8afde", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe", "volume": 50, "labwareId": "a793a135-06aa-4ed6-a1d3-c176c7810afa:opentrons/opentrons_24_aluminumblock_nest_1.5ml_snapcap/1", "wellName": "A1", - "wellLocation": { "origin": "bottom", "offset": { "z": 1 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 1, "x": 0, "y": 0 } + }, "flowRate": 6 } }, { "commandType": "dispense", - "key": "5b1fa15c-d50a-465e-99d6-d3c5631ba18b", + "key": "8491a928-c8ae-4b73-8fd3-43e6e520ea7d", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe", "volume": 50, "labwareId": "fcba73e7-b88e-438e-963e-f8b9a5de0983:opentrons/nest_96_wellplate_100ul_pcr_full_skirt/2", "wellName": "B1", - "wellLocation": { "origin": "bottom", "offset": { "z": 0.5 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 0.5, "x": 0, "y": 0 } + }, "flowRate": 6 } }, { "commandType": "moveToAddressableAreaForDropTip", - "key": "8ca34094-5af4-4c19-bbc9-e6a95ebe7dc7", + "key": "3ddc68fb-3f9e-4395-b234-a8f00b35cf97", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe", "addressableAreaName": "movableTrashA3", @@ -4161,12 +4193,12 @@ }, { "commandType": "dropTipInPlace", - "key": "ed570c7c-bec9-4320-92c6-c5dc4e8ea039", + "key": "c1596fb8-587a-4a9c-9dd0-252dd821085c", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe" } }, { "commandType": "pickUpTip", - "key": "24af8dc9-1d39-4ead-9fb7-c106cf81c4ca", + "key": "9e130b45-4d49-4588-adef-2e4055be2e09", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe", "labwareId": "23ed35de-5bfd-4bb0-8f54-da99a2804ed9:opentrons/opentrons_flex_96_filtertiprack_50ul/1", @@ -4175,31 +4207,37 @@ }, { "commandType": "aspirate", - "key": "3fb7136f-156c-4ef2-ba9c-4010cbee7c45", + "key": "7e014576-f260-4b18-aad5-f45423adb35f", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe", "volume": 50, "labwareId": "a793a135-06aa-4ed6-a1d3-c176c7810afa:opentrons/opentrons_24_aluminumblock_nest_1.5ml_snapcap/1", "wellName": "A1", - "wellLocation": { "origin": "bottom", "offset": { "z": 1 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 1, "x": 0, "y": 0 } + }, "flowRate": 6 } }, { "commandType": "dispense", - "key": "70415053-47db-400e-a765-59930e782fba", + "key": "07e28184-9669-432a-9b68-8dd692680fa5", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe", "volume": 50, "labwareId": "fcba73e7-b88e-438e-963e-f8b9a5de0983:opentrons/nest_96_wellplate_100ul_pcr_full_skirt/2", "wellName": "C1", - "wellLocation": { "origin": "bottom", "offset": { "z": 0.5 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 0.5, "x": 0, "y": 0 } + }, "flowRate": 6 } }, { "commandType": "moveToAddressableAreaForDropTip", - "key": "6bc262a7-6619-4c1d-90ab-694881833ef2", + "key": "4f591d38-4cc1-496b-90dc-fdcff81d3155", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe", "addressableAreaName": "movableTrashA3", @@ -4209,12 +4247,12 @@ }, { "commandType": "dropTipInPlace", - "key": "61c26a5d-05d6-490b-9881-2505f334e148", + "key": "fab6cdf0-a1c5-4643-9d0c-4fce01d88c7f", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe" } }, { "commandType": "pickUpTip", - "key": "e7db6545-1f4f-40bd-a440-08afc48c8a6d", + "key": "7407659a-a612-4209-967b-af9750324a07", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe", "labwareId": "23ed35de-5bfd-4bb0-8f54-da99a2804ed9:opentrons/opentrons_flex_96_filtertiprack_50ul/1", @@ -4223,31 +4261,37 @@ }, { "commandType": "aspirate", - "key": "a6a1af44-5a04-4fdf-aecb-afec57d49809", + "key": "3d307bba-026c-4a9a-8d01-ae93e8cdce1f", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe", "volume": 50, "labwareId": "a793a135-06aa-4ed6-a1d3-c176c7810afa:opentrons/opentrons_24_aluminumblock_nest_1.5ml_snapcap/1", "wellName": "A1", - "wellLocation": { "origin": "bottom", "offset": { "z": 1 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 1, "x": 0, "y": 0 } + }, "flowRate": 6 } }, { "commandType": "dispense", - "key": "fa36f3c3-8a96-4643-adc3-1c856446c432", + "key": "f45088fb-f102-4edf-ad26-5d1d0ac4f215", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe", "volume": 50, "labwareId": "fcba73e7-b88e-438e-963e-f8b9a5de0983:opentrons/nest_96_wellplate_100ul_pcr_full_skirt/2", "wellName": "C1", - "wellLocation": { "origin": "bottom", "offset": { "z": 0.5 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 0.5, "x": 0, "y": 0 } + }, "flowRate": 6 } }, { "commandType": "moveToAddressableAreaForDropTip", - "key": "a3b84d03-7b16-4c8b-ab04-5e80d53c5008", + "key": "3b44aeec-fd56-4fcf-badf-5cdc42ed42c7", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe", "addressableAreaName": "movableTrashA3", @@ -4257,12 +4301,12 @@ }, { "commandType": "dropTipInPlace", - "key": "b3b763fc-d538-4b1c-9a72-8a1c85238e55", + "key": "7a58db8b-f053-46b5-bd89-3a7cba9c1af1", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe" } }, { "commandType": "pickUpTip", - "key": "aeb3764a-c819-4151-acb9-77d07399b13d", + "key": "6449dbc6-430e-468c-863d-3233689c8a63", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe", "labwareId": "23ed35de-5bfd-4bb0-8f54-da99a2804ed9:opentrons/opentrons_flex_96_filtertiprack_50ul/1", @@ -4271,31 +4315,37 @@ }, { "commandType": "aspirate", - "key": "9b15d50e-e1a3-4966-8584-5e32da3afa4c", + "key": "fe2b869a-8d1f-47bf-9688-2deae97b30f9", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe", "volume": 50, "labwareId": "a793a135-06aa-4ed6-a1d3-c176c7810afa:opentrons/opentrons_24_aluminumblock_nest_1.5ml_snapcap/1", "wellName": "A1", - "wellLocation": { "origin": "bottom", "offset": { "z": 1 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 1, "x": 0, "y": 0 } + }, "flowRate": 6 } }, { "commandType": "dispense", - "key": "ada5949a-0085-4f09-b65e-db092a2ddab6", + "key": "e9a20fb6-f0ba-4e25-b1e5-67dbef00f2d0", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe", "volume": 50, "labwareId": "fcba73e7-b88e-438e-963e-f8b9a5de0983:opentrons/nest_96_wellplate_100ul_pcr_full_skirt/2", "wellName": "D1", - "wellLocation": { "origin": "bottom", "offset": { "z": 0.5 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 0.5, "x": 0, "y": 0 } + }, "flowRate": 6 } }, { "commandType": "moveToAddressableAreaForDropTip", - "key": "fbfd8391-eb1e-416f-97b0-4ea4e631b8b5", + "key": "c0c7ae2d-6b13-4ce7-b170-5a2ffb3cc066", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe", "addressableAreaName": "movableTrashA3", @@ -4305,12 +4355,12 @@ }, { "commandType": "dropTipInPlace", - "key": "edc2c62c-4f1e-4b9d-958c-1f9d4594759b", + "key": "eea51b62-8fd2-4c34-8929-48e26c670640", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe" } }, { "commandType": "pickUpTip", - "key": "e9f79e00-bc1f-4f92-9299-401a7d85d78b", + "key": "0a59af4d-5196-4c16-b609-98c565c320da", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe", "labwareId": "23ed35de-5bfd-4bb0-8f54-da99a2804ed9:opentrons/opentrons_flex_96_filtertiprack_50ul/1", @@ -4319,31 +4369,37 @@ }, { "commandType": "aspirate", - "key": "f361f6e5-a558-48dd-b808-383d48305944", + "key": "cc1387fe-4e22-407f-b1f6-8e57153d24d1", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe", "volume": 50, "labwareId": "a793a135-06aa-4ed6-a1d3-c176c7810afa:opentrons/opentrons_24_aluminumblock_nest_1.5ml_snapcap/1", "wellName": "A1", - "wellLocation": { "origin": "bottom", "offset": { "z": 1 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 1, "x": 0, "y": 0 } + }, "flowRate": 6 } }, { "commandType": "dispense", - "key": "ad9f20e5-c018-4241-8ee4-0b94bd4b4b13", + "key": "fdbb2c46-7e42-4dc9-95dd-528397fe2a49", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe", "volume": 50, "labwareId": "fcba73e7-b88e-438e-963e-f8b9a5de0983:opentrons/nest_96_wellplate_100ul_pcr_full_skirt/2", "wellName": "D1", - "wellLocation": { "origin": "bottom", "offset": { "z": 0.5 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 0.5, "x": 0, "y": 0 } + }, "flowRate": 6 } }, { "commandType": "moveToAddressableAreaForDropTip", - "key": "c41c44f9-7969-4b9d-966f-7133927d1746", + "key": "f8000789-3db0-4edc-adaa-234a89c0a2e8", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe", "addressableAreaName": "movableTrashA3", @@ -4353,12 +4409,12 @@ }, { "commandType": "dropTipInPlace", - "key": "e2deb31d-b531-46a0-95b9-894c6143b51e", + "key": "4a6423a4-3fb3-41cb-a2bb-769f882da188", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe" } }, { "commandType": "pickUpTip", - "key": "ad15920f-80f3-4b31-8c69-7a9753760ac5", + "key": "58db6a04-8af4-4580-8b3b-71d27448d36c", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe", "labwareId": "23ed35de-5bfd-4bb0-8f54-da99a2804ed9:opentrons/opentrons_flex_96_filtertiprack_50ul/1", @@ -4367,31 +4423,37 @@ }, { "commandType": "aspirate", - "key": "ee11c611-bd0f-4039-b631-738aceae4b8b", + "key": "6c053630-6298-4bae-8b1b-b7c0fd60cd64", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe", "volume": 50, "labwareId": "a793a135-06aa-4ed6-a1d3-c176c7810afa:opentrons/opentrons_24_aluminumblock_nest_1.5ml_snapcap/1", "wellName": "A1", - "wellLocation": { "origin": "bottom", "offset": { "z": 1 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 1, "x": 0, "y": 0 } + }, "flowRate": 6 } }, { "commandType": "dispense", - "key": "dace1ad5-22a3-4731-8a0e-54378e936e41", + "key": "60bddd52-347b-4e97-af4f-227172c9e383", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe", "volume": 50, "labwareId": "fcba73e7-b88e-438e-963e-f8b9a5de0983:opentrons/nest_96_wellplate_100ul_pcr_full_skirt/2", "wellName": "E1", - "wellLocation": { "origin": "bottom", "offset": { "z": 0.5 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 0.5, "x": 0, "y": 0 } + }, "flowRate": 6 } }, { "commandType": "moveToAddressableAreaForDropTip", - "key": "c0075e01-b1d6-41fc-a482-541eba3dd9ce", + "key": "dcc5e7a5-ce62-40b0-94a8-19ccd9ec7783", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe", "addressableAreaName": "movableTrashA3", @@ -4401,12 +4463,12 @@ }, { "commandType": "dropTipInPlace", - "key": "287153c4-baee-46c6-81e0-db0cd90a8d7b", + "key": "2d0d4405-02e0-44d3-9aa9-093b2bcf8693", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe" } }, { "commandType": "pickUpTip", - "key": "e956cb88-1387-4014-aea0-06237c9ea125", + "key": "8f943b62-e5cc-423b-962d-c9f06a3c39e6", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe", "labwareId": "23ed35de-5bfd-4bb0-8f54-da99a2804ed9:opentrons/opentrons_flex_96_filtertiprack_50ul/1", @@ -4415,31 +4477,37 @@ }, { "commandType": "aspirate", - "key": "3d870b55-af49-42b0-b877-7a3777fea82d", + "key": "d38287f1-db91-4479-a811-6190c472a797", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe", "volume": 50, "labwareId": "a793a135-06aa-4ed6-a1d3-c176c7810afa:opentrons/opentrons_24_aluminumblock_nest_1.5ml_snapcap/1", "wellName": "A1", - "wellLocation": { "origin": "bottom", "offset": { "z": 1 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 1, "x": 0, "y": 0 } + }, "flowRate": 6 } }, { "commandType": "dispense", - "key": "8d06248a-65a5-4372-a666-70ec956df1d4", + "key": "10074111-ee60-4602-8749-326cc7c978ef", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe", "volume": 50, "labwareId": "fcba73e7-b88e-438e-963e-f8b9a5de0983:opentrons/nest_96_wellplate_100ul_pcr_full_skirt/2", "wellName": "E1", - "wellLocation": { "origin": "bottom", "offset": { "z": 0.5 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 0.5, "x": 0, "y": 0 } + }, "flowRate": 6 } }, { "commandType": "moveToAddressableAreaForDropTip", - "key": "3304feb9-69e8-4e70-a3b6-308706f06d0c", + "key": "f0b5078d-30e7-4ae8-bd9c-2380a2acc248", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe", "addressableAreaName": "movableTrashA3", @@ -4449,12 +4517,12 @@ }, { "commandType": "dropTipInPlace", - "key": "2f73eac1-8a7a-4bb2-b634-3c938bb6a541", + "key": "babbd4a6-95d0-46ef-9616-15435bf83e0c", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe" } }, { "commandType": "pickUpTip", - "key": "b08587bf-ac3a-4322-baa4-2d54ab4c74d8", + "key": "44873109-2a10-4925-a393-b3f05ac65cc8", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe", "labwareId": "23ed35de-5bfd-4bb0-8f54-da99a2804ed9:opentrons/opentrons_flex_96_filtertiprack_50ul/1", @@ -4463,31 +4531,37 @@ }, { "commandType": "aspirate", - "key": "627b7b39-4646-42e5-8a6b-ab85c073631a", + "key": "956b196e-e6a0-4e04-9fe4-e54e8f366cd3", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe", "volume": 50, "labwareId": "a793a135-06aa-4ed6-a1d3-c176c7810afa:opentrons/opentrons_24_aluminumblock_nest_1.5ml_snapcap/1", "wellName": "A1", - "wellLocation": { "origin": "bottom", "offset": { "z": 1 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 1, "x": 0, "y": 0 } + }, "flowRate": 6 } }, { "commandType": "dispense", - "key": "f96068bb-8730-46d2-9649-e77060a67d96", + "key": "d9fe1d4f-558e-48e9-9c4f-3349a513da68", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe", "volume": 50, "labwareId": "fcba73e7-b88e-438e-963e-f8b9a5de0983:opentrons/nest_96_wellplate_100ul_pcr_full_skirt/2", "wellName": "F1", - "wellLocation": { "origin": "bottom", "offset": { "z": 0.5 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 0.5, "x": 0, "y": 0 } + }, "flowRate": 6 } }, { "commandType": "moveToAddressableAreaForDropTip", - "key": "717da31c-723f-4761-8a9e-c46e3a0d95d9", + "key": "3410b8d0-d4be-4009-be92-13d7165fa45d", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe", "addressableAreaName": "movableTrashA3", @@ -4497,12 +4571,12 @@ }, { "commandType": "dropTipInPlace", - "key": "9b69289d-3b6c-4caa-ab71-eee65591d5d7", + "key": "b610b324-aa96-44ed-95d0-fa7b6b2771f7", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe" } }, { "commandType": "pickUpTip", - "key": "61f66087-286a-4309-8f56-e95e1d3450db", + "key": "37fe97fb-40d5-449a-ab57-995eb34db25b", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe", "labwareId": "23ed35de-5bfd-4bb0-8f54-da99a2804ed9:opentrons/opentrons_flex_96_filtertiprack_50ul/1", @@ -4511,31 +4585,37 @@ }, { "commandType": "aspirate", - "key": "fcd20bdc-8cd3-4dc4-88ff-572317dfeafa", + "key": "f8fe5dca-1294-4f9a-8b05-7b818317070a", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe", "volume": 50, "labwareId": "a793a135-06aa-4ed6-a1d3-c176c7810afa:opentrons/opentrons_24_aluminumblock_nest_1.5ml_snapcap/1", "wellName": "A1", - "wellLocation": { "origin": "bottom", "offset": { "z": 1 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 1, "x": 0, "y": 0 } + }, "flowRate": 6 } }, { "commandType": "dispense", - "key": "cc4e76bc-f89a-45e5-8200-1bfd3ba3c950", + "key": "9ae4ac38-6188-4e0a-82b1-c8682052eab7", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe", "volume": 50, "labwareId": "fcba73e7-b88e-438e-963e-f8b9a5de0983:opentrons/nest_96_wellplate_100ul_pcr_full_skirt/2", "wellName": "F1", - "wellLocation": { "origin": "bottom", "offset": { "z": 0.5 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 0.5, "x": 0, "y": 0 } + }, "flowRate": 6 } }, { "commandType": "moveToAddressableAreaForDropTip", - "key": "f98a9dd9-6368-45ad-bf34-374ebd304017", + "key": "5a3d6103-e920-419f-8541-6f42aead55b4", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe", "addressableAreaName": "movableTrashA3", @@ -4545,12 +4625,12 @@ }, { "commandType": "dropTipInPlace", - "key": "28f596a5-d900-4335-82d1-c69c2a4f3476", + "key": "ae7fa272-1052-4b9b-9141-832de7f191ae", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe" } }, { "commandType": "pickUpTip", - "key": "cf3e0222-4b78-4c11-8f7c-853edb173ef0", + "key": "001e1eff-7e3a-4762-889b-81bbdd95624e", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe", "labwareId": "23ed35de-5bfd-4bb0-8f54-da99a2804ed9:opentrons/opentrons_flex_96_filtertiprack_50ul/1", @@ -4559,31 +4639,37 @@ }, { "commandType": "aspirate", - "key": "87bf3bc3-d986-4d4e-9303-531db931ecf2", + "key": "4c857bd6-9ee5-4abc-b8f1-93f263421d4f", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe", "volume": 50, "labwareId": "a793a135-06aa-4ed6-a1d3-c176c7810afa:opentrons/opentrons_24_aluminumblock_nest_1.5ml_snapcap/1", "wellName": "A1", - "wellLocation": { "origin": "bottom", "offset": { "z": 1 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 1, "x": 0, "y": 0 } + }, "flowRate": 6 } }, { "commandType": "dispense", - "key": "7d4a2f34-efc4-4c69-ac95-12e90da24846", + "key": "d54ee4e1-019a-4043-a9d6-73f2728ade40", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe", "volume": 50, "labwareId": "fcba73e7-b88e-438e-963e-f8b9a5de0983:opentrons/nest_96_wellplate_100ul_pcr_full_skirt/2", "wellName": "G1", - "wellLocation": { "origin": "bottom", "offset": { "z": 0.5 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 0.5, "x": 0, "y": 0 } + }, "flowRate": 6 } }, { "commandType": "moveToAddressableAreaForDropTip", - "key": "c26ac9f4-f86a-410b-9524-3a107df76154", + "key": "b3d1a836-8198-4543-9c69-5af4340f5e7b", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe", "addressableAreaName": "movableTrashA3", @@ -4593,12 +4679,12 @@ }, { "commandType": "dropTipInPlace", - "key": "cc9ad177-40b1-4063-904f-bdcd5f534ba3", + "key": "d09a4c10-5d65-46b2-aa72-04ebd1e69616", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe" } }, { "commandType": "pickUpTip", - "key": "6f27f601-b3a2-4d72-b1c7-d29fbef1e2fb", + "key": "a18317f2-d1e8-4960-8294-d041900be78c", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe", "labwareId": "23ed35de-5bfd-4bb0-8f54-da99a2804ed9:opentrons/opentrons_flex_96_filtertiprack_50ul/1", @@ -4607,31 +4693,37 @@ }, { "commandType": "aspirate", - "key": "0d614bd3-9723-467a-a8f4-6793875071a6", + "key": "7b5f0098-2f53-4e57-b60a-46c06f4fe167", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe", "volume": 50, "labwareId": "a793a135-06aa-4ed6-a1d3-c176c7810afa:opentrons/opentrons_24_aluminumblock_nest_1.5ml_snapcap/1", "wellName": "A1", - "wellLocation": { "origin": "bottom", "offset": { "z": 1 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 1, "x": 0, "y": 0 } + }, "flowRate": 6 } }, { "commandType": "dispense", - "key": "321bc387-8de7-4456-99d2-f61e31f49ac4", + "key": "e6497c4f-50da-481e-b76d-a6787df6a779", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe", "volume": 50, "labwareId": "fcba73e7-b88e-438e-963e-f8b9a5de0983:opentrons/nest_96_wellplate_100ul_pcr_full_skirt/2", "wellName": "G1", - "wellLocation": { "origin": "bottom", "offset": { "z": 0.5 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 0.5, "x": 0, "y": 0 } + }, "flowRate": 6 } }, { "commandType": "moveToAddressableAreaForDropTip", - "key": "23c76ed2-6751-4f8d-b061-02e54bae4b92", + "key": "7c357bd8-9b73-43d0-a143-57d9b24d651f", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe", "addressableAreaName": "movableTrashA3", @@ -4641,12 +4733,12 @@ }, { "commandType": "dropTipInPlace", - "key": "a33f1f9c-c1eb-42c5-b2d2-dc81d0c92207", + "key": "5ea9d3e3-5c64-4610-bbfc-b71d7e4d3282", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe" } }, { "commandType": "pickUpTip", - "key": "fa8827df-09aa-41df-b2af-794dafab3f36", + "key": "17f52737-8fa4-45df-95e1-e95011c308fd", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe", "labwareId": "23ed35de-5bfd-4bb0-8f54-da99a2804ed9:opentrons/opentrons_flex_96_filtertiprack_50ul/1", @@ -4655,31 +4747,37 @@ }, { "commandType": "aspirate", - "key": "222b1e4d-81fb-44c5-beff-d39ae967766c", + "key": "43f318b4-d316-462b-9d38-d4969cac5494", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe", "volume": 50, "labwareId": "a793a135-06aa-4ed6-a1d3-c176c7810afa:opentrons/opentrons_24_aluminumblock_nest_1.5ml_snapcap/1", "wellName": "A1", - "wellLocation": { "origin": "bottom", "offset": { "z": 1 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 1, "x": 0, "y": 0 } + }, "flowRate": 6 } }, { "commandType": "dispense", - "key": "d6644b59-fd5b-44cf-a5b4-da4bec2ffcd1", + "key": "ed84c3b2-b095-49bc-939b-fd1f5faa6ddd", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe", "volume": 50, "labwareId": "fcba73e7-b88e-438e-963e-f8b9a5de0983:opentrons/nest_96_wellplate_100ul_pcr_full_skirt/2", "wellName": "H1", - "wellLocation": { "origin": "bottom", "offset": { "z": 0.5 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 0.5, "x": 0, "y": 0 } + }, "flowRate": 6 } }, { "commandType": "moveToAddressableAreaForDropTip", - "key": "62a05dff-fd3d-47ec-a852-7ec365fdc60a", + "key": "bdde31de-c35d-403a-bc01-d249c21100dd", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe", "addressableAreaName": "movableTrashA3", @@ -4689,12 +4787,12 @@ }, { "commandType": "dropTipInPlace", - "key": "dcbbb300-64a6-4deb-9733-19b86574106a", + "key": "2d5caff3-718e-4835-90c1-3a0d2ec57a20", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe" } }, { "commandType": "pickUpTip", - "key": "882ee6ef-f2a3-4478-b88a-fd650c11cbc9", + "key": "a9e33581-f053-47cb-9bc4-069dca4fbc1c", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe", "labwareId": "23ed35de-5bfd-4bb0-8f54-da99a2804ed9:opentrons/opentrons_flex_96_filtertiprack_50ul/1", @@ -4703,31 +4801,37 @@ }, { "commandType": "aspirate", - "key": "4745b4e5-0fed-427b-961a-c1e346e5f591", + "key": "5b34a48a-fdf2-4ad1-8c14-3da9ffb680ed", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe", "volume": 50, "labwareId": "a793a135-06aa-4ed6-a1d3-c176c7810afa:opentrons/opentrons_24_aluminumblock_nest_1.5ml_snapcap/1", "wellName": "A1", - "wellLocation": { "origin": "bottom", "offset": { "z": 1 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 1, "x": 0, "y": 0 } + }, "flowRate": 6 } }, { "commandType": "dispense", - "key": "916ce3bf-32a1-45e1-b704-32b75db4e572", + "key": "13cfa89d-7337-4358-86d2-0da34380835d", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe", "volume": 50, "labwareId": "fcba73e7-b88e-438e-963e-f8b9a5de0983:opentrons/nest_96_wellplate_100ul_pcr_full_skirt/2", "wellName": "H1", - "wellLocation": { "origin": "bottom", "offset": { "z": 0.5 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 0.5, "x": 0, "y": 0 } + }, "flowRate": 6 } }, { "commandType": "moveToAddressableAreaForDropTip", - "key": "62fc2bbb-ee9f-4769-9c6d-7eae1d56b7f3", + "key": "ec94b555-dba0-4757-be27-7b8634c55a9a", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe", "addressableAreaName": "movableTrashA3", @@ -4737,12 +4841,12 @@ }, { "commandType": "dropTipInPlace", - "key": "459eed51-26e1-4f67-946c-5f281d9ff5c4", + "key": "efba76a3-5a32-4f02-9dfc-2f1e5ff3e9b6", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe" } }, { "commandType": "pickUpTip", - "key": "a11d26ea-07d0-4fbe-95a8-3229bf3a7974", + "key": "844f8618-5db6-48ba-b0af-ffc12e84eea7", "params": { "pipetteId": "6d1e53c3-2db3-451b-ad60-3fe13781a193", "labwareId": "23ed35de-5bfd-4bb0-8f54-da99a2804ed9:opentrons/opentrons_flex_96_filtertiprack_50ul/1", @@ -4751,7 +4855,7 @@ }, { "commandType": "configureForVolume", - "key": "8355523c-68f9-4d67-b7f0-c19b39a20d58", + "key": "5d899711-013e-460b-845b-9a8ef207dc24", "params": { "pipetteId": "6d1e53c3-2db3-451b-ad60-3fe13781a193", "volume": 10 @@ -4759,55 +4863,67 @@ }, { "commandType": "aspirate", - "key": "cb6f6fd9-ce96-4788-930a-bcb4fc73cab8", + "key": "71923e56-ac8f-486c-9509-c809a994e006", "params": { "pipetteId": "6d1e53c3-2db3-451b-ad60-3fe13781a193", "volume": 10, "labwareId": "fcba73e7-b88e-438e-963e-f8b9a5de0983:opentrons/nest_96_wellplate_100ul_pcr_full_skirt/2", "wellName": "A1", - "wellLocation": { "origin": "bottom", "offset": { "z": 0.5 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 0.5, "x": 0, "y": 0 } + }, "flowRate": 8 } }, { "commandType": "dispense", - "key": "d72adc76-8ece-47d3-86d2-5ccec5b507a4", + "key": "abde93a4-98e5-428c-9dd9-2a65dc3d99bf", "params": { "pipetteId": "6d1e53c3-2db3-451b-ad60-3fe13781a193", "volume": 10, "labwareId": "fcba73e7-b88e-438e-963e-f8b9a5de0983:opentrons/nest_96_wellplate_100ul_pcr_full_skirt/2", "wellName": "A1", - "wellLocation": { "origin": "bottom", "offset": { "z": 0.5 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 0.5, "x": 0, "y": 0 } + }, "flowRate": 8 } }, { "commandType": "aspirate", - "key": "aefc83b0-fcba-4010-b71a-9b79e6134779", + "key": "f9a0576f-5764-478e-bf16-03ef8ab46d3b", "params": { "pipetteId": "6d1e53c3-2db3-451b-ad60-3fe13781a193", "volume": 10, "labwareId": "fcba73e7-b88e-438e-963e-f8b9a5de0983:opentrons/nest_96_wellplate_100ul_pcr_full_skirt/2", "wellName": "A1", - "wellLocation": { "origin": "bottom", "offset": { "z": 0.5 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 0.5, "x": 0, "y": 0 } + }, "flowRate": 8 } }, { "commandType": "dispense", - "key": "955873e5-8fcd-43d4-9f6c-3281922fc97d", + "key": "e1e8644f-f0d0-4946-b599-55d62174b5af", "params": { "pipetteId": "6d1e53c3-2db3-451b-ad60-3fe13781a193", "volume": 10, "labwareId": "fcba73e7-b88e-438e-963e-f8b9a5de0983:opentrons/nest_96_wellplate_100ul_pcr_full_skirt/2", "wellName": "A1", - "wellLocation": { "origin": "bottom", "offset": { "z": 0.5 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 0.5, "x": 0, "y": 0 } + }, "flowRate": 8 } }, { "commandType": "moveToAddressableAreaForDropTip", - "key": "a237c669-fe56-434a-a5fc-225ba5403b28", + "key": "9bb9217e-3c87-4b11-81f4-01aeb6d12bcd", "params": { "pipetteId": "6d1e53c3-2db3-451b-ad60-3fe13781a193", "addressableAreaName": "movableTrashA3", @@ -4817,12 +4933,12 @@ }, { "commandType": "dropTipInPlace", - "key": "87d1c5d5-c40e-480a-a0bf-4a8a63bae3cd", + "key": "dd0506d4-cd19-4fa3-85db-64aef25d8f75", "params": { "pipetteId": "6d1e53c3-2db3-451b-ad60-3fe13781a193" } }, { "commandType": "moveLabware", - "key": "98e741c6-01c6-43d5-8d98-e3950b7dabda", + "key": "bd579612-fa2a-4808-ade0-8e38b9d8b7da", "params": { "labwareId": "fcba73e7-b88e-438e-963e-f8b9a5de0983:opentrons/nest_96_wellplate_100ul_pcr_full_skirt/2", "strategy": "usingGripper", @@ -4831,12 +4947,12 @@ }, { "commandType": "waitForDuration", - "key": "da873c66-a7e4-4810-b2d1-ab037e9156d1", + "key": "da8a328a-2870-4259-b3be-89d3255154fb", "params": { "seconds": 60, "message": "" } }, { "commandType": "moveLabware", - "key": "58ced158-ae4d-4275-814b-cf29bacda1c0", + "key": "64ac3bcc-4ab8-4d15-9b42-d2462686153d", "params": { "labwareId": "fcba73e7-b88e-438e-963e-f8b9a5de0983:opentrons/nest_96_wellplate_100ul_pcr_full_skirt/2", "strategy": "usingGripper", @@ -4845,21 +4961,21 @@ }, { "commandType": "heaterShaker/closeLabwareLatch", - "key": "2706f0f9-5898-47e3-b53f-06df74598b4e", + "key": "cd0e65dc-cd6c-4d0f-b05f-3d8a979d7d09", "params": { "moduleId": "c19dffa3-cb34-4702-bcf6-dcea786257d1:heaterShakerModuleType" } }, { "commandType": "heaterShaker/deactivateHeater", - "key": "073eac12-998e-4075-bcb5-feb215d5f251", + "key": "c980a10c-a99c-4583-831b-8f09f89822fd", "params": { "moduleId": "c19dffa3-cb34-4702-bcf6-dcea786257d1:heaterShakerModuleType" } }, { "commandType": "heaterShaker/setAndWaitForShakeSpeed", - "key": "37fedb11-cfa0-494d-905f-e5539c2960e6", + "key": "15a3aeed-9bd0-49d6-8a6e-43f226e7acfe", "params": { "moduleId": "c19dffa3-cb34-4702-bcf6-dcea786257d1:heaterShakerModuleType", "rpm": 500 @@ -4867,28 +4983,28 @@ }, { "commandType": "heaterShaker/deactivateHeater", - "key": "6431c63c-608d-4f82-bc68-ade76b8c1cc2", + "key": "001d2bdd-b8a2-4285-8aa3-9d9318566b47", "params": { "moduleId": "c19dffa3-cb34-4702-bcf6-dcea786257d1:heaterShakerModuleType" } }, { "commandType": "heaterShaker/deactivateShaker", - "key": "a22834db-00b7-4f66-a681-9cc5dd17031e", + "key": "609e5b71-9dda-47d7-a7c4-0da3802e7e99", "params": { "moduleId": "c19dffa3-cb34-4702-bcf6-dcea786257d1:heaterShakerModuleType" } }, { "commandType": "heaterShaker/openLabwareLatch", - "key": "af548bbd-5620-405a-bda0-ac08ca06fbbd", + "key": "bb80d557-573b-4b09-a0b8-5d73ea22e4a4", "params": { "moduleId": "c19dffa3-cb34-4702-bcf6-dcea786257d1:heaterShakerModuleType" } }, { "commandType": "moveLabware", - "key": "ffb00dfa-99db-4744-9bde-538d0aa7b1a7", + "key": "a37c38e0-7abe-433f-ab9d-adf0774565f6", "params": { "labwareId": "a793a135-06aa-4ed6-a1d3-c176c7810afa:opentrons/opentrons_24_aluminumblock_nest_1.5ml_snapcap/1", "strategy": "manualMoveWithPause", @@ -4897,14 +5013,14 @@ }, { "commandType": "temperatureModule/deactivate", - "key": "8e0e035d-33a2-4995-a557-26c7951de915", + "key": "1558d15f-e4b6-48bb-8c9c-c3ff69812504", "params": { "moduleId": "ef44ad7f-0fd9-46d6-8bc0-c70785644cc8:temperatureModuleType" } }, { "commandType": "moveLabware", - "key": "36c8ae59-9d10-428a-b6fd-3e3b6b49ed09", + "key": "d805d58b-f6e7-406d-8262-5bf3d03448b6", "params": { "labwareId": "239ceac8-23ec-4900-810a-70aeef880273:opentrons/nest_96_wellplate_200ul_flat/2", "strategy": "manualMoveWithPause", diff --git a/protocol-designer/fixtures/protocol/8/doItAllV8.json b/protocol-designer/fixtures/protocol/8/doItAllV8.json index 79c866f5399..2a0e6bcde5d 100644 --- a/protocol-designer/fixtures/protocol/8/doItAllV8.json +++ b/protocol-designer/fixtures/protocol/8/doItAllV8.json @@ -6,7 +6,7 @@ "author": "", "description": "", "created": 1701659107408, - "lastModified": 1711047424926, + "lastModified": 1711742533084, "category": null, "subcategory": null, "tags": [] @@ -15,7 +15,7 @@ "name": "opentrons/protocol-designer", "version": "8.1.0", "data": { - "_internalAppBuildDate": "Thu, 21 Mar 2024 18:51:59 GMT", + "_internalAppBuildDate": "Fri, 29 Mar 2024 20:00:04 GMT", "defaultValues": { "aspirate_mmFromBottom": 1, "dispense_mmFromBottom": 0.5, @@ -154,6 +154,11 @@ "dispense_delay_mmFromBottom": null, "dropTip_location": "9d61f642-8f9b-467d-b2f7-b67fb162fd26:wasteChute", "nozzles": null, + "dispense_x_position": 0, + "dispense_y_position": 0, + "aspirate_x_position": 0, + "aspirate_y_position": 0, + "blowout_z_offset": 0, "id": "d2f74144-a7bf-4ba2-aaab-30d70b2b62c7", "stepType": "moveLiquid", "stepName": "transfer", @@ -3421,7 +3426,7 @@ "commandSchemaId": "opentronsCommandSchemaV8", "commands": [ { - "key": "1809fd39-db28-4928-8773-31bc536fe765", + "key": "f8a4cabe-7cb9-4e38-b937-6655680e2a31", "commandType": "loadPipette", "params": { "pipetteName": "p1000_single_flex", @@ -3430,7 +3435,7 @@ } }, { - "key": "3a5f75b2-15c9-404f-9b87-f102beeb1a45", + "key": "cd2e6185-8d57-4881-9b0c-ebcbd2468c55", "commandType": "loadModule", "params": { "model": "heaterShakerModuleV1", @@ -3439,7 +3444,7 @@ } }, { - "key": "a13ba2f1-e557-4d2f-a304-87847ce68887", + "key": "b2d44cd2-73db-45b3-ab22-e9e765beed75", "commandType": "loadModule", "params": { "model": "thermocyclerModuleV2", @@ -3448,7 +3453,7 @@ } }, { - "key": "e3f1abb9-b076-4b56-a593-0b4033462fea", + "key": "bbd3ee7e-35b8-4168-9df5-13b871c6dfba", "commandType": "loadLabware", "params": { "displayName": "Opentrons 96 PCR Heater-Shaker Adapter", @@ -3462,7 +3467,7 @@ } }, { - "key": "9d92792f-e5d1-4259-8e4b-da8ea83f28df", + "key": "198896f6-4d0e-49ee-b060-bc9d17fbb9bc", "commandType": "loadLabware", "params": { "displayName": "Opentrons Flex 96 Tip Rack 1000 µL", @@ -3474,7 +3479,7 @@ } }, { - "key": "d03df580-7915-4bba-9d34-e92039cfe24d", + "key": "880af66e-2905-4102-b655-0351b30252b1", "commandType": "loadLabware", "params": { "displayName": "Opentrons Tough 96 Well Plate 200 µL PCR Full Skirt", @@ -3488,7 +3493,7 @@ } }, { - "key": "d1e4cf27-a1db-48c4-b784-a21014bb234b", + "key": "478e31cc-12f4-4a30-9cd4-03181a538513", "commandType": "loadLabware", "params": { "displayName": "Axygen 1 Well Reservoir 90 mL", @@ -3501,7 +3506,7 @@ }, { "commandType": "loadLiquid", - "key": "64129bfd-92d7-4c70-9380-33785a6041ff", + "key": "56bffeaa-ee2b-4cb8-91dc-a9e21e8f1655", "params": { "liquidId": "1", "labwareId": "54370838-4fca-4a14-b88a-7840e4903649:opentrons/opentrons_96_wellplate_200ul_pcr_full_skirt/2", @@ -3519,7 +3524,7 @@ }, { "commandType": "loadLiquid", - "key": "ac47f11d-0d9c-48d7-b45b-9ecb269a9a50", + "key": "e95ef8f9-fef7-4dfe-b5db-86a5dff7e5b5", "params": { "liquidId": "0", "labwareId": "8bacda22-9e05-45e8-bef4-cc04414a204f:opentrons/axygen_1_reservoir_90ml/1", @@ -3528,14 +3533,14 @@ }, { "commandType": "thermocycler/openLid", - "key": "bfa8af0c-4cb2-49d3-912b-b07e90a1f752", + "key": "63d31323-1217-4a56-9392-c1c28dc703d7", "params": { "moduleId": "fd6da9f1-d63b-414b-929e-c646b64790e9:thermocyclerModuleType" } }, { "commandType": "moveLabware", - "key": "a991e2d5-5be6-43b1-9a71-2f229aea392f", + "key": "716ec050-c597-490d-b261-20ac8e3b4c2f", "params": { "labwareId": "8bacda22-9e05-45e8-bef4-cc04414a204f:opentrons/axygen_1_reservoir_90ml/1", "strategy": "usingGripper", @@ -3544,7 +3549,7 @@ }, { "commandType": "pickUpTip", - "key": "55826f7b-111e-4768-a6d3-d0a4c4a5e20d", + "key": "635b128e-5cdc-4bdc-9975-c04a49fb7670", "params": { "pipetteId": "9fcd50d9-92b2-45ac-acf1-e2cf773feffc", "labwareId": "f2d371ea-5146-4c89-8200-9c056a7f321a:opentrons/opentrons_flex_96_tiprack_1000ul/1", @@ -3553,31 +3558,37 @@ }, { "commandType": "aspirate", - "key": "bb5688fe-2909-4755-be74-1850d4d05735", + "key": "1a26a0e0-11c2-4940-b32d-8c747e6969a7", "params": { "pipetteId": "9fcd50d9-92b2-45ac-acf1-e2cf773feffc", "volume": 100, "labwareId": "8bacda22-9e05-45e8-bef4-cc04414a204f:opentrons/axygen_1_reservoir_90ml/1", "wellName": "A1", - "wellLocation": { "origin": "bottom", "offset": { "z": 1 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 1, "x": 0, "y": 0 } + }, "flowRate": 160 } }, { "commandType": "dispense", - "key": "33f7aa0b-80e4-41f0-a841-d8aacb4c7f32", + "key": "17f82c54-3e03-46f4-9c65-666aacc5bab3", "params": { "pipetteId": "9fcd50d9-92b2-45ac-acf1-e2cf773feffc", "volume": 100, "labwareId": "54370838-4fca-4a14-b88a-7840e4903649:opentrons/opentrons_96_wellplate_200ul_pcr_full_skirt/2", "wellName": "A1", - "wellLocation": { "origin": "bottom", "offset": { "z": 0.5 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 0.5, "x": 0, "y": 0 } + }, "flowRate": 160 } }, { "commandType": "moveToAddressableArea", - "key": "650a3b63-379d-4327-ae55-9752d04497ab", + "key": "d38dc37e-e466-47c9-a7bc-85322487af8c", "params": { "pipetteId": "9fcd50d9-92b2-45ac-acf1-e2cf773feffc", "addressableAreaName": "1ChannelWasteChute", @@ -3586,12 +3597,12 @@ }, { "commandType": "dropTipInPlace", - "key": "40c51d0f-5a80-4355-91c1-aaaba7489f37", + "key": "69952335-9a0e-4b69-a903-00454f162e8f", "params": { "pipetteId": "9fcd50d9-92b2-45ac-acf1-e2cf773feffc" } }, { "commandType": "pickUpTip", - "key": "006d7584-e3ad-43a9-8fa1-0688f1d74304", + "key": "2a6d6805-bb22-42c6-9d38-321bdbd9f941", "params": { "pipetteId": "9fcd50d9-92b2-45ac-acf1-e2cf773feffc", "labwareId": "f2d371ea-5146-4c89-8200-9c056a7f321a:opentrons/opentrons_flex_96_tiprack_1000ul/1", @@ -3600,31 +3611,37 @@ }, { "commandType": "aspirate", - "key": "562c0ad9-1f97-4e74-af40-107e12019e41", + "key": "087e94b5-a8f7-4637-a830-eb99e2d3a631", "params": { "pipetteId": "9fcd50d9-92b2-45ac-acf1-e2cf773feffc", "volume": 100, "labwareId": "8bacda22-9e05-45e8-bef4-cc04414a204f:opentrons/axygen_1_reservoir_90ml/1", "wellName": "A1", - "wellLocation": { "origin": "bottom", "offset": { "z": 1 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 1, "x": 0, "y": 0 } + }, "flowRate": 160 } }, { "commandType": "dispense", - "key": "cbd55dd4-a746-4bf5-bf43-73afd95ebff2", + "key": "6edf7c6f-858c-4170-9b69-9f230144ba8a", "params": { "pipetteId": "9fcd50d9-92b2-45ac-acf1-e2cf773feffc", "volume": 100, "labwareId": "54370838-4fca-4a14-b88a-7840e4903649:opentrons/opentrons_96_wellplate_200ul_pcr_full_skirt/2", "wellName": "B1", - "wellLocation": { "origin": "bottom", "offset": { "z": 0.5 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 0.5, "x": 0, "y": 0 } + }, "flowRate": 160 } }, { "commandType": "moveToAddressableArea", - "key": "1bac0a50-7a55-4abe-905c-547f006fd62c", + "key": "129a19fb-6a84-4196-a712-7400142cfff2", "params": { "pipetteId": "9fcd50d9-92b2-45ac-acf1-e2cf773feffc", "addressableAreaName": "1ChannelWasteChute", @@ -3633,12 +3650,12 @@ }, { "commandType": "dropTipInPlace", - "key": "480d48a6-b825-406a-bc6c-b95b457a1eba", + "key": "46e0edd9-a8eb-4dc4-840d-496ce6ecb732", "params": { "pipetteId": "9fcd50d9-92b2-45ac-acf1-e2cf773feffc" } }, { "commandType": "pickUpTip", - "key": "aed7d916-7957-4608-8678-895cd03f2bb8", + "key": "2c31e97a-5821-4fd9-b171-d29ac18cda36", "params": { "pipetteId": "9fcd50d9-92b2-45ac-acf1-e2cf773feffc", "labwareId": "f2d371ea-5146-4c89-8200-9c056a7f321a:opentrons/opentrons_flex_96_tiprack_1000ul/1", @@ -3647,31 +3664,37 @@ }, { "commandType": "aspirate", - "key": "6c2a45d8-449f-4d46-858d-01c349ec7481", + "key": "c5d54202-b261-497f-aa71-3bbdb73f2441", "params": { "pipetteId": "9fcd50d9-92b2-45ac-acf1-e2cf773feffc", "volume": 100, "labwareId": "8bacda22-9e05-45e8-bef4-cc04414a204f:opentrons/axygen_1_reservoir_90ml/1", "wellName": "A1", - "wellLocation": { "origin": "bottom", "offset": { "z": 1 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 1, "x": 0, "y": 0 } + }, "flowRate": 160 } }, { "commandType": "dispense", - "key": "2259e5af-9e35-45bc-b869-105e0d6bda3e", + "key": "df57bdd7-104c-4923-a561-002043500c74", "params": { "pipetteId": "9fcd50d9-92b2-45ac-acf1-e2cf773feffc", "volume": 100, "labwareId": "54370838-4fca-4a14-b88a-7840e4903649:opentrons/opentrons_96_wellplate_200ul_pcr_full_skirt/2", "wellName": "C1", - "wellLocation": { "origin": "bottom", "offset": { "z": 0.5 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 0.5, "x": 0, "y": 0 } + }, "flowRate": 160 } }, { "commandType": "moveToAddressableArea", - "key": "4422ed17-8cf6-47f4-b945-352f17a81fb0", + "key": "eddd8f7b-ccd6-4919-885d-bf20bbbc675f", "params": { "pipetteId": "9fcd50d9-92b2-45ac-acf1-e2cf773feffc", "addressableAreaName": "1ChannelWasteChute", @@ -3680,12 +3703,12 @@ }, { "commandType": "dropTipInPlace", - "key": "33bf2ffd-b472-4d01-a063-e6d78cd10f6e", + "key": "2f5e18c4-1436-47f1-9010-975fe41ca901", "params": { "pipetteId": "9fcd50d9-92b2-45ac-acf1-e2cf773feffc" } }, { "commandType": "pickUpTip", - "key": "0b9fe44a-1d94-48ed-9d52-058fb8639425", + "key": "c4508229-340b-42af-850c-f8d4d10caeae", "params": { "pipetteId": "9fcd50d9-92b2-45ac-acf1-e2cf773feffc", "labwareId": "f2d371ea-5146-4c89-8200-9c056a7f321a:opentrons/opentrons_flex_96_tiprack_1000ul/1", @@ -3694,31 +3717,37 @@ }, { "commandType": "aspirate", - "key": "d617d4ec-ae3c-4517-acea-7ff57af655ef", + "key": "7b548807-dd81-479e-a00f-b4cd9d2080ff", "params": { "pipetteId": "9fcd50d9-92b2-45ac-acf1-e2cf773feffc", "volume": 100, "labwareId": "8bacda22-9e05-45e8-bef4-cc04414a204f:opentrons/axygen_1_reservoir_90ml/1", "wellName": "A1", - "wellLocation": { "origin": "bottom", "offset": { "z": 1 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 1, "x": 0, "y": 0 } + }, "flowRate": 160 } }, { "commandType": "dispense", - "key": "99bf9993-2553-4adc-9131-be9fe370b9df", + "key": "8d8053f6-f155-416c-986c-1893f87d979f", "params": { "pipetteId": "9fcd50d9-92b2-45ac-acf1-e2cf773feffc", "volume": 100, "labwareId": "54370838-4fca-4a14-b88a-7840e4903649:opentrons/opentrons_96_wellplate_200ul_pcr_full_skirt/2", "wellName": "D1", - "wellLocation": { "origin": "bottom", "offset": { "z": 0.5 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 0.5, "x": 0, "y": 0 } + }, "flowRate": 160 } }, { "commandType": "moveToAddressableArea", - "key": "4f05b8d1-319d-40b5-a006-31a41ad5742f", + "key": "92fa7df4-7cd5-42fd-8405-7baf417b46e3", "params": { "pipetteId": "9fcd50d9-92b2-45ac-acf1-e2cf773feffc", "addressableAreaName": "1ChannelWasteChute", @@ -3727,12 +3756,12 @@ }, { "commandType": "dropTipInPlace", - "key": "8faee0ed-2458-45d7-b09f-8021317417cd", + "key": "b2cc5f6e-dc14-4a5e-8f54-1fbcf779e850", "params": { "pipetteId": "9fcd50d9-92b2-45ac-acf1-e2cf773feffc" } }, { "commandType": "pickUpTip", - "key": "bf3176ac-63db-4218-8042-d5683092a66d", + "key": "149f4bc1-ecb0-49c8-bf2a-9e1dc7d241dc", "params": { "pipetteId": "9fcd50d9-92b2-45ac-acf1-e2cf773feffc", "labwareId": "f2d371ea-5146-4c89-8200-9c056a7f321a:opentrons/opentrons_flex_96_tiprack_1000ul/1", @@ -3741,31 +3770,37 @@ }, { "commandType": "aspirate", - "key": "fbea3f6f-0421-428a-bf21-6cda35b30407", + "key": "43ee041e-de88-4f88-8d40-700334aaf355", "params": { "pipetteId": "9fcd50d9-92b2-45ac-acf1-e2cf773feffc", "volume": 100, "labwareId": "8bacda22-9e05-45e8-bef4-cc04414a204f:opentrons/axygen_1_reservoir_90ml/1", "wellName": "A1", - "wellLocation": { "origin": "bottom", "offset": { "z": 1 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 1, "x": 0, "y": 0 } + }, "flowRate": 160 } }, { "commandType": "dispense", - "key": "fedd8c6f-777b-4913-afd9-63c919394a5c", + "key": "779c450d-0d43-4b71-aa73-5f29ed51f5dd", "params": { "pipetteId": "9fcd50d9-92b2-45ac-acf1-e2cf773feffc", "volume": 100, "labwareId": "54370838-4fca-4a14-b88a-7840e4903649:opentrons/opentrons_96_wellplate_200ul_pcr_full_skirt/2", "wellName": "E1", - "wellLocation": { "origin": "bottom", "offset": { "z": 0.5 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 0.5, "x": 0, "y": 0 } + }, "flowRate": 160 } }, { "commandType": "moveToAddressableArea", - "key": "a1934186-6d8b-4fdf-b17a-8f9e93f63417", + "key": "b2be4778-5e00-4bc1-8431-cdecb7ad74ad", "params": { "pipetteId": "9fcd50d9-92b2-45ac-acf1-e2cf773feffc", "addressableAreaName": "1ChannelWasteChute", @@ -3774,12 +3809,12 @@ }, { "commandType": "dropTipInPlace", - "key": "4fb7ea89-471a-47c4-8af8-0a6bfdae1d74", + "key": "4fa0e93d-1f79-4af5-9bbf-c0e41f131053", "params": { "pipetteId": "9fcd50d9-92b2-45ac-acf1-e2cf773feffc" } }, { "commandType": "pickUpTip", - "key": "f66afc4e-9476-4ca4-9cdc-a66257031413", + "key": "77a07fa4-8e68-49c2-aad8-74f04328a34b", "params": { "pipetteId": "9fcd50d9-92b2-45ac-acf1-e2cf773feffc", "labwareId": "f2d371ea-5146-4c89-8200-9c056a7f321a:opentrons/opentrons_flex_96_tiprack_1000ul/1", @@ -3788,31 +3823,37 @@ }, { "commandType": "aspirate", - "key": "a629a9e7-e34f-4693-8479-3cb27d44d0b6", + "key": "06c28a5b-53c6-4aa5-89e0-30b509d2c68f", "params": { "pipetteId": "9fcd50d9-92b2-45ac-acf1-e2cf773feffc", "volume": 100, "labwareId": "8bacda22-9e05-45e8-bef4-cc04414a204f:opentrons/axygen_1_reservoir_90ml/1", "wellName": "A1", - "wellLocation": { "origin": "bottom", "offset": { "z": 1 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 1, "x": 0, "y": 0 } + }, "flowRate": 160 } }, { "commandType": "dispense", - "key": "f831d4dd-c2c2-4429-9314-2fbef18546d6", + "key": "0caa3ced-9327-48aa-b59f-07ea65a81702", "params": { "pipetteId": "9fcd50d9-92b2-45ac-acf1-e2cf773feffc", "volume": 100, "labwareId": "54370838-4fca-4a14-b88a-7840e4903649:opentrons/opentrons_96_wellplate_200ul_pcr_full_skirt/2", "wellName": "F1", - "wellLocation": { "origin": "bottom", "offset": { "z": 0.5 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 0.5, "x": 0, "y": 0 } + }, "flowRate": 160 } }, { "commandType": "moveToAddressableArea", - "key": "e05ddba8-7f1b-45a6-a8d9-9de8b01146bc", + "key": "592051e7-385f-49eb-aeb2-aca173c7e8d4", "params": { "pipetteId": "9fcd50d9-92b2-45ac-acf1-e2cf773feffc", "addressableAreaName": "1ChannelWasteChute", @@ -3821,12 +3862,12 @@ }, { "commandType": "dropTipInPlace", - "key": "059f01dc-eb9f-4cfd-92cf-0b67113e4c2d", + "key": "10c97227-329e-453d-bc1c-16b929cc7ad5", "params": { "pipetteId": "9fcd50d9-92b2-45ac-acf1-e2cf773feffc" } }, { "commandType": "pickUpTip", - "key": "2d85f593-c882-45b2-89ec-f3bd9cd7c645", + "key": "a85a3cb6-68e8-43d4-8c87-218bca8fe3ae", "params": { "pipetteId": "9fcd50d9-92b2-45ac-acf1-e2cf773feffc", "labwareId": "f2d371ea-5146-4c89-8200-9c056a7f321a:opentrons/opentrons_flex_96_tiprack_1000ul/1", @@ -3835,31 +3876,37 @@ }, { "commandType": "aspirate", - "key": "860d1800-6f8d-46d6-a939-81569e9641fc", + "key": "8804e9b7-b0e6-4814-bf38-48a5b05fb106", "params": { "pipetteId": "9fcd50d9-92b2-45ac-acf1-e2cf773feffc", "volume": 100, "labwareId": "8bacda22-9e05-45e8-bef4-cc04414a204f:opentrons/axygen_1_reservoir_90ml/1", "wellName": "A1", - "wellLocation": { "origin": "bottom", "offset": { "z": 1 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 1, "x": 0, "y": 0 } + }, "flowRate": 160 } }, { "commandType": "dispense", - "key": "1d9ef0b0-926e-446c-b0df-c57dfc97f34e", + "key": "5cf8eaf7-c60d-41e2-bb90-c10b3dcb092f", "params": { "pipetteId": "9fcd50d9-92b2-45ac-acf1-e2cf773feffc", "volume": 100, "labwareId": "54370838-4fca-4a14-b88a-7840e4903649:opentrons/opentrons_96_wellplate_200ul_pcr_full_skirt/2", "wellName": "G1", - "wellLocation": { "origin": "bottom", "offset": { "z": 0.5 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 0.5, "x": 0, "y": 0 } + }, "flowRate": 160 } }, { "commandType": "moveToAddressableArea", - "key": "c2954781-c45e-46ff-a8fa-36faea77630c", + "key": "f3e72ab1-d7ea-4857-aa42-8f25b2ec5d1b", "params": { "pipetteId": "9fcd50d9-92b2-45ac-acf1-e2cf773feffc", "addressableAreaName": "1ChannelWasteChute", @@ -3868,12 +3915,12 @@ }, { "commandType": "dropTipInPlace", - "key": "05db8e46-e6c4-4039-84ca-cf7a11042eb9", + "key": "2a0395ec-7363-407b-a391-e8e361d5098b", "params": { "pipetteId": "9fcd50d9-92b2-45ac-acf1-e2cf773feffc" } }, { "commandType": "pickUpTip", - "key": "2cbabc82-4412-4bc5-a7d2-12b74b39b641", + "key": "3246289c-9e03-43d4-8451-e6736a8a709d", "params": { "pipetteId": "9fcd50d9-92b2-45ac-acf1-e2cf773feffc", "labwareId": "f2d371ea-5146-4c89-8200-9c056a7f321a:opentrons/opentrons_flex_96_tiprack_1000ul/1", @@ -3882,31 +3929,37 @@ }, { "commandType": "aspirate", - "key": "9f9e94a0-4a33-441c-8864-e64f9a0fda07", + "key": "470b2170-edec-412a-beeb-56de7f85c0ea", "params": { "pipetteId": "9fcd50d9-92b2-45ac-acf1-e2cf773feffc", "volume": 100, "labwareId": "8bacda22-9e05-45e8-bef4-cc04414a204f:opentrons/axygen_1_reservoir_90ml/1", "wellName": "A1", - "wellLocation": { "origin": "bottom", "offset": { "z": 1 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 1, "x": 0, "y": 0 } + }, "flowRate": 160 } }, { "commandType": "dispense", - "key": "d2144ca8-ca39-484a-a8a0-9c70e613be8a", + "key": "dec80858-857c-4ca9-89d1-235affcdfbc8", "params": { "pipetteId": "9fcd50d9-92b2-45ac-acf1-e2cf773feffc", "volume": 100, "labwareId": "54370838-4fca-4a14-b88a-7840e4903649:opentrons/opentrons_96_wellplate_200ul_pcr_full_skirt/2", "wellName": "H1", - "wellLocation": { "origin": "bottom", "offset": { "z": 0.5 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 0.5, "x": 0, "y": 0 } + }, "flowRate": 160 } }, { "commandType": "moveToAddressableArea", - "key": "e8f7d982-7346-4e25-81b1-98e0412553d2", + "key": "998c55f5-86d6-4ba3-ac30-33d818357753", "params": { "pipetteId": "9fcd50d9-92b2-45ac-acf1-e2cf773feffc", "addressableAreaName": "1ChannelWasteChute", @@ -3915,19 +3968,19 @@ }, { "commandType": "dropTipInPlace", - "key": "880baa31-8fdb-4e11-9183-d90052fca1e2", + "key": "47eadfc8-8244-4509-9462-2fa624b8488a", "params": { "pipetteId": "9fcd50d9-92b2-45ac-acf1-e2cf773feffc" } }, { "commandType": "thermocycler/closeLid", - "key": "e1c31c80-51e8-47db-be63-29d861843b56", + "key": "15e90989-96e1-4e86-9381-d56db11b7659", "params": { "moduleId": "fd6da9f1-d63b-414b-929e-c646b64790e9:thermocyclerModuleType" } }, { "commandType": "thermocycler/setTargetBlockTemperature", - "key": "ae59fc04-b753-482e-87f0-8680cdccb6c4", + "key": "0dc52334-283f-458d-91a7-3b19c722a8f6", "params": { "moduleId": "fd6da9f1-d63b-414b-929e-c646b64790e9:thermocyclerModuleType", "celsius": 40 @@ -3935,47 +3988,47 @@ }, { "commandType": "thermocycler/waitForBlockTemperature", - "key": "66261c91-97d7-4170-b2f6-462ad85b660e", + "key": "78800364-855d-467f-8f52-8838892375d2", "params": { "moduleId": "fd6da9f1-d63b-414b-929e-c646b64790e9:thermocyclerModuleType" } }, { "commandType": "waitForDuration", - "key": "d09638c2-a49c-4b38-b22f-d581fb68feca", + "key": "264eed35-aa11-454f-83e1-3771ca54b87a", "params": { "seconds": 60, "message": "" } }, { "commandType": "thermocycler/openLid", - "key": "b5017439-7aa1-483a-a475-3b03ce1a4505", + "key": "80009058-c8ad-4da4-80da-9167e79188aa", "params": { "moduleId": "fd6da9f1-d63b-414b-929e-c646b64790e9:thermocyclerModuleType" } }, { "commandType": "thermocycler/deactivateBlock", - "key": "b9e78735-3881-4493-82cb-4bd628bd288d", + "key": "e8109b8f-f380-44b5-965a-40867be7765b", "params": { "moduleId": "fd6da9f1-d63b-414b-929e-c646b64790e9:thermocyclerModuleType" } }, { "commandType": "heaterShaker/deactivateHeater", - "key": "af91feaf-c12a-4059-abbf-91d33820a1c0", + "key": "389a88e8-7267-4cd8-bd5b-22e86d06150d", "params": { "moduleId": "23347241-80bb-4a7e-9c91-5d9727a9e483:heaterShakerModuleType" } }, { "commandType": "heaterShaker/openLabwareLatch", - "key": "7a7352ad-9879-4b2e-bc48-540ac0b2ad3b", + "key": "de12dc4b-89b8-42be-801d-02b70e3b04ff", "params": { "moduleId": "23347241-80bb-4a7e-9c91-5d9727a9e483:heaterShakerModuleType" } }, { "commandType": "moveLabware", - "key": "a2fe52a9-4acf-4599-afc3-5ed26bd579a8", + "key": "8822ab1b-89a9-4b0c-abac-1e3abb792d63", "params": { "labwareId": "54370838-4fca-4a14-b88a-7840e4903649:opentrons/opentrons_96_wellplate_200ul_pcr_full_skirt/2", "strategy": "usingGripper", @@ -3986,21 +4039,21 @@ }, { "commandType": "heaterShaker/closeLabwareLatch", - "key": "b057b4d6-57ae-4443-b798-2e6d9103c2e5", + "key": "91e9ed0e-4d2e-4eb9-b49b-0e30e5b5ea9d", "params": { "moduleId": "23347241-80bb-4a7e-9c91-5d9727a9e483:heaterShakerModuleType" } }, { "commandType": "heaterShaker/deactivateHeater", - "key": "cb6fba71-dfd4-468b-a351-22279bfad1c1", + "key": "1c03bbae-0989-4d1a-87c9-ee73003298ab", "params": { "moduleId": "23347241-80bb-4a7e-9c91-5d9727a9e483:heaterShakerModuleType" } }, { "commandType": "heaterShaker/setAndWaitForShakeSpeed", - "key": "a8c7211e-11f9-41e2-b977-68b513a2db5d", + "key": "af3f5cbc-801c-425f-a4c7-04c5bac0826c", "params": { "moduleId": "23347241-80bb-4a7e-9c91-5d9727a9e483:heaterShakerModuleType", "rpm": 200 @@ -4008,40 +4061,40 @@ }, { "commandType": "waitForDuration", - "key": "91be8718-5404-496e-957d-011a33f9cfe0", + "key": "af1c659a-fcbb-46aa-9c1b-6f233dee281e", "params": { "seconds": 60 } }, { "commandType": "heaterShaker/deactivateShaker", - "key": "11592571-7419-4880-b987-ace8edf90b8a", + "key": "ca120664-8293-4e0f-b8fd-2feb4c75cbf9", "params": { "moduleId": "23347241-80bb-4a7e-9c91-5d9727a9e483:heaterShakerModuleType" } }, { "commandType": "heaterShaker/deactivateHeater", - "key": "3da43478-0315-4c19-aaf2-087b174e1ecf", + "key": "abb2cb21-1848-4b51-a769-0bb74b8b0aa0", "params": { "moduleId": "23347241-80bb-4a7e-9c91-5d9727a9e483:heaterShakerModuleType" } }, { "commandType": "heaterShaker/deactivateHeater", - "key": "7038cc9a-87e9-4554-a69d-3828d1cf9273", + "key": "bd384e07-ddc3-430b-aa2d-04c9b874b130", "params": { "moduleId": "23347241-80bb-4a7e-9c91-5d9727a9e483:heaterShakerModuleType" } }, { "commandType": "heaterShaker/openLabwareLatch", - "key": "4a2c5d7f-31c0-40ff-ab77-b5eb167f4008", + "key": "25b0e4d1-ebd9-419f-ba55-691724c6ab66", "params": { "moduleId": "23347241-80bb-4a7e-9c91-5d9727a9e483:heaterShakerModuleType" } }, { "commandType": "moveLabware", - "key": "b2f3676b-da1a-411e-b106-fc761a5ce11b", + "key": "26c1f526-457b-46c2-9fe6-30fd595feabc", "params": { "labwareId": "54370838-4fca-4a14-b88a-7840e4903649:opentrons/opentrons_96_wellplate_200ul_pcr_full_skirt/2", "strategy": "usingGripper", @@ -4050,7 +4103,7 @@ }, { "commandType": "moveLabware", - "key": "f76dccda-5917-48cf-97eb-efd0ae2138f2", + "key": "b64778b0-86e3-495a-809d-90a4a636c3ff", "params": { "labwareId": "f2d371ea-5146-4c89-8200-9c056a7f321a:opentrons/opentrons_flex_96_tiprack_1000ul/1", "strategy": "usingGripper", diff --git a/protocol-designer/fixtures/protocol/8/example_1_1_0MigratedToV8.json b/protocol-designer/fixtures/protocol/8/example_1_1_0MigratedToV8.json index 1beae49e74e..56b9885aea9 100644 --- a/protocol-designer/fixtures/protocol/8/example_1_1_0MigratedToV8.json +++ b/protocol-designer/fixtures/protocol/8/example_1_1_0MigratedToV8.json @@ -6,7 +6,7 @@ "author": "Author name", "description": "Description here", "created": 1560957631666, - "lastModified": 1709309281554, + "lastModified": 1711902162091, "category": null, "subcategory": null, "tags": [] @@ -15,7 +15,7 @@ "name": "opentrons/protocol-designer", "version": "8.1.0", "data": { - "_internalAppBuildDate": "Fri, 01 Mar 2024 16:07:10 GMT", + "_internalAppBuildDate": "Sun, 31 Mar 2024 16:22:18 GMT", "defaultValues": { "aspirate_mmFromBottom": 1, "dispense_mmFromBottom": 0.5, @@ -114,7 +114,8 @@ "disposalVolume_checkbox": true, "disposalVolume_volume": "1", "blowout_checkbox": true, - "blowout_location": "d3181bae-ad9c-4c89-9df2-afb2d4ebc94d:trashBin", + "blowout_z_offset": 0, + "blowout_location": "9b1c0d01-9d4f-4016-afe6-9e08b46acf5e:trashBin", "preWetTip": false, "aspirate_airGap_checkbox": false, "aspirate_airGap_volume": null, @@ -126,8 +127,12 @@ "dispense_delay_checkbox": false, "dispense_delay_seconds": "1", "dispense_delay_mmFromBottom": "0.5", - "dropTip_location": "d3181bae-ad9c-4c89-9df2-afb2d4ebc94d:trashBin", + "dropTip_location": "9b1c0d01-9d4f-4016-afe6-9e08b46acf5e:trashBin", "nozzles": null, + "dispense_x_position": 0, + "dispense_y_position": 0, + "aspirate_x_position": 0, + "aspirate_y_position": 0, "id": "e7d36200-92a5-11e9-ac62-1b173f839d9e", "stepType": "moveLiquid", "stepName": "transfer things", @@ -139,6 +144,7 @@ "labware": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", "mix_wellOrder_first": "t2b", "mix_wellOrder_second": "l2r", + "blowout_z_offset": 0, "blowout_checkbox": true, "blowout_location": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", "mix_mmFromBottom": 0.5, @@ -153,9 +159,11 @@ "dispense_delay_seconds": "1", "mix_touchTip_checkbox": true, "mix_touchTip_mmFromBottom": 30.5, - "dropTip_location": "d3181bae-ad9c-4c89-9df2-afb2d4ebc94d:trashBin", + "dropTip_location": "9b1c0d01-9d4f-4016-afe6-9e08b46acf5e:trashBin", "nozzles": null, "tipRack": "c6f4ec70-92a5-11e9-ac62-1b173f839d9e:tiprack-10ul", + "mix_x_position": 0, + "mix_y_position": 0, "id": "18113c80-92a6-11e9-ac62-1b173f839d9e", "stepType": "mix", "stepName": "mix", @@ -3336,7 +3344,7 @@ "commandSchemaId": "opentronsCommandSchemaV8", "commands": [ { - "key": "da14f3fe-db58-4e04-b97e-9d3edc5ab33e", + "key": "818878e2-9a2b-498e-be2d-1d317f6f7af8", "commandType": "loadPipette", "params": { "pipetteName": "p10_single", @@ -3345,7 +3353,7 @@ } }, { - "key": "58ea5ab7-32ea-4923-ae20-e0c91f1d8b3e", + "key": "1ae8e180-58c4-4970-b372-9a8f1869f297", "commandType": "loadPipette", "params": { "pipetteName": "p50_single", @@ -3354,7 +3362,7 @@ } }, { - "key": "8f8828b7-6a4a-4762-873f-96331ea194ba", + "key": "ce9f8375-8577-4062-a9ff-12bc33d3bec5", "commandType": "loadLabware", "params": { "displayName": "tiprack 10ul (1)", @@ -3366,7 +3374,7 @@ } }, { - "key": "919d5eab-85ee-4129-89e2-5fcc8419c81a", + "key": "8f2f7622-476b-40ff-b692-768a69158aa2", "commandType": "loadLabware", "params": { "displayName": "tiprack 200ul (1)", @@ -3378,7 +3386,7 @@ } }, { - "key": "4f7eef41-f93b-4a93-ac00-dd533553390b", + "key": "6802ec5e-204e-4a63-87a9-c6066788e537", "commandType": "loadLabware", "params": { "displayName": "96 deep well (1)", @@ -3391,7 +3399,7 @@ }, { "commandType": "loadLiquid", - "key": "9713cecc-3e57-49e8-85cf-5122cdaf00c8", + "key": "c63af547-a330-4e04-96ea-f04ef3c93ca1", "params": { "liquidId": "1", "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", @@ -3400,7 +3408,7 @@ }, { "commandType": "loadLiquid", - "key": "edbcfbc3-e074-4df6-b637-245f3b5f9fb6", + "key": "d1af9a18-bb2f-4929-b952-7b1e21eadac8", "params": { "liquidId": "0", "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", @@ -3415,7 +3423,7 @@ }, { "commandType": "pickUpTip", - "key": "6113c2d3-43ef-4412-9800-7659de75d37a", + "key": "24f9ab3b-48fd-42cb-8e0d-2128427459fe", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "labwareId": "c6f4ec70-92a5-11e9-ac62-1b173f839d9e:tiprack-10ul", @@ -3424,91 +3432,112 @@ }, { "commandType": "aspirate", - "key": "06b603b3-104e-454f-83b8-7a3dbcfac8b4", + "key": "426ca672-56a0-430d-bdba-23632ad728b0", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "volume": 2, "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", "wellName": "A1", - "wellLocation": { "origin": "bottom", "offset": { "z": 1 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 1, "x": 0, "y": 0 } + }, "flowRate": 0.6 } }, { "commandType": "dispense", - "key": "c77041f4-0e07-46ff-81a4-12c40f7396f6", + "key": "ea2eab58-723d-462e-ae8b-d0daa9462ece", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "volume": 2, "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", "wellName": "A1", - "wellLocation": { "origin": "bottom", "offset": { "z": 1 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 1, "x": 0, "y": 0 } + }, "flowRate": 10 } }, { "commandType": "aspirate", - "key": "56101ed9-70d5-4ce3-8380-0559ddc847df", + "key": "fa061fa1-e5d5-42cf-b9dd-d4b9a6b6eabe", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "volume": 2, "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", "wellName": "A1", - "wellLocation": { "origin": "bottom", "offset": { "z": 1 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 1, "x": 0, "y": 0 } + }, "flowRate": 0.6 } }, { "commandType": "dispense", - "key": "86d596a4-4023-4f56-920a-021924edbcfa", + "key": "1e21ebe5-4e6f-4bc5-8dc3-1f1aa9158ff5", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "volume": 2, "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", "wellName": "A1", - "wellLocation": { "origin": "bottom", "offset": { "z": 1 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 1, "x": 0, "y": 0 } + }, "flowRate": 10 } }, { "commandType": "aspirate", - "key": "1f34249b-ed7b-498c-9fc3-e8b1e5254fe4", + "key": "7ad7bdad-84eb-42a0-b4ac-48949808a041", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "volume": 2, "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", "wellName": "A1", - "wellLocation": { "origin": "bottom", "offset": { "z": 1 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 1, "x": 0, "y": 0 } + }, "flowRate": 0.6 } }, { "commandType": "dispense", - "key": "18cab41b-1f94-4efe-a931-bbc5a1f4d2e8", + "key": "dd723bb6-9eba-4ab6-bc80-03f6f6db17df", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "volume": 2, "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", "wellName": "A1", - "wellLocation": { "origin": "bottom", "offset": { "z": 1 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 1, "x": 0, "y": 0 } + }, "flowRate": 10 } }, { "commandType": "aspirate", - "key": "249922dd-6e84-481a-9422-0b1f50a83e7c", + "key": "eddfcde7-5497-42e7-bff4-56d2052bc552", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "volume": 6, "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", "wellName": "A1", - "wellLocation": { "origin": "bottom", "offset": { "z": 1 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 1, "x": 0, "y": 0 } + }, "flowRate": 0.6 } }, { "commandType": "touchTip", - "key": "3f553815-ec56-43df-bfe7-4fcaa2c51bb9", + "key": "e3e8b3d6-a118-43de-9155-7d1a1da67dbd", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", @@ -3518,77 +3547,82 @@ }, { "commandType": "dispense", - "key": "48a46cfd-1569-4580-be11-f1b919e10528", + "key": "080a9a26-92ba-48ba-84ed-0a10743b7918", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "volume": 6, "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", "wellName": "E8", - "wellLocation": { "origin": "bottom", "offset": { "z": 2.5 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 2.5, "x": 0, "y": 0 } + }, "flowRate": 10 } }, { "commandType": "aspirate", - "key": "5772dac8-ab61-44ac-883b-b4a5d97a7c9a", + "key": "ac6f0caf-5fe8-4d45-9659-1265fd022295", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "volume": 3, "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", "wellName": "E8", - "wellLocation": { "origin": "bottom", "offset": { "z": 2.5 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 2.5, "x": 0, "y": 0 } + }, "flowRate": 0.6 } }, { "commandType": "dispense", - "key": "2ec5b554-c0ad-498e-b71a-70985440b4d5", + "key": "b9e03bec-0741-4dc9-b953-cadd7e7c40b6", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "volume": 3, "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", "wellName": "E8", - "wellLocation": { "origin": "bottom", "offset": { "z": 2.5 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 2.5, "x": 0, "y": 0 } + }, "flowRate": 10 } }, { "commandType": "aspirate", - "key": "116d9aaa-b681-443e-a949-4f272868d031", + "key": "017fd13a-0e3a-4f54-94c3-8d5fc8eb4ba4", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "volume": 3, "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", "wellName": "E8", - "wellLocation": { "origin": "bottom", "offset": { "z": 2.5 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 2.5, "x": 0, "y": 0 } + }, "flowRate": 0.6 } }, { "commandType": "dispense", - "key": "02729f1d-ed43-4b0a-9dac-548a5d25b7b2", + "key": "7961e88d-1b9b-4615-bcbd-31320a03f81c", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "volume": 3, "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", "wellName": "E8", - "wellLocation": { "origin": "bottom", "offset": { "z": 2.5 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 2.5, "x": 0, "y": 0 } + }, "flowRate": 10 } }, - { - "commandType": "touchTip", - "key": "9e1d3a6f-a85a-47db-8ea9-85a7426687f8", - "params": { - "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", - "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", - "wellName": "E8", - "wellLocation": { "origin": "bottom", "offset": { "z": 40.3 } } - } - }, { "commandType": "moveToAddressableArea", - "key": "16e37e6c-72d1-4cc9-8d60-967cf40defe3", + "key": "4d60aa9f-e59b-491a-b494-aef4b877f6fa", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "addressableAreaName": "fixedTrash", @@ -3597,15 +3631,25 @@ }, { "commandType": "blowOutInPlace", - "key": "7a7c4da7-4f95-49d7-b897-e64febe9879c", + "key": "8bf8312b-7058-430e-8344-84ed35dda280", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "flowRate": 1000 } }, + { + "commandType": "touchTip", + "key": "728468cd-08a9-4811-b5a8-ce0649835d29", + "params": { + "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", + "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", + "wellName": "E8", + "wellLocation": { "origin": "bottom", "offset": { "z": 40.3 } } + } + }, { "commandType": "moveToAddressableAreaForDropTip", - "key": "93068973-6f5a-418c-8dbd-819c23cec732", + "key": "1b5e20e3-85d5-4d87-89f9-7d9568696f6d", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "addressableAreaName": "fixedTrash", @@ -3615,12 +3659,12 @@ }, { "commandType": "dropTipInPlace", - "key": "cbff3df7-e4d5-45c6-88bf-1819361578c2", + "key": "3f520a13-6e4f-4ade-bbf8-2fdd35b875c3", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e" } }, { "commandType": "pickUpTip", - "key": "96e12b27-5cf4-4cdc-9d6d-6c7cc8e93796", + "key": "4b8db7a7-609e-431a-bf9d-7cf858c4b8f7", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "labwareId": "c6f4ec70-92a5-11e9-ac62-1b173f839d9e:tiprack-10ul", @@ -3629,91 +3673,112 @@ }, { "commandType": "aspirate", - "key": "d1e6016b-4a1f-4728-acef-99b54b6716cb", + "key": "0355948e-57ca-4572-baa5-7a64b7ef28cc", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "volume": 2, "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", "wellName": "A1", - "wellLocation": { "origin": "bottom", "offset": { "z": 1 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 1, "x": 0, "y": 0 } + }, "flowRate": 0.6 } }, { "commandType": "dispense", - "key": "0f99777e-a204-4011-8f2c-a991440d57b0", + "key": "193a745f-0698-4427-8d0d-d1e4fe24de24", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "volume": 2, "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", "wellName": "A1", - "wellLocation": { "origin": "bottom", "offset": { "z": 1 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 1, "x": 0, "y": 0 } + }, "flowRate": 10 } }, { "commandType": "aspirate", - "key": "03a113dc-1617-48d4-8c9e-6e248a748727", + "key": "8d205199-aa0a-4640-9a23-b3adcca61be2", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "volume": 2, "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", "wellName": "A1", - "wellLocation": { "origin": "bottom", "offset": { "z": 1 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 1, "x": 0, "y": 0 } + }, "flowRate": 0.6 } }, { "commandType": "dispense", - "key": "f1cb2096-a65d-4d00-9558-3d9d0869d9fe", + "key": "fe86a1bb-8c8e-4307-b06e-c92a8e231679", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "volume": 2, "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", "wellName": "A1", - "wellLocation": { "origin": "bottom", "offset": { "z": 1 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 1, "x": 0, "y": 0 } + }, "flowRate": 10 } }, { "commandType": "aspirate", - "key": "1df11cf1-eea7-4789-a177-41fd33acb76a", + "key": "1976e9d0-ee3f-4ca0-a039-147dd8c21399", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "volume": 2, "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", "wellName": "A1", - "wellLocation": { "origin": "bottom", "offset": { "z": 1 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 1, "x": 0, "y": 0 } + }, "flowRate": 0.6 } }, { "commandType": "dispense", - "key": "c432ee2b-ff8a-4eb7-a2da-7d58b5b34567", + "key": "b75876f5-cbf6-43ae-8bb5-1b71641ccc6a", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "volume": 2, "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", "wellName": "A1", - "wellLocation": { "origin": "bottom", "offset": { "z": 1 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 1, "x": 0, "y": 0 } + }, "flowRate": 10 } }, { "commandType": "aspirate", - "key": "51bc3818-c02f-4904-b501-e4ca399160d8", + "key": "c6ff48bc-a06c-4e5b-9172-986375d8a934", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "volume": 6, "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", "wellName": "A1", - "wellLocation": { "origin": "bottom", "offset": { "z": 1 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 1, "x": 0, "y": 0 } + }, "flowRate": 0.6 } }, { "commandType": "touchTip", - "key": "b57fbe11-7a9d-4e21-8315-bb6d59d7bfd4", + "key": "7a15666d-4676-41b5-8752-26cc8a07f17e", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", @@ -3723,77 +3788,82 @@ }, { "commandType": "dispense", - "key": "a91f20ef-4880-4a83-8f84-a6da8bdb4950", + "key": "ec56b383-c163-402e-9996-d4cc69a1cffd", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "volume": 6, "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", "wellName": "D8", - "wellLocation": { "origin": "bottom", "offset": { "z": 2.5 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 2.5, "x": 0, "y": 0 } + }, "flowRate": 10 } }, { "commandType": "aspirate", - "key": "ff8555b1-e3bb-4678-a90b-f5dd5fc3c513", + "key": "cabfdd05-1309-43e2-bfbd-d04bc7de85c9", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "volume": 3, "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", "wellName": "D8", - "wellLocation": { "origin": "bottom", "offset": { "z": 2.5 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 2.5, "x": 0, "y": 0 } + }, "flowRate": 0.6 } }, { "commandType": "dispense", - "key": "27068318-99da-452b-9ee8-5698a998b297", + "key": "05cb631d-9092-46e9-b802-6175fbae1e1f", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "volume": 3, "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", "wellName": "D8", - "wellLocation": { "origin": "bottom", "offset": { "z": 2.5 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 2.5, "x": 0, "y": 0 } + }, "flowRate": 10 } }, { "commandType": "aspirate", - "key": "7eb27547-3200-4030-bebb-f367b887ade4", + "key": "ea50ada1-23d9-4ecf-af9d-3246930afd26", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "volume": 3, "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", "wellName": "D8", - "wellLocation": { "origin": "bottom", "offset": { "z": 2.5 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 2.5, "x": 0, "y": 0 } + }, "flowRate": 0.6 } }, { "commandType": "dispense", - "key": "377193a3-3f10-4cda-8ea6-b0f32f211017", + "key": "2523b9ed-ef76-40c9-8947-18c039e50939", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "volume": 3, "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", "wellName": "D8", - "wellLocation": { "origin": "bottom", "offset": { "z": 2.5 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 2.5, "x": 0, "y": 0 } + }, "flowRate": 10 } }, - { - "commandType": "touchTip", - "key": "79e72c26-f013-49ff-bf88-7a3831d9bd91", - "params": { - "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", - "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", - "wellName": "D8", - "wellLocation": { "origin": "bottom", "offset": { "z": 40.3 } } - } - }, { "commandType": "moveToAddressableArea", - "key": "e61cf837-ec5a-4c04-abee-fcc16e429ca4", + "key": "58c4751a-5628-4596-a171-1ac260259c28", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "addressableAreaName": "fixedTrash", @@ -3802,15 +3872,25 @@ }, { "commandType": "blowOutInPlace", - "key": "5347bceb-47e7-481e-ba78-a049ff87192b", + "key": "ba5016a9-cd7a-41c8-bf17-aadb64664190", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "flowRate": 1000 } }, + { + "commandType": "touchTip", + "key": "1314e2d9-8d46-4663-9bf3-458a300b0add", + "params": { + "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", + "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", + "wellName": "D8", + "wellLocation": { "origin": "bottom", "offset": { "z": 40.3 } } + } + }, { "commandType": "moveToAddressableAreaForDropTip", - "key": "d59e503f-f61f-474e-b2b2-daf6880ae0cd", + "key": "8527d992-4185-4f20-99a9-864541aaa7b6", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "addressableAreaName": "fixedTrash", @@ -3820,12 +3900,12 @@ }, { "commandType": "dropTipInPlace", - "key": "31533601-0084-4abe-b9e2-1628b134cd86", + "key": "8c564bbd-34dd-44d2-ace8-995097f571b9", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e" } }, { "commandType": "pickUpTip", - "key": "6d29b61d-1df9-4366-8395-04d0a5286e83", + "key": "5377f188-8a31-4ff3-8ed3-ff5b651e467b", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "labwareId": "c6f4ec70-92a5-11e9-ac62-1b173f839d9e:tiprack-10ul", @@ -3834,91 +3914,112 @@ }, { "commandType": "aspirate", - "key": "32095f51-4943-4c29-96b4-f291bed0f26f", + "key": "70c291fd-f5c9-4216-9446-de8191fff376", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "volume": 2, "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", "wellName": "A1", - "wellLocation": { "origin": "bottom", "offset": { "z": 1 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 1, "x": 0, "y": 0 } + }, "flowRate": 0.6 } }, { "commandType": "dispense", - "key": "c144dd6b-1dd0-4e9c-a170-09f948d0d6b5", + "key": "7f1299ec-8930-457d-a2d9-c18876da3769", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "volume": 2, "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", "wellName": "A1", - "wellLocation": { "origin": "bottom", "offset": { "z": 1 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 1, "x": 0, "y": 0 } + }, "flowRate": 10 } }, { "commandType": "aspirate", - "key": "0c94efb7-d072-4d76-a9d9-2a2b2d79a5e1", + "key": "d04dee6f-90a4-4b4b-89b8-05f1104431fd", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "volume": 2, "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", "wellName": "A1", - "wellLocation": { "origin": "bottom", "offset": { "z": 1 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 1, "x": 0, "y": 0 } + }, "flowRate": 0.6 } }, { "commandType": "dispense", - "key": "00390b97-a020-4cf8-afe5-e09556ff5b8b", + "key": "c983ed9b-783b-411a-8df2-50ef254b4deb", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "volume": 2, "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", "wellName": "A1", - "wellLocation": { "origin": "bottom", "offset": { "z": 1 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 1, "x": 0, "y": 0 } + }, "flowRate": 10 } }, { "commandType": "aspirate", - "key": "15509964-d974-41b5-979c-299295b3ea38", + "key": "678dc318-94d9-488b-b2e3-f04ed29a2863", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "volume": 2, "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", "wellName": "A1", - "wellLocation": { "origin": "bottom", "offset": { "z": 1 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 1, "x": 0, "y": 0 } + }, "flowRate": 0.6 } }, { "commandType": "dispense", - "key": "0eb459c1-44e4-4bcc-8089-220e81e81b6d", + "key": "6aee8385-14b4-48fa-bef0-3a642d38c1cd", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "volume": 2, "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", "wellName": "A1", - "wellLocation": { "origin": "bottom", "offset": { "z": 1 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 1, "x": 0, "y": 0 } + }, "flowRate": 10 } }, { "commandType": "aspirate", - "key": "9c736eea-7d18-486c-98f0-377641a33f4d", + "key": "c9e9500e-5c89-450c-a56e-7058720a74ce", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "volume": 6, "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", "wellName": "A1", - "wellLocation": { "origin": "bottom", "offset": { "z": 1 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 1, "x": 0, "y": 0 } + }, "flowRate": 0.6 } }, { "commandType": "touchTip", - "key": "9a209558-3ee7-4922-a173-c897a79679c3", + "key": "eeabdbf7-0dda-4246-859f-de8b643184c0", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", @@ -3928,77 +4029,82 @@ }, { "commandType": "dispense", - "key": "7bf46386-39b4-4a31-82a8-f6433ea11856", + "key": "60f965e4-60af-4183-99de-15c77232416d", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "volume": 6, "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", "wellName": "C8", - "wellLocation": { "origin": "bottom", "offset": { "z": 2.5 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 2.5, "x": 0, "y": 0 } + }, "flowRate": 10 } }, { "commandType": "aspirate", - "key": "3ae51fed-c35f-4a45-843e-b17cfba906e3", + "key": "7a40b467-9754-4c02-ae2e-4644cb997555", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "volume": 3, "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", "wellName": "C8", - "wellLocation": { "origin": "bottom", "offset": { "z": 2.5 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 2.5, "x": 0, "y": 0 } + }, "flowRate": 0.6 } }, { "commandType": "dispense", - "key": "eec16e92-d503-4e00-ba67-4f0d0a8ebac1", + "key": "a24675b2-41c7-4908-97ce-6bcf04c3d149", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "volume": 3, "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", "wellName": "C8", - "wellLocation": { "origin": "bottom", "offset": { "z": 2.5 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 2.5, "x": 0, "y": 0 } + }, "flowRate": 10 } }, { "commandType": "aspirate", - "key": "48546952-3edc-4410-a5ef-fe0689a2780a", + "key": "71a467a6-4c67-46e1-b829-f9a02fb6669e", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "volume": 3, "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", "wellName": "C8", - "wellLocation": { "origin": "bottom", "offset": { "z": 2.5 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 2.5, "x": 0, "y": 0 } + }, "flowRate": 0.6 } }, { "commandType": "dispense", - "key": "4b9a4e01-514c-4677-b143-08709ac99a6f", + "key": "b58fb6c6-17f0-44cf-add2-5ad3a99a06fe", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "volume": 3, "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", "wellName": "C8", - "wellLocation": { "origin": "bottom", "offset": { "z": 2.5 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 2.5, "x": 0, "y": 0 } + }, "flowRate": 10 } }, - { - "commandType": "touchTip", - "key": "20eb9f79-6e87-4cf7-b0dd-32c19ad43337", - "params": { - "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", - "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", - "wellName": "C8", - "wellLocation": { "origin": "bottom", "offset": { "z": 40.3 } } - } - }, { "commandType": "moveToAddressableArea", - "key": "dc0e2e7a-b286-483b-80c6-5a6a654013bf", + "key": "b97a7e69-13c0-444b-9405-c84d8ab431bf", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "addressableAreaName": "fixedTrash", @@ -4007,15 +4113,25 @@ }, { "commandType": "blowOutInPlace", - "key": "3a789643-0839-492b-a4c8-30a14db14c16", + "key": "7e767220-28ab-4b59-ae54-1df3a59ac491", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "flowRate": 1000 } }, + { + "commandType": "touchTip", + "key": "a4329dfb-0547-498b-a132-5314bdc37453", + "params": { + "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", + "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", + "wellName": "C8", + "wellLocation": { "origin": "bottom", "offset": { "z": 40.3 } } + } + }, { "commandType": "moveToAddressableAreaForDropTip", - "key": "11d69a61-0591-4f42-88af-3c74e7da0475", + "key": "222528ae-afc3-459f-bd12-291fb6e92977", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "addressableAreaName": "fixedTrash", @@ -4025,12 +4141,12 @@ }, { "commandType": "dropTipInPlace", - "key": "ee65d091-f308-420b-9db9-fbcd28d17f4e", + "key": "a2b1c413-6b6d-4db7-b39f-36e801bb67bf", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e" } }, { "commandType": "pickUpTip", - "key": "995d9fa9-55eb-4fa4-b6de-0131a0402975", + "key": "ee7cca8e-9d5a-4308-b437-91b3ac59e95c", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "labwareId": "c6f4ec70-92a5-11e9-ac62-1b173f839d9e:tiprack-10ul", @@ -4039,91 +4155,112 @@ }, { "commandType": "aspirate", - "key": "aa4c8295-708f-4a5b-b879-505fb559032c", + "key": "9c65eb65-086b-4535-8dd4-fcdc3b1ce711", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "volume": 2, "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", "wellName": "A1", - "wellLocation": { "origin": "bottom", "offset": { "z": 1 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 1, "x": 0, "y": 0 } + }, "flowRate": 0.6 } }, { "commandType": "dispense", - "key": "5d947f6d-15d8-4918-a96c-38aa1198232c", + "key": "de99e84e-c816-42d7-bbaf-c685cf196c84", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "volume": 2, "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", "wellName": "A1", - "wellLocation": { "origin": "bottom", "offset": { "z": 1 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 1, "x": 0, "y": 0 } + }, "flowRate": 10 } }, { "commandType": "aspirate", - "key": "0c8ac8ba-5067-49c1-9d5b-172acf744fe5", + "key": "2bb3b611-e413-4866-9f88-2093be26c559", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "volume": 2, "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", "wellName": "A1", - "wellLocation": { "origin": "bottom", "offset": { "z": 1 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 1, "x": 0, "y": 0 } + }, "flowRate": 0.6 } }, { "commandType": "dispense", - "key": "a72962af-a3e9-43cd-9c47-7f01b32e9ab0", + "key": "51c61ed1-215a-4304-b0bc-f7c0787d9759", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "volume": 2, "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", "wellName": "A1", - "wellLocation": { "origin": "bottom", "offset": { "z": 1 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 1, "x": 0, "y": 0 } + }, "flowRate": 10 } }, { "commandType": "aspirate", - "key": "16815e9f-6894-401b-8f7c-034faa7a92a8", + "key": "a5cb7070-9db9-4d93-94a0-baafdb9e1246", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "volume": 2, "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", "wellName": "A1", - "wellLocation": { "origin": "bottom", "offset": { "z": 1 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 1, "x": 0, "y": 0 } + }, "flowRate": 0.6 } }, { "commandType": "dispense", - "key": "e82647e5-24cc-48cf-ac77-b832a4473f5f", + "key": "b4812aa0-2c04-4f9f-a060-dcddb31655eb", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "volume": 2, "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", "wellName": "A1", - "wellLocation": { "origin": "bottom", "offset": { "z": 1 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 1, "x": 0, "y": 0 } + }, "flowRate": 10 } }, { "commandType": "aspirate", - "key": "b68ec85a-84c2-4e38-98c5-eb1417959b8c", + "key": "09657153-451a-4ce8-a0aa-d238e97b5d4a", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "volume": 6, "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", "wellName": "A1", - "wellLocation": { "origin": "bottom", "offset": { "z": 1 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 1, "x": 0, "y": 0 } + }, "flowRate": 0.6 } }, { "commandType": "touchTip", - "key": "4daf45f1-1952-444e-a702-c951cd34171d", + "key": "1ba61ffa-26f7-4258-806e-459483f8aee2", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", @@ -4133,77 +4270,82 @@ }, { "commandType": "dispense", - "key": "7fd7054a-5321-47cb-b32e-0b6f6be27269", + "key": "3e54188d-9608-4976-b2a8-0262bc6cd9a8", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "volume": 6, "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", "wellName": "E7", - "wellLocation": { "origin": "bottom", "offset": { "z": 2.5 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 2.5, "x": 0, "y": 0 } + }, "flowRate": 10 } }, { "commandType": "aspirate", - "key": "c58c6f6d-89e9-4b89-a5a4-b4fbb5df01e7", + "key": "12abbaa6-4354-4635-86c7-53da228b89e9", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "volume": 3, "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", "wellName": "E7", - "wellLocation": { "origin": "bottom", "offset": { "z": 2.5 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 2.5, "x": 0, "y": 0 } + }, "flowRate": 0.6 } }, { "commandType": "dispense", - "key": "d534677b-d743-463e-a3d5-9e665d8c42ee", + "key": "75989dac-fb90-46e0-8510-05946f0bb820", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "volume": 3, "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", "wellName": "E7", - "wellLocation": { "origin": "bottom", "offset": { "z": 2.5 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 2.5, "x": 0, "y": 0 } + }, "flowRate": 10 } }, { "commandType": "aspirate", - "key": "a521c336-3377-4474-8ef5-0fe5bcfb9856", + "key": "970cd398-3ad1-46ee-a917-9781c74964c8", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "volume": 3, "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", "wellName": "E7", - "wellLocation": { "origin": "bottom", "offset": { "z": 2.5 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 2.5, "x": 0, "y": 0 } + }, "flowRate": 0.6 } }, { "commandType": "dispense", - "key": "b6eff799-e266-4972-8b74-87acd8ae4b20", + "key": "224042a5-8347-4867-b30c-ea349eee0eb0", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "volume": 3, "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", "wellName": "E7", - "wellLocation": { "origin": "bottom", "offset": { "z": 2.5 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 2.5, "x": 0, "y": 0 } + }, "flowRate": 10 } }, - { - "commandType": "touchTip", - "key": "81fe26dd-f249-45f8-81b6-ffe8df296ef3", - "params": { - "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", - "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", - "wellName": "E7", - "wellLocation": { "origin": "bottom", "offset": { "z": 40.3 } } - } - }, { "commandType": "moveToAddressableArea", - "key": "aacbe8ea-f7e7-46ad-bc9a-80e982202e71", + "key": "5bca8d87-fae2-4082-92f1-5da5e9b0b01a", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "addressableAreaName": "fixedTrash", @@ -4212,15 +4354,25 @@ }, { "commandType": "blowOutInPlace", - "key": "c4e0ef3e-1e4e-463d-a821-8c4864eb4f0e", + "key": "6a40c11f-2894-4c0d-ae8c-3069aa7a3ac6", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "flowRate": 1000 } }, + { + "commandType": "touchTip", + "key": "9667d8ab-87f8-4af8-a61c-39fa46e15928", + "params": { + "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", + "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", + "wellName": "E7", + "wellLocation": { "origin": "bottom", "offset": { "z": 40.3 } } + } + }, { "commandType": "moveToAddressableAreaForDropTip", - "key": "c3aff53f-c4b4-460d-aca6-e45d8dfb7fd5", + "key": "54efaffe-8b67-45b0-8a1b-34eb9929230b", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "addressableAreaName": "fixedTrash", @@ -4230,12 +4382,12 @@ }, { "commandType": "dropTipInPlace", - "key": "e779958f-851f-4276-82f6-18879c620bf4", + "key": "4732e9c8-8b22-447d-9e8a-04360782f50c", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e" } }, { "commandType": "pickUpTip", - "key": "e96e028f-f470-42f4-a1b8-9e155d575fcc", + "key": "55fbea4b-e8d2-4cc9-84f1-e531eedc46c8", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "labwareId": "c6f4ec70-92a5-11e9-ac62-1b173f839d9e:tiprack-10ul", @@ -4244,91 +4396,112 @@ }, { "commandType": "aspirate", - "key": "1815c794-0370-4460-9a03-fbae6c084404", + "key": "d735d944-73ff-4713-ac51-c1341e5cc1a9", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "volume": 2, "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", "wellName": "A1", - "wellLocation": { "origin": "bottom", "offset": { "z": 1 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 1, "x": 0, "y": 0 } + }, "flowRate": 0.6 } }, { "commandType": "dispense", - "key": "1c12be09-a4f5-4844-8fd5-957ceefb2404", + "key": "33e8c95b-801c-42c3-9048-fa14b6aa7f29", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "volume": 2, "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", "wellName": "A1", - "wellLocation": { "origin": "bottom", "offset": { "z": 1 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 1, "x": 0, "y": 0 } + }, "flowRate": 10 } }, { "commandType": "aspirate", - "key": "89c92dde-3580-4d90-b7ab-48906a3595b6", + "key": "b25b278a-8b01-4bc2-a1f8-456c7bf8c526", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "volume": 2, "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", "wellName": "A1", - "wellLocation": { "origin": "bottom", "offset": { "z": 1 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 1, "x": 0, "y": 0 } + }, "flowRate": 0.6 } }, { "commandType": "dispense", - "key": "981553e8-1c52-4612-bd66-ee532c4a027f", + "key": "23d673c8-d769-480b-858b-43ac62636220", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "volume": 2, "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", "wellName": "A1", - "wellLocation": { "origin": "bottom", "offset": { "z": 1 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 1, "x": 0, "y": 0 } + }, "flowRate": 10 } }, { "commandType": "aspirate", - "key": "dff7548c-c2ae-48fc-8fb4-33a96f2578d0", + "key": "3452e515-d862-40d0-99e1-34dd0404337f", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "volume": 2, "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", "wellName": "A1", - "wellLocation": { "origin": "bottom", "offset": { "z": 1 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 1, "x": 0, "y": 0 } + }, "flowRate": 0.6 } }, { "commandType": "dispense", - "key": "cf1c3f7f-ad9f-453e-ba12-d1be98e49699", + "key": "36c73f15-d9cd-410c-8699-f19396584618", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "volume": 2, "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", "wellName": "A1", - "wellLocation": { "origin": "bottom", "offset": { "z": 1 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 1, "x": 0, "y": 0 } + }, "flowRate": 10 } }, { "commandType": "aspirate", - "key": "12bf910d-0bde-4a4c-b613-437e873a4078", + "key": "7b78234d-4513-49cc-83e7-10b662ff8675", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "volume": 6, "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", "wellName": "A1", - "wellLocation": { "origin": "bottom", "offset": { "z": 1 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 1, "x": 0, "y": 0 } + }, "flowRate": 0.6 } }, { "commandType": "touchTip", - "key": "3afb66c3-10ee-437f-b6d4-3bf8783ce9cc", + "key": "b1b3ee6f-a9be-4220-8004-7296970de788", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", @@ -4338,77 +4511,82 @@ }, { "commandType": "dispense", - "key": "d66cf63b-f856-4293-9140-0b9d0df28f61", + "key": "f7c5a31f-1a71-478f-a145-eb5c5c567c6d", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "volume": 6, "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", "wellName": "D7", - "wellLocation": { "origin": "bottom", "offset": { "z": 2.5 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 2.5, "x": 0, "y": 0 } + }, "flowRate": 10 } }, { "commandType": "aspirate", - "key": "b7e62341-466a-4089-96dd-3e33ab8abfac", + "key": "5e4a8c3c-5a80-488b-898d-d1074f2c426c", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "volume": 3, "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", "wellName": "D7", - "wellLocation": { "origin": "bottom", "offset": { "z": 2.5 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 2.5, "x": 0, "y": 0 } + }, "flowRate": 0.6 } }, { "commandType": "dispense", - "key": "706802e5-ecfa-4db8-817f-4cda1d3461fe", + "key": "da0e8d29-8619-47e1-b8da-98ccaf2c56fc", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "volume": 3, "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", "wellName": "D7", - "wellLocation": { "origin": "bottom", "offset": { "z": 2.5 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 2.5, "x": 0, "y": 0 } + }, "flowRate": 10 } }, { "commandType": "aspirate", - "key": "fc706917-6d88-4d15-a8dc-f8e533470099", + "key": "3fd622c1-93bc-4e5d-92cb-3dc40f38d92d", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "volume": 3, "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", "wellName": "D7", - "wellLocation": { "origin": "bottom", "offset": { "z": 2.5 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 2.5, "x": 0, "y": 0 } + }, "flowRate": 0.6 } }, { "commandType": "dispense", - "key": "0c8589ba-0894-4df5-8927-48572ff6c401", + "key": "7fe8ecbf-6872-4c45-9f41-b3f5e31b8c42", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "volume": 3, "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", "wellName": "D7", - "wellLocation": { "origin": "bottom", "offset": { "z": 2.5 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 2.5, "x": 0, "y": 0 } + }, "flowRate": 10 } }, - { - "commandType": "touchTip", - "key": "00d269b5-7481-4c43-b054-c57e3fbfe605", - "params": { - "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", - "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", - "wellName": "D7", - "wellLocation": { "origin": "bottom", "offset": { "z": 40.3 } } - } - }, { "commandType": "moveToAddressableArea", - "key": "e15167b0-1b6c-40cb-bafb-1be42e155529", + "key": "7e7f40a5-1b19-414d-b1ec-b0f632ee81eb", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "addressableAreaName": "fixedTrash", @@ -4417,15 +4595,25 @@ }, { "commandType": "blowOutInPlace", - "key": "8ff90ef0-42a8-4300-b1d3-cc894e476029", + "key": "e7d928ca-d918-43a1-973a-e56361029dcd", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "flowRate": 1000 } }, + { + "commandType": "touchTip", + "key": "1665f0f5-1778-49ed-a765-bcdcc3a9c13a", + "params": { + "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", + "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", + "wellName": "D7", + "wellLocation": { "origin": "bottom", "offset": { "z": 40.3 } } + } + }, { "commandType": "moveToAddressableAreaForDropTip", - "key": "021423f6-e2ed-40ee-8305-7da59c111dc0", + "key": "5f71c216-2dd4-4b3f-9958-feac1e0ba419", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "addressableAreaName": "fixedTrash", @@ -4435,12 +4623,12 @@ }, { "commandType": "dropTipInPlace", - "key": "c3883abe-ef2d-42a7-9eb5-a32f7d81ca28", + "key": "0d98fee0-4ada-4ddd-98cc-ee4f51763615", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e" } }, { "commandType": "pickUpTip", - "key": "787b0eb7-866d-4230-8932-5683d2db4143", + "key": "1eca1b12-6dda-4a57-84cc-48ed09a5dcc7", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "labwareId": "c6f4ec70-92a5-11e9-ac62-1b173f839d9e:tiprack-10ul", @@ -4449,91 +4637,112 @@ }, { "commandType": "aspirate", - "key": "d037b353-7b41-4311-a36f-f1aab11d6ac8", + "key": "6468842b-d755-431a-8f39-63390afc45aa", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "volume": 2, "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", "wellName": "A1", - "wellLocation": { "origin": "bottom", "offset": { "z": 1 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 1, "x": 0, "y": 0 } + }, "flowRate": 0.6 } }, { "commandType": "dispense", - "key": "10363067-39c5-42b0-a620-3ee6a2774a9b", + "key": "3f19926d-5262-4869-8830-7eb13951f4fe", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "volume": 2, "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", "wellName": "A1", - "wellLocation": { "origin": "bottom", "offset": { "z": 1 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 1, "x": 0, "y": 0 } + }, "flowRate": 10 } }, { "commandType": "aspirate", - "key": "15eb4102-e34c-4d6e-916f-42ce00375aa7", + "key": "0816f07a-7ddf-41da-91a8-6c55bcf902ff", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "volume": 2, "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", "wellName": "A1", - "wellLocation": { "origin": "bottom", "offset": { "z": 1 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 1, "x": 0, "y": 0 } + }, "flowRate": 0.6 } }, { "commandType": "dispense", - "key": "46711650-9279-4031-b5ea-c0820a32d961", + "key": "6ac9d9b6-b45e-4b0a-90c5-835a680ab914", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "volume": 2, "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", "wellName": "A1", - "wellLocation": { "origin": "bottom", "offset": { "z": 1 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 1, "x": 0, "y": 0 } + }, "flowRate": 10 } }, { "commandType": "aspirate", - "key": "9e34e43e-89da-4b7e-be2e-a6042b3ef954", + "key": "2c0b977d-cc77-44bb-b0a3-62339279f8d4", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "volume": 2, "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", "wellName": "A1", - "wellLocation": { "origin": "bottom", "offset": { "z": 1 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 1, "x": 0, "y": 0 } + }, "flowRate": 0.6 } }, { "commandType": "dispense", - "key": "8114f067-59e2-4011-82da-08c8d2f9aa68", + "key": "b15ab048-c8ae-491b-ba0a-ddb84af43b8a", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "volume": 2, "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", "wellName": "A1", - "wellLocation": { "origin": "bottom", "offset": { "z": 1 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 1, "x": 0, "y": 0 } + }, "flowRate": 10 } }, { "commandType": "aspirate", - "key": "716278f3-86c2-46c8-96a9-ab31e9b8a8f2", + "key": "ebb52c59-bc4d-4f3a-b1b4-10ceea23ecd4", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "volume": 6, "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", "wellName": "A1", - "wellLocation": { "origin": "bottom", "offset": { "z": 1 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 1, "x": 0, "y": 0 } + }, "flowRate": 0.6 } }, { "commandType": "touchTip", - "key": "165b08fc-9663-4e9c-b49f-194a81ba56c4", + "key": "1c48b0b0-c786-4278-a95b-180d8bc8d7fb", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", @@ -4543,77 +4752,82 @@ }, { "commandType": "dispense", - "key": "61113794-7f55-4925-94e4-6ac1e9d0b5c0", + "key": "6db1da99-4bfc-4723-a37b-db57a913a5a0", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "volume": 6, "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", "wellName": "C7", - "wellLocation": { "origin": "bottom", "offset": { "z": 2.5 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 2.5, "x": 0, "y": 0 } + }, "flowRate": 10 } }, { "commandType": "aspirate", - "key": "3195d674-6f23-41e7-968f-5978f4423b11", + "key": "b040900a-f61c-462e-9238-87746a45c0b8", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "volume": 3, "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", "wellName": "C7", - "wellLocation": { "origin": "bottom", "offset": { "z": 2.5 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 2.5, "x": 0, "y": 0 } + }, "flowRate": 0.6 } }, { "commandType": "dispense", - "key": "7e2df534-36d9-4c78-8cff-9894b305aa56", + "key": "8e2de19c-a6b1-4af7-a614-8f692815d667", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "volume": 3, "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", "wellName": "C7", - "wellLocation": { "origin": "bottom", "offset": { "z": 2.5 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 2.5, "x": 0, "y": 0 } + }, "flowRate": 10 } }, { "commandType": "aspirate", - "key": "2f1d06ba-9586-4e14-8ab7-5747aa14d47c", + "key": "a72f4e61-2874-4af0-a471-d97434970e2b", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "volume": 3, "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", "wellName": "C7", - "wellLocation": { "origin": "bottom", "offset": { "z": 2.5 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 2.5, "x": 0, "y": 0 } + }, "flowRate": 0.6 } }, { "commandType": "dispense", - "key": "5358b164-56c2-4042-a8b8-1645e3f8c0c9", + "key": "ff833f33-6c7e-417a-8293-f9a2c2eead8c", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "volume": 3, "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", "wellName": "C7", - "wellLocation": { "origin": "bottom", "offset": { "z": 2.5 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 2.5, "x": 0, "y": 0 } + }, "flowRate": 10 } }, - { - "commandType": "touchTip", - "key": "2643cf2a-5373-43bd-bd37-dd1e62c4c548", - "params": { - "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", - "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", - "wellName": "C7", - "wellLocation": { "origin": "bottom", "offset": { "z": 40.3 } } - } - }, { "commandType": "moveToAddressableArea", - "key": "97fc08d9-59ee-46ee-99d5-20e33acbb2f2", + "key": "40d74de4-9953-43ae-b4bc-518d39005303", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "addressableAreaName": "fixedTrash", @@ -4622,15 +4836,25 @@ }, { "commandType": "blowOutInPlace", - "key": "4f2e4f39-dea7-444f-b6d5-e7cfd6c1bcb2", + "key": "7570e6a2-b2a3-4836-aaa0-13c90ceb08f4", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "flowRate": 1000 } }, + { + "commandType": "touchTip", + "key": "5de67294-430d-4856-aa25-0177b32ef514", + "params": { + "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", + "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", + "wellName": "C7", + "wellLocation": { "origin": "bottom", "offset": { "z": 40.3 } } + } + }, { "commandType": "moveToAddressableAreaForDropTip", - "key": "252afdc4-bebe-47fb-ad4f-e10766436a23", + "key": "b25ac8f3-fe61-4f87-b5f2-40936132a6dd", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "addressableAreaName": "fixedTrash", @@ -4640,12 +4864,12 @@ }, { "commandType": "dropTipInPlace", - "key": "7a36277b-7c2a-401f-8802-2af031444e22", + "key": "aa3d17b8-8d52-462f-9e39-b0d2d83e5407", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e" } }, { "commandType": "pickUpTip", - "key": "e5e61410-a679-4caf-94d0-1234a7337bcc", + "key": "188da1f2-486b-4dfd-b2c8-e0903544fa8d", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "labwareId": "c6f4ec70-92a5-11e9-ac62-1b173f839d9e:tiprack-10ul", @@ -4654,91 +4878,112 @@ }, { "commandType": "aspirate", - "key": "231f4239-1e72-4f15-b393-5103d62197a8", + "key": "df11a136-0f66-4502-ad52-443adc71ca2b", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "volume": 2, "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", "wellName": "A1", - "wellLocation": { "origin": "bottom", "offset": { "z": 1 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 1, "x": 0, "y": 0 } + }, "flowRate": 0.6 } }, { "commandType": "dispense", - "key": "91f9fd1e-7690-4ba3-aa5b-24bfadde94f3", + "key": "00502ab3-b649-4532-ba39-184ff41b00cb", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "volume": 2, "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", "wellName": "A1", - "wellLocation": { "origin": "bottom", "offset": { "z": 1 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 1, "x": 0, "y": 0 } + }, "flowRate": 10 } }, { "commandType": "aspirate", - "key": "d329cf02-bb5a-441b-9433-b6ed36e4b16a", + "key": "cdc0749e-e66b-480e-afe0-3ad6c5e739e4", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "volume": 2, "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", "wellName": "A1", - "wellLocation": { "origin": "bottom", "offset": { "z": 1 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 1, "x": 0, "y": 0 } + }, "flowRate": 0.6 } }, { "commandType": "dispense", - "key": "61c6428b-d0ad-4aa1-8fba-0983fac42a1e", + "key": "65529980-e475-4f51-a8dc-cd1f7e5a5020", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "volume": 2, "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", "wellName": "A1", - "wellLocation": { "origin": "bottom", "offset": { "z": 1 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 1, "x": 0, "y": 0 } + }, "flowRate": 10 } }, { "commandType": "aspirate", - "key": "f022bc59-c825-444d-bf31-dbc784e657ba", + "key": "d9e94497-0439-4675-bb57-cc2e62ea7a84", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "volume": 2, "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", "wellName": "A1", - "wellLocation": { "origin": "bottom", "offset": { "z": 1 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 1, "x": 0, "y": 0 } + }, "flowRate": 0.6 } }, { "commandType": "dispense", - "key": "b99963d8-d11e-4d4b-bbfe-7d1dd46a385f", + "key": "27bd35c9-4ef4-471f-954b-289db56992ad", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "volume": 2, "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", "wellName": "A1", - "wellLocation": { "origin": "bottom", "offset": { "z": 1 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 1, "x": 0, "y": 0 } + }, "flowRate": 10 } }, { "commandType": "aspirate", - "key": "032ce0d1-c61e-4a49-bcd6-e05715ea01a1", + "key": "9241c560-e1d0-4468-ac78-10c9511d0113", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "volume": 6, "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", "wellName": "A1", - "wellLocation": { "origin": "bottom", "offset": { "z": 1 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 1, "x": 0, "y": 0 } + }, "flowRate": 0.6 } }, { "commandType": "touchTip", - "key": "f507c53d-b959-4d2a-88a4-3d760ec0d5a4", + "key": "67e511d9-8198-4c0d-808e-c9600f2aff6b", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", @@ -4748,77 +4993,82 @@ }, { "commandType": "dispense", - "key": "c4768870-6ea0-47d5-bd01-821f76484851", + "key": "ea876b75-dbb7-445e-afb4-efa1fd12eda8", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "volume": 6, "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", "wellName": "E6", - "wellLocation": { "origin": "bottom", "offset": { "z": 2.5 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 2.5, "x": 0, "y": 0 } + }, "flowRate": 10 } }, { "commandType": "aspirate", - "key": "5f0ceccf-c18b-4d38-a67d-227de289baa3", + "key": "7551fb8d-3899-42f4-ba52-9e03c2410ae5", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "volume": 3, "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", "wellName": "E6", - "wellLocation": { "origin": "bottom", "offset": { "z": 2.5 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 2.5, "x": 0, "y": 0 } + }, "flowRate": 0.6 } }, { "commandType": "dispense", - "key": "eb34a3a3-2163-4780-a3f9-0c2c27b266fd", + "key": "dae940af-8337-439f-83c5-39745994b216", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "volume": 3, "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", "wellName": "E6", - "wellLocation": { "origin": "bottom", "offset": { "z": 2.5 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 2.5, "x": 0, "y": 0 } + }, "flowRate": 10 } }, { "commandType": "aspirate", - "key": "aa5b4672-0f67-4f1f-af47-f40cf91dc2a6", + "key": "d9c4b87f-8e3f-415b-9c61-b14cff73fa6e", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "volume": 3, "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", "wellName": "E6", - "wellLocation": { "origin": "bottom", "offset": { "z": 2.5 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 2.5, "x": 0, "y": 0 } + }, "flowRate": 0.6 } }, { "commandType": "dispense", - "key": "55640978-689e-46d6-8d5f-10ba8e970d00", + "key": "6e1ae4be-0622-490d-811a-1442a54f38c6", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "volume": 3, "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", "wellName": "E6", - "wellLocation": { "origin": "bottom", "offset": { "z": 2.5 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 2.5, "x": 0, "y": 0 } + }, "flowRate": 10 } }, - { - "commandType": "touchTip", - "key": "213949a7-feed-4fe0-95bb-57495a558334", - "params": { - "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", - "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", - "wellName": "E6", - "wellLocation": { "origin": "bottom", "offset": { "z": 40.3 } } - } - }, { "commandType": "moveToAddressableArea", - "key": "32e64358-369f-4a0f-b7f7-cdac58b9e1a6", + "key": "2172c551-8f66-49ec-b092-3cecb3ecd1e6", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "addressableAreaName": "fixedTrash", @@ -4827,15 +5077,25 @@ }, { "commandType": "blowOutInPlace", - "key": "d4358e84-b66b-4f58-917c-87ebf2f804cb", + "key": "70f94de0-45c2-4082-85c7-000a3c7d4e05", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "flowRate": 1000 } }, + { + "commandType": "touchTip", + "key": "7a8c6027-3547-4415-97e2-e4a8839cefcb", + "params": { + "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", + "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", + "wellName": "E6", + "wellLocation": { "origin": "bottom", "offset": { "z": 40.3 } } + } + }, { "commandType": "moveToAddressableAreaForDropTip", - "key": "d6bcd44a-459c-40f5-b48b-7f66c056f593", + "key": "9e76549d-de35-4be7-b42f-83e81eb148e5", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "addressableAreaName": "fixedTrash", @@ -4845,12 +5105,12 @@ }, { "commandType": "dropTipInPlace", - "key": "afef5a4a-3808-4f78-a62d-daef9b85293f", + "key": "edb7a124-0334-41a3-b82f-237bf2a63e37", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e" } }, { "commandType": "pickUpTip", - "key": "1fee685b-03b1-4a68-88bf-746d83c1f734", + "key": "f040345b-250f-4fa6-abc0-62e27fe59938", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "labwareId": "c6f4ec70-92a5-11e9-ac62-1b173f839d9e:tiprack-10ul", @@ -4859,91 +5119,112 @@ }, { "commandType": "aspirate", - "key": "c4ef2258-e356-463a-9f47-50288c93896b", + "key": "cd942842-7300-40c1-87a6-28f073ea3dc5", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "volume": 2, "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", "wellName": "A1", - "wellLocation": { "origin": "bottom", "offset": { "z": 1 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 1, "x": 0, "y": 0 } + }, "flowRate": 0.6 } }, { "commandType": "dispense", - "key": "3645a8be-8872-47af-9a30-07afcb9ae234", + "key": "f6a45b15-269b-482d-983b-d3bc5db57d26", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "volume": 2, "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", "wellName": "A1", - "wellLocation": { "origin": "bottom", "offset": { "z": 1 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 1, "x": 0, "y": 0 } + }, "flowRate": 10 } }, { "commandType": "aspirate", - "key": "02580ed2-f298-4da2-9ccb-e751d09f3015", + "key": "7d61c0b4-4555-435c-b837-b559b360a82e", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "volume": 2, "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", "wellName": "A1", - "wellLocation": { "origin": "bottom", "offset": { "z": 1 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 1, "x": 0, "y": 0 } + }, "flowRate": 0.6 } }, { "commandType": "dispense", - "key": "988739b0-1ff9-4c51-9d5e-86abaeaf7f09", + "key": "9f9dfc52-5ca3-42e2-b9d5-3bfa8521de49", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "volume": 2, "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", "wellName": "A1", - "wellLocation": { "origin": "bottom", "offset": { "z": 1 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 1, "x": 0, "y": 0 } + }, "flowRate": 10 } }, { "commandType": "aspirate", - "key": "62e7e213-b5b3-40fb-b3aa-a13d035e44f1", + "key": "11346b4b-af47-46f0-9461-52664eec0d39", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "volume": 2, "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", "wellName": "A1", - "wellLocation": { "origin": "bottom", "offset": { "z": 1 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 1, "x": 0, "y": 0 } + }, "flowRate": 0.6 } }, { "commandType": "dispense", - "key": "97b34c42-511a-4d68-afee-09c493088796", + "key": "23982cac-52ae-484f-b3e7-c52c029b1e9a", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "volume": 2, "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", "wellName": "A1", - "wellLocation": { "origin": "bottom", "offset": { "z": 1 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 1, "x": 0, "y": 0 } + }, "flowRate": 10 } }, { "commandType": "aspirate", - "key": "01ea3e16-49c4-4c23-9123-7f1ade690342", + "key": "148dd2de-1425-482f-8fec-32731007bbff", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "volume": 6, "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", "wellName": "A1", - "wellLocation": { "origin": "bottom", "offset": { "z": 1 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 1, "x": 0, "y": 0 } + }, "flowRate": 0.6 } }, { "commandType": "touchTip", - "key": "fe523115-3e72-4623-81eb-414836ec000b", + "key": "41e664b1-6199-4a33-9857-76df944f516d", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", @@ -4953,77 +5234,82 @@ }, { "commandType": "dispense", - "key": "134b1437-05ae-4c9c-ba9e-3a8e87b1b2f3", + "key": "152340ce-cde0-469e-9882-a8ef3d4a1cde", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "volume": 6, "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", "wellName": "D6", - "wellLocation": { "origin": "bottom", "offset": { "z": 2.5 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 2.5, "x": 0, "y": 0 } + }, "flowRate": 10 } }, { "commandType": "aspirate", - "key": "d6167e35-ef8c-4b1a-800f-240c30ac60af", + "key": "e4e8529f-89fc-4a94-a49d-410b799aa539", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "volume": 3, "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", "wellName": "D6", - "wellLocation": { "origin": "bottom", "offset": { "z": 2.5 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 2.5, "x": 0, "y": 0 } + }, "flowRate": 0.6 } }, { "commandType": "dispense", - "key": "94d06fc3-2155-423d-bbbe-2702134d0b66", + "key": "01461514-1395-4f09-95db-29dea71c1f5b", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "volume": 3, "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", "wellName": "D6", - "wellLocation": { "origin": "bottom", "offset": { "z": 2.5 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 2.5, "x": 0, "y": 0 } + }, "flowRate": 10 } }, { "commandType": "aspirate", - "key": "a8f19aa5-d7f1-4a3e-9647-1b552cfc39aa", + "key": "ff195ab9-cb65-45d1-93a8-a071d0bbed98", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "volume": 3, "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", "wellName": "D6", - "wellLocation": { "origin": "bottom", "offset": { "z": 2.5 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 2.5, "x": 0, "y": 0 } + }, "flowRate": 0.6 } }, { "commandType": "dispense", - "key": "06b8e0be-cc31-46f9-8e82-02b25241bf9b", + "key": "8ba714b7-bcc2-48c3-8c57-0d0ac933b976", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "volume": 3, "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", "wellName": "D6", - "wellLocation": { "origin": "bottom", "offset": { "z": 2.5 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 2.5, "x": 0, "y": 0 } + }, "flowRate": 10 } }, - { - "commandType": "touchTip", - "key": "e3ed68db-2b25-4ae1-802c-5b4a41f7ee68", - "params": { - "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", - "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", - "wellName": "D6", - "wellLocation": { "origin": "bottom", "offset": { "z": 40.3 } } - } - }, { "commandType": "moveToAddressableArea", - "key": "63cea2eb-fde2-4bca-976e-30df41c074b7", + "key": "8c2017b4-9145-46bc-a91f-83f27cc0a828", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "addressableAreaName": "fixedTrash", @@ -5032,15 +5318,25 @@ }, { "commandType": "blowOutInPlace", - "key": "24f20d09-4f31-4745-9c13-56294033a7cd", + "key": "6dba0671-c83f-4fc2-8d9c-3e309448d0e9", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "flowRate": 1000 } }, + { + "commandType": "touchTip", + "key": "15c49bf0-ce06-4687-aeb5-a5dd0736f2f5", + "params": { + "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", + "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", + "wellName": "D6", + "wellLocation": { "origin": "bottom", "offset": { "z": 40.3 } } + } + }, { "commandType": "moveToAddressableAreaForDropTip", - "key": "ccc5d7fe-9806-484e-b4a4-d9bb456e7c04", + "key": "5e494f88-ee95-42f1-bbd4-23b449649b93", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "addressableAreaName": "fixedTrash", @@ -5050,12 +5346,12 @@ }, { "commandType": "dropTipInPlace", - "key": "9837b26a-92cb-4b2c-928a-09f96213ba44", + "key": "e1f4d20a-b36c-4da1-9b1f-529aef638f1f", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e" } }, { "commandType": "pickUpTip", - "key": "f962386f-842e-454f-ade8-0ef08bbcbd43", + "key": "c3d944d3-abe8-4f4c-8e4d-70792c3303f2", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "labwareId": "c6f4ec70-92a5-11e9-ac62-1b173f839d9e:tiprack-10ul", @@ -5064,91 +5360,112 @@ }, { "commandType": "aspirate", - "key": "af9c739b-acf9-4db3-ba58-b34a0d90c70e", + "key": "4432786d-94e4-4958-ae49-8d0679c97fc0", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "volume": 2, "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", "wellName": "A1", - "wellLocation": { "origin": "bottom", "offset": { "z": 1 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 1, "x": 0, "y": 0 } + }, "flowRate": 0.6 } }, { "commandType": "dispense", - "key": "f872765c-27c0-4507-90f3-4259560ca9a4", + "key": "3efc13e5-aac5-4f23-b060-52003c8c827f", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "volume": 2, "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", "wellName": "A1", - "wellLocation": { "origin": "bottom", "offset": { "z": 1 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 1, "x": 0, "y": 0 } + }, "flowRate": 10 } }, { "commandType": "aspirate", - "key": "f3025d61-4322-463e-83ec-e47182b2725d", + "key": "5ec72861-9ac4-4a9b-91e2-907932819e58", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "volume": 2, "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", "wellName": "A1", - "wellLocation": { "origin": "bottom", "offset": { "z": 1 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 1, "x": 0, "y": 0 } + }, "flowRate": 0.6 } }, { "commandType": "dispense", - "key": "7611a735-e1c2-4cc1-82c2-053c63c6ab10", + "key": "994b0746-ea15-4cfb-afa7-d00ff124e0f1", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "volume": 2, "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", "wellName": "A1", - "wellLocation": { "origin": "bottom", "offset": { "z": 1 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 1, "x": 0, "y": 0 } + }, "flowRate": 10 } }, { "commandType": "aspirate", - "key": "a3074ec0-f736-4837-99d4-3b37f0a7ee22", + "key": "2acee0bb-366c-4f1d-b165-f69a1c03b05f", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "volume": 2, "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", "wellName": "A1", - "wellLocation": { "origin": "bottom", "offset": { "z": 1 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 1, "x": 0, "y": 0 } + }, "flowRate": 0.6 } }, { "commandType": "dispense", - "key": "f043597b-a221-4670-9ced-5bda15cd7c4e", + "key": "a44857c1-e5d2-4ce7-a428-41a68e426f3c", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "volume": 2, "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", "wellName": "A1", - "wellLocation": { "origin": "bottom", "offset": { "z": 1 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 1, "x": 0, "y": 0 } + }, "flowRate": 10 } }, { "commandType": "aspirate", - "key": "d02ca9b9-43ef-4826-af1b-5b2f1b668378", + "key": "09f55bdd-61ff-4667-878f-c79e0a21b9c5", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "volume": 6, "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", "wellName": "A1", - "wellLocation": { "origin": "bottom", "offset": { "z": 1 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 1, "x": 0, "y": 0 } + }, "flowRate": 0.6 } }, { "commandType": "touchTip", - "key": "b16f9a78-8c9a-4701-8ce9-a68d549705ff", + "key": "4daa0f4c-e10e-488e-9d19-3a8602a548f4", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", @@ -5158,77 +5475,82 @@ }, { "commandType": "dispense", - "key": "27208993-c49c-4ed2-a58f-fd1c9726da35", + "key": "5f54be1c-fff2-41ae-b512-01a9bb28cc4a", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "volume": 6, "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", "wellName": "C6", - "wellLocation": { "origin": "bottom", "offset": { "z": 2.5 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 2.5, "x": 0, "y": 0 } + }, "flowRate": 10 } }, { "commandType": "aspirate", - "key": "4e6d7f1b-bf01-4dc8-9804-db5891de458d", + "key": "6e42ea13-01ed-461b-8dfa-9bd360982ddf", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "volume": 3, "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", "wellName": "C6", - "wellLocation": { "origin": "bottom", "offset": { "z": 2.5 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 2.5, "x": 0, "y": 0 } + }, "flowRate": 0.6 } }, { "commandType": "dispense", - "key": "0f2634c6-4557-4f70-aeab-aa557d43d63e", + "key": "63d6f42e-0caa-47c4-9341-e3a950f85128", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "volume": 3, "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", "wellName": "C6", - "wellLocation": { "origin": "bottom", "offset": { "z": 2.5 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 2.5, "x": 0, "y": 0 } + }, "flowRate": 10 } }, { "commandType": "aspirate", - "key": "8df3e7e6-c44f-48d8-98cd-49ae2bdceb74", + "key": "c8791232-20bd-4068-a778-4630548b49ae", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "volume": 3, "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", "wellName": "C6", - "wellLocation": { "origin": "bottom", "offset": { "z": 2.5 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 2.5, "x": 0, "y": 0 } + }, "flowRate": 0.6 } }, { "commandType": "dispense", - "key": "4cead8f3-508b-49fc-843c-47708304ac93", + "key": "98e4d5e2-4b75-435f-8809-099806e98694", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "volume": 3, "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", "wellName": "C6", - "wellLocation": { "origin": "bottom", "offset": { "z": 2.5 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 2.5, "x": 0, "y": 0 } + }, "flowRate": 10 } }, - { - "commandType": "touchTip", - "key": "edb37370-7199-459b-a925-17ed47861588", - "params": { - "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", - "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", - "wellName": "C6", - "wellLocation": { "origin": "bottom", "offset": { "z": 40.3 } } - } - }, { "commandType": "moveToAddressableArea", - "key": "7005887b-2511-4f79-aeb3-855150844387", + "key": "921371a0-2df9-4f3e-b28f-0282399e98a3", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "addressableAreaName": "fixedTrash", @@ -5237,15 +5559,25 @@ }, { "commandType": "blowOutInPlace", - "key": "2aeaea31-84e5-4b17-a085-d3eb62c3e89e", + "key": "f9c7ae2a-b401-4c92-8e6a-4366ffb93643", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "flowRate": 1000 } }, + { + "commandType": "touchTip", + "key": "70fbf7e3-cae6-49e7-bfd3-65a5376b5e3e", + "params": { + "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", + "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", + "wellName": "C6", + "wellLocation": { "origin": "bottom", "offset": { "z": 40.3 } } + } + }, { "commandType": "moveToAddressableAreaForDropTip", - "key": "39262fc1-a0f9-4155-9db8-0628b2e013b7", + "key": "74d53fee-f9c6-4a27-a54b-80a79e906b6c", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "addressableAreaName": "fixedTrash", @@ -5255,12 +5587,12 @@ }, { "commandType": "dropTipInPlace", - "key": "4653d001-f682-415e-ae31-c70dca6ce4f7", + "key": "28dc2329-937d-4d2c-8fc3-eecf3f321041", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e" } }, { "commandType": "pickUpTip", - "key": "686a2200-9d23-4a25-bdb7-fd9a32d1c9ac", + "key": "5ad18635-8559-4904-8db4-4e2b19546238", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "labwareId": "c6f4ec70-92a5-11e9-ac62-1b173f839d9e:tiprack-10ul", @@ -5269,90 +5601,108 @@ }, { "commandType": "aspirate", - "key": "b9112647-1963-4a42-9d9f-3294d3962fbe", + "key": "1227b40e-adda-4545-9724-5509ff790adf", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "volume": 5.5, "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", "wellName": "F1", - "wellLocation": { "origin": "bottom", "offset": { "z": 0.5 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 0.5, "x": 0, "y": 0 } + }, "flowRate": 8 } }, { "commandType": "dispense", - "key": "1131307b-8c81-45b6-9395-b1b7f5568708", + "key": "b9c1000c-c52f-4b04-9790-9a2dec7dadd3", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "volume": 5.5, "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", "wellName": "F1", - "wellLocation": { "origin": "bottom", "offset": { "z": 0.5 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 0.5, "x": 0, "y": 0 } + }, "flowRate": 7 } }, { "commandType": "aspirate", - "key": "baa2f965-8f3d-41ff-a124-a045a975a9d8", + "key": "0b5da711-8961-40d0-a294-b4d9eed6c77a", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "volume": 5.5, "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", "wellName": "F1", - "wellLocation": { "origin": "bottom", "offset": { "z": 0.5 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 0.5, "x": 0, "y": 0 } + }, "flowRate": 8 } }, { "commandType": "dispense", - "key": "ee62e490-95d0-45b5-9e8a-1d810de9759e", + "key": "12b3c883-f2b2-4651-816e-e38bb8cb5c85", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "volume": 5.5, "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", "wellName": "F1", - "wellLocation": { "origin": "bottom", "offset": { "z": 0.5 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 0.5, "x": 0, "y": 0 } + }, "flowRate": 7 } }, { "commandType": "aspirate", - "key": "75129558-a345-4881-95ef-2989836e833d", + "key": "b30463df-33e7-4038-97d6-298f7e9cef8e", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "volume": 5.5, "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", "wellName": "F1", - "wellLocation": { "origin": "bottom", "offset": { "z": 0.5 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 0.5, "x": 0, "y": 0 } + }, "flowRate": 8 } }, { "commandType": "dispense", - "key": "fe55ef54-d044-44cf-890d-6990f8c2c546", + "key": "b2c2c14c-6874-406a-b9d1-33bc02b7a74f", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "volume": 5.5, "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", "wellName": "F1", - "wellLocation": { "origin": "bottom", "offset": { "z": 0.5 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 0.5, "x": 0, "y": 0 } + }, "flowRate": 7 } }, { "commandType": "blowout", - "key": "ef6a39e5-1820-498e-82ef-1ccf5f8bf183", + "key": "98f8d095-46f4-4349-8c93-21eebfcf05d3", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", "wellName": "A1", "flowRate": 7, - "wellLocation": { "origin": "bottom", "offset": { "z": 41.3 } } + "wellLocation": { "origin": "top", "offset": { "z": 0 } } } }, { "commandType": "touchTip", - "key": "c6189400-48b1-42ce-9071-6521503ad70e", + "key": "d6985dc6-551c-4ceb-bcc9-c833301b1eac", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "labwareId": "dafd4000-92a5-11e9-ac62-1b173f839d9e:96-deep-well", @@ -5362,7 +5712,7 @@ }, { "commandType": "moveToAddressableAreaForDropTip", - "key": "a3327fbf-7028-4a4b-adae-90a79f19dcfe", + "key": "cdf5e0f0-0598-4e4d-98e8-70a57ff83a4a", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e", "addressableAreaName": "fixedTrash", @@ -5372,12 +5722,12 @@ }, { "commandType": "dropTipInPlace", - "key": "5706a987-1067-4a6f-b0d2-72e4e2efd853", + "key": "1c0dee1c-97fa-4f33-bb36-9b3b7a2ef73e", "params": { "pipetteId": "c6f45030-92a5-11e9-ac62-1b173f839d9e" } }, { "commandType": "waitForDuration", - "key": "3e17b047-d94f-4476-a51d-5a50b40bf65b", + "key": "d306df0a-3ad2-48ac-9ac2-1151895982e0", "params": { "seconds": 3723, "message": "Delay plz" } } ], diff --git a/protocol-designer/fixtures/protocol/8/mix_8_0_0.json b/protocol-designer/fixtures/protocol/8/mix_8_0_0.json index 0cf5bc6679f..6ace9e70926 100644 --- a/protocol-designer/fixtures/protocol/8/mix_8_0_0.json +++ b/protocol-designer/fixtures/protocol/8/mix_8_0_0.json @@ -6,7 +6,7 @@ "author": "", "description": "A test for 5.0.0 -> 5.1.0 migration", "created": 1600714068238, - "lastModified": 1709303322125, + "lastModified": 1711742569351, "category": null, "subcategory": null, "tags": [] @@ -15,7 +15,7 @@ "name": "opentrons/protocol-designer", "version": "8.1.0", "data": { - "_internalAppBuildDate": "Fri, 01 Mar 2024 14:22:27 GMT", + "_internalAppBuildDate": "Fri, 29 Mar 2024 20:00:04 GMT", "defaultValues": { "aspirate_mmFromBottom": 1, "dispense_mmFromBottom": 0.5, @@ -58,6 +58,7 @@ "labware": null, "mix_wellOrder_first": "t2b", "mix_wellOrder_second": "l2r", + "blowout_z_offset": 0, "blowout_checkbox": false, "blowout_location": "5ba7047d-d3e2-4845-9eaa-1974af796ead:trashBin", "mix_mmFromBottom": 0.5, @@ -75,6 +76,8 @@ "dropTip_location": "5ba7047d-d3e2-4845-9eaa-1974af796ead:trashBin", "nozzles": null, "tipRack": "f1c677c0-fc3a-11ea-8809-e959e7d61d96:opentrons/opentrons_96_tiprack_10ul/1", + "mix_x_position": 0, + "mix_y_position": 0, "id": "fc4dc7c0-fc3a-11ea-8809-e959e7d61d96", "stepType": "mix", "stepName": "mix", @@ -2125,7 +2128,7 @@ "commandSchemaId": "opentronsCommandSchemaV8", "commands": [ { - "key": "87303141-a159-4390-ab9e-c737b5e29d2a", + "key": "3004b46c-2b41-4453-8ddc-1629ec3b5249", "commandType": "loadPipette", "params": { "pipetteName": "p20_single_gen2", @@ -2134,7 +2137,7 @@ } }, { - "key": "1dbb2e54-da06-4512-b02c-b3a4c2fc539f", + "key": "c318feee-5ec6-40a0-9ecc-554e67b30ce1", "commandType": "loadLabware", "params": { "displayName": "Opentrons OT-2 96 Tip Rack 10 µL", @@ -2146,7 +2149,7 @@ } }, { - "key": "7c5e3453-255c-4216-a5c3-7787fa4ef106", + "key": "3350dee6-aa60-4569-a801-0dfeb5baf8ed", "commandType": "loadLabware", "params": { "displayName": "Bio-Rad 96 Well Plate 200 µL PCR", @@ -2159,7 +2162,7 @@ }, { "commandType": "waitForDuration", - "key": "929f2a92-418b-411d-aa33-27db0788e1ff", + "key": "797e70f3-5310-48c2-ba06-12adb92a7b4e", "params": { "seconds": 3723, "message": "" } } ], diff --git a/protocol-designer/fixtures/protocol/8/ninetySixChannelFullAndColumn.json b/protocol-designer/fixtures/protocol/8/ninetySixChannelFullAndColumn.json index abc2d223176..702945f0b8c 100644 --- a/protocol-designer/fixtures/protocol/8/ninetySixChannelFullAndColumn.json +++ b/protocol-designer/fixtures/protocol/8/ninetySixChannelFullAndColumn.json @@ -6,7 +6,7 @@ "author": "", "description": "", "created": 1701805621086, - "lastModified": 1709303384383, + "lastModified": 1711742604736, "category": null, "subcategory": null, "tags": [] @@ -15,7 +15,7 @@ "name": "opentrons/protocol-designer", "version": "8.1.0", "data": { - "_internalAppBuildDate": "Fri, 01 Mar 2024 14:22:27 GMT", + "_internalAppBuildDate": "Fri, 29 Mar 2024 20:00:04 GMT", "defaultValues": { "aspirate_mmFromBottom": 1, "dispense_mmFromBottom": 0.5, @@ -78,6 +78,7 @@ "dispense_touchTip_mmFromBottom": null, "disposalVolume_checkbox": true, "disposalVolume_volume": "5", + "blowout_z_offset": 0, "blowout_checkbox": false, "blowout_location": null, "preWetTip": false, @@ -93,6 +94,10 @@ "dispense_delay_mmFromBottom": null, "dropTip_location": "1e553651-9e4d-44b1-a31b-92459642bfd7:trashBin", "nozzles": "ALL", + "dispense_x_position": 0, + "dispense_y_position": 0, + "aspirate_x_position": 0, + "aspirate_y_position": 0, "id": "83a095fa-b649-4105-99d4-177f1a3f363a", "stepType": "moveLiquid", "stepName": "transfer", @@ -129,6 +134,7 @@ "dispense_touchTip_mmFromBottom": null, "disposalVolume_checkbox": true, "disposalVolume_volume": "5", + "blowout_z_offset": 0, "blowout_checkbox": false, "blowout_location": null, "preWetTip": false, @@ -144,6 +150,10 @@ "dispense_delay_mmFromBottom": null, "dropTip_location": "1e553651-9e4d-44b1-a31b-92459642bfd7:trashBin", "nozzles": "COLUMN", + "dispense_x_position": 0, + "dispense_y_position": 0, + "aspirate_x_position": 0, + "aspirate_y_position": 0, "id": "f5ea3139-1585-4848-9d5f-832eb88c99ca", "stepType": "moveLiquid", "stepName": "transfer", @@ -2233,7 +2243,7 @@ "commandSchemaId": "opentronsCommandSchemaV8", "commands": [ { - "key": "e09dc6e2-c0e6-4b28-9460-865c48a3b03f", + "key": "7224d1a7-a7b3-4bb3-bc5c-65aa98565616", "commandType": "loadPipette", "params": { "pipetteName": "p1000_96", @@ -2242,7 +2252,7 @@ } }, { - "key": "3dc22b4a-9fa8-4c61-843d-b45a4054490e", + "key": "dcddeb3c-66d9-4868-9f9f-fbd47d754fc4", "commandType": "loadLabware", "params": { "displayName": "Opentrons Flex 96 Tip Rack Adapter", @@ -2254,7 +2264,7 @@ } }, { - "key": "0f3b11ad-a015-4ece-9267-0ca57c832bfd", + "key": "c206434e-aa1e-44ee-8667-29accd89941a", "commandType": "loadLabware", "params": { "displayName": "Opentrons Flex 96 Tip Rack 50 µL", @@ -2268,7 +2278,7 @@ } }, { - "key": "0194f4bc-e114-4048-af3f-e053db83a79e", + "key": "3cdba839-f0fa-4e50-8399-94338cced032", "commandType": "loadLabware", "params": { "displayName": "Bio-Rad 96 Well Plate 200 µL PCR", @@ -2280,7 +2290,7 @@ } }, { - "key": "c807c9aa-7300-40be-817f-6d2018cd9d95", + "key": "7f75bf03-3036-4847-afbf-4bbefdf6cee8", "commandType": "loadLabware", "params": { "displayName": "Opentrons Flex 96 Tip Rack 50 µL", @@ -2293,7 +2303,7 @@ }, { "commandType": "configureNozzleLayout", - "key": "131fd37b-29cb-41f8-8792-b3c210e2db36", + "key": "2326c781-0416-4319-b954-16929077b5e3", "params": { "pipetteId": "de7da440-95ec-43e8-8723-851321fbd6f9", "configurationParams": { "style": "ALL" } @@ -2301,7 +2311,7 @@ }, { "commandType": "pickUpTip", - "key": "d08a4b16-f17e-4146-adff-68d3235f3174", + "key": "86f7ac25-739d-4a38-8bf4-4730a8e6cce7", "params": { "pipetteId": "de7da440-95ec-43e8-8723-851321fbd6f9", "labwareId": "75aa666f-98d8-4af9-908e-963ced428580:opentrons/opentrons_flex_96_tiprack_50ul/1", @@ -2310,19 +2320,22 @@ }, { "commandType": "aspirate", - "key": "79c1655a-54de-4c5d-8b74-3d866244b229", + "key": "0113e27d-0949-4305-8f0b-5467753dfac3", "params": { "pipetteId": "de7da440-95ec-43e8-8723-851321fbd6f9", "volume": 10, "labwareId": "fe1942b1-1b75-4d3a-9c12-d23004958a12:opentrons/biorad_96_wellplate_200ul_pcr/2", "wellName": "A1", - "wellLocation": { "origin": "bottom", "offset": { "z": 1 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 1, "x": 0, "y": 0 } + }, "flowRate": 6 } }, { "commandType": "moveToAddressableArea", - "key": "e95fefc8-1738-4e24-89ab-e8b27fbde04b", + "key": "79c134c0-5042-4243-8a81-95ad54594ab3", "params": { "pipetteId": "de7da440-95ec-43e8-8723-851321fbd6f9", "addressableAreaName": "movableTrashA3", @@ -2331,7 +2344,7 @@ }, { "commandType": "dispenseInPlace", - "key": "432061e5-a407-43cc-b703-25882875ae58", + "key": "2ce5b534-62b3-4415-bdd6-747fb57545be", "params": { "pipetteId": "de7da440-95ec-43e8-8723-851321fbd6f9", "volume": 10, @@ -2340,7 +2353,7 @@ }, { "commandType": "moveToAddressableAreaForDropTip", - "key": "8e2ba800-c7af-451a-b730-0ef9115b970f", + "key": "7212407e-0bd1-4ef5-a8c7-4c6f95cee357", "params": { "pipetteId": "de7da440-95ec-43e8-8723-851321fbd6f9", "addressableAreaName": "movableTrashA3", @@ -2350,12 +2363,12 @@ }, { "commandType": "dropTipInPlace", - "key": "0cced503-95fa-49fb-8540-2d528819f20d", + "key": "55286f40-e2c1-44f6-a3f3-032bfbf89f3d", "params": { "pipetteId": "de7da440-95ec-43e8-8723-851321fbd6f9" } }, { "commandType": "configureNozzleLayout", - "key": "48a2d952-d9ad-4ed7-9021-31c97c43b175", + "key": "47ab8f5c-a2dc-40e0-a6db-3c2ff6c48778", "params": { "pipetteId": "de7da440-95ec-43e8-8723-851321fbd6f9", "configurationParams": { "primaryNozzle": "A12", "style": "COLUMN" } @@ -2363,7 +2376,7 @@ }, { "commandType": "pickUpTip", - "key": "474ddf94-384e-4c01-acbd-50e43c005c7c", + "key": "c6f563fd-4f3f-4bd8-833e-3519c4fb0026", "params": { "pipetteId": "de7da440-95ec-43e8-8723-851321fbd6f9", "labwareId": "9bd16b50-4ae9-4cfd-8583-3378087e6a6c:opentrons/opentrons_flex_96_tiprack_50ul/1", @@ -2372,19 +2385,22 @@ }, { "commandType": "aspirate", - "key": "1e082d08-89b8-4e5f-b80f-e9190280fad7", + "key": "ee919504-5c21-40c5-9205-00e8aee06718", "params": { "pipetteId": "de7da440-95ec-43e8-8723-851321fbd6f9", "volume": 10, "labwareId": "fe1942b1-1b75-4d3a-9c12-d23004958a12:opentrons/biorad_96_wellplate_200ul_pcr/2", "wellName": "A7", - "wellLocation": { "origin": "bottom", "offset": { "z": 1 } }, + "wellLocation": { + "origin": "bottom", + "offset": { "z": 1, "x": 0, "y": 0 } + }, "flowRate": 6 } }, { "commandType": "moveToAddressableArea", - "key": "42daf0a1-9c17-4c9a-b8e6-90e68e166d1a", + "key": "6c1dbdec-0d3a-4693-810b-b28984382fce", "params": { "pipetteId": "de7da440-95ec-43e8-8723-851321fbd6f9", "addressableAreaName": "movableTrashA3", @@ -2393,7 +2409,7 @@ }, { "commandType": "dispenseInPlace", - "key": "6e36d0e4-e975-4cf6-8dd4-24d74f9d60f7", + "key": "d7ad2bf5-3033-4168-adf4-082306dc5467", "params": { "pipetteId": "de7da440-95ec-43e8-8723-851321fbd6f9", "volume": 10, @@ -2402,7 +2418,7 @@ }, { "commandType": "moveToAddressableAreaForDropTip", - "key": "918fec4b-1947-49c5-8fe1-af24fef2bf3f", + "key": "9ca4968e-0995-4354-95a1-37964599784f", "params": { "pipetteId": "de7da440-95ec-43e8-8723-851321fbd6f9", "addressableAreaName": "movableTrashA3", @@ -2412,7 +2428,7 @@ }, { "commandType": "dropTipInPlace", - "key": "7b5a5ab4-5dbd-4338-890f-38551bd58c4a", + "key": "548bbf90-da13-4487-a878-dd363b17d906", "params": { "pipetteId": "de7da440-95ec-43e8-8723-851321fbd6f9" } } ], diff --git a/protocol-designer/src/components/BatchEditForm/BatchEditMix.tsx b/protocol-designer/src/components/BatchEditForm/BatchEditMix.tsx index 062052ea9d6..76074bc8e3b 100644 --- a/protocol-designer/src/components/BatchEditForm/BatchEditMix.tsx +++ b/protocol-designer/src/components/BatchEditForm/BatchEditMix.tsx @@ -88,7 +88,10 @@ export const BatchEditMix = (props: BatchEditMixProps): JSX.Element => { tiprack={propsForFields.tipRack.value} /> { className={styles.small_field} > { const { labwareOnDeck } = props + const labwareEntities = useSelector(getLabwareEntities) + const adapterId = + labwareEntities[labwareOnDeck.slot] != null + ? labwareEntities[labwareOnDeck.slot].id + : null + const highlighted = useSelector(getHoveredStepLabware).includes( - labwareOnDeck.id + adapterId ?? labwareOnDeck.id ) let isTcProfile = false diff --git a/protocol-designer/src/components/EditModules.tsx b/protocol-designer/src/components/EditModules.tsx index 9df9defbdd9..7a4ef5b48c7 100644 --- a/protocol-designer/src/components/EditModules.tsx +++ b/protocol-designer/src/components/EditModules.tsx @@ -1,14 +1,21 @@ import * as React from 'react' import { useSelector, useDispatch } from 'react-redux' +import { + FLEX_ROBOT_TYPE, + TEMPERATURE_MODULE_TYPE, +} from '@opentrons/shared-data' import { selectors as stepFormSelectors, actions as stepFormActions, } from '../step-forms' import { moveDeckItem } from '../labware-ingred/actions/actions' +import { getRobotType } from '../file-data/selectors' +import { getEnableMoam } from '../feature-flags/selectors' +import { EditMultipleModulesModal } from './modals/EditModulesModal/EditMultipleModulesModal' import { useBlockingHint } from './Hints/useBlockingHint' import { MagneticModuleWarningModalContent } from './modals/EditModulesModal/MagneticModuleWarningModalContent' import { EditModulesModal } from './modals/EditModulesModal' -import { ModuleModel, ModuleType } from '@opentrons/shared-data' +import type { ModuleModel, ModuleType } from '@opentrons/shared-data' export interface EditModulesProps { moduleToEdit: { @@ -27,6 +34,12 @@ export const EditModules = (props: EditModulesProps): JSX.Element => { const { onCloseClick, moduleToEdit } = props const { moduleId, moduleType } = moduleToEdit const _initialDeckSetup = useSelector(stepFormSelectors.getInitialDeckSetup) + const robotType = useSelector(getRobotType) + const moamFf = useSelector(getEnableMoam) + const showMultipleModuleModal = + robotType === FLEX_ROBOT_TYPE && + moduleType === TEMPERATURE_MODULE_TYPE && + moamFf const moduleOnDeck = moduleId ? _initialDeckSetup.modules[moduleId] : null const [ @@ -74,16 +87,24 @@ export const EditModules = (props: EditModulesProps): JSX.Element => { enabled: changeModuleWarningInfo !== null, }) - return ( - changeModuleWarning ?? ( - + ) + if (showMultipleModuleModal) { + modal = ( + ) - ) + } + return changeModuleWarning ?? modal } diff --git a/protocol-designer/src/components/FileSidebar/FileSidebar.tsx b/protocol-designer/src/components/FileSidebar/FileSidebar.tsx index 3049f036b4a..11b8d21053d 100644 --- a/protocol-designer/src/components/FileSidebar/FileSidebar.tsx +++ b/protocol-designer/src/components/FileSidebar/FileSidebar.tsx @@ -129,6 +129,7 @@ function getWarningContent({ const pipettesDetails = pipettesWithoutStep .map(pipette => `${pipette.mount} ${pipette.spec.displayName}`) .join(' and ') + const modulesDetails = modulesWithoutStep .map(moduleOnDeck => t(`modules:module_long_names.${moduleOnDeck.type}`)) .join(' and ') @@ -169,12 +170,14 @@ function getWarningContent({ if (modulesWithoutStep.length) { const moduleCase = modulesWithoutStep.length > 1 ? 'unused_modules' : 'unused_module' + const slotName = modulesWithoutStep.map(module => module.slot) return { content: ( <>

{t(`export_warnings.${moduleCase}.body1`, { modulesDetails, + slotName: slotName, })}

{t(`export_warnings.${moduleCase}.body2`)}

@@ -234,9 +237,9 @@ export function v8WarningContent(t: any): JSX.Element { return (

- {t(`hint.export_v8_protocol_7_1.body1`)}{' '} - {t(`hint.export_v8_protocol_7_1.body2`)} - {t(`hint.export_v8_protocol_7_1.body3`)} + {t(`hint.export_v8_1_protocol_7_3.body1`)}{' '} + {t(`hint.export_v8_1_protocol_7_3.body2`)} + {t(`hint.export_v8_1_protocol_7_3.body3`)}

) @@ -347,7 +350,7 @@ export function FileSidebar(): JSX.Element { content: React.ReactNode } => { return { - hintKey: 'export_v8_protocol_7_1', + hintKey: 'export_v8_1_protocol_7_3', content: v8WarningContent(t), } } diff --git a/protocol-designer/src/components/FileSidebar/__tests__/FileSidebar.test.tsx b/protocol-designer/src/components/FileSidebar/__tests__/FileSidebar.test.tsx index ebe86be63a7..827af5a2aa8 100644 --- a/protocol-designer/src/components/FileSidebar/__tests__/FileSidebar.test.tsx +++ b/protocol-designer/src/components/FileSidebar/__tests__/FileSidebar.test.tsx @@ -1,7 +1,11 @@ import * as React from 'react' import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest' import { fireEvent, screen, cleanup } from '@testing-library/react' -import { FLEX_ROBOT_TYPE } from '@opentrons/shared-data' +import { + FLEX_ROBOT_TYPE, + LabwareDefinition2, + fixtureTiprack300ul, +} from '@opentrons/shared-data' import { renderWithProviders } from '../../../__testing-utils__' import { createFile, getRobotType } from '../../../file-data/selectors' import { @@ -17,11 +21,8 @@ import { import { toggleNewProtocolModal } from '../../../navigation/actions' import { getHasUnsavedChanges } from '../../../load-file/selectors' import { useBlockingHint } from '../../Hints/useBlockingHint' -import { - getUnusedEntities, - getUnusedStagingAreas, - getUnusedTrash, -} from '../utils' +import { getUnusedStagingAreas } from '../utils/getUnusedStagingAreas' +import { getUnusedTrash } from '../utils/getUnusedTrash' import { FileSidebar } from '../FileSidebar' vi.mock('../../../step-forms/selectors') @@ -30,15 +31,14 @@ vi.mock('../../../navigation/actions') vi.mock('../../../navigation/selectors') vi.mock('../../../file-data/selectors') vi.mock('../../Hints/useBlockingHint') -vi.mock('../utils') - +vi.mock('../utils/getUnusedStagingAreas') +vi.mock('../utils/getUnusedTrash') const render = () => { return renderWithProviders(, { i18nInstance: i18n })[0] } describe('FileSidebar', () => { beforeEach(() => { - vi.mocked(getUnusedEntities).mockReturnValue([]) vi.mocked(getUnusedStagingAreas).mockReturnValue([]) vi.mocked(getUnusedTrash).mockReturnValue({ trashBinUnused: false, @@ -74,6 +74,13 @@ describe('FileSidebar', () => { vi.resetAllMocks() cleanup() }) + it('renders the file sidebar and exports with blocking hint for exporting', () => { + vi.mocked(useBlockingHint).mockReturnValue(
mock blocking hint
) + render() + fireEvent.click(screen.getByRole('button', { name: 'Export' })) + expect(vi.mocked(useBlockingHint)).toHaveBeenCalled() + screen.getByText('mock blocking hint') + }) it('renders the file sidebar and buttons work as expected with no warning upon export', () => { render() screen.getByText('Protocol File') @@ -91,19 +98,54 @@ describe('FileSidebar', () => { fireEvent.click(screen.getByRole('button', { name: 'Export' })) screen.getByText('Your protocol has no steps') }) - it('renders the unused pipette and module warning', () => { - vi.mocked(getUnusedEntities).mockReturnValue([ - { - mount: 'left', - name: 'p1000_96', - id: 'pipetteId', - tiprackDefURI: 'mockURI', - spec: { - name: 'mock pip name', - displayName: 'mock display name', + it('renders the unused pipette warning', () => { + vi.mocked(getInitialDeckSetup).mockReturnValue({ + modules: {}, + pipettes: { + pipetteId: { + mount: 'left', + name: 'p1000_96', + id: 'pipetteId', + tiprackLabwareDef: [fixtureTiprack300ul as LabwareDefinition2], + tiprackDefURI: ['mockDefUri'], + spec: { + displayName: 'mock display name', + } as any, + }, + }, + additionalEquipmentOnDeck: {}, + labware: {}, + }) + render() + fireEvent.click(screen.getByRole('button', { name: 'Export' })) + screen.getByText('Unused pipette') + }) + it('renders the unused pieptte and module warning', () => { + vi.mocked(getInitialDeckSetup).mockReturnValue({ + modules: { + moduleId: { + slot: 'A1', + moduleState: {} as any, + id: 'moduleId', + type: 'temperatureModuleType', + model: 'temperatureModuleV2', + }, + }, + pipettes: { + pipetteId: { + mount: 'left', + name: 'p1000_96', + id: 'pipetteId', + tiprackLabwareDef: [fixtureTiprack300ul as LabwareDefinition2], + tiprackDefURI: ['mockDefUri'], + spec: { + displayName: 'mock display name', + } as any, }, }, - ]) + additionalEquipmentOnDeck: {}, + labware: {}, + }) render() fireEvent.click(screen.getByRole('button', { name: 'Export' })) screen.getByText('Unused pipette and module') @@ -140,4 +182,55 @@ describe('FileSidebar', () => { fireEvent.click(screen.getByRole('button', { name: 'Export' })) screen.getByText('Unused gripper') }) + it('renders the unused module warning', () => { + vi.mocked(getInitialDeckSetup).mockReturnValue({ + modules: { + moduleId: { + slot: 'A1', + moduleState: {} as any, + id: 'moduleId', + type: 'temperatureModuleType', + model: 'temperatureModuleV2', + }, + }, + pipettes: {}, + additionalEquipmentOnDeck: {}, + labware: {}, + }) + render() + fireEvent.click(screen.getByRole('button', { name: 'Export' })) + screen.getByText('Unused module') + screen.getByText( + 'The Temperature module specified in your protocol in Slot A1 is not currently used in any step. In order to run this protocol you will need to power up and connect the module to your robot.' + ) + }) + it('renders the unused modules warning', () => { + vi.mocked(getInitialDeckSetup).mockReturnValue({ + modules: { + moduleId: { + slot: 'A1', + moduleState: {} as any, + id: 'moduleId', + type: 'temperatureModuleType', + model: 'temperatureModuleV2', + }, + moduleId2: { + slot: 'B1', + moduleState: {} as any, + id: 'moduleId2', + type: 'temperatureModuleType', + model: 'temperatureModuleV2', + }, + }, + pipettes: {}, + additionalEquipmentOnDeck: {}, + labware: {}, + }) + render() + fireEvent.click(screen.getByRole('button', { name: 'Export' })) + screen.getByText('Unused modules') + screen.getByText( + 'One or more modules specified in your protocol in Slot(s) A1,B1 are not currently used in any step. In order to run this protocol you will need to power up and connect the modules to your robot.' + ) + }) }) diff --git a/protocol-designer/src/components/Hints/index.tsx b/protocol-designer/src/components/Hints/index.tsx index af77a54193b..6f5bafd2527 100644 --- a/protocol-designer/src/components/Hints/index.tsx +++ b/protocol-designer/src/components/Hints/index.tsx @@ -74,12 +74,14 @@ export const Hints = (): JSX.Element | null => {

{t(`hint.${hintKey}.body3`)}

) + case 'multiple_modules_without_labware': case 'module_without_labware': return ( <>

{t(`alert:hint.${hintKey}.body`)}

) + case 'thermocycler_lid_passive_cooling': return ( <> diff --git a/protocol-designer/src/components/StepEditForm/StepEditForm.module.css b/protocol-designer/src/components/StepEditForm/StepEditForm.module.css index 5e27c4358fb..439dccbdf8c 100644 --- a/protocol-designer/src/components/StepEditForm/StepEditForm.module.css +++ b/protocol-designer/src/components/StepEditForm/StepEditForm.module.css @@ -269,33 +269,6 @@ and when that is implemented. margin: 1rem 0 2rem 14rem; } -.engage_height_diagram { - width: 90%; - padding-top: calc(40 / 540 * 90%); - background-repeat: no-repeat; - background-size: cover; - - &:hover { - cursor: pointer; - } -} - -.engage_height_diagram_gen1 { - background-image: url('../../images/modules/engage_height_static_gen1.png'); - - &:hover { - background-image: url('../../images/modules/engage_height_animation_gen1.gif'); - } -} - -.engage_height_diagram_gen2 { - background-image: url('../../images/modules/engage_height_static_gen2.png'); - - &:hover { - background-image: url('../../images/modules/engage_height_animation_gen2.gif'); - } -} - .tc_step_group { margin: 1rem 0; } diff --git a/protocol-designer/src/components/StepEditForm/fields/BlowoutLocationField.tsx b/protocol-designer/src/components/StepEditForm/fields/BlowoutLocationField.tsx index 6e8f91d1ec2..6637092deab 100644 --- a/protocol-designer/src/components/StepEditForm/fields/BlowoutLocationField.tsx +++ b/protocol-designer/src/components/StepEditForm/fields/BlowoutLocationField.tsx @@ -7,8 +7,8 @@ import styles from '../StepEditForm.module.css' import { FieldProps } from '../types' type BlowoutLocationDropdownProps = FieldProps & { - className?: string options: Options + className?: string } export const BlowoutLocationField = ( @@ -28,7 +28,7 @@ export const BlowoutLocationField = ( return ( (false) + const [targetProps, tooltipProps] = useHoverTooltip() + const labwareEntities = useSelector(getLabwareEntities) + + let labwareId = null + if (blowoutLabwareId === SOURCE_WELL_BLOWOUT_DESTINATION) { + labwareId = sourceLabwareId + } else if (blowoutLabwareId === DEST_WELL_BLOWOUT_DESTINATION) { + labwareId = destLabwareId + } + + const labwareZDimension = + labwareId != null + ? labwareEntities[String(labwareId)]?.def.dimensions.zDimension + : 0 + + return ( + <> + {tooltipContent} + {isModalOpen ? ( + setModalOpen(false)} + name={name} + zValue={Number(value)} + updateValue={updateValue} + wellDepthMm={labwareZDimension} + /> + ) : null} + setModalOpen(true)} + id={`BlowoutZOffsetField_${name}`} + data-testid={`BlowoutZOffsetField_${name}`} + > + + + + ) +} diff --git a/protocol-designer/src/components/StepEditForm/fields/DelayFields.tsx b/protocol-designer/src/components/StepEditForm/fields/DelayFields.tsx index 4a4e05801e4..dd49dd71d9f 100644 --- a/protocol-designer/src/components/StepEditForm/fields/DelayFields.tsx +++ b/protocol-designer/src/components/StepEditForm/fields/DelayFields.tsx @@ -37,7 +37,8 @@ export const DelayFields = (props: DelayFieldProps): JSX.Element => { /> {tipPositionFieldName && ( )} diff --git a/protocol-designer/src/components/StepEditForm/fields/TipPositionField/TipPositionAllViz.tsx b/protocol-designer/src/components/StepEditForm/fields/TipPositionField/TipPositionAllViz.tsx new file mode 100644 index 00000000000..d1b219b04d8 --- /dev/null +++ b/protocol-designer/src/components/StepEditForm/fields/TipPositionField/TipPositionAllViz.tsx @@ -0,0 +1,52 @@ +import * as React from 'react' +import round from 'lodash/round' + +import PIPETTE_TIP_IMAGE from '../../../../images/pipette_tip.svg' +import WELL_CROSS_SECTION_IMAGE from '../../../../images/well_cross_section.svg' + +import styles from './TipPositionInput.module.css' + +const WELL_HEIGHT_PIXELS = 145 +const WELL_WIDTH_PIXELS = 100 +const PIXEL_DECIMALS = 2 + +interface TipPositionAllVizProps { + mmFromBottom: number + xPosition: number + wellDepthMm: number + xWidthMm: number +} + +export function TipPositionAllViz(props: TipPositionAllVizProps): JSX.Element { + const { mmFromBottom, xPosition, wellDepthMm, xWidthMm } = props + const fractionOfWellHeight = mmFromBottom / wellDepthMm + const pixelsFromBottom = + Number(fractionOfWellHeight) * WELL_HEIGHT_PIXELS - WELL_HEIGHT_PIXELS + const roundedPixelsFromBottom = round(pixelsFromBottom, PIXEL_DECIMALS) + const bottomPx = wellDepthMm + ? roundedPixelsFromBottom + : mmFromBottom - WELL_HEIGHT_PIXELS + + const xPx = (WELL_WIDTH_PIXELS / xWidthMm) * xPosition + const roundedXPx = round(xPx, PIXEL_DECIMALS) + return ( +
+ + + {props.wellDepthMm !== null && ( + {props.wellDepthMm}mm + )} + +
+ ) +} diff --git a/protocol-designer/src/components/StepEditForm/fields/TipPositionField/TipPositionInput.module.css b/protocol-designer/src/components/StepEditForm/fields/TipPositionField/TipPositionInput.module.css index 181c6ae6f0d..ef185908342 100644 --- a/protocol-designer/src/components/StepEditForm/fields/TipPositionField/TipPositionInput.module.css +++ b/protocol-designer/src/components/StepEditForm/fields/TipPositionField/TipPositionInput.module.css @@ -4,14 +4,14 @@ display: flex; flex-direction: column; justify-content: space-evenly; - height: 5rem; + height: 4rem; } .main_row { display: flex; flex-direction: row; justify-content: space-between; - margin: 3rem 0 2rem; + margin: 1rem 0 2rem; } .position_from_bottom_input { @@ -56,7 +56,6 @@ font-weight: var(--fw-semibold); color: var(--c-blue); position: absolute; - right: 10px; bottom: 45px; align-self: flex-end; } @@ -65,3 +64,14 @@ position: relative; left: 9px; } + +.tip_position_icon { + height: 1.5rem; + width: 1.5rem; + cursor: pointer; + color: #24313f; /* black80 */ +} + +.tip_position_icon:hover { + background-color: #e6e6e6; +} diff --git a/protocol-designer/src/components/StepEditForm/fields/TipPositionField/TipPositionModal.tsx b/protocol-designer/src/components/StepEditForm/fields/TipPositionField/TipPositionModal.tsx index b2417810488..56a9148270f 100644 --- a/protocol-designer/src/components/StepEditForm/fields/TipPositionField/TipPositionModal.tsx +++ b/protocol-designer/src/components/StepEditForm/fields/TipPositionField/TipPositionModal.tsx @@ -2,107 +2,87 @@ import * as React from 'react' import { createPortal } from 'react-dom' import cx from 'classnames' import { useTranslation } from 'react-i18next' -import round from 'lodash/round' import { AlertModal, + DIRECTION_COLUMN, Flex, - HandleKeypress, - Icon, InputField, - OutlineButton, RadioGroup, + SPACING, + StyledText, } from '@opentrons/components' import { getMainPagePortalEl } from '../../../portals/MainPageModalPortal' -import modalStyles from '../../../modals/modal.module.css' import { getIsTouchTipField } from '../../../../form-types' -import { TipPositionZAxisViz } from './TipPositionZAxisViz' +import { PDAlert } from '../../../alerts/PDAlert' +import { TOO_MANY_DECIMALS, PERCENT_RANGE_TO_SHOW_WARNING } from './constants' +import { TipPositionAllViz } from './TipPositionAllViz' +import * as utils from './utils' import styles from './TipPositionInput.module.css' -import * as utils from './utils' -import type { StepFieldName } from '../../../../form-types' +import modalStyles from '../../../modals/modal.module.css' -const SMALL_STEP_MM = 1 -const LARGE_STEP_MM = 10 -const DECIMALS_ALLOWED = 1 +import type { StepFieldName } from '../../../../form-types' -interface Props { - closeModal: () => unknown - isIndeterminate?: boolean - mmFromBottom: number | null +type Offset = 'x' | 'y' | 'z' +interface PositionSpec { name: StepFieldName - updateValue: (val: number | null | undefined) => unknown - wellDepthMm: number + value: number | null + updateValue: (val?: number | null) => void } +export type PositionSpecs = Record -const roundValue = (value: number | string | null): number => { - return round(Number(value), DECIMALS_ALLOWED) +interface TipPositionModalProps { + closeModal: () => void + specs: PositionSpecs + wellDepthMm: number + wellXWidthMm: number + wellYWidthMm: number + isIndeterminate?: boolean } -const TOO_MANY_DECIMALS: 'TOO_MANY_DECIMALS' = 'TOO_MANY_DECIMALS' -const OUT_OF_BOUNDS: 'OUT_OF_BOUNDS' = 'OUT_OF_BOUNDS' -type Error = typeof TOO_MANY_DECIMALS | typeof OUT_OF_BOUNDS - -const getErrorText = (args: { - errors: Error[] - maxMmFromBottom: number - minMmFromBottom: number - isPristine: boolean - t: any -}): string | null => { - const { errors, minMmFromBottom, maxMmFromBottom, isPristine, t } = args - - if (errors.includes(TOO_MANY_DECIMALS)) { - return t('tip_position.errors.TOO_MANY_DECIMALS') - } else if (!isPristine && errors.includes(OUT_OF_BOUNDS)) { - return t('tip_position.errors.OUT_OF_BOUNDS', { - minMmFromBottom, - maxMmFromBottom, - }) - } else { - return null - } -} +export const TipPositionModal = ( + props: TipPositionModalProps +): JSX.Element | null => { + const { + isIndeterminate, + specs, + wellDepthMm, + wellXWidthMm, + wellYWidthMm, + closeModal, + } = props + const zSpec = specs.z + const ySpec = specs.y + const xSpec = specs.x -const getErrors = (args: { - isDefault: boolean - value: string | null - maxMmFromBottom: number - minMmFromBottom: number -}): Error[] => { - const { isDefault, value, maxMmFromBottom, minMmFromBottom } = args - const errors: Error[] = [] - if (isDefault) return errors - - const v = Number(value) - if (value === null || Number.isNaN(v)) { - // blank or otherwise invalid should show this error as a fallback - return [OUT_OF_BOUNDS] - } - const correctDecimals = round(v, DECIMALS_ALLOWED) === v - const outOfBounds = v > maxMmFromBottom || v < minMmFromBottom + const { t } = useTranslation(['modal', 'button']) - if (!correctDecimals) { - errors.push(TOO_MANY_DECIMALS) + if (zSpec == null || xSpec == null || ySpec == null) { + console.error( + 'expected to find specs for one of the positions but could not' + ) } - if (outOfBounds) { - errors.push(OUT_OF_BOUNDS) - } - return errors -} -export const TipPositionModal = (props: Props): JSX.Element => { - const { isIndeterminate, name, wellDepthMm } = props - const { t } = useTranslation(['modal', 'button']) const defaultMmFromBottom = utils.getDefaultMmFromBottom({ - name, + name: zSpec.name, wellDepthMm, }) - const [value, setValue] = React.useState( - props.mmFromBottom === null ? null : String(props.mmFromBottom) + const [zValue, setZValue] = React.useState( + zSpec?.value == null ? null : String(zSpec?.value) + ) + const [yValue, setYValue] = React.useState( + ySpec?.value == null ? null : String(ySpec?.value) ) + const [xValue, setXValue] = React.useState( + xSpec?.value == null ? null : String(xSpec?.value) + ) + const [isDefault, setIsDefault] = React.useState( - !isIndeterminate && props.mmFromBottom === null + !isIndeterminate && + zSpec.value === null && + ySpec.value === 0 && + xSpec.value === 0 ) // in this modal, pristinity hides the OUT_OF_BOUNDS error only. const [isPristine, setPristine] = React.useState(true) @@ -111,54 +91,83 @@ export const TipPositionModal = (props: Props): JSX.Element => { maxMmFromBottom: number minMmFromBottom: number } => { - if (getIsTouchTipField(name)) { + if (getIsTouchTipField(zSpec?.name ?? '')) { return { - maxMmFromBottom: roundValue(wellDepthMm), - minMmFromBottom: roundValue(wellDepthMm / 2), + maxMmFromBottom: utils.roundValue(wellDepthMm, 'up'), + minMmFromBottom: utils.roundValue(wellDepthMm / 2, 'up'), } } return { - maxMmFromBottom: roundValue(wellDepthMm * 2), + maxMmFromBottom: utils.roundValue(wellDepthMm * 2, 'up'), minMmFromBottom: 0, } } + const { maxMmFromBottom, minMmFromBottom } = getMinMaxMmFromBottom() - const errors = getErrors({ - isDefault, - minMmFromBottom, - maxMmFromBottom, - value, - }) - const hasErrors = errors.length > 0 + const { minValue: yMinWidth, maxValue: yMaxWidth } = utils.getMinMaxWidth( + wellYWidthMm + ) + const { minValue: xMinWidth, maxValue: xMaxWidth } = utils.getMinMaxWidth( + wellXWidthMm + ) + + const createErrors = ( + value: string | null, + min: number, + max: number + ): utils.Error[] => { + return utils.getErrors({ isDefault, minMm: min, maxMm: max, value }) + } + const zErrors = createErrors(zValue, minMmFromBottom, maxMmFromBottom) + const xErrors = createErrors(xValue, xMinWidth, xMaxWidth) + const yErrors = createErrors(yValue, yMinWidth, yMaxWidth) + + const hasErrors = + zErrors.length > 0 || xErrors.length > 0 || yErrors.length > 0 const hasVisibleErrors = isPristine - ? errors.includes(TOO_MANY_DECIMALS) + ? zErrors.includes(TOO_MANY_DECIMALS) || + xErrors.includes(TOO_MANY_DECIMALS) || + yErrors.includes(TOO_MANY_DECIMALS) : hasErrors - const errorText = getErrorText({ - errors, - maxMmFromBottom, - minMmFromBottom, - isPristine, - t, - }) + + const createErrorText = ( + errors: utils.Error[], + min: number, + max: number + ): string | null => { + return utils.getErrorText({ errors, minMm: min, maxMm: max, isPristine, t }) + } + + const roundedXMin = utils.roundValue(xMinWidth, 'up') + const roundedYMin = utils.roundValue(yMinWidth, 'up') + const roundedXMax = utils.roundValue(xMaxWidth, 'down') + const roundedYMax = utils.roundValue(yMaxWidth, 'down') + + const zErrorText = createErrorText(zErrors, minMmFromBottom, maxMmFromBottom) + const xErrorText = createErrorText(xErrors, roundedXMin, roundedXMax) + const yErrorText = createErrorText(yErrors, roundedYMin, roundedYMax) const handleDone = (): void => { setPristine(false) - if (!hasErrors) { if (isDefault) { - props.updateValue(null) + zSpec?.updateValue(null) + xSpec?.updateValue(0) + ySpec?.updateValue(0) } else { - props.updateValue(value === null ? null : Number(value)) + zSpec?.updateValue(zValue === null ? null : Number(zValue)) + xSpec?.updateValue(xValue === null ? null : Number(xValue)) + ySpec?.updateValue(yValue === null ? null : Number(yValue)) } - props.closeModal() + closeModal() } } const handleCancel = (): void => { - props.closeModal() + closeModal() } - const handleChange = (newValueRaw: string | number): void => { + const handleZChange = (newValueRaw: string | number): void => { // if string, strip non-number characters from string and cast to number const newValue = typeof newValueRaw === 'string' @@ -166,147 +175,196 @@ export const TipPositionModal = (props: Props): JSX.Element => { : String(newValueRaw) if (newValue === '.') { - setValue('0.') + setZValue('0.') } else { - setValue(Number(newValue) >= 0 ? newValue : '0') + setZValue(Number(newValue) >= 0 ? newValue : '0') } } - const handleInputFieldChange = ( + const handleZInputFieldChange = ( e: React.ChangeEvent ): void => { - handleChange(e.currentTarget.value) + handleZChange(e.currentTarget.value) } - const handleIncrementDecrement = (delta: number): void => { - const prevValue = value === null ? defaultMmFromBottom : Number(value) - setIsDefault(false) - handleChange(roundValue(prevValue + delta)) + const handleXChange = (newValueRaw: string | number): void => { + // if string, strip non-number characters from string and cast to number + const newValue = + typeof newValueRaw === 'string' + ? newValueRaw.replace(/[^-.0-9]/g, '') + : String(newValueRaw) + + if (newValue === '.') { + setXValue('0.') + } else { + setXValue(newValue) + } } - const makeHandleIncrement = (step: number): (() => void) => () => { - handleIncrementDecrement(step) + const handleXInputFieldChange = ( + e: React.ChangeEvent + ): void => { + handleXChange(e.currentTarget.value) } - const makeHandleDecrement = (step: number): (() => void) => () => { - handleIncrementDecrement(step * -1) + const handleYChange = (newValueRaw: string | number): void => { + // if string, strip non-number characters from string and cast to number + const newValue = + typeof newValueRaw === 'string' + ? newValueRaw.replace(/[^-.0-9]/g, '') + : String(newValueRaw) + + if (newValue === '.') { + setYValue('0.') + } else { + setYValue(newValue) + } } - const TipPositionInputField = !isDefault && ( - - ) + const handleYInputFieldChange = ( + e: React.ChangeEvent + ): void => { + handleYChange(e.currentTarget.value) + } + const isXValueNearEdge = + xValue != null && + (parseInt(xValue) > PERCENT_RANGE_TO_SHOW_WARNING * xMaxWidth || + parseInt(xValue) < PERCENT_RANGE_TO_SHOW_WARNING * xMinWidth) + const isYValueNearEdge = + yValue != null && + (parseInt(yValue) > PERCENT_RANGE_TO_SHOW_WARNING * yMaxWidth || + parseInt(yValue) < PERCENT_RANGE_TO_SHOW_WARNING * yMinWidth) + + const TipPositionInputField = !isDefault ? ( + + + + {t('tip_position.field_titles.x_position')} + + + + + + {t('tip_position.field_titles.y_position')} + + + + + + {t('tip_position.field_titles.z_position')} + + + + + ) : null // Mix Form's asp/disp tip position field has different default value text - const isMixAspDispField = name === 'mix_mmFromBottom' + const isMixAspDispField = zSpec?.name === 'mix_mmFromBottom' return createPortal( - - -
-

{t('tip_position.title')}

-

{t(`tip_position.body.${name}`)}

-
-
- -
- ) => { - setIsDefault(e.currentTarget.value === 'default') - }} - options={[ - { - name: isMixAspDispField - ? `Aspirate 1mm, Dispense 0.5mm from the bottom (default)` - : `${defaultMmFromBottom} mm from the bottom (default)`, - value: 'default', - }, - { - name: 'Custom', - value: 'custom', - }, - ]} - name="TipPositionOptions" - /> - {TipPositionInputField} -
- -
- {!isDefault && ( -
- - - - - - -
- )} - -
+
+

{t('tip_position.title')}

+

{t(`tip_position.body.${zSpec?.name}`)}

+
+ + {(isXValueNearEdge || isYValueNearEdge) && !isDefault ? ( + + + + ) : null} + +
+ + + ) => { + setIsDefault(e.currentTarget.value === 'default') + }} + options={[ + { + name: isMixAspDispField + ? t('tip_position.radio_button.mix') + : t('tip_position.radio_button.default', { + defaultMmFromBottom, + }), + value: 'default', + }, + { + name: t('tip_position.radio_button.custom'), + value: 'custom', + }, + ]} + name="TipPositionOptions" + /> + {TipPositionInputField} -
- - , + +
+ +
+
+
+
, getMainPagePortalEl() ) } diff --git a/protocol-designer/src/components/StepEditForm/fields/TipPositionField/TipPositionZAxisViz.tsx b/protocol-designer/src/components/StepEditForm/fields/TipPositionField/TipPositionZAxisViz.tsx index 4b0dc3d512e..cff1fa05a9a 100644 --- a/protocol-designer/src/components/StepEditForm/fields/TipPositionField/TipPositionZAxisViz.tsx +++ b/protocol-designer/src/components/StepEditForm/fields/TipPositionField/TipPositionZAxisViz.tsx @@ -8,19 +8,23 @@ import styles from './TipPositionInput.module.css' const WELL_HEIGHT_PIXELS = 145 const PIXEL_DECIMALS = 2 -interface Props { - mmFromBottom: number +interface TipPositionZAxisVizProps { wellDepthMm: number + mmFromBottom?: number + mmFromTop?: number } -export const TipPositionZAxisViz = (props: Props): JSX.Element => { - const fractionOfWellHeight = props.mmFromBottom / props.wellDepthMm +export function TipPositionZAxisViz( + props: TipPositionZAxisVizProps +): JSX.Element { + const { mmFromBottom, mmFromTop, wellDepthMm } = props + const positionInTube = mmFromBottom ?? mmFromTop ?? 0 + const fractionOfWellHeight = positionInTube / wellDepthMm const pixelsFromBottom = - Number(fractionOfWellHeight) * WELL_HEIGHT_PIXELS - WELL_HEIGHT_PIXELS - const roundedPixelsFromBottom = round(pixelsFromBottom, PIXEL_DECIMALS) - const bottomPx = props.wellDepthMm - ? roundedPixelsFromBottom - : props.mmFromBottom - WELL_HEIGHT_PIXELS + fractionOfWellHeight * WELL_HEIGHT_PIXELS - + (mmFromBottom != null ? WELL_HEIGHT_PIXELS : 0) + const bottomPx = round(pixelsFromBottom, PIXEL_DECIMALS) + return (
void + zValue: number | null + name: StepFieldName + updateValue: (val?: number | null) => unknown + wellDepthMm: number + isIndeterminate?: boolean +} + +export function ZTipPositionModal(props: ZTipPositionModalProps): JSX.Element { + const { + isIndeterminate, + name, + wellDepthMm, + zValue, + closeModal, + updateValue, + } = props + const { t } = useTranslation(['modal', 'button']) + + const isBlowout = name === 'blowout_z_offset' + const defaultMm = isBlowout + ? 0 + : utils.getDefaultMmFromBottom({ + name, + wellDepthMm, + }) + + const [value, setValue] = React.useState( + zValue !== null ? String(zValue) : null + ) + const isSetDefault = isBlowout ? zValue === 0 : zValue === null + const [isDefault, setIsDefault] = React.useState( + !isIndeterminate && isSetDefault + ) + // in this modal, pristinity hides the OUT_OF_BOUNDS error only. + const [isPristine, setPristine] = React.useState(true) + + const getMinMaxMmFromBottom = (): { + maxMmFromBottom: number + minMmFromBottom: number + } => { + if (getIsTouchTipField(name)) { + return { + maxMmFromBottom: utils.roundValue(wellDepthMm, 'up'), + minMmFromBottom: utils.roundValue(wellDepthMm / 2, 'up'), + } + } + return { + maxMmFromBottom: utils.roundValue(wellDepthMm * 2, 'up'), + minMmFromBottom: 0, + } + } + const { maxMmFromBottom, minMmFromBottom } = getMinMaxMmFromBottom() + + // For blowout from the top of the well + const minFromTop = DEFAULT_MM_BLOWOUT_OFFSET_FROM_TOP + const maxFromTop = -wellDepthMm + + const minMm = isBlowout ? maxFromTop : minMmFromBottom + const maxMm = isBlowout ? minFromTop : maxMmFromBottom + + const errors = utils.getErrors({ + isDefault, + minMm, + maxMm, + value, + }) + const hasErrors = errors.length > 0 + const hasVisibleErrors = isPristine + ? errors.includes(TOO_MANY_DECIMALS) + : hasErrors + + const errorText = utils.getErrorText({ + errors, + minMm, + maxMm, + isPristine, + t, + }) + + const handleDone = (): void => { + setPristine(false) + + if (!hasErrors) { + if (isDefault) { + updateValue(null) + } else { + updateValue(value === null ? null : Number(value)) + } + closeModal() + } + } + + const handleCancel = (): void => { + closeModal() + } + + const handleChange = (newValueRaw: string | number): void => { + // if string, strip non-number characters from string and cast to number + const newValue = + typeof newValueRaw === 'string' + ? newValueRaw.replace(/[^-.0-9]/, '') + : String(newValueRaw) + + if (newValue === '.') { + setValue('0.') + } else if (newValue === '-0') { + setValue('0') + } else { + isBlowout + ? setValue(newValue) + : setValue(Number(newValue) >= 0 ? newValue : '0') + } + } + + const handleInputFieldChange = ( + e: React.ChangeEvent + ): void => { + handleChange(e.currentTarget.value) + } + + const handleIncrementDecrement = (delta: number): void => { + const prevValue = value === null ? defaultMm : Number(value) + setIsDefault(false) + handleChange(utils.roundValue(prevValue + delta, 'up')) + } + + const makeHandleIncrement = (step: number): (() => void) => () => { + handleIncrementDecrement(step) + } + + const makeHandleDecrement = (step: number): (() => void) => () => { + handleIncrementDecrement(step * -1) + } + + const TipPositionInputField = !isDefault && ( + + ) + + return createPortal( + + +
+

{t('tip_position.title')}

+

{t(`tip_position.body.${name}`)}

+
+
+ +
+ ) => { + setIsDefault(e.currentTarget.value === 'default') + }} + options={[ + { + name: isBlowout + ? t('tip_position.radio_button.blowout') + : t('tip_position.radio_button.default', { + defaultMm, + }), + value: 'default', + }, + { + name: t('tip_position.radio_button.custom'), + value: 'custom', + }, + ]} + name="TipPositionOptions" + /> + {TipPositionInputField} +
+ +
+ {!isDefault ? ( +
+ + + + + + +
+ ) : null} + +
+
+
+
+
, + getMainPagePortalEl() + ) +} diff --git a/protocol-designer/src/components/StepEditForm/fields/TipPositionField/__tests__/TipPositionField.test.tsx b/protocol-designer/src/components/StepEditForm/fields/TipPositionField/__tests__/TipPositionField.test.tsx new file mode 100644 index 00000000000..36e1d07a0f4 --- /dev/null +++ b/protocol-designer/src/components/StepEditForm/fields/TipPositionField/__tests__/TipPositionField.test.tsx @@ -0,0 +1,113 @@ +import * as React from 'react' +import { fireEvent, screen } from '@testing-library/react' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { fixture96Plate } from '@opentrons/shared-data' +import { renderWithProviders } from '../../../../../__testing-utils__' +import { i18n } from '../../../../../localization' +import { getLabwareEntities } from '../../../../../step-forms/selectors' +import { ZTipPositionModal } from '../ZTipPositionModal' +import { TipPositionModal } from '../TipPositionModal' +import { TipPositionField } from '../index' +import type { LabwareDefinition2 } from '@opentrons/shared-data' + +vi.mock('../../../../../step-forms/selectors') +vi.mock('../ZTipPositionModal') +vi.mock('../TipPositionModal') +const render = (props: React.ComponentProps) => { + return renderWithProviders(, { + i18nInstance: i18n, + })[0] +} +const mockDelay = 'aspirate_delay_mmFromBottom' +const mockAspirate = 'aspirate_mmFromBottom' +const mockLabwareId = 'mockId' +describe('TipPositionField', () => { + let props: React.ComponentProps + + beforeEach(() => { + props = { + zField: mockDelay, + labwareId: mockLabwareId, + propsForFields: { + [mockDelay]: { + name: mockDelay, + value: null, + updateValue: vi.fn(), + tooltipContent: 'mock content', + isIndeterminate: false, + disabled: false, + } as any, + }, + } + vi.mocked(TipPositionModal).mockReturnValue( +
mock TipPositionModal
+ ) + vi.mocked(ZTipPositionModal).mockReturnValue( +
mock ZTipPositionModal
+ ) + vi.mocked(getLabwareEntities).mockReturnValue({ + [mockLabwareId]: { + id: mockLabwareId, + labwareDefURI: 'mock uri', + def: fixture96Plate as LabwareDefinition2, + }, + }) + }) + it('renders the input field and header when x and y fields are not provided', () => { + render(props) + screen.getByText('mm') + fireEvent.click(screen.getByRole('textbox', { name: '' })) + expect(screen.getByRole('textbox', { name: '' })).not.toBeDisabled() + screen.getByText('mock ZTipPositionModal') + }) + it('renders the input field but it is disabled', () => { + props = { + ...props, + propsForFields: { + [mockDelay]: { + name: mockDelay, + value: null, + updateValue: vi.fn(), + tooltipContent: 'mock content', + isIndeterminate: false, + disabled: true, + } as any, + }, + } + render(props) + expect(screen.getByRole('textbox', { name: '' })).toBeDisabled() + }) + it('renders the icon when x,y, and z fields are provided', () => { + const mockX = 'aspirate_x_position' + const mockY = 'aspirate_y_position' + props = { + zField: mockAspirate, + xField: mockX, + yField: mockY, + labwareId: mockLabwareId, + propsForFields: { + [mockAspirate]: { + name: mockAspirate, + value: null, + updateValue: vi.fn(), + tooltipContent: 'mock content', + isIndeterminate: false, + disabled: false, + } as any, + [mockX]: { + name: mockX, + value: null, + updateValue: vi.fn(), + } as any, + [mockY]: { + name: mockY, + value: null, + updateValue: vi.fn(), + } as any, + }, + } + render(props) + fireEvent.click(screen.getByTestId('TipPositionIcon_aspirate_mmFromBottom')) + screen.getByText('mock TipPositionModal') + }) +}) diff --git a/protocol-designer/src/components/StepEditForm/fields/TipPositionField/__tests__/TipPositionModal.test.tsx b/protocol-designer/src/components/StepEditForm/fields/TipPositionField/__tests__/TipPositionModal.test.tsx new file mode 100644 index 00000000000..28b96c4c429 --- /dev/null +++ b/protocol-designer/src/components/StepEditForm/fields/TipPositionField/__tests__/TipPositionModal.test.tsx @@ -0,0 +1,140 @@ +import * as React from 'react' +import { fireEvent, screen } from '@testing-library/react' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { renderWithProviders } from '../../../../../__testing-utils__' +import { i18n } from '../../../../../localization' +import { TipPositionModal } from '../TipPositionModal' +import { TipPositionAllViz } from '../TipPositionAllViz' + +vi.mock('../TipPositionAllViz') +const render = (props: React.ComponentProps) => { + return renderWithProviders(, { + i18nInstance: i18n, + })[0] +} + +const mockUpdateZSpec = vi.fn() +const mockUpdateXSpec = vi.fn() +const mockUpdateYSpec = vi.fn() + +describe('TipPositionModal', () => { + let props: React.ComponentProps + + beforeEach(() => { + props = { + closeModal: vi.fn(), + wellDepthMm: 50, + wellXWidthMm: 10.3, + wellYWidthMm: 10.5, + isIndeterminate: false, + specs: { + z: { + name: 'aspirate_mmFromBottom', + value: null, + updateValue: mockUpdateZSpec, + }, + y: { + name: 'aspirate_y_position', + value: 0, + updateValue: mockUpdateXSpec, + }, + x: { + name: 'aspirate_x_position', + value: 0, + updateValue: mockUpdateYSpec, + }, + }, + } + vi.mocked(TipPositionAllViz).mockReturnValue(
mock TipPositionViz
) + }) + it('renders the modal text and radio button text', () => { + render(props) + screen.getByText('Tip Positioning') + screen.getByText('Change from where in the well the robot aspirates') + screen.getByRole('radio', { name: '1 mm from the bottom center (default)' }) + screen.getByRole('radio', { name: 'Custom' }) + fireEvent.click(screen.getByText('cancel')) + expect(props.closeModal).toHaveBeenCalled() + fireEvent.click(screen.getByText('done')) + expect(props.closeModal).toHaveBeenCalled() + expect(mockUpdateXSpec).toHaveBeenCalled() + expect(mockUpdateYSpec).toHaveBeenCalled() + expect(mockUpdateZSpec).toHaveBeenCalled() + }) + it('renders the alert if the x/y position values are too close to the max/min for x value', () => { + props.specs.x.value = 9.7 + render(props) + screen.getByText('warning') + screen.getByText( + 'The X and/or Y position value is close to edge of the well and might collide with it' + ) + }) + it('renders the alert if the x/y position values are too close to the max/min for y value', () => { + props.specs.y.value = -9.7 + render(props) + screen.getByText('warning') + screen.getByText( + 'The X and/or Y position value is close to edge of the well and might collide with it' + ) + }) + it('renders the custom options, captions, and visual', () => { + render(props) + fireEvent.click(screen.getByRole('radio', { name: 'Custom' })) + expect(screen.getAllByRole('textbox', { name: '' })).toHaveLength(3) + screen.getByText('X position') + screen.getByText('between -5.1 and 5.1') + screen.getByText('Y position') + screen.getByText('between -5.2 and 5.2') + screen.getByText('Z position') + screen.getByText('between 0 and 100') + screen.getByText('mock TipPositionViz') + }) + it('renders a custom input field and clicks on it, calling the mock updates', () => { + render(props) + fireEvent.click(screen.getByRole('radio', { name: 'Custom' })) + const xInputField = screen.getAllByRole('textbox', { name: '' })[0] + fireEvent.change(xInputField, { target: { value: 3 } }) + const yInputField = screen.getAllByRole('textbox', { name: '' })[1] + fireEvent.change(yInputField, { target: { value: -2 } }) + const zInputField = screen.getAllByRole('textbox', { name: '' })[2] + fireEvent.change(zInputField, { target: { value: 10 } }) + fireEvent.click(screen.getByText('done')) + expect(props.closeModal).toHaveBeenCalled() + expect(mockUpdateXSpec).toHaveBeenCalled() + expect(mockUpdateYSpec).toHaveBeenCalled() + expect(mockUpdateZSpec).toHaveBeenCalled() + }) + it('renders custom input fields and displays error texts', () => { + props = { + ...props, + specs: { + z: { + name: 'aspirate_mmFromBottom', + value: 101, + updateValue: mockUpdateZSpec, + }, + y: { + name: 'aspirate_y_position', + value: -500, + updateValue: mockUpdateXSpec, + }, + x: { + name: 'aspirate_x_position', + value: 10.7, + updateValue: mockUpdateYSpec, + }, + }, + } + render(props) + fireEvent.click(screen.getByText('done')) + // display out of bounds error + screen.getByText('accepted range is 0 to 100') + screen.getByText('accepted range is -5.2 to 5.2') + screen.getByText('accepted range is -5.1 to 5.1') + const xInputField = screen.getAllByRole('textbox', { name: '' })[0] + fireEvent.change(xInputField, { target: { value: 3.55555 } }) + fireEvent.click(screen.getByText('done')) + // display too many decimals error + screen.getByText('a max of 1 decimal place is allowed') + }) +}) diff --git a/protocol-designer/src/components/StepEditForm/fields/TipPositionField/__tests__/ZTipPositionModal.test.tsx b/protocol-designer/src/components/StepEditForm/fields/TipPositionField/__tests__/ZTipPositionModal.test.tsx new file mode 100644 index 00000000000..015d5437dbb --- /dev/null +++ b/protocol-designer/src/components/StepEditForm/fields/TipPositionField/__tests__/ZTipPositionModal.test.tsx @@ -0,0 +1,50 @@ +import * as React from 'react' +import { fireEvent, screen } from '@testing-library/react' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { renderWithProviders } from '../../../../../__testing-utils__' +import { i18n } from '../../../../../localization' +import { ZTipPositionModal } from '../ZTipPositionModal' +import { TipPositionZAxisViz } from '../TipPositionZAxisViz' + +vi.mock('../TipPositionZAxisViz') +const render = (props: React.ComponentProps) => { + return renderWithProviders(, { + i18nInstance: i18n, + })[0] +} + +describe('ZTipPositionModal', () => { + let props: React.ComponentProps + + beforeEach(() => { + props = { + closeModal: vi.fn(), + zValue: -2, + updateValue: vi.fn(), + wellDepthMm: 30, + name: 'blowout_z_offset', + } + vi.mocked(TipPositionZAxisViz).mockReturnValue( +
mock TipPositionZAxisViz
+ ) + }) + it('renders the text and radio buttons', () => { + render(props) + screen.getByText('Tip Positioning') + screen.getByText('Change from where in the well the robot emits blowout') + screen.getByRole('radio', { name: '0 mm from the top center (default)' }) + screen.getByRole('radio', { name: 'Custom' }) + fireEvent.click(screen.getByText('cancel')) + expect(props.closeModal).toHaveBeenCalled() + fireEvent.click(screen.getByText('done')) + expect(props.closeModal).toHaveBeenCalled() + expect(props.updateValue).toHaveBeenCalled() + }) + it('renders the custom option, caption, and visual', () => { + render(props) + fireEvent.click(screen.getByRole('radio', { name: 'Custom' })) + expect(screen.getAllByRole('textbox', { name: '' })).toHaveLength(1) + screen.getByText('between -30 and 0') + screen.getByText('mock TipPositionZAxisViz') + }) +}) diff --git a/protocol-designer/src/components/StepEditForm/fields/TipPositionField/constants.ts b/protocol-designer/src/components/StepEditForm/fields/TipPositionField/constants.ts new file mode 100644 index 00000000000..528d9a0262e --- /dev/null +++ b/protocol-designer/src/components/StepEditForm/fields/TipPositionField/constants.ts @@ -0,0 +1,5 @@ +export const DECIMALS_ALLOWED = 1 +export const SMALL_STEP_MM = 1 +export const LARGE_STEP_MM = 10 +export const TOO_MANY_DECIMALS: 'TOO_MANY_DECIMALS' = 'TOO_MANY_DECIMALS' +export const PERCENT_RANGE_TO_SHOW_WARNING = 0.9 diff --git a/protocol-designer/src/components/StepEditForm/fields/TipPositionField/index.tsx b/protocol-designer/src/components/StepEditForm/fields/TipPositionField/index.tsx index ccaa80e13d5..5f60d13cd79 100644 --- a/protocol-designer/src/components/StepEditForm/fields/TipPositionField/index.tsx +++ b/protocol-designer/src/components/StepEditForm/fields/TipPositionField/index.tsx @@ -2,13 +2,16 @@ import * as React from 'react' import { useTranslation } from 'react-i18next' import { useSelector } from 'react-redux' import { + COLORS, + Flex, FormGroup, + Icon, InputField, Tooltip, useHoverTooltip, UseHoverTooltipTargetProps, } from '@opentrons/components' -import { getWellsDepth } from '@opentrons/shared-data' +import { getWellsDepth, getWellDimension } from '@opentrons/shared-data' import { getIsTouchTipField, getIsDelayPositionField, @@ -16,28 +19,40 @@ import { import { selectors as stepFormSelectors } from '../../../../step-forms' import { TipPositionModal } from './TipPositionModal' import { getDefaultMmFromBottom } from './utils' +import { ZTipPositionModal } from './ZTipPositionModal' +import type { + TipXOffsetFields, + TipYOffsetFields, + TipZOffsetFields, +} from '../../../../form-types' +import type { FieldPropsByName } from '../../types' +import type { PositionSpecs } from './TipPositionModal' + import stepFormStyles from '../../StepEditForm.module.css' import styles from './TipPositionInput.module.css' -import type { FieldProps } from '../../types' -interface TipPositionFieldProps extends FieldProps { +interface TipPositionFieldProps { + propsForFields: FieldPropsByName + zField: TipZOffsetFields + xField?: TipXOffsetFields + yField?: TipYOffsetFields labwareId?: string | null - className?: string } export function TipPositionField(props: TipPositionFieldProps): JSX.Element { + const { labwareId, propsForFields, zField, xField, yField } = props const { - disabled, - name, + name: zName, + value: rawZValue, + updateValue: zUpdateValue, tooltipContent, - updateValue, isIndeterminate, - labwareId, - value: rawValue, - } = props + disabled, + } = propsForFields[zField] + const { t } = useTranslation('application') const [targetProps, tooltipProps] = useHoverTooltip() - const [isModalOpen, setModalOpen] = React.useState(false) + const [isModalOpen, setModalOpen] = React.useState(false) const labwareEntities = useSelector(stepFormSelectors.getLabwareEntities) const labwareDef = labwareId != null && labwareEntities[labwareId] != null @@ -45,68 +60,137 @@ export function TipPositionField(props: TipPositionFieldProps): JSX.Element { : null let wellDepthMm = 0 + let wellXWidthMm = 0 + let wellYWidthMm = 0 + if (labwareDef != null) { - // NOTE: only taking depth of first well in labware def, UI not currently equipped for multiple depths + // NOTE: only taking depth of first well in labware def, UI not currently equipped for multiple depths/widths const firstWell = labwareDef.wells.A1 if (firstWell) { wellDepthMm = getWellsDepth(labwareDef, ['A1']) + wellXWidthMm = getWellDimension(labwareDef, ['A1'], 'x') + wellYWidthMm = getWellDimension(labwareDef, ['A1'], 'y') } } - if (wellDepthMm === 0 && labwareId != null && labwareDef != null) { + if ( + (wellDepthMm === 0 || wellXWidthMm === 0 || wellYWidthMm === 0) && + labwareId != null && + labwareDef != null + ) { console.error( - `expected to find the well depth mm with labwareId ${labwareId} but could not` + `expected to find all well dimensions mm with labwareId ${labwareId} but could not` ) } - const handleOpen = (): void => { - if (wellDepthMm) { + const handleOpen = (has3Specs: boolean): void => { + if (has3Specs && wellDepthMm && wellXWidthMm && wellYWidthMm) { + setModalOpen(true) + } + if (!has3Specs && wellDepthMm) { setModalOpen(true) } } const handleClose = (): void => { setModalOpen(false) } - const isTouchTipField = getIsTouchTipField(name) - const isDelayPositionField = getIsDelayPositionField(name) - let value: string | number = '0' - const mmFromBottom = typeof rawValue === 'number' ? rawValue : null + const isTouchTipField = getIsTouchTipField(zName) + const isDelayPositionField = getIsDelayPositionField(zName) + let zValue: string | number = '0' + const mmFromBottom = typeof rawZValue === 'number' ? rawZValue : null if (wellDepthMm !== null) { // show default value for field in parens if no mmFromBottom value is selected - value = - mmFromBottom !== null - ? mmFromBottom - : getDefaultMmFromBottom({ name, wellDepthMm }) + zValue = + mmFromBottom ?? getDefaultMmFromBottom({ name: zName, wellDepthMm }) } + + let modal = ( + + ) + if (yField != null && xField != null) { + const { + name: xName, + value: rawXValue, + updateValue: xUpdateValue, + } = propsForFields[xField] + const { + name: yName, + value: rawYValue, + updateValue: yUpdateValue, + } = propsForFields[yField] + + const specs: PositionSpecs = { + z: { + name: zName, + value: mmFromBottom, + updateValue: zUpdateValue, + }, + x: { + name: xName, + value: rawXValue != null ? Number(rawXValue) : null, + updateValue: xUpdateValue, + }, + y: { + name: yName, + value: rawYValue != null ? Number(rawYValue) : null, + updateValue: yUpdateValue, + }, + } + + modal = ( + + ) + } + return ( <> {tooltipContent} - {isModalOpen && ( - - )} + {isModalOpen ? modal : null} - + {yField != null && xField != null ? ( + handleOpen(true) : () => {}} + id={`TipPositionIcon_${zName}`} + data-testid={`TipPositionIcon_${zName}`} + width="5rem" + > + + + ) : ( + handleOpen(false)} + value={String(zValue)} + isIndeterminate={isIndeterminate} + units={t('units.millimeter')} + id={`TipPositionField_${zName}`} + /> + )} ) diff --git a/protocol-designer/src/components/StepEditForm/fields/TipPositionField/utils.ts b/protocol-designer/src/components/StepEditForm/fields/TipPositionField/utils.ts index c4d4590c5dc..4648aa78933 100644 --- a/protocol-designer/src/components/StepEditForm/fields/TipPositionField/utils.ts +++ b/protocol-designer/src/components/StepEditForm/fields/TipPositionField/utils.ts @@ -1,9 +1,14 @@ +import floor from 'lodash/floor' +import round from 'lodash/round' +import { getIsTouchTipField } from '../../../../form-types' import { DEFAULT_MM_FROM_BOTTOM_ASPIRATE, DEFAULT_MM_FROM_BOTTOM_DISPENSE, DEFAULT_MM_TOUCH_TIP_OFFSET_FROM_TOP, } from '../../../../constants' -import { StepFieldName, getIsTouchTipField } from '../../../../form-types' +import { DECIMALS_ALLOWED, TOO_MANY_DECIMALS } from './constants' +import type { StepFieldName } from '../../../../form-types' + // TODO: Ian + Brian 2019-02-13 this should switch on stepType, not use field // name to infer step type! // @@ -41,3 +46,82 @@ export function getDefaultMmFromBottom(args: { return DEFAULT_MM_TOUCH_TIP_OFFSET_FROM_TOP + wellDepthMm } } + +export const roundValue = ( + value: number | string | null, + direction: 'up' | 'down' +): number => { + if (value === null) return 0 + + switch (direction) { + case 'up': { + return round(Number(value), DECIMALS_ALLOWED) + } + case 'down': { + return floor(Number(value), DECIMALS_ALLOWED) + } + } +} + +const OUT_OF_BOUNDS: 'OUT_OF_BOUNDS' = 'OUT_OF_BOUNDS' +export type Error = typeof TOO_MANY_DECIMALS | typeof OUT_OF_BOUNDS + +export const getErrorText = (args: { + errors: Error[] + maxMm: number + minMm: number + isPristine: boolean + t: any +}): string | null => { + const { errors, minMm, maxMm, isPristine, t } = args + + if (errors.includes(TOO_MANY_DECIMALS)) { + return t('tip_position.errors.TOO_MANY_DECIMALS') + } else if (!isPristine && errors.includes(OUT_OF_BOUNDS)) { + return t('tip_position.errors.OUT_OF_BOUNDS', { + minMm, + maxMm, + }) + } else { + return null + } +} + +export const getErrors = (args: { + isDefault: boolean + value: string | null + maxMm: number + minMm: number +}): Error[] => { + const { isDefault, value: rawValue, maxMm, minMm } = args + const errors: Error[] = [] + if (isDefault) return errors + + const value = Number(rawValue) + if (rawValue === null || Number.isNaN(value)) { + // blank or otherwise invalid should show this error as a fallback + return [OUT_OF_BOUNDS] + } + const incorrectDecimals = round(value, DECIMALS_ALLOWED) !== value + const outOfBounds = value > maxMm || value < minMm + + if (incorrectDecimals) { + errors.push(TOO_MANY_DECIMALS) + } + if (outOfBounds) { + errors.push(OUT_OF_BOUNDS) + } + return errors +} + +interface MinMaxValues { + minValue: number + maxValue: number +} + +export const getMinMaxWidth = (width: number): MinMaxValues => { + return { + minValue: -width * 0.5, + maxValue: width * 0.5, + } +} diff --git a/protocol-designer/src/components/StepEditForm/fields/TiprackField.tsx b/protocol-designer/src/components/StepEditForm/fields/TiprackField.tsx index a9dceb482a2..464b15b4f7f 100644 --- a/protocol-designer/src/components/StepEditForm/fields/TiprackField.tsx +++ b/protocol-designer/src/components/StepEditForm/fields/TiprackField.tsx @@ -1,32 +1,62 @@ import * as React from 'react' import { useTranslation } from 'react-i18next' import { useSelector } from 'react-redux' -import { FormGroup, DropdownField } from '@opentrons/components' +import { + FormGroup, + DropdownField, + useHoverTooltip, + Tooltip, + Box, +} from '@opentrons/components' import { selectors as uiLabwareSelectors } from '../../../ui/labware' -import styles from '../StepEditForm.module.css' - +import { getPipetteEntities } from '../../../step-forms/selectors' import type { FieldProps } from '../types' -export function TiprackField(props: FieldProps): JSX.Element { - const { name, value, onFieldBlur, onFieldFocus, updateValue } = props - const { t } = useTranslation('form') +import styles from '../StepEditForm.module.css' + +interface TiprackFieldProps extends FieldProps { + pipetteId?: unknown +} +export function TiprackField(props: TiprackFieldProps): JSX.Element { + const { + name, + value, + onFieldBlur, + onFieldFocus, + updateValue, + pipetteId, + } = props + const { t } = useTranslation(['form', 'tooltip']) + const [targetProps, tooltipProps] = useHoverTooltip() + const pipetteEntities = useSelector(getPipetteEntities) const options = useSelector(uiLabwareSelectors.getTiprackOptions) + const defaultTipracks = + pipetteId != null ? pipetteEntities[pipetteId as string].tiprackDefURI : [] + const pipetteOptions = options.filter(option => + defaultTipracks.includes(option.defURI) + ) + const hasMissingTiprack = defaultTipracks.length > pipetteOptions.length return ( - - ) => { - updateValue(e.currentTarget.value) - }} - /> - + + + ) => { + updateValue(e.currentTarget.value) + }} + /> + + {hasMissingTiprack ? ( + {t('tooltip:missing_tiprack')} + ) : null} + ) } diff --git a/protocol-designer/src/components/StepEditForm/fields/__tests__/BlowoutZOffsetField.test.tsx b/protocol-designer/src/components/StepEditForm/fields/__tests__/BlowoutZOffsetField.test.tsx new file mode 100644 index 00000000000..fec53a25ac4 --- /dev/null +++ b/protocol-designer/src/components/StepEditForm/fields/__tests__/BlowoutZOffsetField.test.tsx @@ -0,0 +1,53 @@ +import * as React from 'react' +import { describe, it, vi, beforeEach } from 'vitest' +import { fireEvent, screen } from '@testing-library/react' +import { fixture96Plate } from '@opentrons/shared-data' +import { SOURCE_WELL_BLOWOUT_DESTINATION } from '@opentrons/step-generation' +import { getLabwareEntities } from '../../../../step-forms/selectors' +import { renderWithProviders } from '../../../../__testing-utils__' +import { ZTipPositionModal } from '../TipPositionField/ZTipPositionModal' +import { BlowoutZOffsetField } from '../BlowoutZOffsetField' +import type { LabwareDefinition2 } from '@opentrons/shared-data' + +vi.mock('../../../../step-forms/selectors') +vi.mock('../TipPositionField/ZTipPositionModal') +const render = (props: React.ComponentProps) => { + return renderWithProviders()[0] +} +const mockSourceId = 'sourceId' +describe('BlowoutZOffsetField', () => { + let props: React.ComponentProps + + beforeEach(() => { + props = { + disabled: false, + value: null, + name: 'blowout_z_offset', + updateValue: vi.fn(), + onFieldBlur: vi.fn(), + onFieldFocus: vi.fn(), + destLabwareId: SOURCE_WELL_BLOWOUT_DESTINATION, + sourceLabwareId: mockSourceId, + blowoutLabwareId: 'blowoutId', + } + vi.mocked(getLabwareEntities).mockReturnValue({ + [mockSourceId]: { + id: 'mockLabwareId', + labwareDefURI: 'mock uri', + def: fixture96Plate as LabwareDefinition2, + }, + }) + vi.mocked(ZTipPositionModal).mockReturnValue( +
mock ZTipPositionModal
+ ) + }) + it('renders the input field', () => { + render(props) + screen.getByTestId('BlowoutZOffsetField_blowout_z_offset') + }) + it('renders the modal when input field is clicked on', () => { + render(props) + fireEvent.click(screen.getByTestId('BlowoutZOffsetField_blowout_z_offset')) + screen.getByText('mock ZTipPositionModal') + }) +}) diff --git a/protocol-designer/src/components/StepEditForm/fields/__tests__/TiprackField.test.tsx b/protocol-designer/src/components/StepEditForm/fields/__tests__/TiprackField.test.tsx new file mode 100644 index 00000000000..979155a4d88 --- /dev/null +++ b/protocol-designer/src/components/StepEditForm/fields/__tests__/TiprackField.test.tsx @@ -0,0 +1,60 @@ +import * as React from 'react' +import { describe, it, vi, beforeEach } from 'vitest' +import { screen } from '@testing-library/react' +import { i18n } from '../../../../localization' +import { getPipetteEntities } from '../../../../step-forms/selectors' +import { renderWithProviders } from '../../../../__testing-utils__' +import { getTiprackOptions } from '../../../../ui/labware/selectors' +import { TiprackField } from '../TiprackField' + +vi.mock('../../../../ui/labware/selectors') +vi.mock('../../../../step-forms/selectors') + +const render = (props: React.ComponentProps) => { + return renderWithProviders(, { + i18nInstance: i18n, + })[0] +} +const mockMockId = 'mockId' +describe('TiprackField', () => { + let props: React.ComponentProps + + beforeEach(() => { + props = { + disabled: false, + value: null, + name: 'tipRackt', + updateValue: vi.fn(), + onFieldBlur: vi.fn(), + onFieldFocus: vi.fn(), + pipetteId: mockMockId, + } + vi.mocked(getPipetteEntities).mockReturnValue({ + [mockMockId]: { + name: 'p50_single_flex', + spec: {} as any, + id: mockMockId, + tiprackLabwareDef: [], + tiprackDefURI: ['mockDefURI1', 'mockDefURI2'], + }, + }) + vi.mocked(getTiprackOptions).mockReturnValue([ + { + value: 'mockValue', + name: 'tiprack1', + defURI: 'mockDefURI1', + }, + { + value: 'mockValue', + name: 'tiprack2', + defURI: 'mockDefURI2', + }, + ]) + }) + it('renders the dropdown field and texts', () => { + render(props) + screen.getByText('tip rack') + screen.getByText('tiprack1') + screen.getByText('tiprack2') + }) +}) diff --git a/protocol-designer/src/components/StepEditForm/fields/index.ts b/protocol-designer/src/components/StepEditForm/fields/index.ts index 15d7f4bb21f..70d10ffa616 100644 --- a/protocol-designer/src/components/StepEditForm/fields/index.ts +++ b/protocol-designer/src/components/StepEditForm/fields/index.ts @@ -7,6 +7,7 @@ export { TextField } from './TextField' /* Specialized Fields */ export { BlowoutLocationField } from './BlowoutLocationField' +export { BlowoutZOffsetField } from './BlowoutZOffsetField' export { ChangeTipField } from './ChangeTipField' export { DelayFields } from './DelayFields' export { DisposalVolumeField } from './DisposalVolumeField' diff --git a/protocol-designer/src/components/StepEditForm/forms/MagnetForm.tsx b/protocol-designer/src/components/StepEditForm/forms/MagnetForm.tsx index 8873c10eb52..1976767e7e5 100644 --- a/protocol-designer/src/components/StepEditForm/forms/MagnetForm.tsx +++ b/protocol-designer/src/components/StepEditForm/forms/MagnetForm.tsx @@ -1,27 +1,31 @@ import * as React from 'react' -import cx from 'classnames' import { useTranslation } from 'react-i18next' import { useSelector } from 'react-redux' import { FormGroup } from '@opentrons/components' import { MAGNETIC_MODULE_V1 } from '@opentrons/shared-data' import { selectors as uiModuleSelectors } from '../../../ui/modules' -import { selectors as stepFormSelectors } from '../../../step-forms' -import { maskField } from '../../../steplist/fieldLevel' +import { getModuleEntities } from '../../../step-forms/selectors' +import { + MAX_ENGAGE_HEIGHT_V1, + MAX_ENGAGE_HEIGHT_V2, + MIN_ENGAGE_HEIGHT_V1, + MIN_ENGAGE_HEIGHT_V2, +} from '../../../constants' import { TextField, RadioGroupField } from '../fields' -import styles from '../StepEditForm.module.css' +import type { StepFormProps } from '../types' -import { StepFormProps } from '../types' +import styles from '../StepEditForm.module.css' -export const MagnetForm = (props: StepFormProps): JSX.Element => { +export function MagnetForm(props: StepFormProps): JSX.Element { const moduleLabwareOptions = useSelector( uiModuleSelectors.getMagneticLabwareOptions ) + const moduleEntities = useSelector(getModuleEntities) const { t } = useTranslation(['application', 'form']) + const { propsForFields, formData } = props + const { magnetAction, moduleId } = formData - const moduleEntities = useSelector(stepFormSelectors.getModuleEntities) - const { magnetAction, moduleId } = props.formData - const moduleModel = moduleId ? moduleEntities[moduleId]?.model : null - + const moduleModel = moduleEntities[moduleId].model const moduleOption: string | null | undefined = moduleLabwareOptions[0] ? moduleLabwareOptions[0].name : 'No magnetic module' @@ -29,12 +33,21 @@ export const MagnetForm = (props: StepFormProps): JSX.Element => { const defaultEngageHeight = useSelector( uiModuleSelectors.getMagnetLabwareEngageHeight ) - - const engageHeightCaption = defaultEngageHeight - ? `Recommended: ${String(maskField('engageHeight', defaultEngageHeight))}` - : null - - const { propsForFields } = props + const engageHeightMinMax = + moduleModel === MAGNETIC_MODULE_V1 + ? t('magnet_height_caption', { + low: MIN_ENGAGE_HEIGHT_V1, + high: MAX_ENGAGE_HEIGHT_V1, + }) + : t('magnet_height_caption', { + low: MIN_ENGAGE_HEIGHT_V2, + high: MAX_ENGAGE_HEIGHT_V2, + }) + const engageHeightDefault = + defaultEngageHeight != null + ? t('magnet_recommended', { default: defaultEngageHeight }) + : '' + const engageHeightCaption = `${engageHeightMinMax} ${engageHeightDefault}` return (
@@ -91,18 +104,6 @@ export const MagnetForm = (props: StepFormProps): JSX.Element => { )}
- {magnetAction === 'engage' && ( -
-
-
- )}
) } diff --git a/protocol-designer/src/components/StepEditForm/forms/MixForm.tsx b/protocol-designer/src/components/StepEditForm/forms/MixForm.tsx index 87cfdbcd49b..f0eb043b081 100644 --- a/protocol-designer/src/components/StepEditForm/forms/MixForm.tsx +++ b/protocol-designer/src/components/StepEditForm/forms/MixForm.tsx @@ -17,6 +17,7 @@ import { VolumeField, WellOrderField, WellSelectionField, + BlowoutZOffsetField, } from '../fields' import { TiprackField } from '../fields/TiprackField' import { @@ -51,7 +52,10 @@ export const MixForm = (props: StepFormProps): JSX.Element => {
- + {is96Channel ? ( ) : null} @@ -117,7 +121,10 @@ export const MixForm = (props: StepFormProps): JSX.Element => { tiprack={propsForFields.tipRack.value} /> { label={t('form:step_edit_form.field.touchTip.label')} > { stepType: formData.stepType, })} /> +
diff --git a/protocol-designer/src/components/StepEditForm/forms/MoveLiquidForm/SourceDestFields.tsx b/protocol-designer/src/components/StepEditForm/forms/MoveLiquidForm/SourceDestFields.tsx index 77eaa424f36..eadd4fad2a9 100644 --- a/protocol-designer/src/components/StepEditForm/forms/MoveLiquidForm/SourceDestFields.tsx +++ b/protocol-designer/src/components/StepEditForm/forms/MoveLiquidForm/SourceDestFields.tsx @@ -11,6 +11,7 @@ import { TextField, TipPositionField, WellOrderField, + BlowoutZOffsetField, } from '../../fields' import { MixFields } from '../../fields/MixFields' import { @@ -95,7 +96,10 @@ export const SourceDestFields = (props: SourceDestFieldsProps): JSX.Element => { tiprack={propsForFields.tipRack.value} /> { className={styles.small_field} > { stepType: formData.stepType, })} /> + )} {
- + {is96Channel ? ( ) : null} diff --git a/protocol-designer/src/components/StepEditForm/forms/TemperatureForm.tsx b/protocol-designer/src/components/StepEditForm/forms/TemperatureForm.tsx index c14b358dc0c..bcd35a1636f 100644 --- a/protocol-designer/src/components/StepEditForm/forms/TemperatureForm.tsx +++ b/protocol-designer/src/components/StepEditForm/forms/TemperatureForm.tsx @@ -4,16 +4,17 @@ import { useTranslation } from 'react-i18next' import { FormGroup } from '@opentrons/components' import { selectors as uiModuleSelectors } from '../../../ui/modules' import { StepFormDropdown, RadioGroupField, TextField } from '../fields' -import styles from '../StepEditForm.module.css' import type { StepFormProps } from '../types' -export const TemperatureForm = (props: StepFormProps): JSX.Element => { +import styles from '../StepEditForm.module.css' + +export function TemperatureForm(props: StepFormProps): JSX.Element { const { t } = useTranslation(['application', 'form']) const moduleLabwareOptions = useSelector( uiModuleSelectors.getTemperatureLabwareOptions ) - const temperatureModuleId = useSelector( - uiModuleSelectors.getSingleTemperatureModuleId + const temperatureModuleIds = useSelector( + uiModuleSelectors.getTemperatureModuleIds ) const { propsForFields } = props @@ -36,56 +37,47 @@ export const TemperatureForm = (props: StepFormProps): JSX.Element => { options={moduleLabwareOptions} /> - {/* TODO (ka 2020-1-6): - moduleID dropdown will autoselect when creating a new step, - but this will not be the case when returning to a never saved form. - Rather than defaulting to one or the other when null, - display a message (copy, design, etc TBD) that you need to select a module to continue - */} - - {moduleId === null && ( -

- Please ensure a compatible module is present on the deck and - selected to create a temperature step. -

- )} - {moduleId === temperatureModuleId && temperatureModuleId != null && ( - <> -
- - {setTemperature === 'true' && ( - - )} -
-
- -
- - )} + {temperatureModuleIds != null + ? temperatureModuleIds.map(id => + id === moduleId ? ( + +
+ + {setTemperature === 'true' && ( + + )} +
+
+ +
+
+ ) : null + ) + : null}
) diff --git a/protocol-designer/src/components/StepEditForm/forms/__tests__/HeaterShakerForm.test.tsx b/protocol-designer/src/components/StepEditForm/forms/__tests__/HeaterShakerForm.test.tsx index 6ddefc3af74..dbc5bb5a408 100644 --- a/protocol-designer/src/components/StepEditForm/forms/__tests__/HeaterShakerForm.test.tsx +++ b/protocol-designer/src/components/StepEditForm/forms/__tests__/HeaterShakerForm.test.tsx @@ -31,7 +31,7 @@ vi.mock('../../fields', async importOriginal => { const render = (props: React.ComponentProps) => { return renderWithProviders(, { - i18nInstance: i18n as any, + i18nInstance: i18n, })[0] } diff --git a/protocol-designer/src/components/StepEditForm/forms/__tests__/MagnetForm.test.tsx b/protocol-designer/src/components/StepEditForm/forms/__tests__/MagnetForm.test.tsx index 736294018a9..34146989405 100644 --- a/protocol-designer/src/components/StepEditForm/forms/__tests__/MagnetForm.test.tsx +++ b/protocol-designer/src/components/StepEditForm/forms/__tests__/MagnetForm.test.tsx @@ -1,5 +1,98 @@ -import { describe, it } from 'vitest' +import * as React from 'react' +import { describe, it, afterEach, vi, beforeEach } from 'vitest' +import '@testing-library/jest-dom/vitest' +import { cleanup, fireEvent, screen } from '@testing-library/react' +import { renderWithProviders } from '../../../../__testing-utils__' +import { i18n } from '../../../../localization' +import { getMagneticLabwareOptions } from '../../../../ui/modules/selectors' +import { getModuleEntities } from '../../../../step-forms/selectors' +import { getMagnetLabwareEngageHeight } from '../../../../ui/modules/utils' +import { MagnetForm } from '../MagnetForm' + +vi.mock('../../../../ui/modules/utils') +vi.mock('../../../../ui/modules/selectors') +vi.mock('../../../../step-forms/selectors') +const render = (props: React.ComponentProps) => { + return renderWithProviders(, { + i18nInstance: i18n, + })[0] +} describe('MagnetForm', () => { - it.todo('replace deprecated enzyme test') + let props: React.ComponentProps + + beforeEach(() => { + props = { + formData: { + id: 'magnet', + stepType: 'magnet', + moduleId: 'magnetId', + magnetAction: 'engage', + } as any, + focusHandlers: { + blur: vi.fn(), + focus: vi.fn(), + dirtyFields: [], + focusedField: null, + }, + propsForFields: { + magnetAction: { + onFieldFocus: vi.fn(), + onFieldBlur: vi.fn(), + errorToShow: null, + disabled: false, + name: 'magnetAction', + updateValue: vi.fn(), + value: 'engage', + }, + engageHeight: { + onFieldFocus: vi.fn(), + onFieldBlur: vi.fn(), + errorToShow: null, + disabled: false, + name: 'engage height', + updateValue: vi.fn(), + value: 10, + }, + }, + } + vi.mocked(getMagneticLabwareOptions).mockReturnValue([ + { name: 'mock name', value: 'mockValue' }, + ]) + vi.mocked(getModuleEntities).mockReturnValue({ + magnetId: { + id: 'magnetId', + model: 'magneticModuleV2', + type: 'magneticModuleType', + }, + }) + vi.mocked(getMagnetLabwareEngageHeight).mockReturnValue(null) + }) + afterEach(() => { + vi.restoreAllMocks() + cleanup() + }) + + it('renders the text and radio buttons for v2', () => { + render(props) + screen.getByText('magnet') + screen.getByText('module') + screen.getByText('mock name') + screen.getByText('Magnet action') + const engage = screen.getByText('Engage') + screen.getByText('Disengage') + fireEvent.click(engage) + screen.getByText('Must be between -2.5 to 25.') + }) + it('renders the input text for v1', () => { + vi.mocked(getModuleEntities).mockReturnValue({ + magnetId: { + id: 'magnetId', + model: 'magneticModuleV1', + type: 'magneticModuleType', + }, + }) + render(props) + screen.getByText('Must be between 0 to 45.') + }) }) diff --git a/protocol-designer/src/components/StepEditForm/forms/__tests__/TemperatureForm.test.tsx b/protocol-designer/src/components/StepEditForm/forms/__tests__/TemperatureForm.test.tsx new file mode 100644 index 00000000000..a32894d3b84 --- /dev/null +++ b/protocol-designer/src/components/StepEditForm/forms/__tests__/TemperatureForm.test.tsx @@ -0,0 +1,95 @@ +import * as React from 'react' +import { describe, it, vi, beforeEach } from 'vitest' +import { fireEvent, screen } from '@testing-library/react' +import { renderWithProviders } from '../../../../__testing-utils__' +import { i18n } from '../../../../localization' +import { + getTemperatureLabwareOptions, + getTemperatureModuleIds, +} from '../../../../ui/modules/selectors' +import { TemperatureForm } from '../TemperatureForm' + +vi.mock('../../../../ui/modules/selectors', async importOriginal => { + const actualFields = await importOriginal< + typeof import('../../../../ui/modules/selectors') + >() + return { + ...actualFields, + getTemperatureLabwareOptions: vi.fn(), + getTemperatureModuleIds: vi.fn(), + } +}) +const render = (props: React.ComponentProps) => { + return renderWithProviders(, { + i18nInstance: i18n, + })[0] +} + +describe('TemperatureForm', () => { + let props: React.ComponentProps + + beforeEach(() => { + props = { + formData: { + id: 'formId', + stepType: 'temperature', + moduleId: 'mockId', + setTemperature: true, + } as any, + focusHandlers: { + blur: vi.fn(), + focus: vi.fn(), + dirtyFields: [], + focusedField: null, + }, + propsForFields: { + moduleId: { + onFieldFocus: vi.fn(), + onFieldBlur: vi.fn(), + errorToShow: null, + disabled: false, + name: 'setTemperature', + updateValue: vi.fn(), + value: 'mockId', + }, + setTemperature: { + onFieldFocus: vi.fn(), + onFieldBlur: vi.fn(), + errorToShow: null, + disabled: false, + name: 'setTemperature', + updateValue: vi.fn(), + value: true, + }, + targetTemperature: { + onFieldFocus: vi.fn(), + onFieldBlur: vi.fn(), + errorToShow: null, + disabled: false, + name: 'targetTemperature', + updateValue: vi.fn(), + value: null, + }, + }, + } + + vi.mocked(getTemperatureModuleIds).mockReturnValue(['mockId']) + vi.mocked(getTemperatureLabwareOptions).mockReturnValue([ + { + name: 'mock module', + value: 'mockId', + }, + ]) + }) + + it('renders a temperature module', () => { + render(props) + screen.getByText('temperature') + screen.getByText('module') + const change = screen.getByText('Change to temperature') + screen.getByText('Deactivate module') + fireEvent.click(change) + const changeTempInput = screen.getByRole('combobox', { name: '' }) + fireEvent.change(changeTempInput, { target: { value: 40 } }) + }) +}) diff --git a/protocol-designer/src/components/__tests__/EditModules.test.tsx b/protocol-designer/src/components/__tests__/EditModules.test.tsx index 2cb2ed8c55f..fb183a3e9e6 100644 --- a/protocol-designer/src/components/__tests__/EditModules.test.tsx +++ b/protocol-designer/src/components/__tests__/EditModules.test.tsx @@ -1,19 +1,29 @@ import * as React from 'react' import { screen } from '@testing-library/react' import { vi, beforeEach, describe, it } from 'vitest' +import { + FLEX_ROBOT_TYPE, + OT2_ROBOT_TYPE, + TEMPERATURE_MODULE_TYPE, +} from '@opentrons/shared-data' import { i18n } from '../../localization' import { getInitialDeckSetup } from '../../step-forms/selectors' import { getDismissedHints } from '../../tutorial/selectors' import { EditModules } from '../EditModules' import { EditModulesModal } from '../modals/EditModulesModal' import { renderWithProviders } from '../../__testing-utils__' +import { getEnableMoam } from '../../feature-flags/selectors' +import { getRobotType } from '../../file-data/selectors' +import { EditMultipleModulesModal } from '../modals/EditModulesModal/EditMultipleModulesModal' import type { HintKey } from '../../tutorial' vi.mock('../../step-forms/selectors') +vi.mock('../modals/EditModulesModal/EditMultipleModulesModal') vi.mock('../modals/EditModulesModal') vi.mock('../../tutorial/selectors') - +vi.mock('../../file-data/selectors') +vi.mock('../../feature-flags/selectors') const render = (props: React.ComponentProps) => { return renderWithProviders(, { i18nInstance: i18n, @@ -51,11 +61,22 @@ describe('EditModules', () => { vi.mocked(EditModulesModal).mockReturnValue(
mock EditModulesModal
) + vi.mocked(EditMultipleModulesModal).mockReturnValue( +
mock EditMultipleModulesModal
+ ) vi.mocked(getDismissedHints).mockReturnValue([hintKey]) + vi.mocked(getRobotType).mockReturnValue(OT2_ROBOT_TYPE) + vi.mocked(getEnableMoam).mockReturnValue(true) }) - it('renders the edit modules modal', () => { + it('renders the edit modules modal for single modules', () => { render(props) screen.getByText('mock EditModulesModal') }) + it('renders multiple edit modules modal', () => { + props.moduleToEdit.moduleType = TEMPERATURE_MODULE_TYPE + vi.mocked(getRobotType).mockReturnValue(FLEX_ROBOT_TYPE) + render(props) + screen.getByText('mock EditMultipleModulesModal') + }) }) diff --git a/protocol-designer/src/components/alerts/Alerts.tsx b/protocol-designer/src/components/alerts/Alerts.tsx index 6d5f191486a..1fc95e8162f 100644 --- a/protocol-designer/src/components/alerts/Alerts.tsx +++ b/protocol-designer/src/components/alerts/Alerts.tsx @@ -11,6 +11,7 @@ import { import { selectors as stepFormSelectors } from '../../step-forms' import { StepFieldName } from '../../steplist/fieldLevel' import { selectors as fileDataSelectors } from '../../file-data' +import { PRESAVED_STEP_ID } from '../../steplist' import { getVisibleFormWarnings, getVisibleFormErrors, @@ -105,16 +106,6 @@ const AlertsComponent = (props: Props): JSX.Element => { }) } - const dismissWarning = (dismissId: string): void => { - if (stepId) { - dispatch( - dismissActions.dismissTimelineWarning({ - type: dismissId, - stepId, - }) - ) - } - } const makeHandleCloseWarning = (dismissId?: string | null) => () => { console.assert( dismissId, @@ -153,6 +144,28 @@ const AlertsComponent = (props: Props): JSX.Element => { dismissId: warning.type, })) + const dismissWarning = (dismissId: string): void => { + const isTimelineWarning = Object.values(timelineWarnings).some( + warning => warning.dismissId === dismissId + ) + if (isTimelineWarning && stepId) { + dispatch( + dismissActions.dismissTimelineWarning({ + type: dismissId, + stepId, + }) + ) + } else { + dispatch( + dismissActions.dismissFormWarning({ + type: dismissId, + // if stepId does not exist, assume it is a presaved step + stepId: stepId ?? PRESAVED_STEP_ID, + }) + ) + } + } + return ( <> {componentType === 'Form' diff --git a/protocol-designer/src/components/lists/TitledStepList.tsx b/protocol-designer/src/components/lists/TitledStepList.tsx index 0e3da16c542..1b88b291b1e 100644 --- a/protocol-designer/src/components/lists/TitledStepList.tsx +++ b/protocol-designer/src/components/lists/TitledStepList.tsx @@ -110,7 +110,12 @@ export function TitledStepList(props: Props): JSX.Element { )} {iconName && ( - + )}

{props.title}

{collapsible && ( diff --git a/protocol-designer/src/components/modals/AnnouncementModal/announcements.tsx b/protocol-designer/src/components/modals/AnnouncementModal/announcements.tsx index aab430bf549..b10c6d75407 100644 --- a/protocol-designer/src/components/modals/AnnouncementModal/announcements.tsx +++ b/protocol-designer/src/components/modals/AnnouncementModal/announcements.tsx @@ -265,5 +265,39 @@ export const useAnnouncements = (): Announcement[] => { ), }, + { + announcementKey: 'customParamsAndMultiTipAndModule8.1', + image: , + heading: t('announcements.header', { pd: PD }), + message: ( + <> +

+ {t('announcements.customParamsAndMultiTipAndModule.body1', { + pd: PD, + })} +

+
    +
  • {t('announcements.customParamsAndMultiTipAndModule.body2')}
  • +
  • + }} + /> +
  • +
  • {t('announcements.customParamsAndMultiTipAndModule.body4')}
  • +
  • {t('announcements.customParamsAndMultiTipAndModule.body5')}
  • +
+

+ }} + values={{ app: APP }} + /> +

+ + ), + }, ] } diff --git a/protocol-designer/src/components/modals/CreateFileWizard/EquipmentOption.tsx b/protocol-designer/src/components/modals/CreateFileWizard/EquipmentOption.tsx index 97266b07252..76b97572b47 100644 --- a/protocol-designer/src/components/modals/CreateFileWizard/EquipmentOption.tsx +++ b/protocol-designer/src/components/modals/CreateFileWizard/EquipmentOption.tsx @@ -15,48 +15,39 @@ import { TYPOGRAPHY, useHoverTooltip, Tooltip, + DIRECTION_COLUMN, + Box, + StyledText, } from '@opentrons/components' import type { StyleProps } from '@opentrons/components' import type { RobotType } from '@opentrons/shared-data' -const EQUIPMENT_OPTION_STYLE = css` - background-color: ${COLORS.white}; - border-radius: ${BORDERS.borderRadius8}; - border: 1px ${BORDERS.styleSolid} ${COLORS.grey30}; - +const ARROW_STYLE = css` + color: ${COLORS.grey50}; + cursor: pointer; &:hover { - background-color: ${COLORS.grey10}; - border: 1px ${BORDERS.styleSolid} ${COLORS.grey35}; - } - - &:focus { - outline: 2px ${BORDERS.styleSolid} ${COLORS.blue50}; - outline-offset: 3px; + color: ${COLORS.black80}; } ` -const EQUIPMENT_OPTION_SELECTED_STYLE = css` - ${EQUIPMENT_OPTION_STYLE} - background-color: ${COLORS.blue10}; - border: 1px ${BORDERS.styleSolid} ${COLORS.blue50}; - +const ARROW_STYLE_ACTIVE = css` + color: ${COLORS.blue50}; + cursor: pointer; &:hover { - background-color: ${COLORS.blue10}; - border: 1px ${BORDERS.styleSolid} ${COLORS.blue50}; - box-shadow: 0px 1px 3px 0px rgba(0, 0, 0, 0.2); + color: ${COLORS.black80}; } ` -const EQUIPMENT_OPTION_DISABLED_STYLE = css` - ${EQUIPMENT_OPTION_STYLE} - background-color: ${COLORS.white}; - border: 1px ${BORDERS.styleSolid} ${COLORS.grey30}; - - &:hover { - background-color: ${COLORS.white}; - border: 1px ${BORDERS.styleSolid} ${COLORS.grey30}; - } +const ARROW_STYLE_DISABLED = css` + color: ${COLORS.grey50}; ` + +interface MultiplesProps { + numItems: number + maxItems: number + setValue: (num: number) => void + isDisabled: boolean +} interface EquipmentOptionProps extends StyleProps { onClick: React.MouseEventHandler isSelected: boolean @@ -65,6 +56,7 @@ interface EquipmentOptionProps extends StyleProps { image?: React.ReactNode showCheckbox?: boolean disabled?: boolean + multiples?: MultiplesProps } export function EquipmentOption(props: EquipmentOptionProps): JSX.Element { const { @@ -75,10 +67,51 @@ export function EquipmentOption(props: EquipmentOptionProps): JSX.Element { showCheckbox = false, disabled = false, robotType, + multiples, ...styleProps } = props - const { t } = useTranslation('tooltip') - const [targetProps, tooltipProps] = useHoverTooltip() + const { t } = useTranslation(['tooltip', 'shared']) + const [equipmentTargetProps, equipmentTooltipProps] = useHoverTooltip() + const [tempTargetProps, tempTooltipProps] = useHoverTooltip() + const [numMultiples, setNumMultiples] = React.useState(0) + + const EQUIPMENT_OPTION_STYLE = css` + background-color: ${COLORS.white}; + border-radius: ${BORDERS.borderRadius8}; + border: 1px ${BORDERS.styleSolid} ${COLORS.grey30}; + + &:hover { + background-color: ${multiples ? COLORS.white : COLORS.grey10}; + border: 1px ${BORDERS.styleSolid} + ${multiples ? COLORS.grey30 : COLORS.grey35}; + } + + &:focus { + outline: 2px ${BORDERS.styleSolid} ${COLORS.blue50}; + outline-offset: 3px; + } + ` + + const EQUIPMENT_OPTION_SELECTED_STYLE = css` + ${EQUIPMENT_OPTION_STYLE} + background-color: ${COLORS.blue10}; + border: 1px ${BORDERS.styleSolid} ${COLORS.blue50}; + + &:hover { + border: 1px ${BORDERS.styleSolid} ${COLORS.blue50}; + box-shadow: 0px 1px 3px 0px rgba(0, 0, 0, 0.2); + } + ` + + const EQUIPMENT_OPTION_DISABLED_STYLE = css` + ${EQUIPMENT_OPTION_STYLE} + background-color: ${COLORS.white}; + border: 1px ${BORDERS.styleSolid} ${COLORS.grey30}; + + &:hover { + border: 1px ${BORDERS.styleSolid} ${COLORS.grey30}; + } + ` let equipmentOptionStyle if (disabled) { @@ -102,6 +135,66 @@ export function EquipmentOption(props: EquipmentOptionProps): JSX.Element { ) } else if (showCheckbox && disabled) { iconInfo = + } else if (multiples != null) { + const { numItems, maxItems, isDisabled } = multiples + let upArrowStyle = ARROW_STYLE + if (isDisabled || numItems === maxItems) { + upArrowStyle = ARROW_STYLE_DISABLED + } else if (numItems > 0) { + upArrowStyle = ARROW_STYLE_ACTIVE + } + let downArrowStyle = ARROW_STYLE + if (numItems === 0) { + downArrowStyle = ARROW_STYLE_DISABLED + } else if (numItems > 0) { + downArrowStyle = ARROW_STYLE_ACTIVE + } + + iconInfo = ( + + { + multiples.setValue(numMultiples + 1) + setNumMultiples(prevNumMultiples => prevNumMultiples + 1) + } + } + > + + + { + multiples.setValue(numMultiples - 1) + setNumMultiples(prevNumMultiples => prevNumMultiples - 1) + } + } + > + + + {isDisabled || numMultiples === 7 ? ( + + {t('not_enough_space_for_temp')} + + ) : null} + + ) } return ( @@ -117,31 +210,52 @@ export function EquipmentOption(props: EquipmentOptionProps): JSX.Element { : BORDERS.lineBorder } borderRadius={BORDERS.borderRadius8} - cursor={disabled ? 'auto' : 'pointer'} + cursor={disabled || multiples != null ? 'auto' : 'pointer'} backgroundColor={disabled ? COLORS.grey30 : COLORS.transparent} onClick={disabled ? undefined : onClick} {...styleProps} - {...targetProps} + {...equipmentTargetProps} css={equipmentOptionStyle} > {iconInfo} {image} - - {text} - + + + {text} + + {multiples != null ? ( + <> + + + {t('shared:amount')} + {multiples.numItems} + + + ) : null} + {disabled ? ( - + {t( robotType === FLEX_ROBOT_TYPE ? 'disabled_no_space_additional_items' diff --git a/protocol-designer/src/components/modals/CreateFileWizard/InputField.tsx b/protocol-designer/src/components/modals/CreateFileWizard/InputField.tsx index 1140109b303..63a7903907e 100644 --- a/protocol-designer/src/components/modals/CreateFileWizard/InputField.tsx +++ b/protocol-designer/src/components/modals/CreateFileWizard/InputField.tsx @@ -8,7 +8,6 @@ import { COLORS, DIRECTION_COLUMN, Flex, - RESPONSIVENESS, SPACING, TYPOGRAPHY, DISPLAY_INLINE_BLOCK, @@ -60,10 +59,6 @@ function Input(props: InputFieldProps): JSX.Element { border: 1px ${BORDERS.styleSolid} ${error ? COLORS.red50 : COLORS.grey30}; font-size: ${TYPOGRAPHY.fontSizeP}; - @media ${RESPONSIVENESS.touchscreenMediaQuerySpecs} { - padding: 0; - } - &:active { border: 1px ${BORDERS.styleSolid} ${COLORS.grey50}; } diff --git a/protocol-designer/src/components/modals/CreateFileWizard/ModulesAndOtherTile.tsx b/protocol-designer/src/components/modals/CreateFileWizard/ModulesAndOtherTile.tsx index 492b408ae5f..b1ad18b0752 100644 --- a/protocol-designer/src/components/modals/CreateFileWizard/ModulesAndOtherTile.tsx +++ b/protocol-designer/src/components/modals/CreateFileWizard/ModulesAndOtherTile.tsx @@ -30,11 +30,13 @@ import { getModuleDisplayName, getModuleType, FLEX_ROBOT_TYPE, + MAGNETIC_BLOCK_TYPE, } from '@opentrons/shared-data' import { getIsCrashablePipetteSelected } from '../../../step-forms' import gripperImage from '../../../images/flex_gripper.png' import wasteChuteImage from '../../../images/waste_chute.png' import trashBinImage from '../../../images/flex_trash_bin.png' +import { getEnableMoam } from '../../../feature-flags/selectors' import { uuid } from '../../../utils' import { selectors as featureFlagSelectors } from '../../../feature-flags' import { CrashInfoBox, ModuleDiagram } from '../../modules' @@ -42,14 +44,16 @@ import { ModuleFields } from '../FilePipettesModal/ModuleFields' import { GoBack } from './GoBack' import { getCrashableModuleSelected, - getLastCheckedEquipment, - getTrashBinOptionDisabled, + getIsSlotAvailable, + getTrashOptionDisabled, } from './utils' import { EquipmentOption } from './EquipmentOption' import { HandleEnter } from './HandleEnter' import type { AdditionalEquipment, WizardTileProps } from './types' +const MAX_TEMPERATURE_MODULES = 7 + export const DEFAULT_SLOT_MAP: { [moduleModel in ModuleModel]?: string } = { [THERMOCYCLER_MODULE_V2]: 'B1', [HEATERSHAKER_MODULE_V1]: 'D1', @@ -186,14 +190,11 @@ export function ModulesAndOtherTile(props: WizardTileProps): JSX.Element { function FlexModuleFields(props: WizardTileProps): JSX.Element { const { watch, setValue } = props + const enableMoamFf = useSelector(getEnableMoam) const modules = watch('modules') const additionalEquipment = watch('additionalEquipment') const moduleTypesOnDeck = modules != null ? Object.values(modules).map(module => module.type) : [] - const trashBinDisabled = getTrashBinOptionDisabled({ - additionalEquipment, - moduleTypesOnDeck, - }) const handleSetEquipmentOption = (equipment: AdditionalEquipment): void => { if (additionalEquipment.includes(equipment)) { @@ -202,6 +203,11 @@ function FlexModuleFields(props: WizardTileProps): JSX.Element { setValue('additionalEquipment', [...additionalEquipment, equipment]) } } + const trashBinDisabled = getTrashOptionDisabled({ + additionalEquipment, + modules, + trashType: 'trashBin', + }) React.useEffect(() => { if (trashBinDisabled) { @@ -213,43 +219,100 @@ function FlexModuleFields(props: WizardTileProps): JSX.Element { {FLEX_SUPPORTED_MODULE_MODELS.map(moduleModel => { const moduleType = getModuleType(moduleModel) - const moduleOnDeck = moduleTypesOnDeck.includes(moduleType) + const isModuleOnDeck = moduleTypesOnDeck.includes(moduleType) + + const isDisabled = !getIsSlotAvailable(modules, additionalEquipment) + + const handleMultiplesClick = (num: number): void => { + const temperatureModules = + modules != null + ? Object.entries(modules).filter( + ([key, module]) => module.type === TEMPERATURE_MODULE_TYPE + ) + : [] + + if (num > temperatureModules.length) { + for (let i = 0; i < num - temperatureModules.length; i++) { + setValue('modules', { + ...modules, + [uuid()]: { + model: moduleModel, + type: moduleType, + slot: null, + }, + }) + } + } else if (num < temperatureModules.length) { + const modulesToRemove = temperatureModules.length - num + for (let i = 0; i < modulesToRemove; i++) { + const lastTempKey = + temperatureModules[temperatureModules.length - 1 - i][0] + // @ts-expect-error: TS can't determine modules's type correctly + const { [lastTempKey]: omit, ...rest } = modules + setValue('modules', rest) + } + } + } + + const handleOnClick = (): void => { + if ( + (moduleType !== TEMPERATURE_MODULE_TYPE && enableMoamFf) || + !enableMoamFf + ) { + if (isModuleOnDeck) { + const updatedModules = + modules != null + ? Object.fromEntries( + Object.entries(modules).filter( + ([key, value]) => value.type !== moduleType + ) + ) + : {} + setValue('modules', updatedModules) + } else { + setValue('modules', { + ...modules, + [uuid()]: { + model: moduleModel, + type: moduleType, + slot: DEFAULT_SLOT_MAP[moduleModel], + }, + }) + } + } + } + return ( } text={getModuleDisplayName(moduleModel)} disabled={ - getLastCheckedEquipment({ - additionalEquipment, - moduleTypesOnDeck, - }) === moduleType + moduleType === MAGNETIC_BLOCK_TYPE + ? false + : isDisabled && !isModuleOnDeck + } + onClick={handleOnClick} + multiples={ + moduleType === TEMPERATURE_MODULE_TYPE && enableMoamFf + ? { + maxItems: MAX_TEMPERATURE_MODULES, + setValue: handleMultiplesClick, + numItems: + modules != null + ? Object.values(modules).filter( + module => module.type === TEMPERATURE_MODULE_TYPE + ).length + : 0, + isDisabled: isDisabled ?? false, + } + : undefined + } + showCheckbox={ + enableMoamFf ? moduleType !== TEMPERATURE_MODULE_TYPE : true } - onClick={() => { - if (moduleOnDeck) { - const updatedModulesByType = - modules != null - ? Object.fromEntries( - Object.entries(modules).filter( - ([key, value]) => value.type !== moduleType - ) - ) - : {} - setValue('modules', updatedModulesByType) - } else { - setValue('modules', { - ...modules, - [uuid()]: { - model: moduleModel, - type: moduleType, - slot: DEFAULT_SLOT_MAP[moduleModel] ?? '', - }, - }) - } - }} - showCheckbox /> ) })} @@ -271,6 +334,11 @@ function FlexModuleFields(props: WizardTileProps): JSX.Element { robotType={FLEX_ROBOT_TYPE} onClick={() => handleSetEquipmentOption('wasteChute')} isSelected={additionalEquipment.includes('wasteChute')} + disabled={getTrashOptionDisabled({ + additionalEquipment, + modules, + trashType: 'wasteChute', + })} image={ ({ + cutoutId, + cutoutFixtureId: SINGLE_RIGHT_SLOT_FIXTURE, + }) +) + export function StagingAreaTile(props: WizardTileProps): JSX.Element | null { const { getValues, goBack, proceed, setValue, watch } = props const { t } = useTranslation(['modal', 'application']) const { fields, pipettesByMount } = getValues() const additionalEquipment = watch('additionalEquipment') + const modules = watch('modules') const isOt2 = fields.robotType === OT2_ROBOT_TYPE const stagingAreaItems = additionalEquipment.filter(equipment => // TODO(bc, 11/14/2023): refactor the additional items field to include a cutoutId // and a cutoutFixtureId so that we don't have to string parse here to generate them equipment.includes('stagingArea') ) + const unoccupiedStagingAreaSlots = getUnoccupiedStagingAreaSlots(modules) const savedStagingAreaSlots: DeckConfiguration = stagingAreaItems.flatMap( item => { @@ -49,14 +59,7 @@ export function StagingAreaTile(props: WizardTileProps): JSX.Element | null { } ) - const STANDARD_EMPTY_SLOTS: DeckConfiguration = STAGING_AREA_CUTOUTS.map( - cutoutId => ({ - cutoutId, - cutoutFixtureId: SINGLE_RIGHT_SLOT_FIXTURE, - }) - ) - - STANDARD_EMPTY_SLOTS.forEach(emptySlot => { + unoccupiedStagingAreaSlots.forEach(emptySlot => { if ( !savedStagingAreaSlots.some( ({ cutoutId }) => cutoutId === emptySlot.cutoutId @@ -67,7 +70,9 @@ export function StagingAreaTile(props: WizardTileProps): JSX.Element | null { }) const initialSlots = - stagingAreaItems.length > 0 ? savedStagingAreaSlots : STANDARD_EMPTY_SLOTS + stagingAreaItems.length > 0 + ? savedStagingAreaSlots + : unoccupiedStagingAreaSlots const [updatedSlots, setUpdatedSlots] = React.useState( initialSlots diff --git a/protocol-designer/src/components/modals/CreateFileWizard/__tests__/EquipmentOption.test.tsx b/protocol-designer/src/components/modals/CreateFileWizard/__tests__/EquipmentOption.test.tsx index 09128361135..c83b1e99404 100644 --- a/protocol-designer/src/components/modals/CreateFileWizard/__tests__/EquipmentOption.test.tsx +++ b/protocol-designer/src/components/modals/CreateFileWizard/__tests__/EquipmentOption.test.tsx @@ -1,7 +1,7 @@ import * as React from 'react' import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import '@testing-library/jest-dom/vitest' -import { screen, cleanup } from '@testing-library/react' +import { screen, cleanup, fireEvent } from '@testing-library/react' import { BORDERS, COLORS } from '@opentrons/components' import { FLEX_ROBOT_TYPE } from '@opentrons/shared-data' import { i18n } from '../../../../localization' @@ -39,7 +39,7 @@ describe('EquipmentOption', () => { } render(props) expect(screen.getByLabelText('EquipmentOption_flex_mockText')).toHaveStyle( - `background-color: ${COLORS.white}` + `background-color: ${COLORS.grey10}` ) }) it('renders the equipment option without check not selected and image', () => { @@ -73,4 +73,21 @@ describe('EquipmentOption', () => { `border: ${BORDERS.activeLineBorder}` ) }) + it('renders the equipment option with multiples allowed', () => { + props = { + ...props, + multiples: { + numItems: 1, + maxItems: 4, + setValue: vi.fn(), + isDisabled: false, + }, + } + render(props) + screen.getByText('Amount:') + screen.getByText('1') + fireEvent.click(screen.getByTestId('EquipmentOption_upArrow')) + expect(props.multiples?.setValue).toHaveBeenCalled() + screen.getByTestId('EquipmentOption_downArrow') + }) }) diff --git a/protocol-designer/src/components/modals/CreateFileWizard/__tests__/ModulesAndOtherTile.test.tsx b/protocol-designer/src/components/modals/CreateFileWizard/__tests__/ModulesAndOtherTile.test.tsx index ba9924ee13e..63da7f3ed30 100644 --- a/protocol-designer/src/components/modals/CreateFileWizard/__tests__/ModulesAndOtherTile.test.tsx +++ b/protocol-designer/src/components/modals/CreateFileWizard/__tests__/ModulesAndOtherTile.test.tsx @@ -5,7 +5,10 @@ import { fireEvent, screen, cleanup } from '@testing-library/react' import { FLEX_ROBOT_TYPE, OT2_ROBOT_TYPE } from '@opentrons/shared-data' import { renderWithProviders } from '../../../../__testing-utils__' import { i18n } from '../../../../localization' -import { getDisableModuleRestrictions } from '../../../../feature-flags/selectors' +import { + getDisableModuleRestrictions, + getEnableMoam, +} from '../../../../feature-flags/selectors' import { CrashInfoBox } from '../../../modules' import { ModuleFields } from '../../FilePipettesModal/ModuleFields' import { ModulesAndOtherTile } from '../ModulesAndOtherTile' @@ -58,6 +61,7 @@ describe('ModulesAndOtherTile', () => { ...props, ...mockWizardTileProps, } as WizardTileProps + vi.mocked(getEnableMoam).mockReturnValue(true) vi.mocked(CrashInfoBox).mockReturnValue(
mock CrashInfoBox
) vi.mocked(EquipmentOption).mockReturnValue(
mock EquipmentOption
) vi.mocked(getDisableModuleRestrictions).mockReturnValue(false) diff --git a/protocol-designer/src/components/modals/CreateFileWizard/__tests__/utils.test.tsx b/protocol-designer/src/components/modals/CreateFileWizard/__tests__/utils.test.tsx index 213f3466c0e..02289d9277d 100644 --- a/protocol-designer/src/components/modals/CreateFileWizard/__tests__/utils.test.tsx +++ b/protocol-designer/src/components/modals/CreateFileWizard/__tests__/utils.test.tsx @@ -1,15 +1,16 @@ +import { it, describe, expect } from 'vitest' import { FLEX_ROBOT_TYPE, - HEATERSHAKER_MODULE_TYPE, - TEMPERATURE_MODULE_TYPE, - THERMOCYCLER_MODULE_TYPE, + SINGLE_RIGHT_SLOT_FIXTURE, } from '@opentrons/shared-data' -import { it, describe, expect } from 'vitest' import { FLEX_TRASH_DEFAULT_SLOT, - getLastCheckedEquipment, + getUnoccupiedStagingAreaSlots, getTrashSlot, + getTrashOptionDisabled, + getIsSlotAvailable, } from '../utils' +import { STANDARD_EMPTY_SLOTS } from '../StagingAreaTile' import type { FormPipettesByMount } from '../../../../step-forms' import type { AdditionalEquipment, FormState } from '../types' @@ -28,43 +29,94 @@ let MOCK_FORM_STATE = { additionalEquipment: [], } as FormState -describe('getLastCheckedEquipment', () => { - it('should return null when there is no trash bin', () => { - const result = getLastCheckedEquipment({ - additionalEquipment: [], - moduleTypesOnDeck: [], +describe('getUnoccupiedStagingAreaSlots', () => { + it('should return all staging area slots when there are no modules', () => { + const result = getUnoccupiedStagingAreaSlots(null) + expect(result).toStrictEqual(STANDARD_EMPTY_SLOTS) + }) + it('should return one staging area slot when there are modules in the way of the other slots', () => { + const result = getUnoccupiedStagingAreaSlots({ + 0: { model: 'magneticBlockV1', type: 'magneticBlockType', slot: 'A3' }, + 1: { + model: 'temperatureModuleV2', + type: 'temperatureModuleType', + slot: 'B3', + }, + 2: { + model: 'temperatureModuleV2', + type: 'temperatureModuleType', + slot: 'C3', + }, }) - expect(result).toBe(null) + expect(result).toStrictEqual([ + { cutoutId: 'cutoutD3', cutoutFixtureId: SINGLE_RIGHT_SLOT_FIXTURE }, + ]) }) - it('should return null if not all the modules or staging areas are selected', () => { - const LastCheckedProps = { - additionalEquipment: [ - 'trashBin', - 'stagingArea_cutoutD3', - ] as AdditionalEquipment[], - moduleTypesOnDeck: [THERMOCYCLER_MODULE_TYPE], - } - const result = getLastCheckedEquipment(LastCheckedProps) - expect(result).toBe(null) +}) +describe('getIsSlotAvailable', () => { + it('should return true when there are no modules or additional equipment', () => { + const result = getIsSlotAvailable(null, []) + expect(result).toBe(true) }) - it('should return temperature module if other modules and staging areas are selected', () => { - const LastCheckedProps = { - additionalEquipment: [ - 'trashBin', - 'stagingArea_cutoutA3', - 'stagingArea_cutoutB3', - 'stagingArea_cutoutC3', - 'stagingArea_cutoutD3', - ] as AdditionalEquipment[], - moduleTypesOnDeck: [THERMOCYCLER_MODULE_TYPE, HEATERSHAKER_MODULE_TYPE], - } - const result = getLastCheckedEquipment(LastCheckedProps) - expect(result).toBe(TEMPERATURE_MODULE_TYPE) + it('should return false when there is a TC and 7 modules', () => { + const mockModules = { + 0: { + model: 'heaterShakerModuleV1', + type: 'heaterShakerModuleType', + slot: 'D1', + }, + 1: { + model: 'temperatureModuleV2', + type: 'temperatureModuleType', + slot: 'D3', + }, + 2: { + model: 'temperatureModuleV2', + type: 'temperatureModuleType', + slot: 'C1', + }, + 3: { + model: 'temperatureModuleV2', + type: 'temperatureModuleType', + slot: 'B3', + }, + 4: { + model: 'thermocyclerModuleV2', + type: 'thermocyclerModuleType', + slot: 'B1', + }, + 5: { + model: 'temperatureModuleV2', + type: 'temperatureModuleType', + slot: 'A3', + }, + 6: { + model: 'temperatureModuleV2', + type: 'temperatureModuleType', + slot: 'C3', + }, + } as any + const result = getIsSlotAvailable(mockModules, []) + expect(result).toBe(false) + }) + it('should return true when there are 9 additional equipment and 1 is a waste chute on the staging area and one is a gripper', () => { + const mockAdditionalEquipment: AdditionalEquipment[] = [ + 'trashBin', + 'stagingArea_cutoutA3', + 'stagingArea_cutoutB3', + 'stagingArea_cutoutC3', + 'stagingArea_cutoutD3', + 'wasteChute', + 'trashBin', + 'gripper', + 'trashBin', + ] + const result = getIsSlotAvailable(null, mockAdditionalEquipment) + expect(result).toBe(true) }) }) - describe('getTrashSlot', () => { - it('should return the default slot A3 when there is no staging area in that slot', () => { + it('should return the default slot A3 when there is no staging area or module in that slot', () => { MOCK_FORM_STATE = { ...MOCK_FORM_STATE, additionalEquipment: ['trashBin'], @@ -72,7 +124,7 @@ describe('getTrashSlot', () => { const result = getTrashSlot(MOCK_FORM_STATE) expect(result).toBe(FLEX_TRASH_DEFAULT_SLOT) }) - it('should return cutoutB3 when there is a staging area in slot A3', () => { + it('should return cutoutA1 when there is a staging area in slot A3', () => { MOCK_FORM_STATE = { ...MOCK_FORM_STATE, additionalEquipment: ['stagingArea_cutoutA3'], @@ -81,3 +133,61 @@ describe('getTrashSlot', () => { expect(result).toBe('cutoutA1') }) }) +describe('getTrashOptionDisabled', () => { + it('returns false when there is a trash bin already', () => { + const result = getTrashOptionDisabled({ + trashType: 'trashBin', + additionalEquipment: ['trashBin'], + modules: { + 0: { + model: 'heaterShakerModuleV1', + type: 'heaterShakerModuleType', + slot: 'D1', + }, + }, + }) + expect(result).toBe(false) + }) + it('returns false when there is an available slot', () => { + const result = getTrashOptionDisabled({ + trashType: 'trashBin', + additionalEquipment: ['trashBin'], + modules: null, + }) + expect(result).toBe(false) + }) + it('returns true when there is no available slot and trash bin is not selected yet', () => { + const result = getTrashOptionDisabled({ + trashType: 'trashBin', + additionalEquipment: [ + 'stagingArea_cutoutA3', + 'stagingArea_cutoutB3', + 'stagingArea_cutoutC3', + 'stagingArea_cutoutD3', + ], + modules: { + 0: { + model: 'heaterShakerModuleV1', + type: 'heaterShakerModuleType', + slot: 'D1', + }, + 1: { + model: 'temperatureModuleV2', + type: 'temperatureModuleType', + slot: 'C1', + }, + 2: { + model: 'temperatureModuleV2', + type: 'temperatureModuleType', + slot: 'B1', + }, + 3: { + model: 'temperatureModuleV2', + type: 'temperatureModuleType', + slot: 'A1', + }, + }, + }) + expect(result).toBe(true) + }) +}) diff --git a/protocol-designer/src/components/modals/CreateFileWizard/index.tsx b/protocol-designer/src/components/modals/CreateFileWizard/index.tsx index eea2264199a..53ae9a88f6a 100644 --- a/protocol-designer/src/components/modals/CreateFileWizard/index.tsx +++ b/protocol-designer/src/components/modals/CreateFileWizard/index.tsx @@ -43,6 +43,7 @@ import { createDeckFixture, toggleIsGripperRequired, } from '../../../step-forms/actions/additionalItems' +import { createModuleWithNoSlot } from '../../../modules' import { RobotTypeTile } from './RobotTypeTile' import { MetadataTile } from './MetadataTile' import { FirstPipetteTypeTile, SecondPipetteTypeTile } from './PipetteTypeTile' @@ -229,9 +230,29 @@ export function CreateFileWizard(): JSX.Element | null { } // create modules - modules.forEach(moduleArgs => - dispatch(stepFormActions.createModule(moduleArgs)) - ) + // sort so modules with slot are created first + // then modules without a slot are generated in remaining available slots + modules.sort((a, b) => { + if (a.slot == null && b.slot != null) { + return 1 + } + if (b.slot == null && a.slot != null) { + return -1 + } + return 0 + }) + + modules.forEach(moduleArgs => { + return moduleArgs.slot != null + ? dispatch(stepFormActions.createModule(moduleArgs)) + : dispatch( + createModuleWithNoSlot({ + model: moduleArgs.model, + type: moduleArgs.type, + }) + ) + }) + // add gripper if (values.additionalEquipment.includes('gripper')) { dispatch(toggleIsGripperRequired()) @@ -240,15 +261,15 @@ export function CreateFileWizard(): JSX.Element | null { const newTiprackModels: string[] = uniq( pipettes.flatMap(pipette => pipette.tiprackDefURI) ) + const FLEX_MIDDLE_SLOTS = ['C2', 'B2', 'A2'] + const OT2_MIDDLE_SLOTS = ['2', '5', '8', '11'] newTiprackModels.forEach((tiprackDefURI, index) => { - const ot2Slots = index === 0 ? '2' : '5' - const flexSlots = index === 0 ? 'C2' : 'B2' dispatch( labwareIngredActions.createContainer({ slot: values.fields.robotType === FLEX_ROBOT_TYPE - ? flexSlots - : ot2Slots, + ? FLEX_MIDDLE_SLOTS[index] + : OT2_MIDDLE_SLOTS[index], labwareDefURI: tiprackDefURI, adapterUnderLabwareDefURI: values.pipettesByMount.left.pipetteName === 'p1000_96' diff --git a/protocol-designer/src/components/modals/CreateFileWizard/utils.ts b/protocol-designer/src/components/modals/CreateFileWizard/utils.ts index 989dabe2839..7a23706a680 100644 --- a/protocol-designer/src/components/modals/CreateFileWizard/utils.ts +++ b/protocol-designer/src/components/modals/CreateFileWizard/utils.ts @@ -1,62 +1,15 @@ import { - getModuleType, - HEATERSHAKER_MODULE_TYPE, - TEMPERATURE_MODULE_TYPE, THERMOCYCLER_MODULE_TYPE, + WASTE_CHUTE_CUTOUT, } from '@opentrons/shared-data' import { isModuleWithCollisionIssue } from '../../modules' -import { - FLEX_SUPPORTED_MODULE_MODELS, - DEFAULT_SLOT_MAP, -} from './ModulesAndOtherTile' +import { STANDARD_EMPTY_SLOTS } from './StagingAreaTile' -import type { ModuleType } from '@opentrons/shared-data' +import type { DeckConfiguration, ModuleType } from '@opentrons/shared-data' import type { FormModules } from '../../../step-forms' import type { AdditionalEquipment, FormState } from './types' export const FLEX_TRASH_DEFAULT_SLOT = 'cutoutA3' -const ALL_STAGING_AREAS = 4 - -interface LastCheckedProps { - additionalEquipment: AdditionalEquipment[] - moduleTypesOnDeck: ModuleType[] -} - -export const getLastCheckedEquipment = ( - props: LastCheckedProps -): string | null => { - const { additionalEquipment, moduleTypesOnDeck } = props - const hasAllStagingAreas = - additionalEquipment.filter(equipment => equipment.includes('stagingArea')) - .length === ALL_STAGING_AREAS - const hasTrashBin = additionalEquipment.includes('trashBin') - if (!hasTrashBin || !hasAllStagingAreas) { - return null - } - - if ( - moduleTypesOnDeck.includes(THERMOCYCLER_MODULE_TYPE) && - moduleTypesOnDeck.includes(HEATERSHAKER_MODULE_TYPE) - ) { - return TEMPERATURE_MODULE_TYPE - } - - if ( - moduleTypesOnDeck.includes(HEATERSHAKER_MODULE_TYPE) && - moduleTypesOnDeck.includes(TEMPERATURE_MODULE_TYPE) - ) { - return THERMOCYCLER_MODULE_TYPE - } - - if ( - moduleTypesOnDeck.includes(THERMOCYCLER_MODULE_TYPE) && - moduleTypesOnDeck.includes(TEMPERATURE_MODULE_TYPE) - ) { - return HEATERSHAKER_MODULE_TYPE - } - - return null -} export const getCrashableModuleSelected = ( modules: FormModules | null, @@ -75,29 +28,15 @@ export const getCrashableModuleSelected = ( return crashableModuleOnDeck } -export const getTrashBinOptionDisabled = (props: LastCheckedProps): boolean => { - const { additionalEquipment, moduleTypesOnDeck } = props - const allStagingAreasInUse = - additionalEquipment.filter(equipment => equipment.includes('stagingArea')) - .length === ALL_STAGING_AREAS - - const allModulesInSideSlotsOnDeck = - moduleTypesOnDeck.includes(HEATERSHAKER_MODULE_TYPE) && - moduleTypesOnDeck.includes(TEMPERATURE_MODULE_TYPE) && - moduleTypesOnDeck.includes(THERMOCYCLER_MODULE_TYPE) - - return allStagingAreasInUse && allModulesInSideSlotsOnDeck -} - export const MOVABLE_TRASH_CUTOUTS = [ - { - value: 'cutoutA1', - slot: 'A1', - }, { value: 'cutoutA3', slot: 'A3', }, + { + value: 'cutoutA1', + slot: 'A1', + }, { value: 'cutoutB1', slot: 'B1', @@ -124,37 +63,107 @@ export const MOVABLE_TRASH_CUTOUTS = [ }, ] +export const getUnoccupiedStagingAreaSlots = ( + modules: FormState['modules'] +): DeckConfiguration => { + let unoccupiedSlots = STANDARD_EMPTY_SLOTS + const moduleCutoutIds = + modules != null + ? Object.values(modules).flatMap(module => + module.type === THERMOCYCLER_MODULE_TYPE + ? [`cutout${module.slot}`, 'cutoutA1'] + : `cutout${module.slot}` + ) + : [] + + unoccupiedSlots = unoccupiedSlots.filter(emptySlot => { + return !moduleCutoutIds.includes(emptySlot.cutoutId) + }) + + return unoccupiedSlots +} + +const TOTAL_MODULE_SLOTS = 8 + +export const getIsSlotAvailable = ( + modules: FormState['modules'], + additionalEquipment: FormState['additionalEquipment'] +): boolean => { + const moduleLength = modules != null ? Object.keys(modules).length : 0 + const additionalEquipmentLength = additionalEquipment.length + const hasTC = Object.values(modules || {}).some( + module => module.type === THERMOCYCLER_MODULE_TYPE + ) + + const filteredModuleLength = hasTC ? moduleLength + 1 : moduleLength + const hasWasteChute = additionalEquipment.some(equipment => + equipment.includes('wasteChute') + ) + const isStagingAreaInD3 = additionalEquipment + .filter(equipment => equipment.includes('stagingArea')) + .find(stagingArea => stagingArea.split('_')[1] === 'cutoutD3') + const hasGripper = additionalEquipment.some(equipment => + equipment.includes('gripper') + ) + + let filteredAdditionalEquipmentLength = additionalEquipmentLength + if (hasWasteChute && isStagingAreaInD3) { + filteredAdditionalEquipmentLength = filteredAdditionalEquipmentLength - 1 + } + if (hasGripper) { + filteredAdditionalEquipmentLength = filteredAdditionalEquipmentLength - 1 + } + + return ( + filteredModuleLength + filteredAdditionalEquipmentLength < + TOTAL_MODULE_SLOTS + ) +} + +interface TrashOptionDisabledProps { + trashType: 'trashBin' | 'wasteChute' + additionalEquipment: AdditionalEquipment[] + modules: FormModules | null +} + +export const getTrashOptionDisabled = ( + props: TrashOptionDisabledProps +): boolean => { + const { additionalEquipment, modules, trashType } = props + return ( + !getIsSlotAvailable(modules, additionalEquipment) && + !additionalEquipment.includes(trashType) + ) +} + export const getTrashSlot = (values: FormState): string => { const { additionalEquipment, modules } = values - const moduleTypesOnDeck = - modules != null ? Object.values(modules).map(module => module.type) : [] + const moduleSlots = + modules != null + ? Object.values(modules).flatMap(module => + module.type === THERMOCYCLER_MODULE_TYPE + ? [module.slot, 'A1'] + : module.slot + ) + : [] const stagingAreas = additionalEquipment.filter(equipment => equipment.includes('stagingArea') ) // TODO(Jr, 11/16/23): refactor additionalEquipment to store cutouts // so the split isn't needed const cutouts = stagingAreas.map(cutout => cutout.split('_')[1]) - - if (!cutouts.includes(FLEX_TRASH_DEFAULT_SLOT)) { - return FLEX_TRASH_DEFAULT_SLOT - } - - const moduleSlots: string[] = FLEX_SUPPORTED_MODULE_MODELS.reduce( - (slots: string[], model) => { - const moduleType = getModuleType(model) - if (moduleTypesOnDeck.includes(moduleType)) { - const slot = String(DEFAULT_SLOT_MAP[model]) - return moduleType === THERMOCYCLER_MODULE_TYPE - ? [...slots, 'A1', slot] - : [...slots, slot] - } - return slots - }, - [] + const hasWasteChute = additionalEquipment.find(equipment => + equipment.includes('wasteChute') ) + const wasteChuteSlot = Boolean(hasWasteChute) + ? [WASTE_CHUTE_CUTOUT as string] + : [] + const unoccupiedSlot = MOVABLE_TRASH_CUTOUTS.find( cutout => - !cutouts.includes(cutout.value) && !moduleSlots.includes(cutout.slot) + !cutouts.includes(cutout.value) && + !moduleSlots.includes(cutout.slot) && + !wasteChuteSlot.includes(cutout.value) ) if (unoccupiedSlot == null) { console.error( diff --git a/protocol-designer/src/components/modals/EditModulesModal/EditMultipleModulesModal.tsx b/protocol-designer/src/components/modals/EditModulesModal/EditMultipleModulesModal.tsx new file mode 100644 index 00000000000..cc31c4eb071 --- /dev/null +++ b/protocol-designer/src/components/modals/EditModulesModal/EditMultipleModulesModal.tsx @@ -0,0 +1,274 @@ +import * as React from 'react' +import { useTranslation } from 'react-i18next' +import { useSelector, useDispatch } from 'react-redux' +import { Controller, useForm, useWatch } from 'react-hook-form' +import { + BUTTON_TYPE_SUBMIT, + OutlineButton, + ModalShell, + Flex, + SPACING, + DIRECTION_ROW, + Box, + Text, + ALIGN_CENTER, + JUSTIFY_FLEX_END, + JUSTIFY_END, + DeckConfigurator, + DIRECTION_COLUMN, +} from '@opentrons/components' +import { + DeckConfiguration, + SINGLE_RIGHT_SLOT_FIXTURE, + TEMPERATURE_MODULE_CUTOUTS, + TEMPERATURE_MODULE_TYPE, + TEMPERATURE_MODULE_V2, + TEMPERATURE_MODULE_V2_FIXTURE, +} from '@opentrons/shared-data' +import { createModule, deleteModule } from '../../../step-forms/actions' +import { getLabwareOnSlot, getSlotIsEmpty } from '../../../step-forms' +import { getInitialDeckSetup } from '../../../step-forms/selectors' +import { getLabwareIsCompatible } from '../../../utils/labwareModuleCompatibility' +import { PDAlert } from '../../alerts/PDAlert' +import type { Control, ControllerRenderProps } from 'react-hook-form' +import type { CutoutId, ModuleType } from '@opentrons/shared-data' +import type { ModuleOnDeck } from '../../../step-forms' + +export interface EditMultipleModulesModalValues { + selectedAddressableAreas: string[] +} + +interface EditMultipleModulesModalComponentProps + extends EditMultipleModulesModalProps { + control: Control + moduleLocations: string[] | null +} + +const EditMultipleModulesModalComponent = ( + props: EditMultipleModulesModalComponentProps +): JSX.Element => { + const { t } = useTranslation(['button', 'alert']) + const { + onCloseClick, + allModulesOnDeck, + control, + moduleLocations, + moduleType, + } = props + const initialDeckSetup = useSelector(getInitialDeckSetup) + + const selectedSlots = useWatch({ + control, + name: 'selectedAddressableAreas', + defaultValue: moduleLocations ?? [], + }) + const occupiedCutoutIds = selectedSlots + .map(slot => { + const hasModSlot = + allModulesOnDeck.find( + module => + module.type === moduleType && slot === `cutout${module.slot}` + ) != null + const labwareOnSlot = getLabwareOnSlot(initialDeckSetup, slot) + const isLabwareCompatible = + (labwareOnSlot && + getLabwareIsCompatible(labwareOnSlot.def, moduleType)) ?? + true + const isEmpty = + (getSlotIsEmpty(initialDeckSetup, slot, true) || hasModSlot) && + isLabwareCompatible + + return { slot, isEmpty } + }) + .filter(slot => !slot.isEmpty) + const hasConflictedSlot = occupiedCutoutIds.length > 0 + const mappedModules: DeckConfiguration = + moduleLocations != null + ? moduleLocations.flatMap(location => { + return [ + { + cutoutId: location as CutoutId, + cutoutFixtureId: TEMPERATURE_MODULE_V2_FIXTURE, + }, + ] + }) + : [] + const STANDARD_EMPTY_SLOTS: DeckConfiguration = TEMPERATURE_MODULE_CUTOUTS.map( + cutoutId => ({ + cutoutId, + cutoutFixtureId: SINGLE_RIGHT_SLOT_FIXTURE, + }) + ) + + STANDARD_EMPTY_SLOTS.forEach(emptySlot => { + if ( + !mappedModules.some(({ cutoutId }) => cutoutId === emptySlot.cutoutId) + ) { + mappedModules.push(emptySlot) + } + }) + + const selectableSlots = + mappedModules.length > 0 ? mappedModules : STANDARD_EMPTY_SLOTS + const [updatedSlots, setUpdatedSlots] = React.useState( + selectableSlots + ) + const handleClickAdd = ( + cutoutId: string, + field: ControllerRenderProps< + EditMultipleModulesModalValues, + 'selectedAddressableAreas' + > + ): void => { + const modifiedSlots: DeckConfiguration = updatedSlots.map(slot => { + if (slot.cutoutId === cutoutId) { + return { + ...slot, + cutoutFixtureId: TEMPERATURE_MODULE_V2_FIXTURE, + } + } + return slot + }) + setUpdatedSlots(modifiedSlots) + const updatedSelectedSlots = [...selectedSlots, cutoutId] + field.onChange(updatedSelectedSlots) + } + + const handleClickRemove = ( + cutoutId: string, + field: ControllerRenderProps< + EditMultipleModulesModalValues, + 'selectedAddressableAreas' + > + ): void => { + const modifiedSlots: DeckConfiguration = updatedSlots.map(slot => { + if (slot.cutoutId === cutoutId) { + return { ...slot, cutoutFixtureId: SINGLE_RIGHT_SLOT_FIXTURE } + } + return slot + }) + setUpdatedSlots(modifiedSlots) + + field.onChange(selectedSlots.filter(item => item !== cutoutId)) + } + const occupiedSlots = occupiedCutoutIds.map( + occupiedCutout => occupiedCutout.slot.split('cutout')[1] + ) + const alertDescription = t( + `alert:module_placement.SLOTS_OCCUPIED.${ + occupiedSlots.length === 1 ? 'single' : 'multi' + }`, + { + slotName: occupiedSlots, + } + ) + + return ( + <> + + + + {hasConflictedSlot ? ( + + ) : null} + + + ( + handleClickAdd(cutoutId, field)} + handleClickRemove={cutoutId => handleClickRemove(cutoutId, field)} + showExpansion={false} + /> + )} + /> + + + {t('cancel')} + + {t('save')} + + + + ) +} + +export interface EditMultipleModulesModalProps { + onCloseClick: () => void + allModulesOnDeck: ModuleOnDeck[] + moduleType: ModuleType +} +export function EditMultipleModulesModal( + props: EditMultipleModulesModalProps +): JSX.Element { + const { onCloseClick, allModulesOnDeck, moduleType } = props + const { t } = useTranslation('modules') + const dispatch = useDispatch() + const { control, handleSubmit } = useForm() + const moduleLocations = Object.values(allModulesOnDeck) + .filter(module => module.type === moduleType) + .map(temp => `cutout${temp.slot}`) + + const onSaveClick = (data: EditMultipleModulesModalValues): void => { + onCloseClick() + + data.selectedAddressableAreas.forEach(aa => { + const moduleInSlot = Object.values(allModulesOnDeck).find(module => + aa.includes(module.slot) + ) + if (!moduleInSlot) { + dispatch( + createModule({ + slot: aa.split('cutout')[1], + type: TEMPERATURE_MODULE_TYPE, + model: TEMPERATURE_MODULE_V2, + }) + ) + } + }) + Object.values(allModulesOnDeck).forEach(module => { + const moduleCutout = `cutout${module.slot}` + if (!data.selectedAddressableAreas.includes(moduleCutout)) { + dispatch(deleteModule(module.id)) + } + }) + } + + return ( +
+ + + + {t('module_display_names.multipleTemperatureModuleTypes')} + + + + +
+ ) +} diff --git a/protocol-designer/src/components/modals/EditModulesModal/__tests__/EditMultipleModulesModal.test.tsx b/protocol-designer/src/components/modals/EditModulesModal/__tests__/EditMultipleModulesModal.test.tsx new file mode 100644 index 00000000000..fa01bd44ecf --- /dev/null +++ b/protocol-designer/src/components/modals/EditModulesModal/__tests__/EditMultipleModulesModal.test.tsx @@ -0,0 +1,106 @@ +import * as React from 'react' +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { fireEvent, screen, cleanup } from '@testing-library/react' +import { renderWithProviders } from '../../../../__testing-utils__' +import { i18n } from '../../../../localization' +import { getInitialDeckSetup } from '../../../../step-forms/selectors' +import { getLabwareIsCompatible } from '../../../../utils/labwareModuleCompatibility' +import { + getLabwareOnSlot, + getSlotIsEmpty, + ModuleOnDeck, +} from '../../../../step-forms' +import { EditMultipleModulesModal } from '../EditMultipleModulesModal' +import type * as Components from '@opentrons/components' + +vi.mock('../../../../step-forms/selectors') +vi.mock('../../../../utils/labwareModuleCompatibility') +vi.mock('../../../../step-forms') +vi.mock('@opentrons/components', async importOriginal => { + const actual = await importOriginal() + return { + ...actual, + DeckConfigurator: vi.fn(() =>
mock deck config
), + } +}) + +const render = ( + props: React.ComponentProps +) => { + return renderWithProviders(, { + i18nInstance: i18n, + })[0] +} + +const mockTemp: ModuleOnDeck = { + id: 'temperatureId', + type: 'temperatureModuleType', + model: 'temperatureModuleV2', + slot: 'C3', + moduleState: {} as any, +} +const mockTemp2: ModuleOnDeck = { + id: 'temperatureId', + type: 'temperatureModuleType', + model: 'temperatureModuleV2', + slot: 'A1', + moduleState: {} as any, +} +const mockHS: ModuleOnDeck = { + id: 'heaterShakerId', + type: 'heaterShakerModuleType', + model: 'heaterShakerModuleV1', + moduleState: {} as any, + slot: 'A1', +} +describe('EditMultipleModulesModal', () => { + let props: React.ComponentProps + beforeEach(() => { + props = { + moduleType: 'temperatureModuleType', + onCloseClick: vi.fn(), + allModulesOnDeck: [mockTemp, mockTemp2], + } + vi.mocked(getInitialDeckSetup).mockReturnValue({ + modules: { + temperatureId: mockTemp, + temperatureId2: mockTemp2, + }, + labware: {}, + additionalEquipmentOnDeck: {}, + pipettes: {}, + }) + vi.mocked(getLabwareOnSlot).mockReturnValue(null) + vi.mocked(getSlotIsEmpty).mockReturnValue(true) + }) + afterEach(() => { + cleanup() + }) + it('renders modal and buttons with no error', () => { + vi.mocked(getLabwareIsCompatible).mockReturnValue(true) + render(props) + screen.getByText('mock deck config') + screen.getByText('Multiple Temperatures') + fireEvent.click(screen.getByRole('button', { name: 'cancel' })) + expect(props.onCloseClick).toHaveBeenCalled() + screen.getByRole('button', { name: 'save' }) + }) + it('renders modal with a cannot place module error', () => { + vi.mocked(getLabwareOnSlot).mockReturnValue({ slot: 'A1' } as any) + vi.mocked(getLabwareIsCompatible).mockReturnValue(false) + vi.mocked(getSlotIsEmpty).mockReturnValue(false) + props.allModulesOnDeck = [mockTemp, mockTemp2, mockHS] + vi.mocked(getInitialDeckSetup).mockReturnValue({ + modules: { + heaterShakerId: mockHS, + }, + labware: {}, + additionalEquipmentOnDeck: {}, + pipettes: {}, + }) + render(props) + screen.getByText('warning') + screen.getByText('Cannot place module') + screen.getByText('Multiple slots are occupied') + }) +}) diff --git a/protocol-designer/src/components/modules/EditModulesCard.tsx b/protocol-designer/src/components/modules/EditModulesCard.tsx index 40df5ef14a2..27dcc233ede 100644 --- a/protocol-designer/src/components/modules/EditModulesCard.tsx +++ b/protocol-designer/src/components/modules/EditModulesCard.tsx @@ -27,10 +27,12 @@ import { CrashInfoBox } from './CrashInfoBox' import { ModuleRow } from './ModuleRow' import { AdditionalItemsRow } from './AdditionalItemsRow' import { isModuleWithCollisionIssue } from './utils' -import styles from './styles.module.css' -import { AdditionalEquipmentEntity } from '@opentrons/step-generation' import { StagingAreasRow } from './StagingAreasRow' +import { MultipleModulesRow } from './MultipleModulesRow' + +import type { AdditionalEquipmentEntity } from '@opentrons/step-generation' +import styles from './styles.module.css' export interface Props { modules: ModulesForEditModulesCard openEditModuleModal: (moduleType: ModuleType, moduleId?: string) => void @@ -38,6 +40,7 @@ export interface Props { export function EditModulesCard(props: Props): JSX.Element { const { modules, openEditModuleModal } = props + const pipettesByMount = useSelector( stepFormSelectors.getPipettesForEditPipetteForm ) @@ -67,10 +70,10 @@ export function EditModulesCard(props: Props): JSX.Element { ) const hasCrashableMagneticModule = magneticModuleOnDeck && - isModuleWithCollisionIssue(magneticModuleOnDeck.model) + isModuleWithCollisionIssue(magneticModuleOnDeck[0].model) const hasCrashableTempModule = temperatureModuleOnDeck && - isModuleWithCollisionIssue(temperatureModuleOnDeck.model) + isModuleWithCollisionIssue(temperatureModuleOnDeck[0].model) const isHeaterShakerOnDeck = Boolean(heaterShakerOnDeck) const showTempPipetteCollisons = @@ -130,22 +133,33 @@ export function EditModulesCard(props: Props): JSX.Element { ) : null} {SUPPORTED_MODULE_TYPES_FILTERED.map((moduleType, i) => { const moduleData = modules[moduleType] - if (moduleData) { + if (moduleData != null && moduleData.length === 1) { return ( ) + } else if (moduleData != null && moduleData.length > 1) { + return ( + + ) } else { return ( ) diff --git a/protocol-designer/src/components/modules/MultipleModulesRow.tsx b/protocol-designer/src/components/modules/MultipleModulesRow.tsx new file mode 100644 index 00000000000..fa6ffb20c76 --- /dev/null +++ b/protocol-designer/src/components/modules/MultipleModulesRow.tsx @@ -0,0 +1,123 @@ +import * as React from 'react' +import { useTranslation } from 'react-i18next' +import { useDispatch } from 'react-redux' +import { + LabeledValue, + OutlineButton, + ModuleIcon, + C_DARK_GRAY, + SPACING, +} from '@opentrons/components' +import { actions as stepFormActions } from '../../step-forms' +import { DEFAULT_MODEL_FOR_MODULE_TYPE } from '../../constants' +import { ModuleDiagram } from './ModuleDiagram' +import { FlexSlotMap } from './FlexSlotMap' +import type { ModuleModel, ModuleType } from '@opentrons/shared-data' +import type { ModuleOnDeck } from '../../step-forms' + +import styles from './styles.module.css' + +interface MultipleModulesRowProps { + moduleType: ModuleType + openEditModuleModal: (moduleType: ModuleType, moduleId?: string) => void + moduleOnDeckType?: ModuleType + moduleOnDeckModel?: ModuleModel + moduleOnDeck?: ModuleOnDeck[] +} + +export function MultipleModulesRow( + props: MultipleModulesRowProps +): JSX.Element { + const { + moduleOnDeck, + openEditModuleModal, + moduleOnDeckModel, + moduleOnDeckType, + moduleType, + } = props + const { t } = useTranslation(['modules', 'shared']) + const dispatch = useDispatch() + + const type: ModuleType = moduleOnDeckType ?? moduleType + const occupiedSlots = moduleOnDeck?.map(module => module.slot) ?? [] + const occupiedSlotsDisplayName = ( + moduleOnDeck?.map(module => module.slot) ?? [] + ).join(', ') + + const setCurrentModule = (moduleType: ModuleType, moduleId?: string) => () => + openEditModuleModal(moduleType, moduleId) + + const addRemoveText = moduleOnDeck ? t('shared:remove') : t('shared:add') + + const handleAddOrRemove = (): void => { + if (moduleOnDeck != null) { + moduleOnDeck.forEach(module => { + dispatch(stepFormActions.deleteModule(module.id)) + }) + } else { + setCurrentModule(type) + } + } + const handleEditModule = + moduleOnDeck && setCurrentModule(type, moduleOnDeck[0].id) + + return ( +
+

+ + {t( + `module_display_names.${ + occupiedSlots.length > 1 ? 'multipleTemperatureModuleTypes' : type + }` + )} +

+
+
+ +
+
+ {moduleOnDeckModel && ( + + )} +
+
+ {occupiedSlots.length > 0 ? ( + + ) : null} +
+
+ {occupiedSlots.length > 0 ? ( + + ) : null} +
+
+ {moduleOnDeck != null ? ( + + {t('shared:edit')} + + ) : null} + + {addRemoveText} + +
+
+
+ ) +} diff --git a/protocol-designer/src/components/modules/__tests__/MultipleModuleRow.test.tsx b/protocol-designer/src/components/modules/__tests__/MultipleModuleRow.test.tsx new file mode 100644 index 00000000000..9c8e0027327 --- /dev/null +++ b/protocol-designer/src/components/modules/__tests__/MultipleModuleRow.test.tsx @@ -0,0 +1,67 @@ +import * as React from 'react' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { fireEvent, screen } from '@testing-library/react' + +import { i18n } from '../../../localization' +import { renderWithProviders } from '../../../__testing-utils__' +import { MultipleModulesRow } from '../MultipleModulesRow' +import { + TEMPERATURE_MODULE_TYPE, + TEMPERATURE_MODULE_V2, +} from '@opentrons/shared-data' +import { FlexSlotMap } from '../FlexSlotMap' +import { deleteModule } from '../../../step-forms/actions' +import type { ModuleOnDeck } from '../../../step-forms' + +vi.mock('../../../step-forms/actions') +vi.mock('../FlexSlotMap') +const render = (props: React.ComponentProps) => { + return renderWithProviders(, { + i18nInstance: i18n, + })[0] +} + +const mockTemp: ModuleOnDeck = { + id: 'temperatureId', + type: 'temperatureModuleType', + model: 'temperatureModuleV2', + slot: 'C3', + moduleState: {} as any, +} +const mockTemp2: ModuleOnDeck = { + id: 'temperatureId', + type: 'temperatureModuleType', + model: 'temperatureModuleV2', + slot: 'A1', + moduleState: {} as any, +} + +describe('MultipleModuleRow', () => { + let props: React.ComponentProps + beforeEach(() => { + props = { + moduleType: TEMPERATURE_MODULE_TYPE, + openEditModuleModal: vi.fn(), + moduleOnDeckType: TEMPERATURE_MODULE_TYPE, + moduleOnDeckModel: TEMPERATURE_MODULE_V2, + moduleOnDeck: [mockTemp, mockTemp2], + } + vi.mocked(FlexSlotMap).mockReturnValue(
mock FlexSlotMap
) + }) + it('renders 2 modules in the row with text and buttons', () => { + render(props) + screen.getByText('Multiple Temperatures') + screen.getByText('Position:') + screen.getByText('C3, A1') + screen.getByText('mock FlexSlotMap') + fireEvent.click(screen.getByText('edit')) + expect(props.openEditModuleModal).toHaveBeenCalled() + fireEvent.click(screen.getByText('remove')) + expect(vi.mocked(deleteModule)).toHaveBeenCalled() + }) + it('renders no modules', () => { + props.moduleOnDeck = undefined + render(props) + screen.getByText('add') + }) +}) diff --git a/protocol-designer/src/components/steplist/ModuleStepItems.tsx b/protocol-designer/src/components/steplist/ModuleStepItems.tsx index f3e91c1b73d..548caf2964d 100644 --- a/protocol-designer/src/components/steplist/ModuleStepItems.tsx +++ b/protocol-designer/src/components/steplist/ModuleStepItems.tsx @@ -9,8 +9,9 @@ import { } from '@opentrons/components' import { PDListItem } from '../lists' import { LabwareTooltipContents } from './LabwareTooltipContents' +import type { ModuleType } from '@opentrons/shared-data' + import styles from './StepItem.module.css' -import { ModuleType } from '@opentrons/shared-data' export interface ModuleStepItemRowProps { label?: string | null @@ -31,44 +32,64 @@ export const ModuleStepItemRow = ( ) -interface Props { - action?: string +interface ModuleStepItemsProps { moduleType: ModuleType actionText: string - labwareNickname?: string | null - message?: string | null + moduleSlot?: string + action?: string children?: React.ReactNode hideHeader?: boolean + labwareNickname?: string | null + message?: string | null } -export const ModuleStepItems = (props: Props): JSX.Element => { - const { t } = useTranslation('modules') +export function ModuleStepItems(props: ModuleStepItemsProps): JSX.Element { + const { + moduleType, + actionText, + moduleSlot, + action, + hideHeader, + labwareNickname, + children, + message, + } = props + const { t } = useTranslation(['modules', 'application']) const [targetProps, tooltipProps] = useHoverTooltip({ placement: 'bottom-start', strategy: TOOLTIP_FIXED, }) + const moduleLongName = t(`module_long_names.${moduleType}`) + return ( <> - {!props.hideHeader && ( + {!Boolean(hideHeader) ? (
  • - {t(`module_long_names.${props.moduleType}`)} - {props.action} + + {moduleSlot != null + ? t('application:module_and_slot', { + moduleLongName, + slotName: moduleSlot, + }) + : moduleLongName} + + {action}
  • - )} + ) : null} - + - {props.children} - {props.message && ( + {children} + {message != null ? ( - "{props.message}" + "{message}" - )} + ) : null} ) } diff --git a/protocol-designer/src/components/steplist/StepItem.tsx b/protocol-designer/src/components/steplist/StepItem.tsx index c51502348a2..0fbb338cc0f 100644 --- a/protocol-designer/src/components/steplist/StepItem.tsx +++ b/protocol-designer/src/components/steplist/StepItem.tsx @@ -25,6 +25,7 @@ import { makeTemperatureText, makeTimerText, } from '../../utils' +import { InitialDeckSetup } from '../../step-forms' import { PDListItem, TitledStepList } from '../lists' import { TitledListNotes } from '../TitledListNotes' import { AspirateDispenseHeader } from './AspirateDispenseHeader' @@ -121,11 +122,10 @@ export interface StepItemContentsProps { rawForm: FormData | null | undefined stepType: StepType substeps: SubstepItemData | null | undefined - ingredNames: WellIngredientNames labwareNicknamesById: { [labwareId: string]: string } additionalEquipmentEntities: AdditionalEquipmentEntities - + modules: InitialDeckSetup['modules'] highlightSubstep: (substepIdentifier: SubstepIdentifier) => unknown hoveredSubstep: SubstepIdentifier | null | undefined } @@ -293,6 +293,7 @@ export const StepItemContents = ( props: StepItemContentsProps ): JSX.Element | JSX.Element[] | null => { const { + modules, rawForm, stepType, substeps, @@ -326,6 +327,8 @@ export const StepItemContents = ( if (substeps && substeps.substepType === 'temperature') { const temperature = makeTemperatureText(substeps.temperature, t) + const moduleSlot = + substeps.moduleId != null ? modules[substeps.moduleId].slot : '' return ( ) } diff --git a/protocol-designer/src/constants.ts b/protocol-designer/src/constants.ts index bae70d17d7f..b92192565c2 100644 --- a/protocol-designer/src/constants.ts +++ b/protocol-designer/src/constants.ts @@ -65,10 +65,10 @@ export const DEFAULT_MM_BLOWOUT_OFFSET_FROM_TOP = 0 export const DEFAULT_DELAY_SECONDS = 1 export const DEFAULT_WELL_ORDER_FIRST_OPTION: 't2b' = 't2b' export const DEFAULT_WELL_ORDER_SECOND_OPTION: 'l2r' = 'l2r' -export const MIN_ENGAGE_HEIGHT_V1 = -5 -export const MAX_ENGAGE_HEIGHT_V1 = 40 -export const MIN_ENGAGE_HEIGHT_V2 = -4 -export const MAX_ENGAGE_HEIGHT_V2 = 19 +export const MIN_ENGAGE_HEIGHT_V1 = 0 +export const MAX_ENGAGE_HEIGHT_V1 = 45 +export const MIN_ENGAGE_HEIGHT_V2 = -2.5 +export const MAX_ENGAGE_HEIGHT_V2 = 25 export const MIN_TEMP_MODULE_TEMP = 4 export const MAX_TEMP_MODULE_TEMP = 95 export const MIN_HEATER_SHAKER_MODULE_TEMP = 37 diff --git a/protocol-designer/src/containers/ConnectedStepItem.tsx b/protocol-designer/src/containers/ConnectedStepItem.tsx index a6b4ceb1f26..a3ebcb05f41 100644 --- a/protocol-designer/src/containers/ConnectedStepItem.tsx +++ b/protocol-designer/src/containers/ConnectedStepItem.tsx @@ -24,7 +24,6 @@ import { SelectMultipleStepsAction, } from '../ui/steps' import { selectors as fileDataSelectors } from '../file-data' - import { StepItem, StepItemContents, @@ -38,12 +37,15 @@ import { ConfirmDeleteModal, DeleteModalType, } from '../components/modals/ConfirmDeleteModal' +import { + getAdditionalEquipmentEntities, + getInitialDeckSetup, +} from '../step-forms/selectors' -import { SubstepIdentifier } from '../steplist/types' -import { StepIdType } from '../form-types' -import { BaseState, ThunkAction } from '../types' -import { getAdditionalEquipmentEntities } from '../step-forms/selectors' -import { ThunkDispatch } from 'redux-thunk' +import type { ThunkDispatch } from 'redux-thunk' +import type { SubstepIdentifier } from '../steplist/types' +import type { StepIdType } from '../form-types' +import type { BaseState, ThunkAction } from '../types' export interface ConnectedStepItemProps { stepId: StepIdType @@ -86,7 +88,7 @@ export const ConnectedStepItem = ( const hasWarnings = hasTimelineWarningsPerStep[stepId] || hasFormLevelWarningsPerStep[stepId] - + const initialDeckSetup = useSelector(getInitialDeckSetup) const collapsed = useSelector(getCollapsedSteps)[stepId] const hoveredSubstep = useSelector(getHoveredSubstep) const hoveredStep = useSelector(getHoveredStepId) @@ -217,6 +219,7 @@ export const ConnectedStepItem = ( } const stepItemContentsProps: StepItemContentsProps = { + modules: initialDeckSetup.modules, rawForm: step, stepType: step.stepType, substeps, @@ -236,7 +239,6 @@ export const ConnectedStepItem = ( return CLOSE_STEP_FORM_WITH_CHANGES } } - return ( <> {showConfirmation && ( diff --git a/protocol-designer/src/containers/__tests__/ConnectedStepItem.test.tsx b/protocol-designer/src/containers/__tests__/ConnectedStepItem.test.tsx index 4d03b5c16ac..4156202796b 100644 --- a/protocol-designer/src/containers/__tests__/ConnectedStepItem.test.tsx +++ b/protocol-designer/src/containers/__tests__/ConnectedStepItem.test.tsx @@ -1,5 +1,511 @@ -import { describe, it } from 'vitest' +import * as React from 'react' +import { describe, it, beforeEach, vi } from 'vitest' +import { screen } from '@testing-library/react' +import '@testing-library/jest-dom/vitest' +import { fixture96Plate, fixtureTiprack1000ul } from '@opentrons/shared-data' +import { renderWithProviders } from '../../__testing-utils__' +import { i18n } from '../../localization' +import { + getAdditionalEquipmentEntities, + getArgsAndErrorsByStepId, + getBatchEditFormHasUnsavedChanges, + getCurrentFormCanBeSaved, + getCurrentFormHasUnsavedChanges, + getInitialDeckSetup, + getOrderedStepIds, + getSavedStepForms, +} from '../../step-forms/selectors' +import { selectors as labwareIngredSelectors } from '../../labware-ingred/selectors' +import { getErrorStepId, getSubsteps } from '../../file-data/selectors' +import { getHasTimelineWarningsPerStep } from '../../top-selectors/timelineWarnings' +import { getHasFormLevelWarningsPerStep } from '../../dismiss/selectors' +import { + getCollapsedSteps, + getHoveredSubstep, + getIsMultiSelectMode, + getMultiSelectItemIds, + getMultiSelectLastSelected, + getSelectedStepId, +} from '../../ui/steps' +import { getLabwareNicknamesById } from '../../ui/labware/selectors' +import { ConnectedStepItem } from '../ConnectedStepItem' +import type { LabwareDefinition2 } from '@opentrons/shared-data' + +vi.mock('../../step-forms/selectors') +vi.mock('../../file-data/selectors') +vi.mock('../../top-selectors/timelineWarnings') +vi.mock('../../dismiss/selectors') +vi.mock('../../ui/steps') +vi.mock('../../labware-ingred/selectors') +vi.mock('../../ui/labware/selectors') + +const render = (props: React.ComponentProps) => { + return renderWithProviders(, { + i18nInstance: i18n, + }) +} +const pauseStepId = 'pauseId' +const magnetStepId = 'magnetStepId' +const heaterShakerStepId = 'hsStepId' +const thermocyclerStepId = 'tcStepId' +const temperatureStepId = 'tempStepId' +const moveLabwareStepId = 'moveLabwareId' +const mixStepId = 'mixStepId' +const moveLiquidStepId = 'moveLiquidStepId' describe('ConnectedStepItem', () => { - it.todo('replace deprecated enzyme test') + let props: React.ComponentProps + beforeEach(() => { + props = { + stepId: pauseStepId, + stepNumber: 2, + onStepContextMenu: vi.fn(), + } + vi.mocked(getSavedStepForms).mockReturnValue({ + [pauseStepId]: { + stepType: 'pause', + id: pauseStepId, + pauseHour: '1', + pauseMinute: '10', + pauseSecond: '5', + pauseMessage: 'mock message', + pauseTemperature: '10', + }, + [magnetStepId]: { + stepType: 'magnet', + id: magnetStepId, + }, + [heaterShakerStepId]: { + stepType: 'heaterShaker', + id: heaterShakerStepId, + }, + [thermocyclerStepId]: { + stepType: 'thermocycler', + id: thermocyclerStepId, + }, + [temperatureStepId]: { + stepType: 'temperature', + id: temperatureStepId, + }, + [moveLabwareStepId]: { + stepType: 'moveLabware', + id: moveLabwareStepId, + }, + [mixStepId]: { + stepType: 'mix', + id: mixStepId, + }, + [moveLiquidStepId]: { + stepType: 'moveLiquid', + id: moveLiquidStepId, + }, + }) + vi.mocked(getArgsAndErrorsByStepId).mockReturnValue({ + [pauseStepId]: { + errors: false, + stepArgs: null, + }, + [magnetStepId]: { + errors: false, + stepArgs: null, + }, + [heaterShakerStepId]: { + errors: false, + stepArgs: null, + }, + [thermocyclerStepId]: { + errors: false, + stepArgs: null, + }, + [temperatureStepId]: { + errors: false, + stepArgs: null, + }, + [moveLabwareStepId]: { + errors: false, + stepArgs: null, + }, + [mixStepId]: { + errors: false, + stepArgs: null, + }, + [moveLiquidStepId]: { + errors: false, + stepArgs: null, + }, + }) + vi.mocked(getErrorStepId).mockReturnValue(null) + vi.mocked(getHasTimelineWarningsPerStep).mockReturnValue({ + [pauseStepId]: false, + [magnetStepId]: false, + [heaterShakerStepId]: false, + [thermocyclerStepId]: false, + [temperatureStepId]: false, + [moveLabwareStepId]: false, + [mixStepId]: false, + [moveLiquidStepId]: false, + }) + vi.mocked(getHasFormLevelWarningsPerStep).mockReturnValue({ + [pauseStepId]: false, + [magnetStepId]: false, + [heaterShakerStepId]: false, + [thermocyclerStepId]: false, + [temperatureStepId]: false, + [moveLabwareStepId]: false, + [mixStepId]: false, + [moveLiquidStepId]: false, + }) + vi.mocked(getInitialDeckSetup).mockReturnValue({ + pipettes: {}, + modules: { + thermocyclerId: { + id: 'thermocyclerId', + type: 'thermocyclerModuleType', + model: 'thermocyclerModuleV2', + slot: 'B1', + moduleState: {} as any, + }, + temperatureId: { + id: 'temperatureId', + type: 'temperatureModuleType', + model: 'temperatureModuleV2', + slot: 'C3', + moduleState: {} as any, + }, + heaterShakerId: { + id: 'heaterShakerId', + type: 'heaterShakerModuleType', + model: 'heaterShakerModuleV1', + slot: 'D1', + moduleState: {} as any, + }, + magnetId: { + id: 'magnetId', + type: 'magneticModuleType', + model: 'magneticModuleV2', + slot: 'C1', + moduleState: {} as any, + }, + }, + additionalEquipmentOnDeck: { + stagingAreaId: { + name: 'stagingArea', + location: 'B3', + id: 'stagingAreaId', + }, + }, + labware: { + labwareId: { + id: 'labwareId', + labwareDefURI: `opentrons/fixture_96_plate/1`, + slot: 'A2', + def: fixture96Plate as LabwareDefinition2, + }, + tipId: { + id: 'tipId', + labwareDefURI: `opentrons/${fixtureTiprack1000ul.parameters.loadName}/1`, + slot: 'D2', + def: fixtureTiprack1000ul as LabwareDefinition2, + }, + }, + }) + vi.mocked(getCollapsedSteps).mockReturnValue({ + [pauseStepId]: false, + [magnetStepId]: true, + [heaterShakerStepId]: true, + [thermocyclerStepId]: true, + [temperatureStepId]: true, + [moveLabwareStepId]: true, + [mixStepId]: true, + [moveLiquidStepId]: true, + }) + vi.mocked(getHoveredSubstep).mockReturnValue(null) + vi.mocked(getSelectedStepId).mockReturnValue(pauseStepId) + vi.mocked(getOrderedStepIds).mockReturnValue([ + pauseStepId, + magnetStepId, + heaterShakerStepId, + thermocyclerStepId, + moveLabwareStepId, + temperatureStepId, + mixStepId, + moveLiquidStepId, + ]) + vi.mocked(getMultiSelectItemIds).mockReturnValue(null) + vi.mocked(getMultiSelectLastSelected).mockReturnValue(null) + vi.mocked(getIsMultiSelectMode).mockReturnValue(false) + vi.mocked(getSubsteps).mockReturnValue({ + [pauseStepId]: { + substepType: 'pause', + pauseStepArgs: { + commandCreatorFnName: 'delay', + wait: 10, + name: 'pause', + description: '', + meta: { hours: 1, minutes: 10, seconds: 15 }, + }, + }, + [magnetStepId]: { + substepType: 'magnet', + engage: true, + labwareNickname: 'mockLabware', + message: 'engaging height', + }, + [heaterShakerStepId]: { + substepType: 'heaterShaker', + labwareNickname: 'mockLabware', + targetHeaterShakerTemperature: 20, + targetSpeed: 200, + latchOpen: false, + heaterShakerTimerMinutes: 5, + heaterShakerTimerSeconds: 11, + }, + [thermocyclerStepId]: { + substepType: 'thermocyclerProfile', + blockTargetTempHold: 30, + labwareNickname: 'mockLabware', + lidOpenHold: false, + lidTargetTempHold: 32, + meta: { rawProfileItems: [] }, + profileSteps: [ + { holdTime: 7, temperature: 87 }, + { holdTime: 2, temperature: 55 }, + ], + profileTargetLidTemp: 40, + profileVolume: 21, + }, + [temperatureStepId]: { + substepType: 'temperature', + temperature: 18, + labwareNickname: 'mockLabware', + moduleId: 'temperatureId', + message: 'mock message', + }, + [moveLabwareStepId]: { + substepType: 'moveLabware', + moveLabwareArgs: { + commandCreatorFnName: 'moveLabware', + name: 'move labware', + description: '', + labware: 'labwareId', + useGripper: false, + newLocation: { slotName: 'B2' }, + }, + }, + [mixStepId]: { + substepType: 'sourceDest', + multichannel: false, + commandCreatorFnName: 'mix', + parentStepId: mixStepId, + rows: [ + { + activeTips: null, + }, + ], + }, + [moveLiquidStepId]: { + substepType: 'sourceDest', + multichannel: false, + commandCreatorFnName: 'transfer', + parentStepId: moveLiquidStepId, + rows: [ + { + activeTips: { labwareId: 'tipId', wellName: 'A1' }, + substepIndex: 2, + source: { well: 'A1', preIngreds: {}, postIngreds: {} }, + dest: { well: 'A1', preIngreds: {}, postIngreds: {} }, + volume: 50, + }, + ], + }, + }) + vi.mocked(labwareIngredSelectors.getLiquidNamesById).mockReturnValue({}) + vi.mocked(getLabwareNicknamesById).mockReturnValue({}) + vi.mocked(getAdditionalEquipmentEntities).mockReturnValue({ + stagingAreaId: { name: 'stagingArea', location: 'B3', id: 'stagingArea' }, + }) + vi.mocked(getCurrentFormCanBeSaved).mockReturnValue(true) + vi.mocked(getCurrentFormHasUnsavedChanges).mockReturnValue(false) + vi.mocked(getBatchEditFormHasUnsavedChanges).mockReturnValue(false) + }) + it('renders an expanded step item for pause', () => { + render(props) + screen.getByText('2. pause') + screen.getByText('Pause for Time') + screen.getByText('1 h') + screen.getByText('10 m') + screen.getByText('15 s') + }) + it('renders an expanded step item for magnet', () => { + vi.mocked(getCollapsedSteps).mockReturnValue({ + [pauseStepId]: true, + [magnetStepId]: false, + [heaterShakerStepId]: false, + [thermocyclerStepId]: true, + [temperatureStepId]: true, + [moveLabwareStepId]: true, + [mixStepId]: true, + [moveLiquidStepId]: true, + }) + vi.mocked(getSelectedStepId).mockReturnValue(magnetStepId) + props.stepId = magnetStepId + render(props) + screen.getByText('2. magnet') + screen.getByText('Magnetic module') + screen.getByText('mockLabware') + screen.getByText('engage') + }) + it('renders an expanded step item for heater-shaker', () => { + vi.mocked(getCollapsedSteps).mockReturnValue({ + [pauseStepId]: true, + [magnetStepId]: true, + [heaterShakerStepId]: false, + [thermocyclerStepId]: true, + [temperatureStepId]: true, + [moveLabwareStepId]: true, + [mixStepId]: true, + [moveLiquidStepId]: true, + }) + vi.mocked(getSelectedStepId).mockReturnValue(heaterShakerStepId) + props.stepId = heaterShakerStepId + render(props) + screen.getByText('2. heater-shaker') + screen.getByText('Heater-Shaker module') + screen.getByText('go to') + screen.getByText('mockLabware') + screen.getByText('20 °C') + screen.getByText('Labware Latch') + screen.getByText('Closed and Locked') + screen.getByText('Shaker') + screen.getByText('200 rpm') + screen.getByText('Deactivate after') + }) + it('renders an expanded step item for thermocycler', () => { + vi.mocked(getCollapsedSteps).mockReturnValue({ + [pauseStepId]: true, + [magnetStepId]: true, + [heaterShakerStepId]: false, + [thermocyclerStepId]: false, + [temperatureStepId]: true, + [moveLabwareStepId]: true, + [mixStepId]: true, + [moveLiquidStepId]: true, + }) + vi.mocked(getSelectedStepId).mockReturnValue(thermocyclerStepId) + props.stepId = thermocyclerStepId + render(props) + screen.getByText('2. thermocycler') + screen.getByText('Thermocycler module') + screen.getByText('profile') + screen.getByText('mockLabware') + screen.getByText('cycling') + screen.getByText('Lid (closed)') + screen.getByText('40 °C') + screen.getByText('Profile steps (0+ min)') + screen.getByText('Ending hold') + }) + it('renders an expanded step item for a temperature module', () => { + vi.mocked(getCollapsedSteps).mockReturnValue({ + [pauseStepId]: true, + [magnetStepId]: true, + [heaterShakerStepId]: true, + [thermocyclerStepId]: true, + [temperatureStepId]: false, + [moveLabwareStepId]: true, + [mixStepId]: true, + [moveLiquidStepId]: true, + }) + vi.mocked(getSelectedStepId).mockReturnValue(temperatureStepId) + props.stepId = temperatureStepId + render(props) + screen.getByText('2. temperature') + screen.getByText('Temperature module in Slot C3') + screen.getByText('go to') + screen.getByText('mockLabware') + screen.getByText('18 °C') + screen.getByText('"mock message"') + }) + it('renders an expanded step for move labware', () => { + vi.mocked(getCollapsedSteps).mockReturnValue({ + [pauseStepId]: true, + [magnetStepId]: true, + [heaterShakerStepId]: true, + [thermocyclerStepId]: true, + [temperatureStepId]: true, + [moveLabwareStepId]: false, + [mixStepId]: true, + [moveLiquidStepId]: true, + }) + vi.mocked(getSelectedStepId).mockReturnValue(moveLabwareStepId) + props.stepId = moveLabwareStepId + render(props) + screen.getByText('2. move labware') + screen.getByText('Manually') + screen.getByText('labware') + screen.getByText('new location') + }) + it('renders an expanded step for mix', () => { + vi.mocked(getCollapsedSteps).mockReturnValue({ + [pauseStepId]: true, + [magnetStepId]: true, + [heaterShakerStepId]: true, + [thermocyclerStepId]: true, + [temperatureStepId]: true, + [moveLabwareStepId]: true, + [mixStepId]: false, + [moveLiquidStepId]: true, + }) + vi.mocked(getSelectedStepId).mockReturnValue(mixStepId) + props.stepId = mixStepId + render(props) + screen.getByText('2. mix') + screen.getByText('uL') + screen.getByText('μL') + }) + it('renders an expanded step for move liquid (transfer)', () => { + vi.mocked(getCollapsedSteps).mockReturnValue({ + [pauseStepId]: true, + [magnetStepId]: true, + [heaterShakerStepId]: true, + [thermocyclerStepId]: true, + [temperatureStepId]: true, + [moveLabwareStepId]: true, + [mixStepId]: true, + [moveLiquidStepId]: false, + }) + vi.mocked(getSelectedStepId).mockReturnValue(moveLiquidStepId) + props.stepId = moveLiquidStepId + render(props) + screen.getByText('2. transfer') + screen.getByText('ASPIRATE') + screen.getByText('DISPENSE') + screen.getAllByText('A1') + screen.getByText('50 μL') + }) + it('renders a timeline warning icon for move liquid', () => { + vi.mocked(getHasTimelineWarningsPerStep).mockReturnValue({ + [pauseStepId]: false, + [magnetStepId]: false, + [heaterShakerStepId]: false, + [thermocyclerStepId]: false, + [temperatureStepId]: false, + [moveLabwareStepId]: false, + [mixStepId]: false, + [moveLiquidStepId]: true, + }) + vi.mocked(getCollapsedSteps).mockReturnValue({ + [pauseStepId]: true, + [magnetStepId]: true, + [heaterShakerStepId]: true, + [thermocyclerStepId]: true, + [temperatureStepId]: true, + [moveLabwareStepId]: true, + [mixStepId]: true, + [moveLiquidStepId]: false, + }) + vi.mocked(getSelectedStepId).mockReturnValue(moveLiquidStepId) + props.stepId = moveLiquidStepId + render(props) + screen.getByTestId('TitledStepList_icon_alert-circle') + }) }) diff --git a/protocol-designer/src/dismiss/actions.ts b/protocol-designer/src/dismiss/actions.ts index 772d69f02a4..09f2c5a33c7 100644 --- a/protocol-designer/src/dismiss/actions.ts +++ b/protocol-designer/src/dismiss/actions.ts @@ -1,4 +1,5 @@ -import { StepIdType } from '../form-types' +import type { StepIdType } from '../form-types' + export interface DismissAction { type: ActionType payload: { @@ -6,6 +7,7 @@ export interface DismissAction { stepId: StepIdType } } + export type DismissFormWarning = DismissAction<'DISMISS_FORM_WARNING'> export type DismissTimelineWarning = DismissAction<'DISMISS_TIMELINE_WARNING'> export const dismissFormWarning = ( diff --git a/protocol-designer/src/form-types.ts b/protocol-designer/src/form-types.ts index 65d54b29ff9..f369844ad37 100644 --- a/protocol-designer/src/form-types.ts +++ b/protocol-designer/src/form-types.ts @@ -78,6 +78,12 @@ export type StepFieldName = string // | 'dispense_touchTip' // | 'aspirate_disposalVol_checkbox' // | 'aspirate_disposalVol_volume' +// | 'aspirate_x_position +// | 'aspirate_y_position +// | 'dispense_x_position +// | 'dispense_y_position +// | 'mix_x_position +// | 'mix_y_position // TODO Ian 2019-01-16 factor out to some constants.js ? See #2926 export type StepType = | 'moveLabware' @@ -222,6 +228,11 @@ export interface HydratedMoveLiquidFormData { blowout_location: string | null | undefined // labwareId or 'SOURCE_WELL' or 'DEST_WELL' dropTip_location: string nozzles: NozzleConfigurationStyle | null + aspirate_x_position?: number | null + aspirate_y_position?: number | null + dispense_x_position?: number | null + dispense_y_position?: number | null + blowout_z_offset?: number | null } } @@ -263,6 +274,9 @@ export interface HydratedMixFormDataLegacy { dispense_delay_seconds: number | null | undefined dropTip_location: string nozzles: NozzleConfigurationStyle | null + mix_x_position?: number | null + mix_y_position?: number | null + blowout_z_offset?: number | null } export type MagnetAction = 'engage' | 'disengage' export type HydratedMagnetFormData = AnnotationFields & { @@ -303,7 +317,7 @@ export type HydratedMoveLiquidFormDataLegacy = AnnotationFields & stepType: 'moveLiquid' } // fields used in TipPositionInput -export type TipOffsetFields = +export type TipZOffsetFields = | 'aspirate_mmFromBottom' | 'dispense_mmFromBottom' | 'mix_mmFromBottom' @@ -312,6 +326,17 @@ export type TipOffsetFields = | 'aspirate_delay_mmFromBottom' | 'dispense_delay_mmFromBottom' | 'mix_touchTip_mmFromBottom' + +export type TipYOffsetFields = + | 'aspirate_y_position' + | 'dispense_y_position' + | 'mix_y_position' + +export type TipXOffsetFields = + | 'aspirate_x_position' + | 'dispense_x_position' + | 'mix_x_position' + export type DelayCheckboxFields = | 'aspirate_delay_checkbox' | 'dispense_delay_checkbox' diff --git a/protocol-designer/src/images/modules/engage_height_animation_gen1.gif b/protocol-designer/src/images/modules/engage_height_animation_gen1.gif deleted file mode 100644 index eb0bedae573..00000000000 Binary files a/protocol-designer/src/images/modules/engage_height_animation_gen1.gif and /dev/null differ diff --git a/protocol-designer/src/images/modules/engage_height_animation_gen2.gif b/protocol-designer/src/images/modules/engage_height_animation_gen2.gif deleted file mode 100644 index 2865ccb1118..00000000000 Binary files a/protocol-designer/src/images/modules/engage_height_animation_gen2.gif and /dev/null differ diff --git a/protocol-designer/src/images/modules/engage_height_static_gen1.png b/protocol-designer/src/images/modules/engage_height_static_gen1.png deleted file mode 100644 index 1bb216a5f06..00000000000 Binary files a/protocol-designer/src/images/modules/engage_height_static_gen1.png and /dev/null differ diff --git a/protocol-designer/src/images/modules/engage_height_static_gen2.png b/protocol-designer/src/images/modules/engage_height_static_gen2.png deleted file mode 100644 index 9e9e163371b..00000000000 Binary files a/protocol-designer/src/images/modules/engage_height_static_gen2.png and /dev/null differ diff --git a/protocol-designer/src/load-file/migration/8_1_0.ts b/protocol-designer/src/load-file/migration/8_1_0.ts index c60ffb21183..755ef2a3ed2 100644 --- a/protocol-designer/src/load-file/migration/8_1_0.ts +++ b/protocol-designer/src/load-file/migration/8_1_0.ts @@ -54,7 +54,7 @@ export const migrateFile = ( form => form.stepType === 'moveLiquid' || form.stepType === 'mix' ) - const pipettingSavedStepsWithTipRack = pipettingSavedSteps.reduce( + const pipettingSavedStepsWithAdditionalFields = pipettingSavedSteps.reduce( (acc, item) => { const tipRackUri = tiprackAssignments[item.pipette] const tiprackLoadName = @@ -67,8 +67,21 @@ export const migrateFile = ( const tiprackIds = loadLabwareCommands .filter(command => command.params.loadName === tiprackLoadName) .map(command => command.params.labwareId) - - acc[item.id] = { ...item, tipRack: tiprackIds[0] } + const xyKeys = + item.stepType === 'mix' + ? { mix_x_position: 0, mix_y_position: 0 } + : { + aspirate_x_position: 0, + aspirate_y_position: 0, + dispense_x_position: 0, + dispense_y_position: 0, + } + acc[item.id] = { + ...item, + blowout_z_offset: 0, + tipRack: tiprackIds[0], + ...xyKeys, + } return acc }, {} @@ -82,7 +95,7 @@ export const migrateFile = ( ...designerApplication.data, savedStepForms: { ...designerApplication.data.savedStepForms, - ...pipettingSavedStepsWithTipRack, + ...pipettingSavedStepsWithAdditionalFields, }, pipetteTiprackAssignments: newTiprackAssignments, }, diff --git a/protocol-designer/src/localization/en/alert.json b/protocol-designer/src/localization/en/alert.json index 272e51a9363..34ac8c33a02 100644 --- a/protocol-designer/src/localization/en/alert.json +++ b/protocol-designer/src/localization/en/alert.json @@ -49,10 +49,14 @@ "title": "Missing labware", "body": "Your module has no labware on it. We recommend you add labware before proceeding." }, - "export_v8_protocol_7_1": { + "multiple_modules_without_labware": { + "title": "Missing labware", + "body": "One or more module has no labware on it. We recommend you add labware before proceeding" + }, + "export_v8_1_protocol_7_3": { "title": "Robot and app update may be required", "body1": "This protocol can only run on app and robot server version", - "body2": "7.1 or higher", + "body2": "7.3.0 or higher", "body3": ". Please ensure your robot is updated to the correct version." }, "change_magnet_module_model": { @@ -214,6 +218,11 @@ "export": "Export", "import": "Import", "module_placement": { + "SLOTS_OCCUPIED": { + "title": "Cannot place module", + "single": "Slot {{slotName}} is occupied", + "multi": "Multiple slots are occupied" + }, "SLOT_OCCUPIED": { "title": "Cannot place module", "body": "Slot {{selectedSlot}} is occupied. Navigate to the design tab and remove the labware or remove the additional item to continue." @@ -256,12 +265,12 @@ }, "unused_module": { "heading": "Unused module", - "body1": "The {{modulesDetails}} specified in your protocol are not currently used in any step. In order to run this protocol you will need to power up and connect the module to your robot.", + "body1": "The {{modulesDetails}} specified in your protocol in Slot {{slotName}} is not currently used in any step. In order to run this protocol you will need to power up and connect the module to your robot.", "body2": "If you don't intend to use the module, please consider removing it from your protocol." }, "unused_modules": { "heading": "Unused modules", - "body1": "The {{modulesDetails}} specified in your protocol are not currently used in any step. In order to run this protocol you will need to power up and connect the modules to your robot.", + "body1": "One or more modules specified in your protocol in Slot(s) {{slotName}} are not currently used in any step. In order to run this protocol you will need to power up and connect the modules to your robot.", "body2": "If you don't intend to use these modules, please consider removing them from your protocol." }, "unused_gripper": { diff --git a/protocol-designer/src/localization/en/application.json b/protocol-designer/src/localization/en/application.json index dfa905ea70c..7943a006e6f 100644 --- a/protocol-designer/src/localization/en/application.json +++ b/protocol-designer/src/localization/en/application.json @@ -15,6 +15,8 @@ "update": "UPDATE", "updated": "UPDATED", "pipettes": "Pipettes", + "magnet_height_caption": "Must be between {{low}} to {{high}}.", + "magnet_recommended": "The recommended height is {{default}}", "networking": { "generic_verification_failure": "Something went wrong with your unique link. Fill out the form below to have a new one sent to your email. Please contact the Opentrons Support team if you require further help.", "unauthorized_verification_failure": "This unique link has expired and is no longer valid, to have a new link sent to your email, fill out the form below.", @@ -23,6 +25,7 @@ "next": "Next", "no_batch_edit_shared_settings": "Batch editing of settings is only available for Transfer or Mix steps", "manually": "Manually", + "module_and_slot": "{{moduleLongName}} in Slot {{slotName}}", "stepType": { "mix": "mix", "moveLabware": "move labware", diff --git a/protocol-designer/src/localization/en/modal.json b/protocol-designer/src/localization/en/modal.json index edceb80718f..6d51439a828 100644 --- a/protocol-designer/src/localization/en/modal.json +++ b/protocol-designer/src/localization/en/modal.json @@ -42,6 +42,14 @@ "deckConfigAnd96Channel": { "body1": "Introducing the {{pd}} 8.0 with deck configuration and 96-channel pipette support!", "body2": "All protocols now require {{app}} version 7.1+ to run." + }, + "customParamsAndMultiTipAndModule": { + "body1": "Introducing {{pd}} 8.1. Starting today, you will be able to:", + "body2": "Customize blowout speed and height.", + "body3": "Adjust horizontal position within a well when aspirating, dispensing, or mixing.", + "body4": "Assign up to three types of tip racks to a single pipette.", + "body5": "Add multiple Temperature Modules to the deck (Flex only).", + "body6": "All protocols require {{app}} version 7.3.0 or later to run." } }, "labware_selection": { @@ -61,7 +69,16 @@ }, "tip_position": { "title": "Tip Positioning", + "caption": "between {{min}} and {{max}}", + "warning": "The X and/or Y position value is close to edge of the well and might collide with it", + "radio_button": { + "default": "{{defaultMmFromBottom}} mm from the bottom center (default)", + "blowout": "0 mm from the top center (default)", + "mix": "Aspirate 1mm, Dispense 0.5mm from the bottom center (default)", + "custom": "Custom" + }, "body": { + "blowout_z_offset": "Change from where in the well the robot emits blowout", "aspirate_mmFromBottom": "Change from where in the well the robot aspirates", "dispense_mmFromBottom": "Change from where in the well the robot dispenses", "mix_mmFromBottom": "Change from where in the well the robot aspirates and dispenses during the mix", @@ -71,9 +88,14 @@ "aspirate_delay_mmFromBottom": "Change from where in the well the robot delays after aspirating", "dispense_delay_mmFromBottom": "Change from where in the well the robot delays after dispensing" }, + "field_titles": { + "z_position": "Z position", + "x_position": "X position", + "y_position": "Y position" + }, "errors": { "TOO_MANY_DECIMALS": "a max of 1 decimal place is allowed", - "OUT_OF_BOUNDS": "accepted range is {{minMmFromBottom}} to {{maxMmFromBottom}}" + "OUT_OF_BOUNDS": "accepted range is {{minMm}} to {{maxMm}}" }, "field_label": "Distance from bottom of well" }, @@ -155,7 +177,7 @@ "closeUnsavedStepForm": { "title": "Unsaved step form", "body": "You have not saved this step form. If you navigate away without saving, this step will be deleted.", - "confirm_button": "delete step" + "confirm_button": "discard step" }, "closeBatchEditForm": { "title": "Unsaved changes to multiple steps", diff --git a/protocol-designer/src/localization/en/modules.json b/protocol-designer/src/localization/en/modules.json index 10a50dc0775..5cad25ca050 100644 --- a/protocol-designer/src/localization/en/modules.json +++ b/protocol-designer/src/localization/en/modules.json @@ -6,6 +6,7 @@ "wasteChute": "Waste Chute" }, "module_display_names": { + "multipleTemperatureModuleTypes": "Multiple Temperatures", "temperatureModuleType": "Temperature", "magneticModuleType": "Magnetic", "thermocyclerModuleType": "Thermocycler", diff --git a/protocol-designer/src/localization/en/shared.json b/protocol-designer/src/localization/en/shared.json index d69d55ffe32..89d916bce35 100644 --- a/protocol-designer/src/localization/en/shared.json +++ b/protocol-designer/src/localization/en/shared.json @@ -1,5 +1,6 @@ { "add": "add", + "amount": "Amount:", "confirm_reorder": "Are you sure you want to reorder these steps, it may cause errors?", "edit": "edit", "exit": "exit", diff --git a/protocol-designer/src/localization/en/tooltip.json b/protocol-designer/src/localization/en/tooltip.json index 59d2f32d1c9..8e293d8efdd 100644 --- a/protocol-designer/src/localization/en/tooltip.json +++ b/protocol-designer/src/localization/en/tooltip.json @@ -4,9 +4,11 @@ "disabled_cannot_delete_trash": "A Trash Bin or Waste Chute is required", "disabled_off_deck": "Off-deck labware cannot be modified unless on starting deck state.", "disabled_step_creation": "New steps cannot be added in Batch Edit mode.", - "disabled_no_space_additional_items": "No space for this combination of staging area slots and modules.", + "disabled_no_space_additional_items": "No space for this combination of staging area slots, trash, and modules.", "disabled_you_can_add_one_type": "Only one module of each type is allowed on the deck at a time", + "not_enough_space_for_temp": "There is not enough space on the deck to add more temperature modules", "not_in_beta": "ⓘ Coming Soon", + "missing_tiprack": "Missing a tiprack? Make sure it is added to the deck", "step_description": { "heaterShaker": "Set heat, shake, or labware latch commands for the Heater-Shaker module", @@ -26,7 +28,7 @@ "aspirate_delay_mmFromBottom": "Distance from the bottom of the well", "aspirate_flowRate": "The speed at which the pipette aspirates", "aspirate_mix_checkbox": "Pipette up and down before aspirating", - "aspirate_mmFromBottom": "Distance from the bottom of the well", + "aspirate_mmFromBottom": "Adjust tip position for aspirate", "aspirate_touchTip_checkbox": "Touch tip to each side of well after aspirating", "aspirate_touchTip_mmFromBottom": "Distance from the bottom of the well", "blowout_checkbox": "Where to dispose of remaining volume in tip", @@ -37,16 +39,17 @@ "dispense_delay_mmFromBottom": "Distance from the bottom of the well", "dispense_flowRate": "The speed at which the pipette dispenses", "dispense_mix_checkbox": "Pipette up and down after dispensing", - "dispense_mmFromBottom": "Distance from the bottom of the well", + "dispense_mmFromBottom": "Adjust tip position for dispense", "dispense_touchTip_checkbox": "Touch tip to each side of well after dispensing", "dispense_touchTip_mmFromBottom": "Distance from the bottom of the well", "disposalVolume_checkbox": "Aspirate extra volume that is disposed of after a multi-dispense is complete. We recommend a disposal volume of at least the pipette's minimum.", "heaterShakerSetTimer": "Once this counter has elapsed, the module will deactivate the heater and shaker", - "mix_mmFromBottom": "Distance from the bottom of the well", + "mix_mmFromBottom": "Adjust tip position", "mix_touchTip_checkbox": "Touch tip to each side of the well after mixing", "mix_touchTip_mmFromBottom": "Distance from the bottom of the well", "preWetTip": "Pre-wet pipette tip by aspirating and dispensing 2/3 of the tip's max volume", - "volume": "Volume to dispense in each well" + "volume": "Volume to dispense in each well", + "blowout_z_offset": "The height at which blowout occurs from the top of the well" }, "indeterminate": { "aspirate_airGap_checkbox": "Not all selected steps are using this setting", @@ -61,14 +64,22 @@ "mix_touchTip_checkbox": "Not all selected steps are using this setting", "preWetTip": "Not all selected steps are using this setting" }, + "mix": { + "disabled": { + "mix_mmFromBottom": "Tip position adjustment is not supported", + "blowout_z_offset": "Blowout location and destination labware must first be selected" + } + }, "moveLiquid": { "disabled": { "$generic": "Incompatible with current path", "aspirate_touchTip_checkbox": "Touch tip is not supported", "blowout_checkbox": "Redundant with disposal volume", "dispense_mix_checkbox": "Unable to mix in a waste chute or trash bin", + "aspirate_mmFromBottom": "Tip position adjustment is not supported", "dispense_mmFromBottom": "Tip position adjustment is not supported", - "dispense_touchTip_checkbox": "Touch tip is not supported" + "dispense_touchTip_checkbox": "Touch tip is not supported", + "blowout_z_offset": "Blowout location, source, and destination labware must first be selected" } }, "moveLabware": { diff --git a/protocol-designer/src/modules/__tests__/moduleData.test.tsx b/protocol-designer/src/modules/__tests__/moduleData.test.tsx new file mode 100644 index 00000000000..9d27732bf56 --- /dev/null +++ b/protocol-designer/src/modules/__tests__/moduleData.test.tsx @@ -0,0 +1,88 @@ +import { describe, it, expect } from 'vitest' +import { getNextAvailableModuleSlot } from '../moduleData' +import type { InitialDeckSetup } from '../../step-forms' + +describe('getNextAvailableModuleSlot', () => { + it('renders slot D1 when no slots are occupied', () => { + const mockInitialDeckSetup: InitialDeckSetup = { + modules: {}, + labware: {}, + pipettes: {}, + additionalEquipmentOnDeck: {}, + } + const result = getNextAvailableModuleSlot(mockInitialDeckSetup) + expect(result).toBe('D1') + }) + it('renders slot C1 when other slots are occupied', () => { + const mockInitialDeckSetup: InitialDeckSetup = { + modules: {}, + labware: {}, + pipettes: {}, + additionalEquipmentOnDeck: { + wasteChuteId: { + name: 'wasteChute', + id: 'wasteChuteId', + location: 'D3', + }, + trashBinId: { + name: 'trashBin', + id: 'trashBinId', + location: 'D1', + }, + }, + } + const result = getNextAvailableModuleSlot(mockInitialDeckSetup) + expect(result).toBe('C1') + }) + it('renders undefined when all slots are occupied', () => { + const mockInitialDeckSetup: InitialDeckSetup = { + modules: { + thermocycler: { + model: 'thermocyclerModuleV2', + id: 'thermocycler', + type: 'thermocyclerModuleType', + slot: 'B1', + moduleState: {} as any, + }, + temperature: { + model: 'temperatureModuleV2', + id: 'temperature', + type: 'temperatureModuleType', + slot: 'C1', + moduleState: {} as any, + }, + }, + labware: {}, + pipettes: {}, + additionalEquipmentOnDeck: { + wasteChuteId: { + name: 'wasteChute', + id: 'wasteChuteId', + location: 'D3', + }, + trashBinId: { + name: 'trashBin', + id: 'trashBinId', + location: 'D1', + }, + stagingArea1: { + name: 'stagingArea', + id: 'stagingArea1', + location: 'A3', + }, + stagingArea2: { + name: 'stagingArea', + id: 'stagingArea2', + location: 'B3', + }, + stagingArea3: { + name: 'stagingArea', + id: 'stagingArea3', + location: 'C3', + }, + }, + } + const result = getNextAvailableModuleSlot(mockInitialDeckSetup) + expect(result).toBe(undefined) + }) +}) diff --git a/protocol-designer/src/modules/index.ts b/protocol-designer/src/modules/index.ts index 82b41275ed4..8ca029f4e14 100644 --- a/protocol-designer/src/modules/index.ts +++ b/protocol-designer/src/modules/index.ts @@ -1 +1,2 @@ export * from './moduleData' +export * from './thunks' diff --git a/protocol-designer/src/modules/moduleData.ts b/protocol-designer/src/modules/moduleData.ts index a2d05f33bc8..240a2e11eae 100644 --- a/protocol-designer/src/modules/moduleData.ts +++ b/protocol-designer/src/modules/moduleData.ts @@ -1,14 +1,27 @@ -import { SPAN7_8_10_11_SLOT } from '../constants' +import { COLUMN_4_SLOTS } from '@opentrons/step-generation' import { MAGNETIC_MODULE_TYPE, TEMPERATURE_MODULE_TYPE, THERMOCYCLER_MODULE_TYPE, HEATERSHAKER_MODULE_TYPE, - ModuleType, MAGNETIC_BLOCK_TYPE, + MOVABLE_TRASH_ADDRESSABLE_AREAS, + WASTE_CHUTE_ADDRESSABLE_AREAS, + FIXED_TRASH_ID, +} from '@opentrons/shared-data' +import { SPAN7_8_10_11_SLOT } from '../constants' +import { getStagingAreaAddressableAreas } from '../utils' +import { getSlotIsEmpty } from '../step-forms' +import type { + ModuleType, RobotType, + CutoutId, + AddressableAreaName, } from '@opentrons/shared-data' -import { DropdownOption } from '@opentrons/components' +import type { DropdownOption } from '@opentrons/components' +import type { InitialDeckSetup } from '../step-forms' +import type { DeckSlot } from '../types' + export const SUPPORTED_MODULE_TYPES: ModuleType[] = [ HEATERSHAKER_MODULE_TYPE, MAGNETIC_BLOCK_TYPE, @@ -270,3 +283,32 @@ export function getAllModuleSlotsByType( } return slot } + +const FLEX_MODULE_SLOTS = ['D1', 'D3', 'C1', 'C3', 'B1', 'B3', 'A1', 'A3'] + +export function getNextAvailableModuleSlot( + initialDeckSetup: InitialDeckSetup +): DeckSlot | undefined { + return FLEX_MODULE_SLOTS.find(slot => { + const cutoutIds = Object.values(initialDeckSetup.additionalEquipmentOnDeck) + .filter(ae => ae.name === 'stagingArea') + .map(ae => ae.location as CutoutId) + const stagingAreaAddressableAreaNames = getStagingAreaAddressableAreas( + cutoutIds + ) + const addressableAreaName = stagingAreaAddressableAreaNames.find( + aa => aa === slot + ) + let isSlotEmpty: boolean = getSlotIsEmpty(initialDeckSetup, slot, true) + if (addressableAreaName == null && COLUMN_4_SLOTS.includes(slot)) { + isSlotEmpty = false + } else if ( + MOVABLE_TRASH_ADDRESSABLE_AREAS.includes(slot as AddressableAreaName) || + WASTE_CHUTE_ADDRESSABLE_AREAS.includes(slot as AddressableAreaName) || + slot === FIXED_TRASH_ID + ) { + isSlotEmpty = false + } + return isSlotEmpty + }) +} diff --git a/protocol-designer/src/modules/thunks.ts b/protocol-designer/src/modules/thunks.ts new file mode 100644 index 00000000000..655eeb07a7c --- /dev/null +++ b/protocol-designer/src/modules/thunks.ts @@ -0,0 +1,33 @@ +import { selectors as stepFormSelectors } from '../step-forms' +import { uuid } from '../utils' +import { getNextAvailableModuleSlot } from './moduleData' +import type { ModuleModel, ModuleType } from '@opentrons/shared-data' +import type { CreateModuleAction } from '../step-forms/actions' +import type { ThunkAction } from '../types' + +interface CreateModuleWithNoSloArgs { + type: ModuleType + model: ModuleModel +} +export const createModuleWithNoSlot: ( + args: CreateModuleWithNoSloArgs +) => ThunkAction = args => (dispatch, getState) => { + const { model, type } = args + const state = getState() + const initialDeckSetup = stepFormSelectors.getInitialDeckSetup(state) + const slot = getNextAvailableModuleSlot(initialDeckSetup) + + if (slot == null) { + console.assert(slot, 'expected to find available slot but could not') + } + + dispatch({ + type: 'CREATE_MODULE', + payload: { + model, + type, + slot: slot ?? '', + id: `${uuid()}:${type}}`, + }, + }) +} diff --git a/protocol-designer/src/step-forms/reducers/index.ts b/protocol-designer/src/step-forms/reducers/index.ts index a19cd27eeac..5d42a31b086 100644 --- a/protocol-designer/src/step-forms/reducers/index.ts +++ b/protocol-designer/src/step-forms/reducers/index.ts @@ -67,8 +67,10 @@ import { createPresavedStepForm, getDeckItemIdInSlot, getIdsInRange, + getUnoccupiedSlotForMoveableTrash, } from '../utils' -import { + +import type { CreateModuleAction, CreatePipettesAction, DeleteModuleAction, @@ -1379,6 +1381,12 @@ export const additionalEquipmentInvariantProperties = handleActions { const stagingAreaId = `${uuid()}:stagingArea` const cutoutId = getCutoutIdByAddressableArea( @@ -1531,11 +1539,11 @@ export const additionalEquipmentInvariantProperties = handleActions getLabwareDisplayName(def) ), @@ -455,7 +454,10 @@ export const getModulesForEditModulesCard: Selector< reduce( initialDeckSetup.modules, (acc, moduleOnDeck: ModuleOnDeck, id) => { - acc[moduleOnDeck.type] = moduleOnDeck + if (!acc[moduleOnDeck.type]) { + acc[moduleOnDeck.type] = [] + } + acc[moduleOnDeck.type]?.push(moduleOnDeck) return acc }, { diff --git a/protocol-designer/src/step-forms/test/createPresavedStepForm.test.ts b/protocol-designer/src/step-forms/test/createPresavedStepForm.test.ts index 526c4c784b1..11b93d8dbc6 100644 --- a/protocol-designer/src/step-forms/test/createPresavedStepForm.test.ts +++ b/protocol-designer/src/step-forms/test/createPresavedStepForm.test.ts @@ -187,6 +187,11 @@ describe('createPresavedStepForm', () => { stepDetails: '', stepName: 'transfer', volume: null, + aspirate_x_position: 0, + aspirate_y_position: 0, + dispense_x_position: 0, + dispense_y_position: 0, + blowout_z_offset: 0, }) }) describe('mix step', () => { @@ -210,6 +215,9 @@ describe('createPresavedStepForm', () => { mix_wellOrder_first: 't2b', mix_wellOrder_second: 'l2r', blowout_checkbox: false, + mix_x_position: 0, + mix_y_position: 0, + blowout_z_offset: 0, blowout_location: null, changeTip: 'always', stepDetails: '', diff --git a/protocol-designer/src/step-forms/test/utils.test.ts b/protocol-designer/src/step-forms/test/utils.test.ts index f848fe1241d..d7dbe1960b5 100644 --- a/protocol-designer/src/step-forms/test/utils.test.ts +++ b/protocol-designer/src/step-forms/test/utils.test.ts @@ -1,5 +1,7 @@ import { describe, it, expect } from 'vitest' -import { getIdsInRange } from '../utils' +import { getIdsInRange, getUnoccupiedSlotForMoveableTrash } from '../utils' +import type { AddressableAreaName, CreateCommand } from '@opentrons/shared-data' + describe('getIdsInRange', () => { it('gets id in array of length 1', () => { expect(getIdsInRange(['X'], 'X', 'X')).toEqual(['X']) @@ -29,3 +31,126 @@ describe('getIdsInRange', () => { expect(getIdsInRange(orderedIds, 'T', 'T')).toEqual(['T']) }) }) +describe('getUnoccupiedSlotForMoveableTrash', () => { + it('returns slot C1 when all other slots are occupied by modules, labware, moveLabware, and staging areas', () => { + const mockPDFile: any = { + commands: [ + { + key: '7353ae60-c85e-45c4-8d69-59ff3a97debd', + commandType: 'loadModule', + params: { + model: 'thermocyclerModuleV2', + location: { slotName: 'B1' }, + moduleId: + '771f390f-01a9-4615-9c4e-4dbfc95844b5:thermocyclerModuleType', + }, + }, + { + key: '82e5d08f-ceae-4eb8-8600-b61a973d47d9', + commandType: 'loadModule', + params: { + model: 'heaterShakerModuleV1', + location: { slotName: 'D1' }, + moduleId: + 'b9df03af-3844-4ae8-a1cf-cae61a6b4992:heaterShakerModuleType', + }, + }, + { + key: '49bc2a29-a7d2-42a6-8610-e07a9ad166df', + commandType: 'loadModule', + params: { + model: 'temperatureModuleV2', + location: { slotName: 'D3' }, + moduleId: + '52bea856-eea6-473c-80df-b316f3559692:temperatureModuleType', + }, + }, + { + key: '864fadd7-f2c1-400a-b2ef-24d0c887a3c8', + commandType: 'loadLabware', + params: { + displayName: 'Opentrons Flex 96 Tip Rack 50 µL', + labwareId: + '88881828-037c-4445-ba57-121164f4a53a:opentrons/opentrons_flex_96_tiprack_50ul/1', + loadName: 'opentrons_flex_96_tiprack_50ul', + namespace: 'opentrons', + version: 1, + location: { slotName: 'C2' }, + }, + }, + { + key: '79994418-d664-4884-9441-4b0fa62bd143', + commandType: 'loadLabware', + params: { + displayName: 'Bio-Rad 96 Well Plate 200 µL PCR', + labwareId: + '733c04a8-ae8c-449f-a1f9-ca3783fdda58:opentrons/biorad_96_wellplate_200ul_pcr/2', + loadName: 'biorad_96_wellplate_200ul_pcr', + namespace: 'opentrons', + version: 2, + location: { addressableAreaName: 'A4' }, + }, + }, + { + key: 'b2170a2c-d202-4129-9cd7-ffa4e35d57bb', + commandType: 'loadLabware', + params: { + displayName: 'Corning 24 Well Plate 3.4 mL Flat', + labwareId: + '32e97c67-866e-4153-bcb7-2b86b1d3f1fe:opentrons/corning_24_wellplate_3.4ml_flat/2', + loadName: 'corning_24_wellplate_3.4ml_flat', + namespace: 'opentrons', + version: 2, + location: { slotName: 'B3' }, + }, + }, + { + key: 'fb1807fe-ca16-4f75-b44d-803d704c7d98', + commandType: 'loadLabware', + params: { + displayName: 'Opentrons Flex 96 Tip Rack 50 µL', + labwareId: + '11fdsa8b1-bf4b-4a6c-80cb-b8e5bdfe309b:opentrons/opentrons_flex_96_tiprack_50ul/1', + loadName: 'opentrons_flex_96_tiprack_50ul', + namespace: 'opentrons', + version: 1, + location: { + labwareId: + '32e97c67-866e-4153-bcb7-2b86b1d3f1fe:opentrons/corning_24_wellplate_3.4ml_flat/2', + }, + }, + }, + { + commandType: 'moveLabware', + key: '1395243a-958f-4305-9687-52cdaf39f2b6', + params: { + labwareId: + '733c04a8-ae8c-449f-a1f9-ca3783fdda58:opentrons/biorad_96_wellplate_200ul_pcr/2', + strategy: 'usingGripper', + newLocation: { slotName: 'C1' }, + }, + }, + { + commandType: 'moveLabware', + key: '4e39e7ec-4ada-4e3c-8369-1ff7421061a9', + params: { + labwareId: + '32e97c67-866e-4153-bcb7-2b86b1d3f1fe:opentrons/corning_24_wellplate_3.4ml_flat/2', + strategy: 'usingGripper', + newLocation: { addressableAreaName: 'A4' }, + }, + }, + ] as CreateCommand[], + } + const mockStagingAreaSlotNames: AddressableAreaName[] = ['A4', 'B4'] + const mockHasWasteChuteCommands = false + + expect( + getUnoccupiedSlotForMoveableTrash( + mockPDFile, + mockHasWasteChuteCommands, + mockStagingAreaSlotNames + ) + ).toStrictEqual('C3') + }) +}) diff --git a/protocol-designer/src/step-forms/types.ts b/protocol-designer/src/step-forms/types.ts index 81422cc985b..24dee9b0c46 100644 --- a/protocol-designer/src/step-forms/types.ts +++ b/protocol-designer/src/step-forms/types.ts @@ -72,7 +72,7 @@ export interface ModuleTemporalProperties { } export type ModuleOnDeck = ModuleEntity & ModuleTemporalProperties export type ModulesForEditModulesCard = Partial< - Record + Record > // =========== LABWARE ======== export type NormalizedLabwareById = Record< diff --git a/protocol-designer/src/step-forms/utils/getProfileItemsHaveErrors.ts b/protocol-designer/src/step-forms/utils/getProfileItemsHaveErrors.ts index 68e2f151172..6b5fc39fbad 100644 --- a/protocol-designer/src/step-forms/utils/getProfileItemsHaveErrors.ts +++ b/protocol-designer/src/step-forms/utils/getProfileItemsHaveErrors.ts @@ -1,5 +1,6 @@ import { getProfileFieldErrors } from '../../steplist/fieldLevel' -import { ProfileItem, PROFILE_CYCLE } from '../../form-types' +import { PROFILE_CYCLE } from '../../form-types' +import type { ProfileItem } from '../../form-types' const _someFieldsHaveErrors = (item: ProfileItem): boolean => { for (const fieldName in item) { diff --git a/protocol-designer/src/step-forms/utils/index.ts b/protocol-designer/src/step-forms/utils/index.ts index 73596b481c6..d9b2d108132 100644 --- a/protocol-designer/src/step-forms/utils/index.ts +++ b/protocol-designer/src/step-forms/utils/index.ts @@ -6,20 +6,23 @@ import { getPipetteSpecsV2, GEN_ONE_MULTI_PIPETTES, THERMOCYCLER_MODULE_TYPE, + THERMOCYCLER_MODULE_V2, + WASTE_CHUTE_CUTOUT, + FLEX_ROBOT_TYPE, } from '@opentrons/shared-data' import { SPAN7_8_10_11_SLOT, TC_SPAN_SLOTS } from '../../constants' import { hydrateField } from '../../steplist/fieldLevel' import { LabwareDefByDefURI } from '../../labware-defs' -import type { DeckSlotId, ModuleType } from '@opentrons/shared-data' +import { getCutoutIdByAddressableArea } from '../../utils' import type { - AdditionalEquipmentOnDeck, - InitialDeckSetup, - ModuleOnDeck, - FormPipettesByMount, - FormPipette, - LabwareOnDeck as LabwareOnDeckType, -} from '../types' -import type { DeckSlot } from '../../types' + AddressableAreaName, + CutoutId, + DeckSlotId, + LoadLabwareCreateCommand, + LoadModuleCreateCommand, + ModuleType, + MoveLabwareCreateCommand, +} from '@opentrons/shared-data' import type { NormalizedPipette, NormalizedPipetteById, @@ -28,9 +31,54 @@ import type { InvariantContext, ModuleEntity, } from '@opentrons/step-generation' +import type { DeckSlot } from '../../types' import type { FormData } from '../../form-types' +import type { PDProtocolFile } from '../../file-types' +import type { + AdditionalEquipmentOnDeck, + InitialDeckSetup, + ModuleOnDeck, + FormPipettesByMount, + FormPipette, + LabwareOnDeck as LabwareOnDeckType, +} from '../types' export { createPresavedStepForm } from './createPresavedStepForm' +const MOVABLE_TRASH_CUTOUTS = [ + { + value: 'cutoutA3', + slot: 'A3', + }, + { + value: 'cutoutA1', + slot: 'A1', + }, + { + value: 'cutoutB1', + slot: 'B1', + }, + { + value: 'cutoutB3', + slot: 'B3', + }, + { + value: 'cutoutC1', + slot: 'C1', + }, + { + value: 'cutoutC3', + slot: 'C3', + }, + { + value: 'cutoutD1', + slot: 'D1', + }, + { + value: 'cutoutD3', + slot: 'D3', + }, +] + const slotToCutoutOt2Map: { [key: string]: string } = { '1': 'cutout1', '2': 'cutout2', @@ -248,3 +296,82 @@ export function getHydratedForm( // @ts-expect-error(sa, 2021-6-14):type this properly in #3161 return hydratedForm } + +export const getUnoccupiedSlotForMoveableTrash = ( + file: PDProtocolFile, + hasWasteChuteCommands: boolean, + stagingAreaSlotNames: AddressableAreaName[] +): string => { + const wasteChuteSlot = hasWasteChuteCommands ? [WASTE_CHUTE_CUTOUT] : [] + const stagingAreaCutoutIds = stagingAreaSlotNames.map(slotName => + getCutoutIdByAddressableArea( + slotName, + 'stagingAreaRightSlot', + FLEX_ROBOT_TYPE + ) + ) + const allLoadLabwareSlotNames = Object.values(file.commands) + .filter( + (command): command is LoadLabwareCreateCommand => + command.commandType === 'loadLabware' + ) + .reduce((acc: string[], command) => { + const location = command.params.location + if ( + location !== 'offDeck' && + location !== null && + 'slotName' in location + ) { + return [...acc, location.slotName] + } + return acc + }, []) + + const allLoadModuleSlotNames = Object.values(file.commands) + .filter( + (command): command is LoadModuleCreateCommand => + command.commandType === 'loadModule' + ) + .flatMap(command => { + // special-casing Thermocycler + if (command.params.model === THERMOCYCLER_MODULE_V2) { + return ['A1', command.params.location.slotName] + } else { + return command.params.location.slotName + } + }) + + const allMoveLabwareLocations = Object.values(file.commands) + .filter( + (command): command is MoveLabwareCreateCommand => + command.commandType === 'moveLabware' + ) + .reduce((acc: string[], command) => { + const newLocation = command.params.newLocation + if ( + newLocation !== 'offDeck' && + newLocation !== null && + 'slotName' in newLocation + ) { + return [...acc, newLocation.slotName] + } + return acc + }, []) + + const unoccupiedSlot = MOVABLE_TRASH_CUTOUTS.find( + cutout => + !allLoadLabwareSlotNames.includes(cutout.slot) && + !allLoadModuleSlotNames.includes(cutout.slot) && + !allMoveLabwareLocations.includes(cutout.slot) && + !wasteChuteSlot.includes(cutout.value as typeof WASTE_CHUTE_CUTOUT) && + !stagingAreaCutoutIds.includes(cutout.value as CutoutId) + ) + if (unoccupiedSlot == null) { + console.error( + 'Expected to find an unoccupied slot for auto-generating a trash bin but could not' + ) + return '' + } + + return unoccupiedSlot.slot +} diff --git a/protocol-designer/src/steplist/formLevel/getDefaultsForStepType.ts b/protocol-designer/src/steplist/formLevel/getDefaultsForStepType.ts index 66656441dd1..b90eb6f028e 100644 --- a/protocol-designer/src/steplist/formLevel/getDefaultsForStepType.ts +++ b/protocol-designer/src/steplist/formLevel/getDefaultsForStepType.ts @@ -4,6 +4,7 @@ import { DEFAULT_WELL_ORDER_FIRST_OPTION, DEFAULT_WELL_ORDER_SECOND_OPTION, DEFAULT_DELAY_SECONDS, + DEFAULT_MM_BLOWOUT_OFFSET_FROM_TOP, } from '../../constants' import { StepType, StepFieldName } from '../../form-types' export function getDefaultsForStepType( @@ -37,6 +38,9 @@ export function getDefaultsForStepType( dropTip_location: null, nozzles: null, tipRack: null, + mix_x_position: 0, + mix_y_position: 0, + blowout_z_offset: DEFAULT_MM_BLOWOUT_OFFSET_FROM_TOP, } case 'moveLiquid': @@ -86,6 +90,11 @@ export function getDefaultsForStepType( dispense_delay_mmFromBottom: null, dropTip_location: null, nozzles: null, + dispense_x_position: 0, + dispense_y_position: 0, + aspirate_x_position: 0, + aspirate_y_position: 0, + blowout_z_offset: DEFAULT_MM_BLOWOUT_OFFSET_FROM_TOP, } case 'moveLabware': diff --git a/protocol-designer/src/steplist/formLevel/getDisabledFields/getDisabledFieldsMixForm.ts b/protocol-designer/src/steplist/formLevel/getDisabledFields/getDisabledFieldsMixForm.ts index 16765d26436..d480b455666 100644 --- a/protocol-designer/src/steplist/formLevel/getDisabledFields/getDisabledFieldsMixForm.ts +++ b/protocol-designer/src/steplist/formLevel/getDisabledFields/getDisabledFieldsMixForm.ts @@ -1,3 +1,4 @@ +import { DEST_WELL_BLOWOUT_DESTINATION } from '@opentrons/step-generation' import type { HydratedFormdata } from '../../../form-types' // NOTE: expects that '_checkbox' fields are implemented so that // when checkbox is disabled, its dependent fields are hidden @@ -21,5 +22,14 @@ export function getDisabledFieldsMixForm( disabled.add('mix_touchTip_checkbox') } + if ( + !hydratedForm.blowout_location || + hydratedForm.blowout_location.includes('wasteChute') || + hydratedForm.blowout_location.includes('trashBin') || + (hydratedForm.blowout_location === DEST_WELL_BLOWOUT_DESTINATION && + !hydratedForm.labware) + ) { + disabled.add('blowout_z_offset') + } return disabled } diff --git a/protocol-designer/src/steplist/formLevel/getDisabledFields/getDisabledFieldsMoveLiquidForm.ts b/protocol-designer/src/steplist/formLevel/getDisabledFields/getDisabledFieldsMoveLiquidForm.ts index ec514c81cce..5ca7db1395f 100644 --- a/protocol-designer/src/steplist/formLevel/getDisabledFields/getDisabledFieldsMoveLiquidForm.ts +++ b/protocol-designer/src/steplist/formLevel/getDisabledFields/getDisabledFieldsMoveLiquidForm.ts @@ -1,3 +1,7 @@ +import { + DEST_WELL_BLOWOUT_DESTINATION, + SOURCE_WELL_BLOWOUT_DESTINATION, +} from '@opentrons/step-generation' import type { HydratedFormdata } from '../../../form-types' // NOTE: expects that '_checkbox' fields are implemented so that // when checkbox is disabled, its dependent fields are hidden @@ -37,5 +41,17 @@ export function getDisabledFieldsMoveLiquidForm( disabled.add(prefix + '_wells') } }) + + if ( + !hydratedForm.blowout_location || + hydratedForm.blowout_location.includes('wasteChute') || + hydratedForm.blowout_location.includes('trashBin') || + (hydratedForm.blowout_location === SOURCE_WELL_BLOWOUT_DESTINATION && + !hydratedForm.aspirate_labware) || + (hydratedForm.blowout_location === DEST_WELL_BLOWOUT_DESTINATION && + !hydratedForm.dispense_labware) + ) { + disabled.add('blowout_z_offset') + } return disabled } diff --git a/protocol-designer/src/steplist/formLevel/index.ts b/protocol-designer/src/steplist/formLevel/index.ts index 669e048ab4e..64c4fbff39b 100644 --- a/protocol-designer/src/steplist/formLevel/index.ts +++ b/protocol-designer/src/steplist/formLevel/index.ts @@ -29,6 +29,9 @@ import { minDisposalVolume, minAspirateAirGapVolume, minDispenseAirGapVolume, + aspirateTipPositionInTube, + dispenseTipPositionInTube, + mixTipPositionInTube, } from './warnings' import { HydratedFormdata, StepType } from '../../form-types' @@ -52,7 +55,10 @@ interface FormHelpers { const stepFormHelperMap: Partial> = { mix: { getErrors: composeErrors(incompatibleLabware, volumeTooHigh), - getWarnings: composeWarnings(belowPipetteMinimumVolume), + getWarnings: composeWarnings( + belowPipetteMinimumVolume, + mixTipPositionInTube + ), }, pause: { getErrors: composeErrors(pauseForTimeOrUntilTold), @@ -68,7 +74,9 @@ const stepFormHelperMap: Partial> = { maxDispenseWellVolume, minDisposalVolume, minAspirateAirGapVolume, - minDispenseAirGapVolume + minDispenseAirGapVolume, + aspirateTipPositionInTube, + dispenseTipPositionInTube ), }, magnet: { diff --git a/protocol-designer/src/steplist/formLevel/moveLabwareFormErrors.ts b/protocol-designer/src/steplist/formLevel/moveLabwareFormErrors.ts index 28828c7524d..b9ee871772d 100644 --- a/protocol-designer/src/steplist/formLevel/moveLabwareFormErrors.ts +++ b/protocol-designer/src/steplist/formLevel/moveLabwareFormErrors.ts @@ -1,8 +1,9 @@ -import { LabwareLocation } from '@opentrons/shared-data' +import { getLabwareDefIsStandard } from '@opentrons/shared-data' import { COMPATIBLE_LABWARE_ALLOWLIST_BY_MODULE_TYPE, COMPATIBLE_LABWARE_ALLOWLIST_FOR_ADAPTER, } from '../../utils/labwareModuleCompatibility' +import type { LabwareLocation } from '@opentrons/shared-data' import type { InvariantContext, LabwareEntity, @@ -11,15 +12,18 @@ import type { ProfileFormError } from './profileErrors' type HydratedFormData = any -// TODO(Jr, 1/16/24): look into the use case of this util since the i18n strings -// previously listed in this util were not found in any json. const getMoveLabwareError = ( labware: LabwareEntity, newLocation: LabwareLocation, invariantContext: InvariantContext ): string | null => { let errorString: string | null = null - if (labware == null || newLocation == null || newLocation === 'offDeck') + if ( + labware == null || + newLocation == null || + newLocation === 'offDeck' || + !getLabwareDefIsStandard(labware?.def) + ) return null const selectedLabwareDefUri = labware?.labwareDefURI if ('moduleId' in newLocation) { diff --git a/protocol-designer/src/steplist/formLevel/stepFormToArgs/mixFormToArgs.ts b/protocol-designer/src/steplist/formLevel/stepFormToArgs/mixFormToArgs.ts index 741355f95a0..d28f6dc42df 100644 --- a/protocol-designer/src/steplist/formLevel/stepFormToArgs/mixFormToArgs.ts +++ b/protocol-designer/src/steplist/formLevel/stepFormToArgs/mixFormToArgs.ts @@ -15,7 +15,15 @@ type MixStepArgs = MixArgs export const mixFormToArgs = ( hydratedFormData: HydratedMixFormDataLegacy ): MixStepArgs => { - const { labware, pipette, dropTip_location, nozzles } = hydratedFormData + const { + labware, + pipette, + dropTip_location, + nozzles, + mix_x_position, + mix_y_position, + blowout_z_offset, + } = hydratedFormData const matchingTipLiquidSpecs = getMatchingTipLiquidSpecs( pipette, hydratedFormData.volume, @@ -66,7 +74,7 @@ export const mixFormToArgs = ( matchingTipLiquidSpecs?.defaultBlowOutFlowRate.default const blowoutOffsetFromTopMm = blowoutLocation - ? DEFAULT_MM_BLOWOUT_OFFSET_FROM_TOP + ? blowout_z_offset ?? DEFAULT_MM_BLOWOUT_OFFSET_FROM_TOP : 0 // Delay settings const aspirateDelaySeconds = getMixDelayData( @@ -105,5 +113,9 @@ export const mixFormToArgs = ( dispenseDelaySeconds, dropTipLocation: dropTip_location, nozzles, + aspirateXOffset: mix_x_position ?? 0, + dispenseXOffset: mix_x_position ?? 0, + aspirateYOffset: mix_y_position ?? 0, + dispenseYOffset: mix_y_position ?? 0, } } diff --git a/protocol-designer/src/steplist/formLevel/stepFormToArgs/moveLiquidFormToArgs.ts b/protocol-designer/src/steplist/formLevel/stepFormToArgs/moveLiquidFormToArgs.ts index 7d330f54dbf..05910f13332 100644 --- a/protocol-designer/src/steplist/formLevel/stepFormToArgs/moveLiquidFormToArgs.ts +++ b/protocol-designer/src/steplist/formLevel/stepFormToArgs/moveLiquidFormToArgs.ts @@ -78,6 +78,11 @@ export const moveLiquidFormToArgs = ( path, tipRack, nozzles, + aspirate_x_position, + dispense_x_position, + aspirate_y_position, + dispense_y_position, + blowout_z_offset, } = fields let sourceWells = getOrderedWells( fields.aspirate_wells, @@ -161,7 +166,10 @@ export const moveLiquidFormToArgs = ( ) const blowoutLocation = (fields.blowout_checkbox && fields.blowout_location) || null - const blowoutOffsetFromTopMm = DEFAULT_MM_BLOWOUT_OFFSET_FROM_TOP + const blowoutOffsetFromTopMm = + blowoutLocation != null + ? blowout_z_offset ?? DEFAULT_MM_BLOWOUT_OFFSET_FROM_TOP + : DEFAULT_MM_BLOWOUT_OFFSET_FROM_TOP const aspirateAirGapVolume = getAirGapData( fields, 'aspirate_airGap_checkbox', @@ -211,6 +219,10 @@ export const moveLiquidFormToArgs = ( name: hydratedFormData.stepName, dropTipLocation, nozzles, + aspirateXOffset: aspirate_x_position ?? 0, + aspirateYOffset: aspirate_y_position ?? 0, + dispenseXOffset: dispense_x_position ?? 0, + dispenseYOffset: dispense_y_position ?? 0, } console.assert( sourceWellsUnordered.length > 0, diff --git a/protocol-designer/src/steplist/formLevel/test/getDefaultsForStepType.test.ts b/protocol-designer/src/steplist/formLevel/test/getDefaultsForStepType.test.ts index 84803e31a74..081d7809566 100644 --- a/protocol-designer/src/steplist/formLevel/test/getDefaultsForStepType.test.ts +++ b/protocol-designer/src/steplist/formLevel/test/getDefaultsForStepType.test.ts @@ -59,13 +59,17 @@ describe('getDefaultsForStepType', () => { aspirate_delay_checkbox: false, aspirate_delay_mmFromBottom: null, aspirate_delay_seconds: `${DEFAULT_DELAY_SECONDS}`, - + aspirate_x_position: 0, + aspirate_y_position: 0, dispense_airGap_checkbox: false, dispense_airGap_volume: null, dispense_delay_checkbox: false, dispense_delay_seconds: `${DEFAULT_DELAY_SECONDS}`, dispense_delay_mmFromBottom: null, tipRack: null, + dispense_x_position: 0, + dispense_y_position: 0, + blowout_z_offset: 0, }) }) }) @@ -94,6 +98,9 @@ describe('getDefaultsForStepType', () => { aspirate_flowRate: null, dispense_flowRate: null, tipRack: null, + mix_x_position: 0, + mix_y_position: 0, + blowout_z_offset: 0, }) }) }) diff --git a/protocol-designer/src/steplist/formLevel/test/warnings.test.ts b/protocol-designer/src/steplist/formLevel/test/warnings.test.ts index d441007b206..16b1c5030f3 100644 --- a/protocol-designer/src/steplist/formLevel/test/warnings.test.ts +++ b/protocol-designer/src/steplist/formLevel/test/warnings.test.ts @@ -1,11 +1,16 @@ import { describe, it, beforeEach, expect } from 'vitest' -import { fixture_24_tuberack } from '@opentrons/shared-data/labware/fixtures/2' +import { fixture24Tuberack, fixture96Plate } from '@opentrons/shared-data' import { _minAirGapVolume, belowPipetteMinimumVolume, minDisposalVolume, maxDispenseWellVolume, + aspirateTipPositionInTube, + dispenseTipPositionInTube, + mixTipPositionInTube, } from '../warnings' +import type { LabwareEntity } from '@opentrons/step-generation' +import type { LabwareDefinition2 } from '@opentrons/shared-data' type CheckboxFields = 'aspirate_airGap_checkbox' | 'dispense_airGap_checkbox' type VolumeFields = 'aspirate_airGap_volume' | 'dispense_airGap_volume' @@ -16,11 +21,15 @@ describe('Min air gap volume', () => { const volumeField = `${aspDisp}_airGap_volume` as VolumeFields describe(`${aspOrDisp} -> air gap`, () => { - let pipette: { spec: { minVolume: number } } + let pipette: { spec: { liquids: { default: { minVolume: number } } } } beforeEach(() => { pipette = { spec: { - minVolume: 100, + liquids: { + default: { + minVolume: 100, + }, + }, }, } }) @@ -82,12 +91,18 @@ describe('Min air gap volume', () => { }) }) describe('Below pipette minimum volume', () => { - let fieldsWithPipette: { pipette: { spec: { minVolume: number } } } + let fieldsWithPipette: { + pipette: { spec: { liquids: { default: { minVolume: number } } } } + } beforeEach(() => { fieldsWithPipette = { pipette: { spec: { - minVolume: 100, + liquids: { + default: { + minVolume: 100, + }, + }, }, }, } @@ -119,7 +134,7 @@ describe('Below pipette minimum volume', () => { }) describe('Below min disposal volume', () => { let fieldsWithPipette: { - pipette: { spec: { minVolume: number } } + pipette: { spec: { liquids: { default: { minVolume: number } } } } disposalVolume_checkbox: boolean disposalVolume_volume: number path: string @@ -128,7 +143,11 @@ describe('Below min disposal volume', () => { fieldsWithPipette = { pipette: { spec: { - minVolume: 100, + liquids: { + default: { + minVolume: 100, + }, + }, }, }, disposalVolume_checkbox: true, @@ -201,7 +220,7 @@ describe('Max dispense well volume', () => { let fieldsWithDispenseLabware: any beforeEach(() => { fieldsWithDispenseLabware = { - dispense_labware: { def: { ...fixture_24_tuberack } }, + dispense_labware: { def: fixture24Tuberack }, dispense_wells: ['A1', 'A2'], } }) @@ -244,4 +263,75 @@ describe('Max dispense well volume', () => { // @ts-expect-error(sa, 2021-6-15): maxDispenseWellVolume might return null, need to null check before property access expect(maxDispenseWellVolume(fields).type).toBe('OVER_MAX_WELL_VOLUME') }) + describe('tip position in tube warnings', () => { + let fields: { + aspirate_labware: LabwareEntity + aspirate_mmFromBottom: number | null + labware: LabwareEntity + mix_mmFromBottom: number + dispense_labware: LabwareEntity + dispense_mmFromBottom: number | null + } + beforeEach(() => { + fields = { + aspirate_labware: { + def: fixture24Tuberack as LabwareDefinition2, + id: 'mockId', + labwareDefURI: 'mockURI', + }, + aspirate_mmFromBottom: null, + labware: { + def: fixture24Tuberack as LabwareDefinition2, + id: 'mockId', + labwareDefURI: 'mockURI', + }, + mix_mmFromBottom: 0.5, + dispense_labware: { + def: fixture24Tuberack as LabwareDefinition2, + id: 'mockId', + labwareDefURI: 'mockURI', + }, + dispense_mmFromBottom: null, + } + }) + it('renders the errors for all 3', () => { + expect(aspirateTipPositionInTube(fields)?.type).toBe( + 'ASPIRATE_TIP_POSITIONED_LOW_IN_TUBE' + ) + expect(dispenseTipPositionInTube(fields)?.type).toBe( + 'DISPENSE_TIP_POSITIONED_LOW_IN_TUBE' + ) + expect(mixTipPositionInTube(fields)?.type).toBe( + 'MIX_TIP_POSITIONED_LOW_IN_TUBE' + ) + }) + it('renders null for all 3 when the number has been adjusted', () => { + fields.aspirate_mmFromBottom = 3 + fields.dispense_mmFromBottom = 3 + fields.mix_mmFromBottom = 3 + expect(aspirateTipPositionInTube(fields)).toBe(null) + expect(dispenseTipPositionInTube(fields)).toBe(null) + expect(mixTipPositionInTube(fields)).toBe(null) + }) + it('renders null for all 3 when the labware is not a tube rack', () => { + fields.aspirate_labware = { + def: fixture96Plate as LabwareDefinition2, + id: 'mockId', + labwareDefURI: 'mockURI', + } + fields.labware = { + def: fixture96Plate as LabwareDefinition2, + id: 'mockId', + labwareDefURI: 'mockURI', + } + fields.dispense_labware = { + def: fixture96Plate as LabwareDefinition2, + id: 'mockId', + labwareDefURI: 'mockURI', + } + expect(aspirateTipPositionInTube(fields)).toBe(null) + expect(dispenseTipPositionInTube(fields)).toBe(null) + expect(mixTipPositionInTube(fields)).toBe(null) + }) + }) }) diff --git a/protocol-designer/src/steplist/formLevel/warnings.tsx b/protocol-designer/src/steplist/formLevel/warnings.tsx index 1b6fa0ab071..6a9c31a1a72 100644 --- a/protocol-designer/src/steplist/formLevel/warnings.tsx +++ b/protocol-designer/src/steplist/formLevel/warnings.tsx @@ -1,16 +1,19 @@ import * as React from 'react' import { getWellTotalVolume } from '@opentrons/shared-data' import { KnowledgeBaseLink } from '../../components/KnowledgeBaseLink' -import { FormError } from './errors' +import type { FormError } from './errors' /******************* ** Warning Messages ** ********************/ export type FormWarningType = + | 'ASPIRATE_TIP_POSITIONED_LOW_IN_TUBE' + | 'BELOW_MIN_AIR_GAP_VOLUME' + | 'BELOW_MIN_DISPOSAL_VOLUME' | 'BELOW_PIPETTE_MINIMUM_VOLUME' + | 'DISPENSE_TIP_POSITIONED_LOW_IN_TUBE' | 'OVER_MAX_WELL_VOLUME' - | 'BELOW_MIN_DISPOSAL_VOLUME' - | 'BELOW_MIN_AIR_GAP_VOLUME' + | 'MIX_TIP_POSITIONED_LOW_IN_TUBE' export type FormWarning = FormError & { type: FormWarningType @@ -56,6 +59,27 @@ const belowMinDisposalVolumeWarning = (min: number): FormWarning => ({ dependentFields: ['disposalVolume_volume', 'pipette'], }) +const aspirateTipPositionedLowInTube = (): FormWarning => ({ + type: 'ASPIRATE_TIP_POSITIONED_LOW_IN_TUBE', + title: + 'The default aspirate height is 1mm from the bottom of the well, which could cause liquid overflow or pipette damage. Edit tip position in advanced settings.', + dependentFields: ['aspirate_labware'], +}) + +const dispenseTipPositionedLowInTube = (): FormWarning => ({ + type: 'DISPENSE_TIP_POSITIONED_LOW_IN_TUBE', + title: + 'The default dispense height is 0.5mm from the bottom of the well, which could cause liquid overflow or pipette damage. Edit tip position in advanced settings.', + dependentFields: ['dispense_labware'], +}) + +const mixTipPositionedLowInTube = (): FormWarning => ({ + type: 'MIX_TIP_POSITIONED_LOW_IN_TUBE', + title: + 'The default mix height is 0.5mm from the bottom of the well, which could cause liquid overflow or pipette damage. Edit tip position in advanced settings.', + dependentFields: ['labware'], +}) + export type WarningChecker = (val: unknown) => FormWarning | null /******************* @@ -64,14 +88,57 @@ export type WarningChecker = (val: unknown) => FormWarning | null // TODO: real HydratedFormData type export type HydratedFormData = any +export const aspirateTipPositionInTube = ( + fields: HydratedFormData +): FormWarning | null => { + const { aspirate_labware, aspirate_mmFromBottom } = fields + let isTubeRack: boolean = false + if (aspirate_labware != null) { + isTubeRack = aspirate_labware.def.metadata.displayCategory === 'tubeRack' + } + return isTubeRack && aspirate_mmFromBottom === null + ? aspirateTipPositionedLowInTube() + : null +} +export const dispenseTipPositionInTube = ( + fields: HydratedFormData +): FormWarning | null => { + const { dispense_labware, dispense_mmFromBottom } = fields + let isTubeRack: boolean = false + if (dispense_labware != null) { + isTubeRack = + // checking that the dispense labware is a labware and not a trash/waste chute + 'def' in dispense_labware + ? dispense_labware.def.metadata.displayCategory === 'tubeRack' + : false + } + return isTubeRack && dispense_mmFromBottom === null + ? dispenseTipPositionedLowInTube() + : null +} +export const mixTipPositionInTube = ( + fields: HydratedFormData +): FormWarning | null => { + const { labware, mix_mmFromBottom } = fields + let isTubeRack: boolean = false + if (labware != null) { + isTubeRack = labware.def.metadata.displayCategory === 'tubeRack' + } + return isTubeRack && mix_mmFromBottom === 0.5 + ? mixTipPositionedLowInTube() + : null +} export const belowPipetteMinimumVolume = ( fields: HydratedFormData ): FormWarning | null => { const { pipette, volume } = fields if (!(pipette && pipette.spec)) return null - return volume < pipette.spec.minVolume - ? belowPipetteMinVolumeWarning(pipette.spec.minVolume) - : null + const liquidSpecs = pipette.spec.liquids + const minVolume = + 'lowVolumeDefault' in liquidSpecs + ? liquidSpecs.lowVolumeDefault.minVolume + : liquidSpecs.default.minVolume + return volume < minVolume ? belowPipetteMinVolumeWarning(minVolume) : null } export const maxDispenseWellVolume = ( @@ -102,11 +169,16 @@ export const minDisposalVolume = ( } = fields if (!(pipette && pipette.spec) || path !== 'multiDispense') return null const isUnselected = !disposalVolume_checkbox || !disposalVolume_volume - if (isUnselected) return belowMinDisposalVolumeWarning(pipette.spec.minVolume) - const isBelowMin = disposalVolume_volume < pipette.spec.minVolume - return isBelowMin - ? belowMinDisposalVolumeWarning(pipette.spec.minVolume) - : null + const liquidSpecs = pipette.spec.liquids + const minVolume = + 'lowVolumeDefault' in liquidSpecs + ? liquidSpecs.lowVolumeDefault.minVolume + : liquidSpecs.default.minVolume + if (isUnselected) { + return belowMinDisposalVolumeWarning(minVolume) + } + const isBelowMin = disposalVolume_volume < minVolume + return isBelowMin ? belowMinDisposalVolumeWarning(minVolume) : null } // both aspirate and dispense air gap volumes have the same minimums @@ -117,10 +189,16 @@ export const _minAirGapVolume = ( const checkboxValue = fields[checkboxField] const volumeValue = fields[volumeField] const { pipette } = fields - if (!checkboxValue || !volumeValue || !pipette || !pipette.spec) return null - - const isBelowMin = Number(volumeValue) < pipette.spec.minVolume - return isBelowMin ? belowMinAirGapVolumeWarning(pipette.spec.minVolume) : null + if (!checkboxValue || !volumeValue || !pipette || !pipette.spec) { + return null + } + const liquidSpecs = pipette.spec.liquids + const minVolume = + 'lowVolumeDefault' in liquidSpecs + ? liquidSpecs.lowVolumeDefault.minVolume + : liquidSpecs.default.minVolume + const isBelowMin = Number(volumeValue) < minVolume + return isBelowMin ? belowMinAirGapVolumeWarning(minVolume) : null } export const minAspirateAirGapVolume: ( diff --git a/protocol-designer/src/steplist/generateSubstepItem.ts b/protocol-designer/src/steplist/generateSubstepItem.ts index f16b48f412c..edfac2fd19e 100644 --- a/protocol-designer/src/steplist/generateSubstepItem.ts +++ b/protocol-designer/src/steplist/generateSubstepItem.ts @@ -411,6 +411,7 @@ export function generateSubstepItem( temperature: temperature, labwareNickname: labwareNames?.nickname, message: stepArgs.message, + moduleId: stepArgs.module, } } diff --git a/protocol-designer/src/steplist/test/generateSubsteps.test.ts b/protocol-designer/src/steplist/test/generateSubsteps.test.ts index df8c3f5c334..1c2483e0487 100644 --- a/protocol-designer/src/steplist/test/generateSubsteps.test.ts +++ b/protocol-designer/src/steplist/test/generateSubsteps.test.ts @@ -622,6 +622,7 @@ describe('generateSubstepItem', () => { temperature: 45, labwareNickname: 'temp nickname', message: null, + moduleId: 'tempId', }) }) @@ -652,6 +653,7 @@ describe('generateSubstepItem', () => { temperature: 0, labwareNickname: 'temp nickname', message: null, + moduleId: 'tempId', }) }) @@ -680,6 +682,7 @@ describe('generateSubstepItem', () => { temperature: null, labwareNickname: 'temp nickname', message: null, + moduleId: 'tempId', }) }) diff --git a/protocol-designer/src/steplist/types.ts b/protocol-designer/src/steplist/types.ts index 273fe87afdc..297c13e7194 100644 --- a/protocol-designer/src/steplist/types.ts +++ b/protocol-designer/src/steplist/types.ts @@ -5,9 +5,9 @@ import { PauseArgs, ThermocyclerProfileStepArgs, } from '@opentrons/step-generation' -import { ModuleType } from '@opentrons/shared-data' -import { StepIdType } from '../form-types' -import { FormError } from './formLevel/errors' +import type { ModuleType } from '@opentrons/shared-data' +import type { StepIdType } from '../form-types' +import type { FormError } from './formLevel/errors' // timeline start and end export const START_TERMINAL_ITEM_ID: '__initial_setup__' = '__initial_setup__' export const END_TERMINAL_ITEM_ID: '__end__' = '__end__' @@ -105,6 +105,7 @@ export interface TemperatureSubstepItem { substepType: 'temperature' temperature: number | null labwareNickname: string | null | undefined + moduleId: string | null message?: string } export interface PauseSubstepItem { diff --git a/protocol-designer/src/timelineMiddleware/__tests__/generateRobotStateTimeline.test.ts b/protocol-designer/src/timelineMiddleware/__tests__/generateRobotStateTimeline.test.ts index ede96f0be52..1717dc838cb 100644 --- a/protocol-designer/src/timelineMiddleware/__tests__/generateRobotStateTimeline.test.ts +++ b/protocol-designer/src/timelineMiddleware/__tests__/generateRobotStateTimeline.test.ts @@ -50,6 +50,10 @@ describe('generateRobotStateTimeline', () => { description: null, nozzles: null, tipRack: 'tiprack1Id', + aspirateXOffset: 0, + aspirateYOffset: 0, + dispenseXOffset: 0, + dispenseYOffset: 0, }, }, b: { @@ -86,6 +90,10 @@ describe('generateRobotStateTimeline', () => { description: null, nozzles: null, tipRack: 'tiprack1Id', + aspirateXOffset: 0, + aspirateYOffset: 0, + dispenseXOffset: 0, + dispenseYOffset: 0, }, }, c: { @@ -114,6 +122,10 @@ describe('generateRobotStateTimeline', () => { dispenseDelaySeconds: null, nozzles: null, tipRack: 'tiprack1Id', + aspirateXOffset: 0, + aspirateYOffset: 0, + dispenseXOffset: 0, + dispenseYOffset: 0, }, }, } diff --git a/protocol-designer/src/top-selectors/labware-locations/index.ts b/protocol-designer/src/top-selectors/labware-locations/index.ts index 9396bd121b8..6c66367fb4f 100644 --- a/protocol-designer/src/top-selectors/labware-locations/index.ts +++ b/protocol-designer/src/top-selectors/labware-locations/index.ts @@ -11,6 +11,7 @@ import { STAGING_AREA_RIGHT_SLOT_FIXTURE, isAddressableAreaStandardSlot, MOVABLE_TRASH_ADDRESSABLE_AREAS, + FLEX_MODULE_ADDRESSABLE_AREAS, } from '@opentrons/shared-data' import { COLUMN_4_SLOTS } from '@opentrons/step-generation' import { @@ -232,7 +233,8 @@ export const getUnoccupiedLabwareLocationOptions: Selector< .includes(slotId) && !isTrashSlot && !WASTE_CHUTE_ADDRESSABLE_AREAS.includes(slotId) && - !notSelectedStagingAreaAddressableAreas.includes(slotId) + !notSelectedStagingAreaAddressableAreas.includes(slotId) && + !FLEX_MODULE_ADDRESSABLE_AREAS.includes(slotId) ) }) .map(slotId => ({ name: slotId, value: slotId })) diff --git a/protocol-designer/src/tutorial/index.ts b/protocol-designer/src/tutorial/index.ts index a0eee9ffff3..6d82f7832c9 100644 --- a/protocol-designer/src/tutorial/index.ts +++ b/protocol-designer/src/tutorial/index.ts @@ -2,6 +2,7 @@ import * as actions from './actions' import { rootReducer, RootState } from './reducers' import * as selectors from './selectors' type HintKey = // normal hints + | 'multiple_modules_without_labware' | 'add_liquids_and_labware' | 'deck_setup_explanation' | 'module_without_labware' @@ -10,7 +11,7 @@ type HintKey = // normal hints | 'waste_chute_warning' // blocking hints | 'custom_labware_with_modules' - | 'export_v8_protocol_7_1' + | 'export_v8_1_protocol_7_3' | 'change_magnet_module_model' // DEPRECATED HINTS (keep a record to avoid name collisions with old persisted dismissal states) // 'export_v4_protocol' @@ -19,5 +20,6 @@ type HintKey = // normal hints // | 'export_v6_protocol_6_10' // | 'export_v6_protocol_6_20' // | 'export_v7_protocol_7_0' +// | 'export_v8_protocol_7_1' export { actions, rootReducer, selectors } export type { RootState, HintKey } diff --git a/protocol-designer/src/ui/labware/selectors.ts b/protocol-designer/src/ui/labware/selectors.ts index dd4be8f0c62..27b3ea9f3ae 100644 --- a/protocol-designer/src/ui/labware/selectors.ts +++ b/protocol-designer/src/ui/labware/selectors.ts @@ -241,17 +241,22 @@ export const getDisposalOptions = createSelector( } ) -export const getTiprackOptions: Selector = createSelector( +export interface TiprackOption { + name: string + value: string + defURI: string +} +export const getTiprackOptions: Selector = createSelector( stepFormSelectors.getLabwareEntities, getLabwareNicknamesById, (labwareEntities, nicknamesById) => { const options = reduce( labwareEntities, ( - acc: Options, + acc: TiprackOption[], labwareEntity: LabwareEntity, labwareId: string - ): Options => { + ): TiprackOption[] => { const labwareDefURI = labwareEntity.labwareDefURI const optionValues = acc.map(option => option.value) @@ -266,12 +271,13 @@ export const getTiprackOptions: Selector = createSelector( { name: nicknamesById[labwareId], value: labwareId, + defURI: labwareDefURI, }, ] } }, [] ) - return _sortLabwareDropdownOptions(options) + return options } ) diff --git a/protocol-designer/src/ui/modules/selectors.ts b/protocol-designer/src/ui/modules/selectors.ts index 75057c88dfa..1d5ec7bdb08 100644 --- a/protocol-designer/src/ui/modules/selectors.ts +++ b/protocol-designer/src/ui/modules/selectors.ts @@ -1,4 +1,5 @@ import { createSelector } from 'reselect' +import mapValues from 'lodash/mapValues' import { getLabwareDisplayName, MAGNETIC_MODULE_TYPE, @@ -6,7 +7,6 @@ import { THERMOCYCLER_MODULE_TYPE, HEATERSHAKER_MODULE_TYPE, } from '@opentrons/shared-data' -import mapValues from 'lodash/mapValues' import { getInitialDeckSetup } from '../../step-forms/selectors' import { getLabwareNicknamesById } from '../labware/selectors' import { @@ -15,10 +15,14 @@ import { getModuleOnDeckByType, getModuleHasLabware, getMagnetLabwareEngageHeight as getMagnetLabwareEngageHeightUtil, + getModulesOnDeckByType, + getModulesHaveLabware, + ModuleAndLabware, } from './utils' -import { Options } from '@opentrons/components' -import { Selector } from '../../types' -import { LabwareNamesByModuleId } from '../../steplist/types' +import type { Options } from '@opentrons/components' +import type { Selector } from '../../types' +import type { LabwareNamesByModuleId } from '../../steplist/types' + export const getLabwareNamesByModuleId: Selector = createSelector( getInitialDeckSetup, getLabwareNicknamesById, @@ -84,16 +88,18 @@ export const getSingleMagneticModuleId: Selector< getModuleOnDeckByType(initialDeckSetup, MAGNETIC_MODULE_TYPE)?.id || null ) -/** Get single temperature module (assumes no multiples) */ -export const getSingleTemperatureModuleId: Selector< - string | null +/** Get all temperature modules */ +export const getTemperatureModuleIds: Selector< + string[] | null > = createSelector( getInitialDeckSetup, initialDeckSetup => - getModuleOnDeckByType(initialDeckSetup, TEMPERATURE_MODULE_TYPE)?.id || null + getModulesOnDeckByType(initialDeckSetup, TEMPERATURE_MODULE_TYPE)?.map( + module => module.id + ) || null ) -/** Get single temperature module (assumes no multiples) */ +/** Get single thermocycler module (assumes no multiples) */ export const getSingleThermocyclerModuleId: Selector< string | null > = createSelector( @@ -111,13 +117,12 @@ export const getMagnetModuleHasLabware: Selector = createSelector( } ) -/** Returns boolean if temperature module has labware */ -export const getTemperatureModuleHasLabware: Selector = createSelector( - getInitialDeckSetup, - initialDeckSetup => { - return getModuleHasLabware(initialDeckSetup, TEMPERATURE_MODULE_TYPE) - } -) +/** Returns all moduleIds and if they have labware for MoaM */ +export const getTemperatureModulesHaveLabware: Selector< + ModuleAndLabware[] +> = createSelector(getInitialDeckSetup, initialDeckSetup => { + return getModulesHaveLabware(initialDeckSetup, TEMPERATURE_MODULE_TYPE) +}) /** Returns boolean if thermocycler module has labware */ export const getThermocyclerModuleHasLabware: Selector = createSelector( diff --git a/protocol-designer/src/ui/modules/utils.ts b/protocol-designer/src/ui/modules/utils.ts index fcd1ddb5f43..e49e8ad7b33 100644 --- a/protocol-designer/src/ui/modules/utils.ts +++ b/protocol-designer/src/ui/modules/utils.ts @@ -20,12 +20,25 @@ export function getModuleOnDeckByType( (moduleOnDeck: ModuleOnDeck) => moduleOnDeck.type === type ) } +export function getModulesOnDeckByType( + initialDeckSetup: InitialDeckSetup, + type: ModuleType +): ModuleOnDeck[] | null | undefined { + return values(initialDeckSetup.modules).filter( + (moduleOnDeck: ModuleOnDeck) => moduleOnDeck.type === type + ) +} export function getLabwareOnModule( initialDeckSetup: InitialDeckSetup, moduleId: string ): LabwareOnDeck | null | undefined { return values(initialDeckSetup.labware).find( - (lab: LabwareOnDeck) => lab.slot === moduleId + (labware: LabwareOnDeck) => + labware.slot === moduleId || + // acccount for adapter! + values(initialDeckSetup.labware).find( + adapter => adapter.id === labware.slot && adapter.slot === moduleId + ) ) } export function getModuleUnderLabware( @@ -81,28 +94,39 @@ export function getModuleLabwareOptions( nicknamesById: Record, type: ModuleType ): Options { - const moduleOnDeck = getModuleOnDeckByType(initialDeckSetup, type) - const labware = - moduleOnDeck && getLabwareOnModule(initialDeckSetup, moduleOnDeck.id) + const labwares = initialDeckSetup.labware + const modulesOnDeck = getModulesOnDeckByType(initialDeckSetup, type) const module = getModuleShortNames(type) let options: Options = [] - if (moduleOnDeck) { - if (labware) { - options = [ - { - name: `${nicknamesById[labware.id]} in ${module}`, + if (modulesOnDeck != null) { + options = modulesOnDeck.map(moduleOnDeck => { + const labware = getLabwareOnModule(initialDeckSetup, moduleOnDeck.id) + if (labware) { + const labwareOnAdapterId = + labwares[labware.id] != null ? labwares[labware.id].id : null + if (labwareOnAdapterId != null) { + return { + name: `${nicknamesById[labwareOnAdapterId]} in ${ + nicknamesById[labware.id] + } in ${module} in slot ${moduleOnDeck.slot}`, + value: moduleOnDeck.id, + } + } else { + return { + name: `${nicknamesById[labware.id]} in ${module} in slot ${ + moduleOnDeck.slot + }`, + value: moduleOnDeck.id, + } + } + } else { + return { + name: `No labware in ${module} in slot ${moduleOnDeck.slot}`, value: moduleOnDeck.id, - }, - ] - } else { - options = [ - { - name: `${module} No labware on module`, - value: moduleOnDeck.id, - }, - ] - } + } + } + }) } return options @@ -116,6 +140,29 @@ export function getModuleHasLabware( moduleOnDeck && getLabwareOnModule(initialDeckSetup, moduleOnDeck.id) return Boolean(moduleOnDeck) && Boolean(labware) } + +export interface ModuleAndLabware { + moduleId: string + hasLabware: boolean +} + +export function getModulesHaveLabware( + initialDeckSetup: InitialDeckSetup, + type: ModuleType +): ModuleAndLabware[] { + const modulesOnDeck = getModulesOnDeckByType(initialDeckSetup, type) + const moduleAndLabware: ModuleAndLabware[] = [] + modulesOnDeck?.forEach(module => { + const labwareHasModule = getLabwareOnModule(initialDeckSetup, module.id) + + moduleAndLabware.push({ + moduleId: module.id, + hasLabware: labwareHasModule != null, + }) + }) + return moduleAndLabware +} + export const getMagnetLabwareEngageHeight = ( initialDeckSetup: InitialDeckSetup, magnetModuleId: string | null diff --git a/protocol-designer/src/ui/steps/actions/__tests__/addAndSelectStepWithHints.test.ts b/protocol-designer/src/ui/steps/actions/__tests__/addAndSelectStepWithHints.test.ts index 2a087d4ac31..56046da6a98 100644 --- a/protocol-designer/src/ui/steps/actions/__tests__/addAndSelectStepWithHints.test.ts +++ b/protocol-designer/src/ui/steps/actions/__tests__/addAndSelectStepWithHints.test.ts @@ -19,15 +19,13 @@ beforeEach(() => { vi.mocked(addHint).mockReturnValue('addHintReturnValue' as any) vi.mocked(labwareIngredSelectors.getDeckHasLiquid).mockReturnValue(true) vi.mocked(uiModuleSelectors.getMagnetModuleHasLabware).mockReturnValue(false) - vi.mocked(uiModuleSelectors.getTemperatureModuleHasLabware).mockReturnValue( - false + vi.mocked(uiModuleSelectors.getTemperatureModulesHaveLabware).mockReturnValue( + [] ) vi.mocked(uiModuleSelectors.getThermocyclerModuleHasLabware).mockReturnValue( false ) - vi.mocked(uiModuleSelectors.getSingleTemperatureModuleId).mockReturnValue( - null - ) + vi.mocked(uiModuleSelectors.getTemperatureModuleIds).mockReturnValue(null) vi.mocked(uiModuleSelectors.getSingleThermocyclerModuleId).mockReturnValue( null ) @@ -89,10 +87,11 @@ describe('addAndSelectStepWithHints', () => { stepType: 'magnet' as StepType, selectorValues: { getMagnetModuleHasLabware: false, - getTemperatureModuleHasLabware: false, + getTemperatureModulesHaveLabware: [], getThermocyclerModuleHasLabware: false, getSingleTemperatureModuleId: null, getSingleThermocyclerModuleId: null, + getTemperatureModuleIds: [], }, }, { @@ -100,10 +99,13 @@ describe('addAndSelectStepWithHints', () => { stepType: 'temperature' as StepType, selectorValues: { getMagnetModuleHasLabware: false, - getTemperatureModuleHasLabware: false, + getTemperatureModulesHaveLabware: [ + { moduleId: 'mockId', hasLabware: false }, + ], getThermocyclerModuleHasLabware: false, getSingleTemperatureModuleId: 'something', getSingleThermocyclerModuleId: null, + getTemperatureModuleIds: ['mockId'], }, }, { @@ -111,10 +113,11 @@ describe('addAndSelectStepWithHints', () => { stepType: 'temperature' as StepType, selectorValues: { getMagnetModuleHasLabware: false, - getTemperatureModuleHasLabware: false, + getTemperatureModulesHaveLabware: [], getThermocyclerModuleHasLabware: false, getSingleTemperatureModuleId: null, getSingleThermocyclerModuleId: 'something', + getTemperatureModuleIds: [], }, }, ].forEach(({ testName, stepType, selectorValues }) => { @@ -123,14 +126,14 @@ describe('addAndSelectStepWithHints', () => { selectorValues.getMagnetModuleHasLabware ) vi.mocked( - uiModuleSelectors.getTemperatureModuleHasLabware - ).mockReturnValue(selectorValues.getTemperatureModuleHasLabware) + uiModuleSelectors.getTemperatureModulesHaveLabware + ).mockReturnValue(selectorValues.getTemperatureModulesHaveLabware) vi.mocked( uiModuleSelectors.getThermocyclerModuleHasLabware ).mockReturnValue(selectorValues.getThermocyclerModuleHasLabware) - vi.mocked( - uiModuleSelectors.getSingleTemperatureModuleId - ).mockReturnValue(selectorValues.getSingleTemperatureModuleId) + vi.mocked(uiModuleSelectors.getTemperatureModuleIds).mockReturnValue( + selectorValues.getTemperatureModuleIds + ) vi.mocked( uiModuleSelectors.getSingleThermocyclerModuleId ).mockReturnValue(selectorValues.getSingleThermocyclerModuleId) @@ -159,4 +162,56 @@ describe('addAndSelectStepWithHints', () => { }) }) }) + describe('ADD_HINT "multiple_modules_without_labware"', () => { + ;[ + { + testName: 'temperature step, when temperature module has no labware', + stepType: 'temperature' as StepType, + selectorValues: { + getMagnetModuleHasLabware: false, + getTemperatureModulesHaveLabware: [ + { moduleId: 'mockId', hasLabware: false }, + { moduleId: 'mockId2', hasLabware: true }, + ], + getThermocyclerModuleHasLabware: false, + getSingleTemperatureModuleId: 'something', + getSingleThermocyclerModuleId: null, + getTemperatureModuleIds: ['mockId', 'mockId2'], + }, + }, + ].forEach(({ testName, stepType, selectorValues }) => { + it(`should be dispatched (after addStep thunk is dispatched) for ${testName}`, () => { + vi.mocked( + uiModuleSelectors.getTemperatureModulesHaveLabware + ).mockReturnValue(selectorValues.getTemperatureModulesHaveLabware) + + vi.mocked(uiModuleSelectors.getTemperatureModuleIds).mockReturnValue( + selectorValues.getTemperatureModuleIds + ) + + const payload = { + stepType, + } + addAndSelectStepWithHints(payload)(dispatch, getState) + expect(vi.mocked(addHint).mock.calls).toEqual([ + ['multiple_modules_without_labware'], + ]) + expect(dispatch.mock.calls).toEqual([ + [ + { + type: 'ADD_STEP', + payload: { + id: PRESAVED_STEP_ID, + stepType, + }, + meta: { + robotStateTimeline: 'mockGetRobotStateTimelineValue', + }, + }, + ], + ['addHintReturnValue'], + ]) + }) + }) + }) }) diff --git a/protocol-designer/src/ui/steps/actions/thunks/index.ts b/protocol-designer/src/ui/steps/actions/thunks/index.ts index c6d8be20159..9cc31de8ab8 100644 --- a/protocol-designer/src/ui/steps/actions/thunks/index.ts +++ b/protocol-designer/src/ui/steps/actions/thunks/index.ts @@ -40,18 +40,21 @@ export const addAndSelectStepWithHints: (arg: { const magnetModuleHasLabware = uiModuleSelectors.getMagnetModuleHasLabware( state ) - const temperatureModuleHasLabware = uiModuleSelectors.getTemperatureModuleHasLabware( + const temperatureModulesHaveLabware = uiModuleSelectors.getTemperatureModulesHaveLabware( state ) const thermocyclerModuleHasLabware = uiModuleSelectors.getThermocyclerModuleHasLabware( state ) - const temperatureModuleOnDeck = uiModuleSelectors.getSingleTemperatureModuleId( + const temperatureModuleOnDeck = uiModuleSelectors.getTemperatureModuleIds( state ) const thermocyclerModuleOnDeck = uiModuleSelectors.getSingleThermocyclerModuleId( state ) + const tempHasNoLabware = temperatureModulesHaveLabware.some( + module => !module.hasLabware + ) // TODO: Ian 2019-01-17 move out to centralized step info file - see #2926 const stepNeedsLiquid = ['mix', 'moveLiquid'].includes(payload.stepType) const stepMagnetNeedsLabware = ['magnet'].includes(payload.stepType) @@ -59,15 +62,17 @@ export const addAndSelectStepWithHints: (arg: { const stepModuleMissingLabware = (stepMagnetNeedsLabware && !magnetModuleHasLabware) || (stepTemperatureNeedsLabware && - ((temperatureModuleOnDeck && !temperatureModuleHasLabware) || - (thermocyclerModuleOnDeck && !thermocyclerModuleHasLabware))) + thermocyclerModuleOnDeck && + !thermocyclerModuleHasLabware) || + (temperatureModuleOnDeck?.length === 1 && tempHasNoLabware) if (stepNeedsLiquid && !deckHasLiquid) { dispatch(tutorialActions.addHint('add_liquids_and_labware')) } - if (stepModuleMissingLabware) { dispatch(tutorialActions.addHint('module_without_labware')) + } else if (temperatureModuleOnDeck && tempHasNoLabware) { + dispatch(tutorialActions.addHint('multiple_modules_without_labware')) } } export interface ReorderSelectedStepAction { diff --git a/protocol-designer/src/ui/steps/selectors.ts b/protocol-designer/src/ui/steps/selectors.ts index f9a228366d3..8ed2eeb20dd 100644 --- a/protocol-designer/src/ui/steps/selectors.ts +++ b/protocol-designer/src/ui/steps/selectors.ts @@ -136,10 +136,11 @@ export const getHoveredStepLabware = createSelector( // only 1 labware return [stepArgs.labware] } - // @ts-expect-error(sa, 2021-6-15): type narrow stepArgs.module - if (stepArgs.module) { - // @ts-expect-error(sa, 2021-6-15): this expect error should not be necessary after type narrowing above - const labware = getLabwareOnModule(initialDeckState, stepArgs.module) + if ('module' in stepArgs) { + const labware = getLabwareOnModule( + initialDeckState, + stepArgs.module ?? '' + ) return labware ? [labware.id] : [] } @@ -150,8 +151,9 @@ export const getHoveredStepLabware = createSelector( // step types that have no labware that gets highlighted if (!(stepArgs.commandCreatorFnName === 'delay')) { - // TODO Ian 2018-05-08 use assert here console.warn( + // @ts-expect-error: should only reach this warning when new step is added and + // highlighted wells is not yet implemented `getHoveredStepLabware does not support step type "${stepArgs.commandCreatorFnName}"` ) } diff --git a/protocol-designer/src/ui/steps/test/selectors.test.ts b/protocol-designer/src/ui/steps/test/selectors.test.ts index 5cf64a59160..e5aa13d10c5 100644 --- a/protocol-designer/src/ui/steps/test/selectors.test.ts +++ b/protocol-designer/src/ui/steps/test/selectors.test.ts @@ -418,10 +418,26 @@ describe('_getSavedMultiSelectFieldValues', () => { isIndeterminate: false, value: undefined, }, + aspirate_labware: { value: 'aspirate_labware_id', isIndeterminate: false, }, + aspirate_x_position: { + isIndeterminate: false, + }, + aspirate_y_position: { + isIndeterminate: false, + }, + dispense_x_position: { + isIndeterminate: false, + }, + dispense_y_position: { + isIndeterminate: false, + }, + blowout_z_offset: { + isIndeterminate: false, + }, aspirate_wells: { isIndeterminate: true, }, @@ -669,6 +685,21 @@ describe('_getSavedMultiSelectFieldValues', () => { path: { isIndeterminate: true, }, + aspirate_x_position: { + isIndeterminate: false, + }, + aspirate_y_position: { + isIndeterminate: false, + }, + dispense_x_position: { + isIndeterminate: false, + }, + dispense_y_position: { + isIndeterminate: false, + }, + blowout_z_offset: { + isIndeterminate: false, + }, preWetTip: { isIndeterminate: true, }, @@ -850,6 +881,15 @@ describe('_getSavedMultiSelectFieldValues', () => { mix_touchTip_checkbox: { value: false, isIndeterminate: false }, mix_touchTip_mmFromBottom: { value: null, isIndeterminate: false }, nozzles: { value: undefined, isIndeterminate: false }, + mix_x_position: { + isIndeterminate: false, + }, + mix_y_position: { + isIndeterminate: false, + }, + blowout_z_offset: { + isIndeterminate: false, + }, dropTip_location: { value: 'fixedTrash', isIndeterminate: false, @@ -920,6 +960,15 @@ describe('_getSavedMultiSelectFieldValues', () => { mix_touchTip_checkbox: { isIndeterminate: true }, mix_touchTip_mmFromBottom: { isIndeterminate: true }, nozzles: { isIndeterminate: true }, + mix_x_position: { + isIndeterminate: false, + }, + mix_y_position: { + isIndeterminate: false, + }, + blowout_z_offset: { + isIndeterminate: false, + }, dropTip_location: { value: 'fixedTrash', isIndeterminate: false, diff --git a/protocol-designer/vite.config.ts b/protocol-designer/vite.config.ts index 70d055a6fd8..7f7b8dd680d 100644 --- a/protocol-designer/vite.config.ts +++ b/protocol-designer/vite.config.ts @@ -1,12 +1,13 @@ import path from 'path' -import { UserConfig, defineConfig } from 'vite' +import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' import postCssImport from 'postcss-import' import postCssApply from 'postcss-apply' import postColorModFunction from 'postcss-color-mod-function' import postCssPresetEnv from 'postcss-preset-env' import lostCss from 'lost' -import { versionForProject } from '../scripts/git-version' +import { versionForProject } from '../scripts/git-version.mjs' +import type { UserConfig } from 'vite' const testAliases: {} | { 'file-saver': string } = process.env.CYPRESS === '1' diff --git a/react-api-client/src/protocols/__tests__/useCreateProtocolAnalysisMutation.test.tsx b/react-api-client/src/protocols/__tests__/useCreateProtocolAnalysisMutation.test.tsx new file mode 100644 index 00000000000..e04c020fb1d --- /dev/null +++ b/react-api-client/src/protocols/__tests__/useCreateProtocolAnalysisMutation.test.tsx @@ -0,0 +1,77 @@ +import * as React from 'react' +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { QueryClient, QueryClientProvider } from 'react-query' +import { act, renderHook, waitFor } from '@testing-library/react' +import { createProtocolAnalysis } from '@opentrons/api-client' +import { useHost } from '../../api' +import { useCreateProtocolAnalysisMutation } from '..' +import type { HostConfig, Response } from '@opentrons/api-client' +import type { ProtocolAnalysisSummary } from '@opentrons/shared-data' + +vi.mock('@opentrons/api-client') +vi.mock('../../api/useHost') + +const HOST_CONFIG: HostConfig = { hostname: 'localhost' } +const ANALYSIS_SUMMARY_RESPONSE = [ + { id: 'fakeAnalysis1', status: 'completed' }, + { id: 'fakeAnalysis2', status: 'pending' }, +] as ProtocolAnalysisSummary[] + +describe('useCreateProtocolAnalysisMutation hook', () => { + let wrapper: React.FunctionComponent<{ children: React.ReactNode }> + + beforeEach(() => { + const queryClient = new QueryClient() + const clientProvider: React.FunctionComponent<{ + children: React.ReactNode + }> = ({ children }) => ( + {children} + ) + wrapper = clientProvider + }) + + it('should return no data when calling createProtocolAnalysis if the request fails', async () => { + vi.mocked(useHost).mockReturnValue(HOST_CONFIG) + vi.mocked(createProtocolAnalysis).mockRejectedValue('oh no') + + const { result } = renderHook( + () => useCreateProtocolAnalysisMutation('fake-protocol-key'), + { + wrapper, + } + ) + + expect(result.current.data).toBeUndefined() + result.current.createProtocolAnalysis({ + protocolKey: 'fake-protocol-key', + runTimeParameterValues: {}, + }) + await waitFor(() => { + expect(result.current.data).toBeUndefined() + }) + }) + + it('should create an array of ProtocolAnalysisSummaries when calling the createProtocolAnalysis callback', async () => { + vi.mocked(useHost).mockReturnValue(HOST_CONFIG) + vi.mocked(createProtocolAnalysis).mockResolvedValue({ + data: ANALYSIS_SUMMARY_RESPONSE, + } as Response) + + const { result } = renderHook( + () => useCreateProtocolAnalysisMutation('fake-protocol-key'), + { + wrapper, + } + ) + act(() => + result.current.createProtocolAnalysis({ + protocolKey: 'fake-protocol-key', + runTimeParameterValues: {}, + }) + ) + + await waitFor(() => { + expect(result.current.data).toEqual(ANALYSIS_SUMMARY_RESPONSE) + }) + }) +}) diff --git a/react-api-client/src/protocols/__tests__/useCreateProtocolMutation.test.tsx b/react-api-client/src/protocols/__tests__/useCreateProtocolMutation.test.tsx index f6192eb8ec0..9872410db23 100644 --- a/react-api-client/src/protocols/__tests__/useCreateProtocolMutation.test.tsx +++ b/react-api-client/src/protocols/__tests__/useCreateProtocolMutation.test.tsx @@ -95,6 +95,7 @@ describe('useCreateProtocolMutation hook', () => { result.current.createProtocol({ files: createProtocolData, protocolKey: 'fakeProtocolKey', + runTimeParameterValues: { fakeParamName: 5.0 }, }) ) diff --git a/react-api-client/src/protocols/index.ts b/react-api-client/src/protocols/index.ts index ddf7c3eeaac..561dee01e8b 100644 --- a/react-api-client/src/protocols/index.ts +++ b/react-api-client/src/protocols/index.ts @@ -4,4 +4,5 @@ export { useProtocolQuery } from './useProtocolQuery' export { useProtocolAnalysesQuery } from './useProtocolAnalysesQuery' export { useProtocolAnalysisAsDocumentQuery } from './useProtocolAnalysisAsDocumentQuery' export { useCreateProtocolMutation } from './useCreateProtocolMutation' +export { useCreateProtocolAnalysisMutation } from './useCreateProtocolAnalysisMutation' export { useDeleteProtocolMutation } from './useDeleteProtocolMutation' diff --git a/react-api-client/src/protocols/useCreateProtocolAnalysisMutation.ts b/react-api-client/src/protocols/useCreateProtocolAnalysisMutation.ts new file mode 100644 index 00000000000..f8ba6e10586 --- /dev/null +++ b/react-api-client/src/protocols/useCreateProtocolAnalysisMutation.ts @@ -0,0 +1,86 @@ +import { createProtocolAnalysis } from '@opentrons/api-client' +import { useMutation, useQueryClient } from 'react-query' +import { useHost } from '../api' +import type { + ErrorResponse, + HostConfig, + RunTimeParameterCreateData, +} from '@opentrons/api-client' +import type { ProtocolAnalysisSummary } from '@opentrons/shared-data' +import type { AxiosError } from 'axios' +import type { + UseMutationResult, + UseMutationOptions, + UseMutateFunction, +} from 'react-query' + +export interface CreateProtocolAnalysisVariables { + protocolKey: string + runTimeParameterValues?: RunTimeParameterCreateData + forceReAnalyze?: boolean +} +export type UseCreateProtocolMutationResult = UseMutationResult< + ProtocolAnalysisSummary[], + AxiosError, + CreateProtocolAnalysisVariables +> & { + createProtocolAnalysis: UseMutateFunction< + ProtocolAnalysisSummary[], + AxiosError, + CreateProtocolAnalysisVariables + > +} + +export type UseCreateProtocolAnalysisMutationOptions = UseMutationOptions< + ProtocolAnalysisSummary[], + AxiosError, + CreateProtocolAnalysisVariables +> + +export function useCreateProtocolAnalysisMutation( + protocolId: string | null, + hostOverride?: HostConfig | null, + options: UseCreateProtocolAnalysisMutationOptions | undefined = {} +): UseCreateProtocolMutationResult { + const contextHost = useHost() + const host = + hostOverride != null ? { ...contextHost, ...hostOverride } : contextHost + const queryClient = useQueryClient() + + const mutation = useMutation< + ProtocolAnalysisSummary[], + AxiosError, + CreateProtocolAnalysisVariables + >( + [host, 'protocols', protocolId, 'analyses'], + ({ protocolKey, runTimeParameterValues, forceReAnalyze }) => + createProtocolAnalysis( + host as HostConfig, + protocolKey, + runTimeParameterValues, + forceReAnalyze + ) + .then(response => { + queryClient + .invalidateQueries([host, 'protocols', protocolId, 'analyses']) + .then(() => + queryClient.setQueryData( + [host, 'protocols', protocolId, 'analyses'], + response.data + ) + ) + .catch((e: Error) => { + throw e + }) + return response.data + }) + .catch((e: Error) => { + throw e + }), + options + ) + return { + ...mutation, + createProtocolAnalysis: mutation.mutate, + } +} diff --git a/react-api-client/src/protocols/useCreateProtocolMutation.ts b/react-api-client/src/protocols/useCreateProtocolMutation.ts index 1474787b75e..2e36321e311 100644 --- a/react-api-client/src/protocols/useCreateProtocolMutation.ts +++ b/react-api-client/src/protocols/useCreateProtocolMutation.ts @@ -8,11 +8,17 @@ import { import { createProtocol } from '@opentrons/api-client' import { useHost } from '../api' import type { AxiosError } from 'axios' -import type { ErrorResponse, HostConfig, Protocol } from '@opentrons/api-client' +import type { + ErrorResponse, + HostConfig, + Protocol, + RunTimeParameterCreateData, +} from '@opentrons/api-client' export interface CreateProtocolVariables { files: File[] protocolKey?: string + runTimeParameterValues?: RunTimeParameterCreateData } export type UseCreateProtocolMutationResult = UseMutationResult< Protocol, @@ -34,7 +40,8 @@ export type UseCreateProtocolMutationOptions = UseMutationOptions< export function useCreateProtocolMutation( options: UseCreateProtocolMutationOptions = {}, - hostOverride?: HostConfig | null + hostOverride?: HostConfig | null, + runTimeParameterValues?: RunTimeParameterCreateData ): UseCreateProtocolMutationResult { const contextHost = useHost() const host = @@ -48,7 +55,12 @@ export function useCreateProtocolMutation( >( [host, 'protocols'], ({ files: protocolFiles, protocolKey }) => - createProtocol(host as HostConfig, protocolFiles, protocolKey) + createProtocol( + host as HostConfig, + protocolFiles, + protocolKey, + runTimeParameterValues + ) .then(response => { const protocolId = response.data.data.id queryClient diff --git a/react-api-client/src/robot/__tests__/useRobotSettingsQuery.test.tsx b/react-api-client/src/robot/__tests__/useRobotSettingsQuery.test.tsx new file mode 100644 index 00000000000..2f980be9473 --- /dev/null +++ b/react-api-client/src/robot/__tests__/useRobotSettingsQuery.test.tsx @@ -0,0 +1,81 @@ +import * as React from 'react' +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest' +import { QueryClient, QueryClientProvider } from 'react-query' +import { renderHook, waitFor } from '@testing-library/react' + +import { getRobotSettings } from '@opentrons/api-client' +import { useHost } from '../../api' +import { useRobotSettingsQuery } from '..' + +import type { + HostConfig, + Response, + RobotSettingsResponse, +} from '@opentrons/api-client' +import type { UseRobotSettingsQueryOptions } from '../useRobotSettingsQuery' + +vi.mock('@opentrons/api-client') +vi.mock('../../api/useHost') + +const HOST_CONFIG: HostConfig = { hostname: 'localhost' } +const ROBOT_SETTINGS_RESPONSE: RobotSettingsResponse = { + settings: [ + { + id: 'enableOEMMode', + title: 'Enable OEM Mode', + description: 'a mode for an OEM', + value: false, + }, + ], +} + +describe('useRobotSettingsQuery hook', () => { + let wrapper: React.FunctionComponent< + { children: React.ReactNode } & UseRobotSettingsQueryOptions + > + + beforeEach(() => { + const queryClient = new QueryClient() + const clientProvider: React.FunctionComponent< + { children: React.ReactNode } & UseRobotSettingsQueryOptions + > = ({ children }) => ( + {children} + ) + + wrapper = clientProvider + }) + + afterEach(() => { + vi.resetAllMocks() + }) + + it('should return no data if no host', () => { + vi.mocked(useHost).mockReturnValue(null) + + const { result } = renderHook(() => useRobotSettingsQuery(), { wrapper }) + + expect(result.current?.data).toBeUndefined() + }) + + it('should return no data if robot settings request fails', () => { + vi.mocked(useHost).mockReturnValue(HOST_CONFIG) + vi.mocked(getRobotSettings).mockRejectedValue('oh no') + + const { result } = renderHook(() => useRobotSettingsQuery(), { wrapper }) + + expect(result.current?.data).toBeUndefined() + }) + + it('should return robot settings response data', async () => { + vi.mocked(useHost).mockReturnValue(HOST_CONFIG) + vi.mocked(getRobotSettings).mockResolvedValue({ + data: ROBOT_SETTINGS_RESPONSE, + } as Response) + + const { result } = renderHook(() => useRobotSettingsQuery(), { wrapper }) + + await waitFor(() => { + expect(result.current?.data).toEqual(ROBOT_SETTINGS_RESPONSE) + }) + }) +}) diff --git a/react-api-client/src/robot/index.ts b/react-api-client/src/robot/index.ts index 8a539abcea9..0ac1c3341b5 100644 --- a/react-api-client/src/robot/index.ts +++ b/react-api-client/src/robot/index.ts @@ -3,3 +3,5 @@ export { useEstopQuery } from './useEstopQuery' export { useLightsQuery } from './useLightsQuery' export { useAcknowledgeEstopDisengageMutation } from './useAcknowledgeEstopDisengageMutation' export { useSetLightsMutation } from './useSetLightsMutation' +export { useRobotSettingsQuery } from './useRobotSettingsQuery' +export { useUpdateRobotSettingMutation } from './useUpdateRobotSettingMutation' diff --git a/react-api-client/src/robot/useRobotSettingsQuery.ts b/react-api-client/src/robot/useRobotSettingsQuery.ts new file mode 100644 index 00000000000..455457ec83b --- /dev/null +++ b/react-api-client/src/robot/useRobotSettingsQuery.ts @@ -0,0 +1,21 @@ +import { useQuery } from 'react-query' +import { getRobotSettings } from '@opentrons/api-client' +import { useHost } from '../api' + +import type { UseQueryResult, UseQueryOptions } from 'react-query' +import type { HostConfig, RobotSettingsResponse } from '@opentrons/api-client' + +export type UseRobotSettingsQueryOptions = UseQueryOptions + +export function useRobotSettingsQuery( + options: UseRobotSettingsQueryOptions = {} +): UseQueryResult { + const host = useHost() + const query = useQuery( + [host as HostConfig, 'robot_settings'], + () => getRobotSettings(host as HostConfig).then(response => response.data), + { enabled: host !== null, ...options } + ) + + return query +} diff --git a/react-api-client/src/robot/useUpdateRobotSettingMutation.ts b/react-api-client/src/robot/useUpdateRobotSettingMutation.ts new file mode 100644 index 00000000000..83765fb5a70 --- /dev/null +++ b/react-api-client/src/robot/useUpdateRobotSettingMutation.ts @@ -0,0 +1,68 @@ +import { useMutation } from 'react-query' +import { updateRobotSetting } from '@opentrons/api-client' +import { useHost } from '../api' + +import type { AxiosError } from 'axios' +import type { + UseMutateFunction, + UseMutationOptions, + UseMutationResult, +} from 'react-query' +import type { + ErrorResponse, + HostConfig, + RobotSettings, +} from '@opentrons/api-client' + +export interface UpdateRobotSettingVariables { + id: string + value: boolean +} + +export type UseUpdateRobotSettingMutationResult = UseMutationResult< + RobotSettings, + AxiosError, + UpdateRobotSettingVariables +> & { + updateRobotSetting: UseMutateFunction< + RobotSettings, + AxiosError, + UpdateRobotSettingVariables + > +} + +export type UseUpdateRobotSettingnMutationOptions = UseMutationOptions< + RobotSettings, + AxiosError, + UpdateRobotSettingVariables +> + +export function useUpdateRobotSettingMutation( + options: UseUpdateRobotSettingnMutationOptions = {} +): UseUpdateRobotSettingMutationResult { + const host = useHost() + // const queryClient = useQueryClient() + + const mutation = useMutation< + RobotSettings, + AxiosError, + UpdateRobotSettingVariables + >( + [host, 'robot_settings'], + ({ id, value }) => + updateRobotSetting(host as HostConfig, id, value).then(response => { + // TODO: investigate ODD top level behavior when invalidating this query + // queryClient + // .invalidateQueries([host, 'robot_settings']) + // .catch((e: Error) => { + // throw e + // }) + return response.data?.settings ?? [] + }), + options + ) + return { + ...mutation, + updateRobotSetting: mutation.mutate, + } +} diff --git a/react-api-client/src/runs/__fixtures__/runs.ts b/react-api-client/src/runs/__fixtures__/runs.ts index e97ed37ca63..9320df9fbea 100644 --- a/react-api-client/src/runs/__fixtures__/runs.ts +++ b/react-api-client/src/runs/__fixtures__/runs.ts @@ -32,6 +32,7 @@ export const mockPausedRun: RunData = { pipettes: [], labware: [], modules: [], + runTimeParameters: [], } export const mockRunningRun: RunData = { @@ -61,6 +62,7 @@ export const mockRunningRun: RunData = { pipettes: [], labware: [], modules: [], + runTimeParameters: [], } export const mockRunResponse: Run = { diff --git a/react-api-client/src/runs/useAllCommandsQuery.ts b/react-api-client/src/runs/useAllCommandsQuery.ts index bb542bd8370..f258b61836f 100644 --- a/react-api-client/src/runs/useAllCommandsQuery.ts +++ b/react-api-client/src/runs/useAllCommandsQuery.ts @@ -16,21 +16,25 @@ export const DEFAULT_PARAMS: GetCommandsParams = { export function useAllCommandsQuery( runId: string | null, - params: GetCommandsParams = DEFAULT_PARAMS, + params?: GetCommandsParams | null, options: UseQueryOptions = {} ): UseQueryResult { const host = useHost() + const nullCheckedParams = params ?? DEFAULT_PARAMS + const allOptions: UseQueryOptions = { ...options, enabled: host !== null && runId != null && options.enabled !== false, } - const { cursor, pageLength } = params + const { cursor, pageLength } = nullCheckedParams const query = useQuery( [host, 'runs', runId, 'commands', cursor, pageLength], () => { - return getCommands(host as HostConfig, runId as string, params).then( - response => response.data - ) + return getCommands( + host as HostConfig, + runId as string, + nullCheckedParams + ).then(response => response.data) }, allOptions ) diff --git a/react-api-client/src/system/index.ts b/react-api-client/src/system/index.ts index 10dc4d8ba66..faabb1e9f35 100644 --- a/react-api-client/src/system/index.ts +++ b/react-api-client/src/system/index.ts @@ -1,2 +1,3 @@ export { useAuthorization } from './useAuthorization' export { useConnectionsQuery } from './useConnectionsQuery' +export { useCreateSplashMutation } from './useCreateSplashMutation' diff --git a/react-api-client/src/system/useCreateSplashMutation.ts b/react-api-client/src/system/useCreateSplashMutation.ts new file mode 100644 index 00000000000..783dc1cf7b4 --- /dev/null +++ b/react-api-client/src/system/useCreateSplashMutation.ts @@ -0,0 +1,58 @@ +import { useMutation } from 'react-query' +import { createSplash } from '@opentrons/api-client' +import { useHost } from '../api' + +import type { AxiosError, AxiosResponse } from 'axios' +import type { + UseMutationResult, + UseMutationOptions, + UseMutateFunction, +} from 'react-query' +import type { ErrorResponse, HostConfig } from '@opentrons/api-client' + +export interface CreateSplashRequestData { + file: File +} +export type UseCreateSplashMutationResult = UseMutationResult< + AxiosResponse, + AxiosError, + CreateSplashRequestData +> & { + createSplash: UseMutateFunction< + AxiosResponse, + AxiosError, + CreateSplashRequestData + > +} + +export type UseCreateSplashMutationOptions = UseMutationOptions< + AxiosResponse, + AxiosError, + CreateSplashRequestData +> + +export function useCreateSplashMutation( + options: UseCreateSplashMutationOptions = {}, + hostOverride?: HostConfig | null +): UseCreateSplashMutationResult { + const contextHost = useHost() + const host = + hostOverride != null ? { ...contextHost, ...hostOverride } : contextHost + + const mutation = useMutation< + AxiosResponse, + AxiosError, + CreateSplashRequestData + >( + [host, 'splash'], + ({ file }) => + createSplash(host as HostConfig, file).catch(e => { + throw e + }), + options + ) + return { + ...mutation, + createSplash: mutation.mutate, + } +} diff --git a/robot-server/Pipfile b/robot-server/Pipfile index e6c1b7ba794..2d22c6dc34c 100755 --- a/robot-server/Pipfile +++ b/robot-server/Pipfile @@ -36,9 +36,11 @@ sqlalchemy2-stubs = "==0.0.2a21" # limited by tavern python-box = "==6.1.0" types-paho-mqtt = "==1.6.0.20240106" +performance-metrics = {file = "../performance-metrics", editable = true} [packages] anyio = "==3.7.1" +aiohttp = "==3.8.1" # fastapi >=0.100.0 is intended for use with pydantic 2.x, and while it theoretically is # backwards compatible, best to be sure fastapi = "==0.99.1" diff --git a/robot-server/Pipfile.lock b/robot-server/Pipfile.lock index e97832aab95..2ea9f545696 100644 --- a/robot-server/Pipfile.lock +++ b/robot-server/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "d56512f7ae8f68fd80ec6eff41af08576468087a45578f5b2c8241e42d95b887" + "sha256": "9f64ba7d87b9c9fd510aac5c4a22fa748c1bb3b9936826ef2b4b13454c1c5e2b" }, "pipfile-spec": 6, "requires": { @@ -16,6 +16,85 @@ ] }, "default": { + "aiohttp": { + "hashes": [ + "sha256:01d7bdb774a9acc838e6b8f1d114f45303841b89b95984cbb7d80ea41172a9e3", + "sha256:03a6d5349c9ee8f79ab3ff3694d6ce1cfc3ced1c9d36200cb8f08ba06bd3b782", + "sha256:04d48b8ce6ab3cf2097b1855e1505181bdd05586ca275f2505514a6e274e8e75", + "sha256:0770e2806a30e744b4e21c9d73b7bee18a1cfa3c47991ee2e5a65b887c49d5cf", + "sha256:07b05cd3305e8a73112103c834e91cd27ce5b4bd07850c4b4dbd1877d3f45be7", + "sha256:086f92daf51a032d062ec5f58af5ca6a44d082c35299c96376a41cbb33034675", + "sha256:099ebd2c37ac74cce10a3527d2b49af80243e2a4fa39e7bce41617fbc35fa3c1", + "sha256:0c7ebbbde809ff4e970824b2b6cb7e4222be6b95a296e46c03cf050878fc1785", + "sha256:102e487eeb82afac440581e5d7f8f44560b36cf0bdd11abc51a46c1cd88914d4", + "sha256:11691cf4dc5b94236ccc609b70fec991234e7ef8d4c02dd0c9668d1e486f5abf", + "sha256:11a67c0d562e07067c4e86bffc1553f2cf5b664d6111c894671b2b8712f3aba5", + "sha256:12de6add4038df8f72fac606dff775791a60f113a725c960f2bab01d8b8e6b15", + "sha256:13487abd2f761d4be7c8ff9080de2671e53fff69711d46de703c310c4c9317ca", + "sha256:15b09b06dae900777833fe7fc4b4aa426556ce95847a3e8d7548e2d19e34edb8", + "sha256:1c182cb873bc91b411e184dab7a2b664d4fea2743df0e4d57402f7f3fa644bac", + "sha256:1ed0b6477896559f17b9eaeb6d38e07f7f9ffe40b9f0f9627ae8b9926ae260a8", + "sha256:28d490af82bc6b7ce53ff31337a18a10498303fe66f701ab65ef27e143c3b0ef", + "sha256:2e5d962cf7e1d426aa0e528a7e198658cdc8aa4fe87f781d039ad75dcd52c516", + "sha256:2ed076098b171573161eb146afcb9129b5ff63308960aeca4b676d9d3c35e700", + "sha256:2f2f69dca064926e79997f45b2f34e202b320fd3782f17a91941f7eb85502ee2", + "sha256:31560d268ff62143e92423ef183680b9829b1b482c011713ae941997921eebc8", + "sha256:31d1e1c0dbf19ebccbfd62eff461518dcb1e307b195e93bba60c965a4dcf1ba0", + "sha256:37951ad2f4a6df6506750a23f7cbabad24c73c65f23f72e95897bb2cecbae676", + "sha256:3af642b43ce56c24d063325dd2cf20ee012d2b9ba4c3c008755a301aaea720ad", + "sha256:44db35a9e15d6fe5c40d74952e803b1d96e964f683b5a78c3cc64eb177878155", + "sha256:473d93d4450880fe278696549f2e7aed8cd23708c3c1997981464475f32137db", + "sha256:477c3ea0ba410b2b56b7efb072c36fa91b1e6fc331761798fa3f28bb224830dd", + "sha256:4a4a4e30bf1edcad13fb0804300557aedd07a92cabc74382fdd0ba6ca2661091", + "sha256:4aed991a28ea3ce320dc8ce655875e1e00a11bdd29fe9444dd4f88c30d558602", + "sha256:51467000f3647d519272392f484126aa716f747859794ac9924a7aafa86cd411", + "sha256:55c3d1072704d27401c92339144d199d9de7b52627f724a949fc7d5fc56d8b93", + "sha256:589c72667a5febd36f1315aa6e5f56dd4aa4862df295cb51c769d16142ddd7cd", + "sha256:5bfde62d1d2641a1f5173b8c8c2d96ceb4854f54a44c23102e2ccc7e02f003ec", + "sha256:5c23b1ad869653bc818e972b7a3a79852d0e494e9ab7e1a701a3decc49c20d51", + "sha256:61bfc23df345d8c9716d03717c2ed5e27374e0fe6f659ea64edcd27b4b044cf7", + "sha256:6ae828d3a003f03ae31915c31fa684b9890ea44c9c989056fea96e3d12a9fa17", + "sha256:6c7cefb4b0640703eb1069835c02486669312bf2f12b48a748e0a7756d0de33d", + "sha256:6d69f36d445c45cda7b3b26afef2fc34ef5ac0cdc75584a87ef307ee3c8c6d00", + "sha256:6f0d5f33feb5f69ddd57a4a4bd3d56c719a141080b445cbf18f238973c5c9923", + "sha256:6f8b01295e26c68b3a1b90efb7a89029110d3a4139270b24fda961893216c440", + "sha256:713ac174a629d39b7c6a3aa757b337599798da4c1157114a314e4e391cd28e32", + "sha256:718626a174e7e467f0558954f94af117b7d4695d48eb980146016afa4b580b2e", + "sha256:7187a76598bdb895af0adbd2fb7474d7f6025d170bc0a1130242da817ce9e7d1", + "sha256:71927042ed6365a09a98a6377501af5c9f0a4d38083652bcd2281a06a5976724", + "sha256:7d08744e9bae2ca9c382581f7dce1273fe3c9bae94ff572c3626e8da5b193c6a", + "sha256:7dadf3c307b31e0e61689cbf9e06be7a867c563d5a63ce9dca578f956609abf8", + "sha256:81e3d8c34c623ca4e36c46524a3530e99c0bc95ed068fd6e9b55cb721d408fb2", + "sha256:844a9b460871ee0a0b0b68a64890dae9c415e513db0f4a7e3cab41a0f2fedf33", + "sha256:8b7ef7cbd4fec9a1e811a5de813311ed4f7ac7d93e0fda233c9b3e1428f7dd7b", + "sha256:97ef77eb6b044134c0b3a96e16abcb05ecce892965a2124c566af0fd60f717e2", + "sha256:99b5eeae8e019e7aad8af8bb314fb908dd2e028b3cdaad87ec05095394cce632", + "sha256:a25fa703a527158aaf10dafd956f7d42ac6d30ec80e9a70846253dd13e2f067b", + "sha256:a2f635ce61a89c5732537a7896b6319a8fcfa23ba09bec36e1b1ac0ab31270d2", + "sha256:a79004bb58748f31ae1cbe9fa891054baaa46fb106c2dc7af9f8e3304dc30316", + "sha256:a996d01ca39b8dfe77440f3cd600825d05841088fd6bc0144cc6c2ec14cc5f74", + "sha256:b0e20cddbd676ab8a64c774fefa0ad787cc506afd844de95da56060348021e96", + "sha256:b6613280ccedf24354406caf785db748bebbddcf31408b20c0b48cb86af76866", + "sha256:b9d00268fcb9f66fbcc7cd9fe423741d90c75ee029a1d15c09b22d23253c0a44", + "sha256:bb01ba6b0d3f6c68b89fce7305080145d4877ad3acaed424bae4d4ee75faa950", + "sha256:c2aef4703f1f2ddc6df17519885dbfa3514929149d3ff900b73f45998f2532fa", + "sha256:c34dc4958b232ef6188c4318cb7b2c2d80521c9a56c52449f8f93ab7bc2a8a1c", + "sha256:c3630c3ef435c0a7c549ba170a0633a56e92629aeed0e707fec832dee313fb7a", + "sha256:c3d6a4d0619e09dcd61021debf7059955c2004fa29f48788a3dfaf9c9901a7cd", + "sha256:d15367ce87c8e9e09b0f989bfd72dc641bcd04ba091c68cd305312d00962addd", + "sha256:d2f9b69293c33aaa53d923032fe227feac867f81682f002ce33ffae978f0a9a9", + "sha256:e999f2d0e12eea01caeecb17b653f3713d758f6dcc770417cf29ef08d3931421", + "sha256:ea302f34477fda3f85560a06d9ebdc7fa41e82420e892fc50b577e35fc6a50b2", + "sha256:eaba923151d9deea315be1f3e2b31cc39a6d1d2f682f942905951f4e40200922", + "sha256:ef9612483cb35171d51d9173647eed5d0069eaa2ee812793a75373447d487aa4", + "sha256:f5315a2eb0239185af1bddb1abf472d877fede3cc8d143c6cddad37678293237", + "sha256:fa0ffcace9b3aa34d205d8130f7873fcfefcb6a4dd3dd705b0dab69af6712642", + "sha256:fc5471e1a54de15ef71c1bc6ebe80d4dc681ea600e68bfd1cbce40427f0b7578" + ], + "index": "pypi", + "markers": "python_version >= '3.6'", + "version": "==3.8.1" + }, "aionotify": { "hashes": [ "sha256:385e1becfaac2d9f4326673033d53912ef9565b6febdedbec593ee966df392c6", @@ -23,6 +102,14 @@ ], "version": "==0.2.0" }, + "aiosignal": { + "hashes": [ + "sha256:54cd96e15e1649b75d6c87526a6ff0b6c1b0dd3459f43d9ca11d48c339b68cfc", + "sha256:f8376fb07dd1e86a584e4fcdec80b36b7f81aac666ebc724e2c090300dd83b17" + ], + "markers": "python_version >= '3.7'", + "version": "==1.3.1" + }, "anyio": { "hashes": [ "sha256:44a3c9aba0f5defa43261a8b3efb97891f2bd7d804e0e1f56419befa1adfc780", @@ -32,6 +119,14 @@ "markers": "python_version >= '3.7'", "version": "==3.7.1" }, + "async-timeout": { + "hashes": [ + "sha256:4640d96be84d82d02ed59ea2b7105a0f7b33abe8703703cd0ab0bf87c427522f", + "sha256:7405140ff1230c310e51dc27b3145b9092d659ce68ff733fb0cefe3ee42be028" + ], + "markers": "python_version >= '3.7'", + "version": "==4.0.3" + }, "attrs": { "hashes": [ "sha256:935dc3b529c262f6cf76e50877d35a4bd3c1de194fd41f47a2b7ae8f19971f30", @@ -40,6 +135,14 @@ "markers": "python_version >= '3.7'", "version": "==23.2.0" }, + "charset-normalizer": { + "hashes": [ + "sha256:5a3d016c7c547f69d6f81fb0db9449ce888b418b5b9952cc5e6e66843e9dd845", + "sha256:83e9a75d1911279afd89352c68b45348559d1fc0506b054b346651b5e7fee29f" + ], + "markers": "python_full_version >= '3.6.0'", + "version": "==2.1.1" + }, "click": { "hashes": [ "sha256:24e1a4a9ec5bf6299411369b208c1df2188d9eb8d916302fe6bf03faed227f1e", @@ -66,6 +169,153 @@ "markers": "python_version >= '3.7'", "version": "==0.99.1" }, + "frozenlist": { + "hashes": [ + "sha256:04ced3e6a46b4cfffe20f9ae482818e34eba9b5fb0ce4056e4cc9b6e212d09b7", + "sha256:0633c8d5337cb5c77acbccc6357ac49a1770b8c487e5b3505c57b949b4b82e98", + "sha256:068b63f23b17df8569b7fdca5517edef76171cf3897eb68beb01341131fbd2ad", + "sha256:0c250a29735d4f15321007fb02865f0e6b6a41a6b88f1f523ca1596ab5f50bd5", + "sha256:1979bc0aeb89b33b588c51c54ab0161791149f2461ea7c7c946d95d5f93b56ae", + "sha256:1a4471094e146b6790f61b98616ab8e44f72661879cc63fa1049d13ef711e71e", + "sha256:1b280e6507ea8a4fa0c0a7150b4e526a8d113989e28eaaef946cc77ffd7efc0a", + "sha256:1d0ce09d36d53bbbe566fe296965b23b961764c0bcf3ce2fa45f463745c04701", + "sha256:20b51fa3f588ff2fe658663db52a41a4f7aa6c04f6201449c6c7c476bd255c0d", + "sha256:23b2d7679b73fe0e5a4560b672a39f98dfc6f60df63823b0a9970525325b95f6", + "sha256:23b701e65c7b36e4bf15546a89279bd4d8675faabc287d06bbcfac7d3c33e1e6", + "sha256:2471c201b70d58a0f0c1f91261542a03d9a5e088ed3dc6c160d614c01649c106", + "sha256:27657df69e8801be6c3638054e202a135c7f299267f1a55ed3a598934f6c0d75", + "sha256:29acab3f66f0f24674b7dc4736477bcd4bc3ad4b896f5f45379a67bce8b96868", + "sha256:32453c1de775c889eb4e22f1197fe3bdfe457d16476ea407472b9442e6295f7a", + "sha256:3a670dc61eb0d0eb7080890c13de3066790f9049b47b0de04007090807c776b0", + "sha256:3e0153a805a98f5ada7e09826255ba99fb4f7524bb81bf6b47fb702666484ae1", + "sha256:410478a0c562d1a5bcc2f7ea448359fcb050ed48b3c6f6f4f18c313a9bdb1826", + "sha256:442acde1e068288a4ba7acfe05f5f343e19fac87bfc96d89eb886b0363e977ec", + "sha256:48f6a4533887e189dae092f1cf981f2e3885175f7a0f33c91fb5b7b682b6bab6", + "sha256:4f57dab5fe3407b6c0c1cc907ac98e8a189f9e418f3b6e54d65a718aaafe3950", + "sha256:4f9c515e7914626b2a2e1e311794b4c35720a0be87af52b79ff8e1429fc25f19", + "sha256:55fdc093b5a3cb41d420884cdaf37a1e74c3c37a31f46e66286d9145d2063bd0", + "sha256:5667ed53d68d91920defdf4035d1cdaa3c3121dc0b113255124bcfada1cfa1b8", + "sha256:590344787a90ae57d62511dd7c736ed56b428f04cd8c161fcc5e7232c130c69a", + "sha256:5a7d70357e7cee13f470c7883a063aae5fe209a493c57d86eb7f5a6f910fae09", + "sha256:5c3894db91f5a489fc8fa6a9991820f368f0b3cbdb9cd8849547ccfab3392d86", + "sha256:5c849d495bf5154cd8da18a9eb15db127d4dba2968d88831aff6f0331ea9bd4c", + "sha256:64536573d0a2cb6e625cf309984e2d873979709f2cf22839bf2d61790b448ad5", + "sha256:693945278a31f2086d9bf3df0fe8254bbeaef1fe71e1351c3bd730aa7d31c41b", + "sha256:6db4667b187a6742b33afbbaf05a7bc551ffcf1ced0000a571aedbb4aa42fc7b", + "sha256:6eb73fa5426ea69ee0e012fb59cdc76a15b1283d6e32e4f8dc4482ec67d1194d", + "sha256:722e1124aec435320ae01ee3ac7bec11a5d47f25d0ed6328f2273d287bc3abb0", + "sha256:7268252af60904bf52c26173cbadc3a071cece75f873705419c8681f24d3edea", + "sha256:74fb4bee6880b529a0c6560885fce4dc95936920f9f20f53d99a213f7bf66776", + "sha256:780d3a35680ced9ce682fbcf4cb9c2bad3136eeff760ab33707b71db84664e3a", + "sha256:82e8211d69a4f4bc360ea22cd6555f8e61a1bd211d1d5d39d3d228b48c83a897", + "sha256:89aa2c2eeb20957be2d950b85974b30a01a762f3308cd02bb15e1ad632e22dc7", + "sha256:8aefbba5f69d42246543407ed2461db31006b0f76c4e32dfd6f42215a2c41d09", + "sha256:96ec70beabbd3b10e8bfe52616a13561e58fe84c0101dd031dc78f250d5128b9", + "sha256:9750cc7fe1ae3b1611bb8cfc3f9ec11d532244235d75901fb6b8e42ce9229dfe", + "sha256:9acbb16f06fe7f52f441bb6f413ebae6c37baa6ef9edd49cdd567216da8600cd", + "sha256:9d3e0c25a2350080e9319724dede4f31f43a6c9779be48021a7f4ebde8b2d742", + "sha256:a06339f38e9ed3a64e4c4e43aec7f59084033647f908e4259d279a52d3757d09", + "sha256:a0cb6f11204443f27a1628b0e460f37fb30f624be6051d490fa7d7e26d4af3d0", + "sha256:a7496bfe1da7fb1a4e1cc23bb67c58fab69311cc7d32b5a99c2007b4b2a0e932", + "sha256:a828c57f00f729620a442881cc60e57cfcec6842ba38e1b19fd3e47ac0ff8dc1", + "sha256:a9b2de4cf0cdd5bd2dee4c4f63a653c61d2408055ab77b151c1957f221cabf2a", + "sha256:b46c8ae3a8f1f41a0d2ef350c0b6e65822d80772fe46b653ab6b6274f61d4a49", + "sha256:b7e3ed87d4138356775346e6845cccbe66cd9e207f3cd11d2f0b9fd13681359d", + "sha256:b7f2f9f912dca3934c1baec2e4585a674ef16fe00218d833856408c48d5beee7", + "sha256:ba60bb19387e13597fb059f32cd4d59445d7b18b69a745b8f8e5db0346f33480", + "sha256:beee944ae828747fd7cb216a70f120767fc9f4f00bacae8543c14a6831673f89", + "sha256:bfa4a17e17ce9abf47a74ae02f32d014c5e9404b6d9ac7f729e01562bbee601e", + "sha256:c037a86e8513059a2613aaba4d817bb90b9d9b6b69aace3ce9c877e8c8ed402b", + "sha256:c302220494f5c1ebeb0912ea782bcd5e2f8308037b3c7553fad0e48ebad6ad82", + "sha256:c6321c9efe29975232da3bd0af0ad216800a47e93d763ce64f291917a381b8eb", + "sha256:c757a9dd70d72b076d6f68efdbb9bc943665ae954dad2801b874c8c69e185068", + "sha256:c99169d4ff810155ca50b4da3b075cbde79752443117d89429595c2e8e37fed8", + "sha256:c9c92be9fd329ac801cc420e08452b70e7aeab94ea4233a4804f0915c14eba9b", + "sha256:cc7b01b3754ea68a62bd77ce6020afaffb44a590c2289089289363472d13aedb", + "sha256:db9e724bebd621d9beca794f2a4ff1d26eed5965b004a97f1f1685a173b869c2", + "sha256:dca69045298ce5c11fd539682cff879cc1e664c245d1c64da929813e54241d11", + "sha256:dd9b1baec094d91bf36ec729445f7769d0d0cf6b64d04d86e45baf89e2b9059b", + "sha256:e02a0e11cf6597299b9f3bbd3f93d79217cb90cfd1411aec33848b13f5c656cc", + "sha256:e6a20a581f9ce92d389a8c7d7c3dd47c81fd5d6e655c8dddf341e14aa48659d0", + "sha256:e7004be74cbb7d9f34553a5ce5fb08be14fb33bc86f332fb71cbe5216362a497", + "sha256:e774d53b1a477a67838a904131c4b0eef6b3d8a651f8b138b04f748fccfefe17", + "sha256:edb678da49d9f72c9f6c609fbe41a5dfb9a9282f9e6a2253d5a91e0fc382d7c0", + "sha256:f146e0911cb2f1da549fc58fc7bcd2b836a44b79ef871980d605ec392ff6b0d2", + "sha256:f56e2333dda1fe0f909e7cc59f021eba0d2307bc6f012a1ccf2beca6ba362439", + "sha256:f9a3ea26252bd92f570600098783d1371354d89d5f6b7dfd87359d669f2109b5", + "sha256:f9aa1878d1083b276b0196f2dfbe00c9b7e752475ed3b682025ff20c1c1f51ac", + "sha256:fb3c2db03683b5767dedb5769b8a40ebb47d6f7f45b1b3e3b4b51ec8ad9d9825", + "sha256:fbeb989b5cc29e8daf7f976b421c220f1b8c731cbf22b9130d8815418ea45887", + "sha256:fde5bd59ab5357e3853313127f4d3565fc7dad314a74d7b5d43c22c6a5ed2ced", + "sha256:fe1a06da377e3a1062ae5fe0926e12b84eceb8a50b350ddca72dc85015873f74" + ], + "markers": "python_version >= '3.8'", + "version": "==1.4.1" + }, + "greenlet": { + "hashes": [ + "sha256:01bc7ea167cf943b4c802068e178bbf70ae2e8c080467070d01bfa02f337ee67", + "sha256:0448abc479fab28b00cb472d278828b3ccca164531daab4e970a0458786055d6", + "sha256:086152f8fbc5955df88382e8a75984e2bb1c892ad2e3c80a2508954e52295257", + "sha256:098d86f528c855ead3479afe84b49242e174ed262456c342d70fc7f972bc13c4", + "sha256:149e94a2dd82d19838fe4b2259f1b6b9957d5ba1b25640d2380bea9c5df37676", + "sha256:1551a8195c0d4a68fac7a4325efac0d541b48def35feb49d803674ac32582f61", + "sha256:15d79dd26056573940fcb8c7413d84118086f2ec1a8acdfa854631084393efcc", + "sha256:1996cb9306c8595335bb157d133daf5cf9f693ef413e7673cb07e3e5871379ca", + "sha256:1a7191e42732df52cb5f39d3527217e7ab73cae2cb3694d241e18f53d84ea9a7", + "sha256:1ea188d4f49089fc6fb283845ab18a2518d279c7cd9da1065d7a84e991748728", + "sha256:1f672519db1796ca0d8753f9e78ec02355e862d0998193038c7073045899f305", + "sha256:2516a9957eed41dd8f1ec0c604f1cdc86758b587d964668b5b196a9db5bfcde6", + "sha256:2797aa5aedac23af156bbb5a6aa2cd3427ada2972c828244eb7d1b9255846379", + "sha256:2dd6e660effd852586b6a8478a1d244b8dc90ab5b1321751d2ea15deb49ed414", + "sha256:3ddc0f794e6ad661e321caa8d2f0a55ce01213c74722587256fb6566049a8b04", + "sha256:3ed7fb269f15dc662787f4119ec300ad0702fa1b19d2135a37c2c4de6fadfd4a", + "sha256:419b386f84949bf0e7c73e6032e3457b82a787c1ab4a0e43732898a761cc9dbf", + "sha256:43374442353259554ce33599da8b692d5aa96f8976d567d4badf263371fbe491", + "sha256:52f59dd9c96ad2fc0d5724107444f76eb20aaccb675bf825df6435acb7703559", + "sha256:57e8974f23e47dac22b83436bdcf23080ade568ce77df33159e019d161ce1d1e", + "sha256:5b51e85cb5ceda94e79d019ed36b35386e8c37d22f07d6a751cb659b180d5274", + "sha256:649dde7de1a5eceb258f9cb00bdf50e978c9db1b996964cd80703614c86495eb", + "sha256:64d7675ad83578e3fc149b617a444fab8efdafc9385471f868eb5ff83e446b8b", + "sha256:68834da854554926fbedd38c76e60c4a2e3198c6fbed520b106a8986445caaf9", + "sha256:6b66c9c1e7ccabad3a7d037b2bcb740122a7b17a53734b7d72a344ce39882a1b", + "sha256:70fb482fdf2c707765ab5f0b6655e9cfcf3780d8d87355a063547b41177599be", + "sha256:7170375bcc99f1a2fbd9c306f5be8764eaf3ac6b5cb968862cad4c7057756506", + "sha256:73a411ef564e0e097dbe7e866bb2dda0f027e072b04da387282b02c308807405", + "sha256:77457465d89b8263bca14759d7c1684df840b6811b2499838cc5b040a8b5b113", + "sha256:7f362975f2d179f9e26928c5b517524e89dd48530a0202570d55ad6ca5d8a56f", + "sha256:81bb9c6d52e8321f09c3d165b2a78c680506d9af285bfccbad9fb7ad5a5da3e5", + "sha256:881b7db1ebff4ba09aaaeae6aa491daeb226c8150fc20e836ad00041bcb11230", + "sha256:894393ce10ceac937e56ec00bb71c4c2f8209ad516e96033e4b3b1de270e200d", + "sha256:99bf650dc5d69546e076f413a87481ee1d2d09aaaaaca058c9251b6d8c14783f", + "sha256:9da2bd29ed9e4f15955dd1595ad7bc9320308a3b766ef7f837e23ad4b4aac31a", + "sha256:afaff6cf5200befd5cec055b07d1c0a5a06c040fe5ad148abcd11ba6ab9b114e", + "sha256:b1b5667cced97081bf57b8fa1d6bfca67814b0afd38208d52538316e9422fc61", + "sha256:b37eef18ea55f2ffd8f00ff8fe7c8d3818abd3e25fb73fae2ca3b672e333a7a6", + "sha256:b542be2440edc2d48547b5923c408cbe0fc94afb9f18741faa6ae970dbcb9b6d", + "sha256:b7dcbe92cc99f08c8dd11f930de4d99ef756c3591a5377d1d9cd7dd5e896da71", + "sha256:b7f009caad047246ed379e1c4dbcb8b020f0a390667ea74d2387be2998f58a22", + "sha256:bba5387a6975598857d86de9eac14210a49d554a77eb8261cc68b7d082f78ce2", + "sha256:c5e1536de2aad7bf62e27baf79225d0d64360d4168cf2e6becb91baf1ed074f3", + "sha256:c5ee858cfe08f34712f548c3c363e807e7186f03ad7a5039ebadb29e8c6be067", + "sha256:c9db1c18f0eaad2f804728c67d6c610778456e3e1cc4ab4bbd5eeb8e6053c6fc", + "sha256:d353cadd6083fdb056bb46ed07e4340b0869c305c8ca54ef9da3421acbdf6881", + "sha256:d46677c85c5ba00a9cb6f7a00b2bfa6f812192d2c9f7d9c4f6a55b60216712f3", + "sha256:d4d1ac74f5c0c0524e4a24335350edad7e5f03b9532da7ea4d3c54d527784f2e", + "sha256:d73a9fe764d77f87f8ec26a0c85144d6a951a6c438dfe50487df5595c6373eac", + "sha256:da70d4d51c8b306bb7a031d5cff6cc25ad253affe89b70352af5f1cb68e74b53", + "sha256:daf3cb43b7cf2ba96d614252ce1684c1bccee6b2183a01328c98d36fcd7d5cb0", + "sha256:dca1e2f3ca00b84a396bc1bce13dd21f680f035314d2379c4160c98153b2059b", + "sha256:dd4f49ae60e10adbc94b45c0b5e6a179acc1736cf7a90160b404076ee283cf83", + "sha256:e1f145462f1fa6e4a4ae3c0f782e580ce44d57c8f2c7aae1b6fa88c0b2efdb41", + "sha256:e3391d1e16e2a5a1507d83e4a8b100f4ee626e8eca43cf2cadb543de69827c4c", + "sha256:fcd2469d6a2cf298f198f0487e0a5b1a47a42ca0fa4dfd1b6862c999f018ebbf", + "sha256:fd096eb7ffef17c456cfa587523c5f92321ae02427ff955bebe9e3c63bc9f0da", + "sha256:fe754d231288e1e64323cfad462fcee8f0288654c10bdf4f603a39ed923bef33" + ], + "markers": "python_version >= '3' and platform_machine == 'aarch64' or (platform_machine == 'ppc64le' or (platform_machine == 'x86_64' or (platform_machine == 'amd64' or (platform_machine == 'AMD64' or (platform_machine == 'win32' or platform_machine == 'WIN32')))))", + "version": "==3.0.3" + }, "h11": { "hashes": [ "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d", @@ -93,65 +343,161 @@ }, "msgpack": { "hashes": [ - "sha256:04ad6069c86e531682f9e1e71b71c1c3937d6014a7c3e9edd2aa81ad58842862", - "sha256:0bfdd914e55e0d2c9e1526de210f6fe8ffe9705f2b1dfcc4aecc92a4cb4b533d", - "sha256:1dc93e8e4653bdb5910aed79f11e165c85732067614f180f70534f056da97db3", - "sha256:1e2d69948e4132813b8d1131f29f9101bc2c915f26089a6d632001a5c1349672", - "sha256:235a31ec7db685f5c82233bddf9858748b89b8119bf4538d514536c485c15fe0", - "sha256:27dcd6f46a21c18fa5e5deed92a43d4554e3df8d8ca5a47bf0615d6a5f39dbc9", - "sha256:28efb066cde83c479dfe5a48141a53bc7e5f13f785b92ddde336c716663039ee", - "sha256:3476fae43db72bd11f29a5147ae2f3cb22e2f1a91d575ef130d2bf49afd21c46", - "sha256:36e17c4592231a7dbd2ed09027823ab295d2791b3b1efb2aee874b10548b7524", - "sha256:384d779f0d6f1b110eae74cb0659d9aa6ff35aaf547b3955abf2ab4c901c4819", - "sha256:38949d30b11ae5f95c3c91917ee7a6b239f5ec276f271f28638dec9156f82cfc", - "sha256:3967e4ad1aa9da62fd53e346ed17d7b2e922cba5ab93bdd46febcac39be636fc", - "sha256:3e7bf4442b310ff154b7bb9d81eb2c016b7d597e364f97d72b1acc3817a0fdc1", - "sha256:3f0c8c6dfa6605ab8ff0611995ee30d4f9fcff89966cf562733b4008a3d60d82", - "sha256:484ae3240666ad34cfa31eea7b8c6cd2f1fdaae21d73ce2974211df099a95d81", - "sha256:4a7b4f35de6a304b5533c238bee86b670b75b03d31b7797929caa7a624b5dda6", - "sha256:4cb14ce54d9b857be9591ac364cb08dc2d6a5c4318c1182cb1d02274029d590d", - "sha256:4e71bc4416de195d6e9b4ee93ad3f2f6b2ce11d042b4d7a7ee00bbe0358bd0c2", - "sha256:52700dc63a4676669b341ba33520f4d6e43d3ca58d422e22ba66d1736b0a6e4c", - "sha256:572efc93db7a4d27e404501975ca6d2d9775705c2d922390d878fcf768d92c87", - "sha256:576eb384292b139821c41995523654ad82d1916da6a60cff129c715a6223ea84", - "sha256:5b0bf0effb196ed76b7ad883848143427a73c355ae8e569fa538365064188b8e", - "sha256:5b6ccc0c85916998d788b295765ea0e9cb9aac7e4a8ed71d12e7d8ac31c23c95", - "sha256:5ed82f5a7af3697b1c4786053736f24a0efd0a1b8a130d4c7bfee4b9ded0f08f", - "sha256:6d4c80667de2e36970ebf74f42d1088cc9ee7ef5f4e8c35eee1b40eafd33ca5b", - "sha256:730076207cb816138cf1af7f7237b208340a2c5e749707457d70705715c93b93", - "sha256:7687e22a31e976a0e7fc99c2f4d11ca45eff652a81eb8c8085e9609298916dcf", - "sha256:822ea70dc4018c7e6223f13affd1c5c30c0f5c12ac1f96cd8e9949acddb48a61", - "sha256:84b0daf226913133f899ea9b30618722d45feffa67e4fe867b0b5ae83a34060c", - "sha256:85765fdf4b27eb5086f05ac0491090fc76f4f2b28e09d9350c31aac25a5aaff8", - "sha256:8dd178c4c80706546702c59529ffc005681bd6dc2ea234c450661b205445a34d", - "sha256:8f5b234f567cf76ee489502ceb7165c2a5cecec081db2b37e35332b537f8157c", - "sha256:98bbd754a422a0b123c66a4c341de0474cad4a5c10c164ceed6ea090f3563db4", - "sha256:993584fc821c58d5993521bfdcd31a4adf025c7d745bbd4d12ccfecf695af5ba", - "sha256:a40821a89dc373d6427e2b44b572efc36a2778d3f543299e2f24eb1a5de65415", - "sha256:b291f0ee7961a597cbbcc77709374087fa2a9afe7bdb6a40dbbd9b127e79afee", - "sha256:b573a43ef7c368ba4ea06050a957c2a7550f729c31f11dd616d2ac4aba99888d", - "sha256:b610ff0f24e9f11c9ae653c67ff8cc03c075131401b3e5ef4b82570d1728f8a9", - "sha256:bdf38ba2d393c7911ae989c3bbba510ebbcdf4ecbdbfec36272abe350c454075", - "sha256:bfef2bb6ef068827bbd021017a107194956918ab43ce4d6dc945ffa13efbc25f", - "sha256:cab3db8bab4b7e635c1c97270d7a4b2a90c070b33cbc00c99ef3f9be03d3e1f7", - "sha256:cb70766519500281815dfd7a87d3a178acf7ce95390544b8c90587d76b227681", - "sha256:cca1b62fe70d761a282496b96a5e51c44c213e410a964bdffe0928e611368329", - "sha256:ccf9a39706b604d884d2cb1e27fe973bc55f2890c52f38df742bc1d79ab9f5e1", - "sha256:dc43f1ec66eb8440567186ae2f8c447d91e0372d793dfe8c222aec857b81a8cf", - "sha256:dd632777ff3beaaf629f1ab4396caf7ba0bdd075d948a69460d13d44357aca4c", - "sha256:e45ae4927759289c30ccba8d9fdce62bb414977ba158286b5ddaf8df2cddb5c5", - "sha256:e50ebce52f41370707f1e21a59514e3375e3edd6e1832f5e5235237db933c98b", - "sha256:ebbbba226f0a108a7366bf4b59bf0f30a12fd5e75100c630267d94d7f0ad20e5", - "sha256:ec79ff6159dffcc30853b2ad612ed572af86c92b5168aa3fc01a67b0fa40665e", - "sha256:f0936e08e0003f66bfd97e74ee530427707297b0d0361247e9b4f59ab78ddc8b", - "sha256:f26a07a6e877c76a88e3cecac8531908d980d3d5067ff69213653649ec0f60ad", - "sha256:f64e376cd20d3f030190e8c32e1c64582eba56ac6dc7d5b0b49a9d44021b52fd", - "sha256:f6ffbc252eb0d229aeb2f9ad051200668fc3a9aaa8994e49f0cb2ffe2b7867e7", - "sha256:f9a7c509542db4eceed3dcf21ee5267ab565a83555c9b88a8109dcecc4709002", - "sha256:ff1d0899f104f3921d94579a5638847f783c9b04f2d5f229392ca77fba5b82fc" + "sha256:00e073efcba9ea99db5acef3959efa45b52bc67b61b00823d2a1a6944bf45982", + "sha256:0726c282d188e204281ebd8de31724b7d749adebc086873a59efb8cf7ae27df3", + "sha256:0ceea77719d45c839fd73abcb190b8390412a890df2f83fb8cf49b2a4b5c2f40", + "sha256:114be227f5213ef8b215c22dde19532f5da9652e56e8ce969bf0a26d7c419fee", + "sha256:13577ec9e247f8741c84d06b9ece5f654920d8365a4b636ce0e44f15e07ec693", + "sha256:1876b0b653a808fcd50123b953af170c535027bf1d053b59790eebb0aeb38950", + "sha256:1ab0bbcd4d1f7b6991ee7c753655b481c50084294218de69365f8f1970d4c151", + "sha256:1cce488457370ffd1f953846f82323cb6b2ad2190987cd4d70b2713e17268d24", + "sha256:26ee97a8261e6e35885c2ecd2fd4a6d38252246f94a2aec23665a4e66d066305", + "sha256:3528807cbbb7f315bb81959d5961855e7ba52aa60a3097151cb21956fbc7502b", + "sha256:374a8e88ddab84b9ada695d255679fb99c53513c0a51778796fcf0944d6c789c", + "sha256:376081f471a2ef24828b83a641a02c575d6103a3ad7fd7dade5486cad10ea659", + "sha256:3923a1778f7e5ef31865893fdca12a8d7dc03a44b33e2a5f3295416314c09f5d", + "sha256:4916727e31c28be8beaf11cf117d6f6f188dcc36daae4e851fee88646f5b6b18", + "sha256:493c5c5e44b06d6c9268ce21b302c9ca055c1fd3484c25ba41d34476c76ee746", + "sha256:505fe3d03856ac7d215dbe005414bc28505d26f0c128906037e66d98c4e95868", + "sha256:5845fdf5e5d5b78a49b826fcdc0eb2e2aa7191980e3d2cfd2a30303a74f212e2", + "sha256:5c330eace3dd100bdb54b5653b966de7f51c26ec4a7d4e87132d9b4f738220ba", + "sha256:5dbf059fb4b7c240c873c1245ee112505be27497e90f7c6591261c7d3c3a8228", + "sha256:5e390971d082dba073c05dbd56322427d3280b7cc8b53484c9377adfbae67dc2", + "sha256:5fbb160554e319f7b22ecf530a80a3ff496d38e8e07ae763b9e82fadfe96f273", + "sha256:64d0fcd436c5683fdd7c907eeae5e2cbb5eb872fafbc03a43609d7941840995c", + "sha256:69284049d07fce531c17404fcba2bb1df472bc2dcdac642ae71a2d079d950653", + "sha256:6a0e76621f6e1f908ae52860bdcb58e1ca85231a9b0545e64509c931dd34275a", + "sha256:73ee792784d48aa338bba28063e19a27e8d989344f34aad14ea6e1b9bd83f596", + "sha256:74398a4cf19de42e1498368c36eed45d9528f5fd0155241e82c4082b7e16cffd", + "sha256:7938111ed1358f536daf311be244f34df7bf3cdedb3ed883787aca97778b28d8", + "sha256:82d92c773fbc6942a7a8b520d22c11cfc8fd83bba86116bfcf962c2f5c2ecdaa", + "sha256:83b5c044f3eff2a6534768ccfd50425939e7a8b5cf9a7261c385de1e20dcfc85", + "sha256:8db8e423192303ed77cff4dce3a4b88dbfaf43979d280181558af5e2c3c71afc", + "sha256:9517004e21664f2b5a5fd6333b0731b9cf0817403a941b393d89a2f1dc2bd836", + "sha256:95c02b0e27e706e48d0e5426d1710ca78e0f0628d6e89d5b5a5b91a5f12274f3", + "sha256:99881222f4a8c2f641f25703963a5cefb076adffd959e0558dc9f803a52d6a58", + "sha256:9ee32dcb8e531adae1f1ca568822e9b3a738369b3b686d1477cbc643c4a9c128", + "sha256:a22e47578b30a3e199ab067a4d43d790249b3c0587d9a771921f86250c8435db", + "sha256:b5505774ea2a73a86ea176e8a9a4a7c8bf5d521050f0f6f8426afe798689243f", + "sha256:bd739c9251d01e0279ce729e37b39d49a08c0420d3fee7f2a4968c0576678f77", + "sha256:d16a786905034e7e34098634b184a7d81f91d4c3d246edc6bd7aefb2fd8ea6ad", + "sha256:d3420522057ebab1728b21ad473aa950026d07cb09da41103f8e597dfbfaeb13", + "sha256:d56fd9f1f1cdc8227d7b7918f55091349741904d9520c65f0139a9755952c9e8", + "sha256:d661dc4785affa9d0edfdd1e59ec056a58b3dbb9f196fa43587f3ddac654ac7b", + "sha256:dfe1f0f0ed5785c187144c46a292b8c34c1295c01da12e10ccddfc16def4448a", + "sha256:e1dd7839443592d00e96db831eddb4111a2a81a46b028f0facd60a09ebbdd543", + "sha256:e2872993e209f7ed04d963e4b4fbae72d034844ec66bc4ca403329db2074377b", + "sha256:e2f879ab92ce502a1e65fce390eab619774dda6a6ff719718069ac94084098ce", + "sha256:e3aa7e51d738e0ec0afbed661261513b38b3014754c9459508399baf14ae0c9d", + "sha256:e532dbd6ddfe13946de050d7474e3f5fb6ec774fbb1a188aaf469b08cf04189a", + "sha256:e6b7842518a63a9f17107eb176320960ec095a8ee3b4420b5f688e24bf50c53c", + "sha256:e75753aeda0ddc4c28dce4c32ba2f6ec30b1b02f6c0b14e547841ba5b24f753f", + "sha256:eadb9f826c138e6cf3c49d6f8de88225a3c0ab181a9b4ba792e006e5292d150e", + "sha256:ed59dd52075f8fc91da6053b12e8c89e37aa043f8986efd89e61fae69dc1b011", + "sha256:ef254a06bcea461e65ff0373d8a0dd1ed3aa004af48839f002a0c994a6f72d04", + "sha256:f3709997b228685fe53e8c433e2df9f0cdb5f4542bd5114ed17ac3c0129b0480", + "sha256:f51bab98d52739c50c56658cc303f190785f9a2cd97b823357e7aeae54c8f68a", + "sha256:f9904e24646570539a8950400602d66d2b2c492b9010ea7e965025cb71d0c86d", + "sha256:f9af38a89b6a5c04b7d18c492c8ccf2aee7048aff1ce8437c4683bb5a1df893d" ], "markers": "platform_system != 'Windows'", - "version": "==1.0.7" + "version": "==1.0.8" + }, + "multidict": { + "hashes": [ + "sha256:01265f5e40f5a17f8241d52656ed27192be03bfa8764d88e8220141d1e4b3556", + "sha256:0275e35209c27a3f7951e1ce7aaf93ce0d163b28948444bec61dd7badc6d3f8c", + "sha256:04bde7a7b3de05732a4eb39c94574db1ec99abb56162d6c520ad26f83267de29", + "sha256:04da1bb8c8dbadf2a18a452639771951c662c5ad03aefe4884775454be322c9b", + "sha256:09a892e4a9fb47331da06948690ae38eaa2426de97b4ccbfafbdcbe5c8f37ff8", + "sha256:0d63c74e3d7ab26de115c49bffc92cc77ed23395303d496eae515d4204a625e7", + "sha256:107c0cdefe028703fb5dafe640a409cb146d44a6ae201e55b35a4af8e95457dd", + "sha256:141b43360bfd3bdd75f15ed811850763555a251e38b2405967f8e25fb43f7d40", + "sha256:14c2976aa9038c2629efa2c148022ed5eb4cb939e15ec7aace7ca932f48f9ba6", + "sha256:19fe01cea168585ba0f678cad6f58133db2aa14eccaf22f88e4a6dccadfad8b3", + "sha256:1d147090048129ce3c453f0292e7697d333db95e52616b3793922945804a433c", + "sha256:1d9ea7a7e779d7a3561aade7d596649fbecfa5c08a7674b11b423783217933f9", + "sha256:215ed703caf15f578dca76ee6f6b21b7603791ae090fbf1ef9d865571039ade5", + "sha256:21fd81c4ebdb4f214161be351eb5bcf385426bf023041da2fd9e60681f3cebae", + "sha256:220dd781e3f7af2c2c1053da9fa96d9cf3072ca58f057f4c5adaaa1cab8fc442", + "sha256:228b644ae063c10e7f324ab1ab6b548bdf6f8b47f3ec234fef1093bc2735e5f9", + "sha256:29bfeb0dff5cb5fdab2023a7a9947b3b4af63e9c47cae2a10ad58394b517fddc", + "sha256:2f4848aa3baa109e6ab81fe2006c77ed4d3cd1e0ac2c1fbddb7b1277c168788c", + "sha256:2faa5ae9376faba05f630d7e5e6be05be22913782b927b19d12b8145968a85ea", + "sha256:2ffc42c922dbfddb4a4c3b438eb056828719f07608af27d163191cb3e3aa6cc5", + "sha256:37b15024f864916b4951adb95d3a80c9431299080341ab9544ed148091b53f50", + "sha256:3cc2ad10255f903656017363cd59436f2111443a76f996584d1077e43ee51182", + "sha256:3d25f19500588cbc47dc19081d78131c32637c25804df8414463ec908631e453", + "sha256:403c0911cd5d5791605808b942c88a8155c2592e05332d2bf78f18697a5fa15e", + "sha256:411bf8515f3be9813d06004cac41ccf7d1cd46dfe233705933dd163b60e37600", + "sha256:425bf820055005bfc8aa9a0b99ccb52cc2f4070153e34b701acc98d201693733", + "sha256:435a0984199d81ca178b9ae2c26ec3d49692d20ee29bc4c11a2a8d4514c67eda", + "sha256:4a6a4f196f08c58c59e0b8ef8ec441d12aee4125a7d4f4fef000ccb22f8d7241", + "sha256:4cc0ef8b962ac7a5e62b9e826bd0cd5040e7d401bc45a6835910ed699037a461", + "sha256:51d035609b86722963404f711db441cf7134f1889107fb171a970c9701f92e1e", + "sha256:53689bb4e102200a4fafa9de9c7c3c212ab40a7ab2c8e474491914d2305f187e", + "sha256:55205d03e8a598cfc688c71ca8ea5f66447164efff8869517f175ea632c7cb7b", + "sha256:5c0631926c4f58e9a5ccce555ad7747d9a9f8b10619621f22f9635f069f6233e", + "sha256:5cb241881eefd96b46f89b1a056187ea8e9ba14ab88ba632e68d7a2ecb7aadf7", + "sha256:60d698e8179a42ec85172d12f50b1668254628425a6bd611aba022257cac1386", + "sha256:612d1156111ae11d14afaf3a0669ebf6c170dbb735e510a7438ffe2369a847fd", + "sha256:6214c5a5571802c33f80e6c84713b2c79e024995b9c5897f794b43e714daeec9", + "sha256:6939c95381e003f54cd4c5516740faba40cf5ad3eeff460c3ad1d3e0ea2549bf", + "sha256:69db76c09796b313331bb7048229e3bee7928eb62bab5e071e9f7fcc4879caee", + "sha256:6bf7a982604375a8d49b6cc1b781c1747f243d91b81035a9b43a2126c04766f5", + "sha256:766c8f7511df26d9f11cd3a8be623e59cca73d44643abab3f8c8c07620524e4a", + "sha256:76c0de87358b192de7ea9649beb392f107dcad9ad27276324c24c91774ca5271", + "sha256:76f067f5121dcecf0d63a67f29080b26c43c71a98b10c701b0677e4a065fbd54", + "sha256:7901c05ead4b3fb75113fb1dd33eb1253c6d3ee37ce93305acd9d38e0b5f21a4", + "sha256:79660376075cfd4b2c80f295528aa6beb2058fd289f4c9252f986751a4cd0496", + "sha256:79a6d2ba910adb2cbafc95dad936f8b9386e77c84c35bc0add315b856d7c3abb", + "sha256:7afcdd1fc07befad18ec4523a782cde4e93e0a2bf71239894b8d61ee578c1319", + "sha256:7be7047bd08accdb7487737631d25735c9a04327911de89ff1b26b81745bd4e3", + "sha256:7c6390cf87ff6234643428991b7359b5f59cc15155695deb4eda5c777d2b880f", + "sha256:7df704ca8cf4a073334e0427ae2345323613e4df18cc224f647f251e5e75a527", + "sha256:85f67aed7bb647f93e7520633d8f51d3cbc6ab96957c71272b286b2f30dc70ed", + "sha256:896ebdcf62683551312c30e20614305f53125750803b614e9e6ce74a96232604", + "sha256:92d16a3e275e38293623ebf639c471d3e03bb20b8ebb845237e0d3664914caef", + "sha256:99f60d34c048c5c2fabc766108c103612344c46e35d4ed9ae0673d33c8fb26e8", + "sha256:9fe7b0653ba3d9d65cbe7698cca585bf0f8c83dbbcc710db9c90f478e175f2d5", + "sha256:a3145cb08d8625b2d3fee1b2d596a8766352979c9bffe5d7833e0503d0f0b5e5", + "sha256:aeaf541ddbad8311a87dd695ed9642401131ea39ad7bc8cf3ef3967fd093b626", + "sha256:b55358304d7a73d7bdf5de62494aaf70bd33015831ffd98bc498b433dfe5b10c", + "sha256:b82cc8ace10ab5bd93235dfaab2021c70637005e1ac787031f4d1da63d493c1d", + "sha256:c0868d64af83169e4d4152ec612637a543f7a336e4a307b119e98042e852ad9c", + "sha256:c1c1496e73051918fcd4f58ff2e0f2f3066d1c76a0c6aeffd9b45d53243702cc", + "sha256:c9bf56195c6bbd293340ea82eafd0071cb3d450c703d2c93afb89f93b8386ccc", + "sha256:cbebcd5bcaf1eaf302617c114aa67569dd3f090dd0ce8ba9e35e9985b41ac35b", + "sha256:cd6c8fca38178e12c00418de737aef1261576bd1b6e8c6134d3e729a4e858b38", + "sha256:ceb3b7e6a0135e092de86110c5a74e46bda4bd4fbfeeb3a3bcec79c0f861e450", + "sha256:cf590b134eb70629e350691ecca88eac3e3b8b3c86992042fb82e3cb1830d5e1", + "sha256:d3eb1ceec286eba8220c26f3b0096cf189aea7057b6e7b7a2e60ed36b373b77f", + "sha256:d65f25da8e248202bd47445cec78e0025c0fe7582b23ec69c3b27a640dd7a8e3", + "sha256:d6f6d4f185481c9669b9447bf9d9cf3b95a0e9df9d169bbc17e363b7d5487755", + "sha256:d84a5c3a5f7ce6db1f999fb9438f686bc2e09d38143f2d93d8406ed2dd6b9226", + "sha256:d946b0a9eb8aaa590df1fe082cee553ceab173e6cb5b03239716338629c50c7a", + "sha256:dce1c6912ab9ff5f179eaf6efe7365c1f425ed690b03341911bf4939ef2f3046", + "sha256:de170c7b4fe6859beb8926e84f7d7d6c693dfe8e27372ce3b76f01c46e489fcf", + "sha256:e02021f87a5b6932fa6ce916ca004c4d441509d33bbdbeca70d05dff5e9d2479", + "sha256:e030047e85cbcedbfc073f71836d62dd5dadfbe7531cae27789ff66bc551bd5e", + "sha256:e0e79d91e71b9867c73323a3444724d496c037e578a0e1755ae159ba14f4f3d1", + "sha256:e4428b29611e989719874670fd152b6625500ad6c686d464e99f5aaeeaca175a", + "sha256:e4972624066095e52b569e02b5ca97dbd7a7ddd4294bf4e7247d52635630dd83", + "sha256:e7be68734bd8c9a513f2b0cfd508802d6609da068f40dc57d4e3494cefc92929", + "sha256:e8e94e6912639a02ce173341ff62cc1201232ab86b8a8fcc05572741a5dc7d93", + "sha256:ea1456df2a27c73ce51120fa2f519f1bea2f4a03a917f4a43c8707cf4cbbae1a", + "sha256:ebd8d160f91a764652d3e51ce0d2956b38efe37c9231cd82cfc0bed2e40b581c", + "sha256:eca2e9d0cc5a889850e9bbd68e98314ada174ff6ccd1129500103df7a94a7a44", + "sha256:edd08e6f2f1a390bf137080507e44ccc086353c8e98c657e666c017718561b89", + "sha256:f285e862d2f153a70586579c15c44656f888806ed0e5b56b64489afe4a2dbfba", + "sha256:f2a1dee728b52b33eebff5072817176c172050d44d67befd681609b4746e1c2e", + "sha256:f7e301075edaf50500f0b341543c41194d8df3ae5caf4702f2095f3ca73dd8da", + "sha256:fb616be3538599e797a2017cccca78e354c767165e8858ab5116813146041a24", + "sha256:fce28b3c8a81b6b36dfac9feb1de115bab619b3c13905b419ec71d03a3fc1423", + "sha256:fe5d7785250541f7f5019ab9cba2c71169dc7d74d0f45253f8313f436458a4ef" + ], + "markers": "python_version >= '3.7'", + "version": "==6.0.5" }, "numpy": { "hashes": [ @@ -182,6 +528,7 @@ }, "opentrons": { "editable": true, + "markers": "python_version >= '3.8'", "path": "../api" }, "opentrons-hardware": { @@ -193,15 +540,16 @@ }, "opentrons-shared-data": { "editable": true, + "markers": "python_version >= '3.8'", "path": "../shared-data/python" }, "packaging": { "hashes": [ - "sha256:048fb0e9405036518eaaf48a55953c750c11e1a1b68e0dd1a9d62ed0c092cfc5", - "sha256:8c491190033a9af7e1d931d0b5dacc2ef47509b34dd0de67ed209b5203fc88c7" + "sha256:2ddfb553fdf02fb784c234c7ba6ccc288296ceabec964ad2eae3777778130bc5", + "sha256:eb82c5e3e56209074766e6885bb04b8c38a0c015d0a30036ebe7ece34c9989e9" ], "markers": "python_version >= '3.7'", - "version": "==23.2" + "version": "==24.0" }, "paho-mqtt": { "hashes": [ @@ -333,19 +681,19 @@ }, "setuptools": { "hashes": [ - "sha256:385eb4edd9c9d5c17540511303e39a147ce2fc04bc55289c322b9e5904fe2c05", - "sha256:be1af57fc409f93647f2e8e4573a142ed38724b8cdd389706a867bb4efcf1e78" + "sha256:6c1fccdac05a97e598fb0ae3bbed5904ccb317337a51139dcd51453611bbb987", + "sha256:c636ac361bc47580504644275c9ad802c50415c7522212252c033bd15f301f32" ], "markers": "python_version >= '3.8'", - "version": "==69.0.3" + "version": "==69.5.1" }, "sniffio": { "hashes": [ - "sha256:e60305c5e5d314f5389259b7f22aaa33d8f7dee49763119234af3755c55b9101", - "sha256:eecefdce1e5bbfb7ad2eeaabf7c1eeb404d7757c379bd1f7e5cce9d8bf425384" + "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", + "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc" ], "markers": "python_version >= '3.7'", - "version": "==1.3.0" + "version": "==1.3.1" }, "sqlalchemy": { "hashes": [ @@ -417,12 +765,12 @@ }, "typing-extensions": { "hashes": [ - "sha256:23478f88c37f27d76ac8aee6c905017a143b0b1b886c3c9f66bc2fd94f9f5783", - "sha256:af72aea155e91adfc61c3ae9e0e342dbc0cba726d6cba4b6c72c1f34e47291cd" + "sha256:83f085bd5ca59c80295fc2a82ab5dac679cbe02b9f33f7d83af68e241bea51b0", + "sha256:c1f94d72897edaf4ce775bb7558d5b79d8126906a14ea5ed1635921406c0387a" ], "index": "pypi", "markers": "python_version >= '3.8'", - "version": "==4.9.0" + "version": "==4.11.0" }, "uvicorn": { "hashes": [ @@ -518,6 +866,102 @@ "markers": "python_full_version >= '3.7.0'", "version": "==1.2.0" }, + "yarl": { + "hashes": [ + "sha256:008d3e808d03ef28542372d01057fd09168419cdc8f848efe2804f894ae03e51", + "sha256:03caa9507d3d3c83bca08650678e25364e1843b484f19986a527630ca376ecce", + "sha256:07574b007ee20e5c375a8fe4a0789fad26db905f9813be0f9fef5a68080de559", + "sha256:09efe4615ada057ba2d30df871d2f668af661e971dfeedf0c159927d48bbeff0", + "sha256:0d2454f0aef65ea81037759be5ca9947539667eecebca092733b2eb43c965a81", + "sha256:0e9d124c191d5b881060a9e5060627694c3bdd1fe24c5eecc8d5d7d0eb6faabc", + "sha256:18580f672e44ce1238b82f7fb87d727c4a131f3a9d33a5e0e82b793362bf18b4", + "sha256:1f23e4fe1e8794f74b6027d7cf19dc25f8b63af1483d91d595d4a07eca1fb26c", + "sha256:206a55215e6d05dbc6c98ce598a59e6fbd0c493e2de4ea6cc2f4934d5a18d130", + "sha256:23d32a2594cb5d565d358a92e151315d1b2268bc10f4610d098f96b147370136", + "sha256:26a1dc6285e03f3cc9e839a2da83bcbf31dcb0d004c72d0730e755b33466c30e", + "sha256:29e0f83f37610f173eb7e7b5562dd71467993495e568e708d99e9d1944f561ec", + "sha256:2b134fd795e2322b7684155b7855cc99409d10b2e408056db2b93b51a52accc7", + "sha256:2d47552b6e52c3319fede1b60b3de120fe83bde9b7bddad11a69fb0af7db32f1", + "sha256:357495293086c5b6d34ca9616a43d329317feab7917518bc97a08f9e55648455", + "sha256:35a2b9396879ce32754bd457d31a51ff0a9d426fd9e0e3c33394bf4b9036b099", + "sha256:3777ce5536d17989c91696db1d459574e9a9bd37660ea7ee4d3344579bb6f129", + "sha256:3986b6f41ad22988e53d5778f91855dc0399b043fc8946d4f2e68af22ee9ff10", + "sha256:44d8ffbb9c06e5a7f529f38f53eda23e50d1ed33c6c869e01481d3fafa6b8142", + "sha256:49a180c2e0743d5d6e0b4d1a9e5f633c62eca3f8a86ba5dd3c471060e352ca98", + "sha256:4aa9741085f635934f3a2583e16fcf62ba835719a8b2b28fb2917bb0537c1dfa", + "sha256:4b21516d181cd77ebd06ce160ef8cc2a5e9ad35fb1c5930882baff5ac865eee7", + "sha256:4b3c1ffe10069f655ea2d731808e76e0f452fc6c749bea04781daf18e6039525", + "sha256:4c7d56b293cc071e82532f70adcbd8b61909eec973ae9d2d1f9b233f3d943f2c", + "sha256:4e9035df8d0880b2f1c7f5031f33f69e071dfe72ee9310cfc76f7b605958ceb9", + "sha256:54525ae423d7b7a8ee81ba189f131054defdb122cde31ff17477951464c1691c", + "sha256:549d19c84c55d11687ddbd47eeb348a89df9cb30e1993f1b128f4685cd0ebbf8", + "sha256:54beabb809ffcacbd9d28ac57b0db46e42a6e341a030293fb3185c409e626b8b", + "sha256:566db86717cf8080b99b58b083b773a908ae40f06681e87e589a976faf8246bf", + "sha256:5a2e2433eb9344a163aced6a5f6c9222c0786e5a9e9cac2c89f0b28433f56e23", + "sha256:5aef935237d60a51a62b86249839b51345f47564208c6ee615ed2a40878dccdd", + "sha256:604f31d97fa493083ea21bd9b92c419012531c4e17ea6da0f65cacdcf5d0bd27", + "sha256:63b20738b5aac74e239622d2fe30df4fca4942a86e31bf47a81a0e94c14df94f", + "sha256:686a0c2f85f83463272ddffd4deb5e591c98aac1897d65e92319f729c320eece", + "sha256:6a962e04b8f91f8c4e5917e518d17958e3bdee71fd1d8b88cdce74dd0ebbf434", + "sha256:6ad6d10ed9b67a382b45f29ea028f92d25bc0bc1daf6c5b801b90b5aa70fb9ec", + "sha256:6f5cb257bc2ec58f437da2b37a8cd48f666db96d47b8a3115c29f316313654ff", + "sha256:6fe79f998a4052d79e1c30eeb7d6c1c1056ad33300f682465e1b4e9b5a188b78", + "sha256:7855426dfbddac81896b6e533ebefc0af2f132d4a47340cee6d22cac7190022d", + "sha256:7d5aaac37d19b2904bb9dfe12cdb08c8443e7ba7d2852894ad448d4b8f442863", + "sha256:801e9264d19643548651b9db361ce3287176671fb0117f96b5ac0ee1c3530d53", + "sha256:81eb57278deb6098a5b62e88ad8281b2ba09f2f1147c4767522353eaa6260b31", + "sha256:824d6c50492add5da9374875ce72db7a0733b29c2394890aef23d533106e2b15", + "sha256:8397a3817d7dcdd14bb266283cd1d6fc7264a48c186b986f32e86d86d35fbac5", + "sha256:848cd2a1df56ddbffeb375535fb62c9d1645dde33ca4d51341378b3f5954429b", + "sha256:84fc30f71689d7fc9168b92788abc977dc8cefa806909565fc2951d02f6b7d57", + "sha256:8619d6915b3b0b34420cf9b2bb6d81ef59d984cb0fde7544e9ece32b4b3043c3", + "sha256:8a854227cf581330ffa2c4824d96e52ee621dd571078a252c25e3a3b3d94a1b1", + "sha256:8be9e837ea9113676e5754b43b940b50cce76d9ed7d2461df1af39a8ee674d9f", + "sha256:928cecb0ef9d5a7946eb6ff58417ad2fe9375762382f1bf5c55e61645f2c43ad", + "sha256:957b4774373cf6f709359e5c8c4a0af9f6d7875db657adb0feaf8d6cb3c3964c", + "sha256:992f18e0ea248ee03b5a6e8b3b4738850ae7dbb172cc41c966462801cbf62cf7", + "sha256:9fc5fc1eeb029757349ad26bbc5880557389a03fa6ada41703db5e068881e5f2", + "sha256:a00862fb23195b6b8322f7d781b0dc1d82cb3bcac346d1e38689370cc1cc398b", + "sha256:a3a6ed1d525bfb91b3fc9b690c5a21bb52de28c018530ad85093cc488bee2dd2", + "sha256:a6327976c7c2f4ee6816eff196e25385ccc02cb81427952414a64811037bbc8b", + "sha256:a7409f968456111140c1c95301cadf071bd30a81cbd7ab829169fb9e3d72eae9", + "sha256:a825ec844298c791fd28ed14ed1bffc56a98d15b8c58a20e0e08c1f5f2bea1be", + "sha256:a8c1df72eb746f4136fe9a2e72b0c9dc1da1cbd23b5372f94b5820ff8ae30e0e", + "sha256:a9bd00dc3bc395a662900f33f74feb3e757429e545d831eef5bb280252631984", + "sha256:aa102d6d280a5455ad6a0f9e6d769989638718e938a6a0a2ff3f4a7ff8c62cc4", + "sha256:aaaea1e536f98754a6e5c56091baa1b6ce2f2700cc4a00b0d49eca8dea471074", + "sha256:ad4d7a90a92e528aadf4965d685c17dacff3df282db1121136c382dc0b6014d2", + "sha256:b8477c1ee4bd47c57d49621a062121c3023609f7a13b8a46953eb6c9716ca392", + "sha256:ba6f52cbc7809cd8d74604cce9c14868306ae4aa0282016b641c661f981a6e91", + "sha256:bac8d525a8dbc2a1507ec731d2867025d11ceadcb4dd421423a5d42c56818541", + "sha256:bef596fdaa8f26e3d66af846bbe77057237cb6e8efff8cd7cc8dff9a62278bbf", + "sha256:c0ec0ed476f77db9fb29bca17f0a8fcc7bc97ad4c6c1d8959c507decb22e8572", + "sha256:c38c9ddb6103ceae4e4498f9c08fac9b590c5c71b0370f98714768e22ac6fa66", + "sha256:c7224cab95645c7ab53791022ae77a4509472613e839dab722a72abe5a684575", + "sha256:c74018551e31269d56fab81a728f683667e7c28c04e807ba08f8c9e3bba32f14", + "sha256:ca06675212f94e7a610e85ca36948bb8fc023e458dd6c63ef71abfd482481aa5", + "sha256:d1d2532b340b692880261c15aee4dc94dd22ca5d61b9db9a8a361953d36410b1", + "sha256:d25039a474c4c72a5ad4b52495056f843a7ff07b632c1b92ea9043a3d9950f6e", + "sha256:d5ff2c858f5f6a42c2a8e751100f237c5e869cbde669a724f2062d4c4ef93551", + "sha256:d7d7f7de27b8944f1fee2c26a88b4dabc2409d2fea7a9ed3df79b67277644e17", + "sha256:d7eeb6d22331e2fd42fce928a81c697c9ee2d51400bd1a28803965883e13cead", + "sha256:d8a1c6c0be645c745a081c192e747c5de06e944a0d21245f4cf7c05e457c36e0", + "sha256:d8b889777de69897406c9fb0b76cdf2fd0f31267861ae7501d93003d55f54fbe", + "sha256:d9e09c9d74f4566e905a0b8fa668c58109f7624db96a2171f21747abc7524234", + "sha256:db8e58b9d79200c76956cefd14d5c90af54416ff5353c5bfd7cbe58818e26ef0", + "sha256:ddb2a5c08a4eaaba605340fdee8fc08e406c56617566d9643ad8bf6852778fc7", + "sha256:e0381b4ce23ff92f8170080c97678040fc5b08da85e9e292292aba67fdac6c34", + "sha256:e23a6d84d9d1738dbc6e38167776107e63307dfc8ad108e580548d1f2c587f42", + "sha256:e516dc8baf7b380e6c1c26792610230f37147bb754d6426462ab115a02944385", + "sha256:ea65804b5dc88dacd4a40279af0cdadcfe74b3e5b4c897aa0d81cf86927fee78", + "sha256:ec61d826d80fc293ed46c9dd26995921e3a82146feacd952ef0757236fc137be", + "sha256:ee04010f26d5102399bd17f8df8bc38dc7ccd7701dc77f4a68c5b8d733406958", + "sha256:f3bc6af6e2b8f92eced34ef6a96ffb248e863af20ef4fde9448cc8c9b858b749", + "sha256:f7d6b36dd2e029b6bcb8a13cf19664c7b8e19ab3a58e0fefbb5b8461447ed5ec" + ], + "markers": "python_version >= '3.7'", + "version": "==1.9.4" + }, "zipp": { "hashes": [ "sha256:957cfda87797e389580cb8b9e3870841ca991e2125350677b2ca83a0e99390a3", @@ -594,11 +1038,11 @@ }, "charset-normalizer": { "hashes": [ - "sha256:2857e29ff0d34db842cd7ca3230549d1a697f96ee6d3fb071cfa6c7393832597", - "sha256:6881edbebdb17b39b4eaaa821b438bf6eddffb4468cf344f09f89def34a8b1df" + "sha256:5a3d016c7c547f69d6f81fb0db9449ce888b418b5b9952cc5e6e66843e9dd845", + "sha256:83e9a75d1911279afd89352c68b45348559d1fc0506b054b346651b5e7fee29f" ], - "markers": "python_version >= '3'", - "version": "==2.0.12" + "markers": "python_full_version >= '3.6.0'", + "version": "==2.1.1" }, "click": { "hashes": [ @@ -622,61 +1066,61 @@ "toml" ], "hashes": [ - "sha256:0193657651f5399d433c92f8ae264aff31fc1d066deee4b831549526433f3f61", - "sha256:02f2edb575d62172aa28fe00efe821ae31f25dc3d589055b3fb64d51e52e4ab1", - "sha256:0491275c3b9971cdbd28a4595c2cb5838f08036bca31765bad5e17edf900b2c7", - "sha256:077d366e724f24fc02dbfe9d946534357fda71af9764ff99d73c3c596001bbd7", - "sha256:10e88e7f41e6197ea0429ae18f21ff521d4f4490aa33048f6c6f94c6045a6a75", - "sha256:18e961aa13b6d47f758cc5879383d27b5b3f3dcd9ce8cdbfdc2571fe86feb4dd", - "sha256:1a78b656a4d12b0490ca72651fe4d9f5e07e3c6461063a9b6265ee45eb2bdd35", - "sha256:1ed4b95480952b1a26d863e546fa5094564aa0065e1e5f0d4d0041f293251d04", - "sha256:23b27b8a698e749b61809fb637eb98ebf0e505710ec46a8aa6f1be7dc0dc43a6", - "sha256:23f5881362dcb0e1a92b84b3c2809bdc90db892332daab81ad8f642d8ed55042", - "sha256:32a8d985462e37cfdab611a6f95b09d7c091d07668fdc26e47a725ee575fe166", - "sha256:3468cc8720402af37b6c6e7e2a9cdb9f6c16c728638a2ebc768ba1ef6f26c3a1", - "sha256:379d4c7abad5afbe9d88cc31ea8ca262296480a86af945b08214eb1a556a3e4d", - "sha256:3cacfaefe6089d477264001f90f55b7881ba615953414999c46cc9713ff93c8c", - "sha256:3e3424c554391dc9ef4a92ad28665756566a28fecf47308f91841f6c49288e66", - "sha256:46342fed0fff72efcda77040b14728049200cbba1279e0bf1188f1f2078c1d70", - "sha256:536d609c6963c50055bab766d9951b6c394759190d03311f3e9fcf194ca909e1", - "sha256:5d6850e6e36e332d5511a48a251790ddc545e16e8beaf046c03985c69ccb2676", - "sha256:6008adeca04a445ea6ef31b2cbaf1d01d02986047606f7da266629afee982630", - "sha256:64e723ca82a84053dd7bfcc986bdb34af8d9da83c521c19d6b472bc6880e191a", - "sha256:6b00e21f86598b6330f0019b40fb397e705135040dbedc2ca9a93c7441178e74", - "sha256:6d224f0c4c9c98290a6990259073f496fcec1b5cc613eecbd22786d398ded3ad", - "sha256:6dceb61d40cbfcf45f51e59933c784a50846dc03211054bd76b421a713dcdf19", - "sha256:7ac8f8eb153724f84885a1374999b7e45734bf93a87d8df1e7ce2146860edef6", - "sha256:85ccc5fa54c2ed64bd91ed3b4a627b9cce04646a659512a051fa82a92c04a448", - "sha256:869b5046d41abfea3e381dd143407b0d29b8282a904a19cb908fa24d090cc018", - "sha256:8bdb0285a0202888d19ec6b6d23d5990410decb932b709f2b0dfe216d031d218", - "sha256:8dfc5e195bbef80aabd81596ef52a1277ee7143fe419efc3c4d8ba2754671756", - "sha256:8e738a492b6221f8dcf281b67129510835461132b03024830ac0e554311a5c54", - "sha256:918440dea04521f499721c039863ef95433314b1db00ff826a02580c1f503e45", - "sha256:9641e21670c68c7e57d2053ddf6c443e4f0a6e18e547e86af3fad0795414a628", - "sha256:9d2f9d4cc2a53b38cabc2d6d80f7f9b7e3da26b2f53d48f05876fef7956b6968", - "sha256:a07f61fc452c43cd5328b392e52555f7d1952400a1ad09086c4a8addccbd138d", - "sha256:a3277f5fa7483c927fe3a7b017b39351610265308f5267ac6d4c2b64cc1d8d25", - "sha256:a4a3907011d39dbc3e37bdc5df0a8c93853c369039b59efa33a7b6669de04c60", - "sha256:aeb2c2688ed93b027eb0d26aa188ada34acb22dceea256d76390eea135083950", - "sha256:b094116f0b6155e36a304ff912f89bbb5067157aff5f94060ff20bbabdc8da06", - "sha256:b8ffb498a83d7e0305968289441914154fb0ef5d8b3157df02a90c6695978295", - "sha256:b9bb62fac84d5f2ff523304e59e5c439955fb3b7f44e3d7b2085184db74d733b", - "sha256:c61f66d93d712f6e03369b6a7769233bfda880b12f417eefdd4f16d1deb2fc4c", - "sha256:ca6e61dc52f601d1d224526360cdeab0d0712ec104a2ce6cc5ccef6ed9a233bc", - "sha256:ca7b26a5e456a843b9b6683eada193fc1f65c761b3a473941efe5a291f604c74", - "sha256:d12c923757de24e4e2110cf8832d83a886a4cf215c6e61ed506006872b43a6d1", - "sha256:d17bbc946f52ca67adf72a5ee783cd7cd3477f8f8796f59b4974a9b59cacc9ee", - "sha256:dfd1e1b9f0898817babf840b77ce9fe655ecbe8b1b327983df485b30df8cc011", - "sha256:e0860a348bf7004c812c8368d1fc7f77fe8e4c095d661a579196a9533778e156", - "sha256:f2f5968608b1fe2a1d00d01ad1017ee27efd99b3437e08b83ded9b7af3f6f766", - "sha256:f3771b23bb3675a06f5d885c3630b1d01ea6cac9e84a01aaf5508706dba546c5", - "sha256:f68ef3660677e6624c8cace943e4765545f8191313a07288a53d3da188bd8581", - "sha256:f86f368e1c7ce897bf2457b9eb61169a44e2ef797099fb5728482b8d69f3f016", - "sha256:f90515974b39f4dea2f27c0959688621b46d96d5a626cf9c53dbc653a895c05c", - "sha256:fe558371c1bdf3b8fa03e097c523fb9645b8730399c14fe7721ee9c9e2a545d3" + "sha256:00838a35b882694afda09f85e469c96367daa3f3f2b097d846a7216993d37f4c", + "sha256:0513b9508b93da4e1716744ef6ebc507aff016ba115ffe8ecff744d1322a7b63", + "sha256:09c3255458533cb76ef55da8cc49ffab9e33f083739c8bd4f58e79fecfe288f7", + "sha256:09ef9199ed6653989ebbcaacc9b62b514bb63ea2f90256e71fea3ed74bd8ff6f", + "sha256:09fa497a8ab37784fbb20ab699c246053ac294d13fc7eb40ec007a5043ec91f8", + "sha256:0f9f50e7ef2a71e2fae92774c99170eb8304e3fdf9c8c3c7ae9bab3e7229c5cf", + "sha256:137eb07173141545e07403cca94ab625cc1cc6bc4c1e97b6e3846270e7e1fea0", + "sha256:1f384c3cc76aeedce208643697fb3e8437604b512255de6d18dae3f27655a384", + "sha256:201bef2eea65e0e9c56343115ba3814e896afe6d36ffd37bab783261db430f76", + "sha256:38dd60d7bf242c4ed5b38e094baf6401faa114fc09e9e6632374388a404f98e7", + "sha256:3b799445b9f7ee8bf299cfaed6f5b226c0037b74886a4e11515e569b36fe310d", + "sha256:3ea79bb50e805cd6ac058dfa3b5c8f6c040cb87fe83de10845857f5535d1db70", + "sha256:40209e141059b9370a2657c9b15607815359ab3ef9918f0196b6fccce8d3230f", + "sha256:41c9c5f3de16b903b610d09650e5e27adbfa7f500302718c9ffd1c12cf9d6818", + "sha256:54eb8d1bf7cacfbf2a3186019bcf01d11c666bd495ed18717162f7eb1e9dd00b", + "sha256:598825b51b81c808cb6f078dcb972f96af96b078faa47af7dfcdf282835baa8d", + "sha256:5fc1de20b2d4a061b3df27ab9b7c7111e9a710f10dc2b84d33a4ab25065994ec", + "sha256:623512f8ba53c422fcfb2ce68362c97945095b864cda94a92edbaf5994201083", + "sha256:690db6517f09336559dc0b5f55342df62370a48f5469fabf502db2c6d1cffcd2", + "sha256:69eb372f7e2ece89f14751fbcbe470295d73ed41ecd37ca36ed2eb47512a6ab9", + "sha256:73bfb9c09951125d06ee473bed216e2c3742f530fc5acc1383883125de76d9cd", + "sha256:742a76a12aa45b44d236815d282b03cfb1de3b4323f3e4ec933acfae08e54ade", + "sha256:7c95949560050d04d46b919301826525597f07b33beba6187d04fa64d47ac82e", + "sha256:8130a2aa2acb8788e0b56938786c33c7c98562697bf9f4c7d6e8e5e3a0501e4a", + "sha256:8a2b2b78c78293782fd3767d53e6474582f62443d0504b1554370bde86cc8227", + "sha256:8ce1415194b4a6bd0cdcc3a1dfbf58b63f910dcb7330fe15bdff542c56949f87", + "sha256:9ca28a302acb19b6af89e90f33ee3e1906961f94b54ea37de6737b7ca9d8827c", + "sha256:a4cdc86d54b5da0df6d3d3a2f0b710949286094c3a6700c21e9015932b81447e", + "sha256:aa5b1c1bfc28384f1f53b69a023d789f72b2e0ab1b3787aae16992a7ca21056c", + "sha256:aadacf9a2f407a4688d700e4ebab33a7e2e408f2ca04dbf4aef17585389eff3e", + "sha256:ae71e7ddb7a413dd60052e90528f2f65270aad4b509563af6d03d53e979feafd", + "sha256:b14706df8b2de49869ae03a5ccbc211f4041750cd4a66f698df89d44f4bd30ec", + "sha256:b1a93009cb80730c9bca5d6d4665494b725b6e8e157c1cb7f2db5b4b122ea562", + "sha256:b2991665420a803495e0b90a79233c1433d6ed77ef282e8e152a324bbbc5e0c8", + "sha256:b2c5edc4ac10a7ef6605a966c58929ec6c1bd0917fb8c15cb3363f65aa40e677", + "sha256:b4d33f418f46362995f1e9d4f3a35a1b6322cb959c31d88ae56b0298e1c22357", + "sha256:b91cbc4b195444e7e258ba27ac33769c41b94967919f10037e6355e998af255c", + "sha256:c74880fc64d4958159fbd537a091d2a585448a8f8508bf248d72112723974cbd", + "sha256:c901df83d097649e257e803be22592aedfd5182f07b3cc87d640bbb9afd50f49", + "sha256:cac99918c7bba15302a2d81f0312c08054a3359eaa1929c7e4b26ebe41e9b286", + "sha256:cc4f1358cb0c78edef3ed237ef2c86056206bb8d9140e73b6b89fbcfcbdd40e1", + "sha256:ccd341521be3d1b3daeb41960ae94a5e87abe2f46f17224ba5d6f2b8398016cf", + "sha256:ce4b94265ca988c3f8e479e741693d143026632672e3ff924f25fab50518dd51", + "sha256:cf271892d13e43bc2b51e6908ec9a6a5094a4df1d8af0bfc360088ee6c684409", + "sha256:d5ae728ff3b5401cc320d792866987e7e7e880e6ebd24433b70a33b643bb0384", + "sha256:d71eec7d83298f1af3326ce0ff1d0ea83c7cb98f72b577097f9083b20bdaf05e", + "sha256:d898fe162d26929b5960e4e138651f7427048e72c853607f2b200909794ed978", + "sha256:d89d7b2974cae412400e88f35d86af72208e1ede1a541954af5d944a8ba46c57", + "sha256:dfa8fe35a0bb90382837b238fff375de15f0dcdb9ae68ff85f7a63649c98527e", + "sha256:e0be5efd5127542ef31f165de269f77560d6cdef525fffa446de6f7e9186cfb2", + "sha256:fdfafb32984684eb03c2d83e1e51f64f0906b11e64482df3c5db936ce3839d48", + "sha256:ff7687ca3d7028d8a5f0ebae95a6e4827c5616b31a4ee1192bdfde697db110d4" ], "markers": "python_version >= '3.8'", - "version": "==7.4.1" + "version": "==7.4.4" }, "decoy": { "hashes": [ @@ -703,11 +1147,11 @@ }, "execnet": { "hashes": [ - "sha256:88256416ae766bc9e8895c76a87928c0012183da3cc4fc18016e6f050e025f41", - "sha256:cc59bc4423742fd71ad227122eb0dd44db51efb3dc4095b45ac9a08c770096af" + "sha256:26dee51f1b80cebd6d0ca8e74dd8745419761d3bef34163928cbebbdc4749fdc", + "sha256:5189b52c6121c24feae288166ab41b32549c7e2348652736540b9e6e7d4e72e3" ], - "markers": "python_version >= '3.7'", - "version": "==2.0.2" + "markers": "python_version >= '3.8'", + "version": "==2.1.1" }, "flake8": { "hashes": [ @@ -764,11 +1208,11 @@ }, "httpcore": { "hashes": [ - "sha256:096cc05bca73b8e459a1fc3dcf585148f63e534eae4339559c9b8a8d6399acc7", - "sha256:9fc092e4799b26174648e54b74ed5f683132a464e95643b226e00c2ed2fa6535" + "sha256:34a38e2f9291467ee3b44e89dd52615370e152954ba21721378a87b2960f7a61", + "sha256:421f18bac248b25d310f3cacd198d55b8e6125c107797b609ff9b7a6ba7991b5" ], "markers": "python_version >= '3.8'", - "version": "==1.0.2" + "version": "==1.0.5" }, "httpx": { "hashes": [ @@ -812,14 +1256,6 @@ "markers": "python_version >= '3.7'", "version": "==4.17.3" }, - "jsonschema-specifications": { - "hashes": [ - "sha256:48a76787b3e70f5ed53f1160d2b81f586e4ca6d1548c5de7085d1682674764cc", - "sha256:87e4fdf3a94858b8a2ba2778d9ba57d8a9cafca7c7489c46ba0d30a8bc6a9c3c" - ], - "markers": "python_version >= '3.8'", - "version": "==2023.12.1" - }, "mccabe": { "hashes": [ "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325", @@ -879,13 +1315,18 @@ "markers": "python_version >= '3.5'", "version": "==1.0.0" }, + "opentrons-shared-data": { + "editable": true, + "markers": "python_version >= '3.8'", + "path": "../shared-data/python" + }, "packaging": { "hashes": [ - "sha256:048fb0e9405036518eaaf48a55953c750c11e1a1b68e0dd1a9d62ed0c092cfc5", - "sha256:8c491190033a9af7e1d931d0b5dacc2ef47509b34dd0de67ed209b5203fc88c7" + "sha256:2ddfb553fdf02fb784c234c7ba6ccc288296ceabec964ad2eae3777778130bc5", + "sha256:eb82c5e3e56209074766e6885bb04b8c38a0c015d0a30036ebe7ece34c9989e9" ], "markers": "python_version >= '3.7'", - "version": "==23.2" + "version": "==24.0" }, "paho-mqtt": { "hashes": [ @@ -909,6 +1350,10 @@ "markers": "python_version >= '2.6'", "version": "==6.0.0" }, + "performance-metrics": { + "editable": true, + "file": "../performance-metrics" + }, "platformdirs": { "hashes": [ "sha256:0614df2a2f37e1a662acbd8e2b25b92ccf8632929bc6d43467e17fe89c75e068", @@ -941,6 +1386,49 @@ "markers": "python_version >= '3.8'", "version": "==2.11.1" }, + "pydantic": { + "hashes": [ + "sha256:0fe8a415cea8f340e7a9af9c54fc71a649b43e8ca3cc732986116b3cb135d303", + "sha256:1289c180abd4bd4555bb927c42ee42abc3aee02b0fb2d1223fb7c6e5bef87dbe", + "sha256:1eb2085c13bce1612da8537b2d90f549c8cbb05c67e8f22854e201bde5d98a47", + "sha256:2031de0967c279df0d8a1c72b4ffc411ecd06bac607a212892757db7462fc494", + "sha256:2a7bac939fa326db1ab741c9d7f44c565a1d1e80908b3797f7f81a4f86bc8d33", + "sha256:2d5a58feb9a39f481eda4d5ca220aa8b9d4f21a41274760b9bc66bfd72595b86", + "sha256:2f9a6fab5f82ada41d56b0602606a5506aab165ca54e52bc4545028382ef1c5d", + "sha256:2fcfb5296d7877af406ba1547dfde9943b1256d8928732267e2653c26938cd9c", + "sha256:549a8e3d81df0a85226963611950b12d2d334f214436a19537b2efed61b7639a", + "sha256:598da88dfa127b666852bef6d0d796573a8cf5009ffd62104094a4fe39599565", + "sha256:5d1197e462e0364906cbc19681605cb7c036f2475c899b6f296104ad42b9f5fb", + "sha256:69328e15cfda2c392da4e713443c7dbffa1505bc9d566e71e55abe14c97ddc62", + "sha256:6a9dfa722316f4acf4460afdf5d41d5246a80e249c7ff475c43a3a1e9d75cf62", + "sha256:6b30bcb8cbfccfcf02acb8f1a261143fab622831d9c0989707e0e659f77a18e0", + "sha256:6c076be61cd0177a8433c0adcb03475baf4ee91edf5a4e550161ad57fc90f523", + "sha256:771735dc43cf8383959dc9b90aa281f0b6092321ca98677c5fb6125a6f56d58d", + "sha256:795e34e6cc065f8f498c89b894a3c6da294a936ee71e644e4bd44de048af1405", + "sha256:87afda5539d5140cb8ba9e8b8c8865cb5b1463924d38490d73d3ccfd80896b3f", + "sha256:8fb2aa3ab3728d950bcc885a2e9eff6c8fc40bc0b7bb434e555c215491bcf48b", + "sha256:a1fcb59f2f355ec350073af41d927bf83a63b50e640f4dbaa01053a28b7a7718", + "sha256:a5e7add47a5b5a40c49b3036d464e3c7802f8ae0d1e66035ea16aa5b7a3923ed", + "sha256:a73f489aebd0c2121ed974054cb2759af8a9f747de120acd2c3394cf84176ccb", + "sha256:ab26038b8375581dc832a63c948f261ae0aa21f1d34c1293469f135fa92972a5", + "sha256:b0d191db0f92dfcb1dec210ca244fdae5cbe918c6050b342d619c09d31eea0cc", + "sha256:b749a43aa51e32839c9d71dc67eb1e4221bb04af1033a32e3923d46f9effa942", + "sha256:b7ccf02d7eb340b216ec33e53a3a629856afe1c6e0ef91d84a4e6f2fb2ca70fe", + "sha256:ba5b2e6fe6ca2b7e013398bc7d7b170e21cce322d266ffcd57cca313e54fb246", + "sha256:ba5c4a8552bff16c61882db58544116d021d0b31ee7c66958d14cf386a5b5350", + "sha256:c79e6a11a07da7374f46970410b41d5e266f7f38f6a17a9c4823db80dadf4303", + "sha256:ca48477862372ac3770969b9d75f1bf66131d386dba79506c46d75e6b48c1e09", + "sha256:dea7adcc33d5d105896401a1f37d56b47d443a2b2605ff8a969a0ed5543f7e33", + "sha256:e0a16d274b588767602b7646fa05af2782576a6cf1022f4ba74cbb4db66f6ca8", + "sha256:e4129b528c6baa99a429f97ce733fff478ec955513630e61b49804b6cf9b224a", + "sha256:e5f805d2d5d0a41633651a73fa4ecdd0b3d7a49de4ec3fadf062fe16501ddbf1", + "sha256:ef6c96b2baa2100ec91a4b428f80d8f28a3c9e53568219b6c298c1125572ebc6", + "sha256:fdbdd1d630195689f325c9ef1a12900524dceb503b00a987663ff4f58669b93d" + ], + "index": "pypi", + "markers": "python_version >= '3.7'", + "version": "==1.10.12" + }, "pydocstyle": { "hashes": [ "sha256:118762d452a49d6b05e194ef344a55822987a462831ade91ec5c06fd2169d019", @@ -972,6 +1460,44 @@ ], "version": "==1.8.0" }, + "pyrsistent": { + "hashes": [ + "sha256:0724c506cd8b63c69c7f883cc233aac948c1ea946ea95996ad8b1380c25e1d3f", + "sha256:09848306523a3aba463c4b49493a760e7a6ca52e4826aa100ee99d8d39b7ad1e", + "sha256:0f3b1bcaa1f0629c978b355a7c37acd58907390149b7311b5db1b37648eb6958", + "sha256:21cc459636983764e692b9eba7144cdd54fdec23ccdb1e8ba392a63666c60c34", + "sha256:2e14c95c16211d166f59c6611533d0dacce2e25de0f76e4c140fde250997b3ca", + "sha256:2e2c116cc804d9b09ce9814d17df5edf1df0c624aba3b43bc1ad90411487036d", + "sha256:4021a7f963d88ccd15b523787d18ed5e5269ce57aa4037146a2377ff607ae87d", + "sha256:4c48f78f62ab596c679086084d0dd13254ae4f3d6c72a83ffdf5ebdef8f265a4", + "sha256:4f5c2d012671b7391803263419e31b5c7c21e7c95c8760d7fc35602353dee714", + "sha256:58b8f6366e152092194ae68fefe18b9f0b4f89227dfd86a07770c3d86097aebf", + "sha256:59a89bccd615551391f3237e00006a26bcf98a4d18623a19909a2c48b8e986ee", + "sha256:5cdd7ef1ea7a491ae70d826b6cc64868de09a1d5ff9ef8d574250d0940e275b8", + "sha256:6288b3fa6622ad8a91e6eb759cfc48ff3089e7c17fb1d4c59a919769314af224", + "sha256:6d270ec9dd33cdb13f4d62c95c1a5a50e6b7cdd86302b494217137f760495b9d", + "sha256:79ed12ba79935adaac1664fd7e0e585a22caa539dfc9b7c7c6d5ebf91fb89054", + "sha256:7d29c23bdf6e5438c755b941cef867ec2a4a172ceb9f50553b6ed70d50dfd656", + "sha256:8441cf9616d642c475684d6cf2520dd24812e996ba9af15e606df5f6fd9d04a7", + "sha256:881bbea27bbd32d37eb24dd320a5e745a2a5b092a17f6debc1349252fac85423", + "sha256:8c3aba3e01235221e5b229a6c05f585f344734bd1ad42a8ac51493d74722bbce", + "sha256:a14798c3005ec892bbada26485c2eea3b54109cb2533713e355c806891f63c5e", + "sha256:b14decb628fac50db5e02ee5a35a9c0772d20277824cfe845c8a8b717c15daa3", + "sha256:b318ca24db0f0518630e8b6f3831e9cba78f099ed5c1d65ffe3e023003043ba0", + "sha256:c1beb78af5423b879edaf23c5591ff292cf7c33979734c99aa66d5914ead880f", + "sha256:c55acc4733aad6560a7f5f818466631f07efc001fd023f34a6c203f8b6df0f0b", + "sha256:ca52d1ceae015859d16aded12584c59eb3825f7b50c6cfd621d4231a6cc624ce", + "sha256:cae40a9e3ce178415040a0383f00e8d68b569e97f31928a3a8ad37e3fde6df6a", + "sha256:e78d0c7c1e99a4a45c99143900ea0546025e41bb59ebc10182e947cf1ece9174", + "sha256:ef3992833fbd686ee783590639f4b8343a57f1f75de8633749d984dc0eb16c86", + "sha256:f058a615031eea4ef94ead6456f5ec2026c19fb5bd6bfe86e9665c4158cf802f", + "sha256:f5ac696f02b3fc01a710427585c855f65cd9c640e14f52abe52020722bb4906b", + "sha256:f920385a11207dc372a028b3f1e1038bb244b3ec38d448e6d8e43c6b3ba20e98", + "sha256:fed2c3216a605dc9a6ea50c7e84c82906e3684c4e80d2908208f662a6cbf9022" + ], + "markers": "python_version >= '3.8'", + "version": "==0.20.0" + }, "pytest": { "hashes": [ "sha256:130328f552dcfac0b1cec75c12e3f005619dc5f874f0a06e8ff7263f0ee6225e", @@ -983,12 +1509,12 @@ }, "pytest-asyncio": { "hashes": [ - "sha256:2143d9d9375bf372a73260e4114541485e84fca350b0b6b92674ca56ff5f7ea2", - "sha256:b0079dfac14b60cd1ce4691fbfb1748fe939db7d0234b5aba97197d10fbe0fef" + "sha256:68516fdd1018ac57b846c9846b954f0393b26f094764a28c955eabb0536a4e8a", + "sha256:ffe523a89c1c222598c76856e76852b787504ddb72dd5d9b6617ffa8aa2cde5f" ], "index": "pypi", "markers": "python_version >= '3.8'", - "version": "==0.23.4" + "version": "==0.23.6" }, "pytest-cov": { "hashes": [ @@ -1050,11 +1576,11 @@ }, "python-dateutil": { "hashes": [ - "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86", - "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9" + "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", + "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427" ], "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==2.8.2" + "version": "==2.9.0.post0" }, "pyyaml": { "hashes": [ @@ -1113,14 +1639,6 @@ "markers": "python_version >= '3.6'", "version": "==6.0.1" }, - "referencing": { - "hashes": [ - "sha256:39240f2ecc770258f28b642dd47fd74bc8b02484de54e1882b74b35ebd779bd5", - "sha256:c775fedf74bc0f9189c2a3be1c12fd03e8c23f4d371dce795df44e06c5b412f7" - ], - "markers": "python_version >= '3.8'", - "version": "==0.33.0" - }, "requests": { "hashes": [ "sha256:68d7c56fd5a8999887728ef304a6d12edc7be74f1cfa47714fc8b414525c9a61", @@ -1130,118 +1648,13 @@ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'", "version": "==2.27.1" }, - "rpds-py": { - "hashes": [ - "sha256:01f58a7306b64e0a4fe042047dd2b7d411ee82e54240284bab63e325762c1147", - "sha256:0210b2668f24c078307260bf88bdac9d6f1093635df5123789bfee4d8d7fc8e7", - "sha256:02866e060219514940342a1f84303a1ef7a1dad0ac311792fbbe19b521b489d2", - "sha256:0387ce69ba06e43df54e43968090f3626e231e4bc9150e4c3246947567695f68", - "sha256:060f412230d5f19fc8c8b75f315931b408d8ebf56aec33ef4168d1b9e54200b1", - "sha256:071bc28c589b86bc6351a339114fb7a029f5cddbaca34103aa573eba7b482382", - "sha256:0bfb09bf41fe7c51413f563373e5f537eaa653d7adc4830399d4e9bdc199959d", - "sha256:10162fe3f5f47c37ebf6d8ff5a2368508fe22007e3077bf25b9c7d803454d921", - "sha256:149c5cd24f729e3567b56e1795f74577aa3126c14c11e457bec1b1c90d212e38", - "sha256:1701fc54460ae2e5efc1dd6350eafd7a760f516df8dbe51d4a1c79d69472fbd4", - "sha256:1957a2ab607f9added64478a6982742eb29f109d89d065fa44e01691a20fc20a", - "sha256:1a746a6d49665058a5896000e8d9d2f1a6acba8a03b389c1e4c06e11e0b7f40d", - "sha256:1bfcad3109c1e5ba3cbe2f421614e70439f72897515a96c462ea657261b96518", - "sha256:1d36b2b59e8cc6e576f8f7b671e32f2ff43153f0ad6d0201250a7c07f25d570e", - "sha256:1db228102ab9d1ff4c64148c96320d0be7044fa28bd865a9ce628ce98da5973d", - "sha256:1dc29db3900cb1bb40353772417800f29c3d078dbc8024fd64655a04ee3c4bdf", - "sha256:1e626b365293a2142a62b9a614e1f8e331b28f3ca57b9f05ebbf4cf2a0f0bdc5", - "sha256:1f3c3461ebb4c4f1bbc70b15d20b565759f97a5aaf13af811fcefc892e9197ba", - "sha256:20de7b7179e2031a04042e85dc463a93a82bc177eeba5ddd13ff746325558aa6", - "sha256:24e4900a6643f87058a27320f81336d527ccfe503984528edde4bb660c8c8d59", - "sha256:2528ff96d09f12e638695f3a2e0c609c7b84c6df7c5ae9bfeb9252b6fa686253", - "sha256:25f071737dae674ca8937a73d0f43f5a52e92c2d178330b4c0bb6ab05586ffa6", - "sha256:270987bc22e7e5a962b1094953ae901395e8c1e1e83ad016c5cfcfff75a15a3f", - "sha256:292f7344a3301802e7c25c53792fae7d1593cb0e50964e7bcdcc5cf533d634e3", - "sha256:2953937f83820376b5979318840f3ee47477d94c17b940fe31d9458d79ae7eea", - "sha256:2a792b2e1d3038daa83fa474d559acfd6dc1e3650ee93b2662ddc17dbff20ad1", - "sha256:2a7b2f2f56a16a6d62e55354dd329d929560442bd92e87397b7a9586a32e3e76", - "sha256:2f4eb548daf4836e3b2c662033bfbfc551db58d30fd8fe660314f86bf8510b93", - "sha256:3664d126d3388a887db44c2e293f87d500c4184ec43d5d14d2d2babdb4c64cad", - "sha256:3677fcca7fb728c86a78660c7fb1b07b69b281964673f486ae72860e13f512ad", - "sha256:380e0df2e9d5d5d339803cfc6d183a5442ad7ab3c63c2a0982e8c824566c5ccc", - "sha256:3ac732390d529d8469b831949c78085b034bff67f584559340008d0f6041a049", - "sha256:4128980a14ed805e1b91a7ed551250282a8ddf8201a4e9f8f5b7e6225f54170d", - "sha256:4341bd7579611cf50e7b20bb8c2e23512a3dc79de987a1f411cb458ab670eb90", - "sha256:436474f17733c7dca0fbf096d36ae65277e8645039df12a0fa52445ca494729d", - "sha256:4dc889a9d8a34758d0fcc9ac86adb97bab3fb7f0c4d29794357eb147536483fd", - "sha256:4e21b76075c01d65d0f0f34302b5a7457d95721d5e0667aea65e5bb3ab415c25", - "sha256:516fb8c77805159e97a689e2f1c80655c7658f5af601c34ffdb916605598cda2", - "sha256:5576ee2f3a309d2bb403ec292d5958ce03953b0e57a11d224c1f134feaf8c40f", - "sha256:5a024fa96d541fd7edaa0e9d904601c6445e95a729a2900c5aec6555fe921ed6", - "sha256:5d0e8a6434a3fbf77d11448c9c25b2f25244226cfbec1a5159947cac5b8c5fa4", - "sha256:5e7d63ec01fe7c76c2dbb7e972fece45acbb8836e72682bde138e7e039906e2c", - "sha256:60e820ee1004327609b28db8307acc27f5f2e9a0b185b2064c5f23e815f248f8", - "sha256:637b802f3f069a64436d432117a7e58fab414b4e27a7e81049817ae94de45d8d", - "sha256:65dcf105c1943cba45d19207ef51b8bc46d232a381e94dd38719d52d3980015b", - "sha256:698ea95a60c8b16b58be9d854c9f993c639f5c214cf9ba782eca53a8789d6b19", - "sha256:70fcc6c2906cfa5c6a552ba7ae2ce64b6c32f437d8f3f8eea49925b278a61453", - "sha256:720215373a280f78a1814becb1312d4e4d1077b1202a56d2b0815e95ccb99ce9", - "sha256:7450dbd659fed6dd41d1a7d47ed767e893ba402af8ae664c157c255ec6067fde", - "sha256:7b7d9ca34542099b4e185b3c2a2b2eda2e318a7dbde0b0d83357a6d4421b5296", - "sha256:7fbd70cb8b54fe745301921b0816c08b6d917593429dfc437fd024b5ba713c58", - "sha256:81038ff87a4e04c22e1d81f947c6ac46f122e0c80460b9006e6517c4d842a6ec", - "sha256:810685321f4a304b2b55577c915bece4c4a06dfe38f6e62d9cc1d6ca8ee86b99", - "sha256:82ada4a8ed9e82e443fcef87e22a3eed3654dd3adf6e3b3a0deb70f03e86142a", - "sha256:841320e1841bb53fada91c9725e766bb25009cfd4144e92298db296fb6c894fb", - "sha256:8587fd64c2a91c33cdc39d0cebdaf30e79491cc029a37fcd458ba863f8815383", - "sha256:8ffe53e1d8ef2520ebcf0c9fec15bb721da59e8ef283b6ff3079613b1e30513d", - "sha256:9051e3d2af8f55b42061603e29e744724cb5f65b128a491446cc029b3e2ea896", - "sha256:91e5a8200e65aaac342a791272c564dffcf1281abd635d304d6c4e6b495f29dc", - "sha256:93432e747fb07fa567ad9cc7aaadd6e29710e515aabf939dfbed8046041346c6", - "sha256:938eab7323a736533f015e6069a7d53ef2dcc841e4e533b782c2bfb9fb12d84b", - "sha256:9584f8f52010295a4a417221861df9bea4c72d9632562b6e59b3c7b87a1522b7", - "sha256:9737bdaa0ad33d34c0efc718741abaafce62fadae72c8b251df9b0c823c63b22", - "sha256:99da0a4686ada4ed0f778120a0ea8d066de1a0a92ab0d13ae68492a437db78bf", - "sha256:99f567dae93e10be2daaa896e07513dd4bf9c2ecf0576e0533ac36ba3b1d5394", - "sha256:9bdf1303df671179eaf2cb41e8515a07fc78d9d00f111eadbe3e14262f59c3d0", - "sha256:9f0e4dc0f17dcea4ab9d13ac5c666b6b5337042b4d8f27e01b70fae41dd65c57", - "sha256:a000133a90eea274a6f28adc3084643263b1e7c1a5a66eb0a0a7a36aa757ed74", - "sha256:a3264e3e858de4fc601741498215835ff324ff2482fd4e4af61b46512dd7fc83", - "sha256:a71169d505af63bb4d20d23a8fbd4c6ce272e7bce6cc31f617152aa784436f29", - "sha256:a967dd6afda7715d911c25a6ba1517975acd8d1092b2f326718725461a3d33f9", - "sha256:aa5bfb13f1e89151ade0eb812f7b0d7a4d643406caaad65ce1cbabe0a66d695f", - "sha256:ae35e8e6801c5ab071b992cb2da958eee76340e6926ec693b5ff7d6381441745", - "sha256:b686f25377f9c006acbac63f61614416a6317133ab7fafe5de5f7dc8a06d42eb", - "sha256:b760a56e080a826c2e5af09002c1a037382ed21d03134eb6294812dda268c811", - "sha256:b86b21b348f7e5485fae740d845c65a880f5d1eda1e063bc59bef92d1f7d0c55", - "sha256:b9412abdf0ba70faa6e2ee6c0cc62a8defb772e78860cef419865917d86c7342", - "sha256:bd345a13ce06e94c753dab52f8e71e5252aec1e4f8022d24d56decd31e1b9b23", - "sha256:be22ae34d68544df293152b7e50895ba70d2a833ad9566932d750d3625918b82", - "sha256:bf046179d011e6114daf12a534d874958b039342b347348a78b7cdf0dd9d6041", - "sha256:c3d2010656999b63e628a3c694f23020322b4178c450dc478558a2b6ef3cb9bb", - "sha256:c64602e8be701c6cfe42064b71c84ce62ce66ddc6422c15463fd8127db3d8066", - "sha256:d65e6b4f1443048eb7e833c2accb4fa7ee67cc7d54f31b4f0555b474758bee55", - "sha256:d8bbd8e56f3ba25a7d0cf980fc42b34028848a53a0e36c9918550e0280b9d0b6", - "sha256:da1ead63368c04a9bded7904757dfcae01eba0e0f9bc41d3d7f57ebf1c04015a", - "sha256:dbbb95e6fc91ea3102505d111b327004d1c4ce98d56a4a02e82cd451f9f57140", - "sha256:dbc56680ecf585a384fbd93cd42bc82668b77cb525343170a2d86dafaed2a84b", - "sha256:df3b6f45ba4515632c5064e35ca7f31d51d13d1479673185ba8f9fefbbed58b9", - "sha256:dfe07308b311a8293a0d5ef4e61411c5c20f682db6b5e73de6c7c8824272c256", - "sha256:e796051f2070f47230c745d0a77a91088fbee2cc0502e9b796b9c6471983718c", - "sha256:efa767c220d94aa4ac3a6dd3aeb986e9f229eaf5bce92d8b1b3018d06bed3772", - "sha256:f0b8bf5b8db49d8fd40f54772a1dcf262e8be0ad2ab0206b5a2ec109c176c0a4", - "sha256:f175e95a197f6a4059b50757a3dca33b32b61691bdbd22c29e8a8d21d3914cae", - "sha256:f2f3b28b40fddcb6c1f1f6c88c6f3769cd933fa493ceb79da45968a21dccc920", - "sha256:f6c43b6f97209e370124baf2bf40bb1e8edc25311a158867eb1c3a5d449ebc7a", - "sha256:f7f4cb1f173385e8a39c29510dd11a78bf44e360fb75610594973f5ea141028b", - "sha256:fad059a4bd14c45776600d223ec194e77db6c20255578bb5bcdd7c18fd169361", - "sha256:ff1dcb8e8bc2261a088821b2595ef031c91d499a0c1b031c152d43fe0a6ecec8", - "sha256:ffee088ea9b593cc6160518ba9bd319b5475e5f3e578e4552d63818773c6f56a" - ], - "markers": "python_version >= '3.8'", - "version": "==0.17.1" - }, "ruamel.yaml": { "hashes": [ - "sha256:61917e3a35a569c1133a8f772e1226961bf5a1198bea7e23f06a0841dea1ab0e", - "sha256:a013ac02f99a69cdd6277d9664689eb1acba07069f912823177c5eced21a6ada" + "sha256:57b53ba33def16c4f3d807c0ccbc00f8a6081827e81ba2491691b76882d0c636", + "sha256:8b27e6a217e786c6fbe5634d8f3f11bc63e0f80f6a5890f28863d9c45aac311b" ], "markers": "python_version >= '3.7'", - "version": "==0.18.5" + "version": "==0.18.6" }, "ruamel.yaml.clib": { "hashes": [ @@ -1309,11 +1722,11 @@ }, "sniffio": { "hashes": [ - "sha256:e60305c5e5d314f5389259b7f22aaa33d8f7dee49763119234af3755c55b9101", - "sha256:eecefdce1e5bbfb7ad2eeaabf7c1eeb404d7757c379bd1f7e5cce9d8bf425384" + "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", + "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc" ], "markers": "python_version >= '3.7'", - "version": "==1.3.0" + "version": "==1.3.1" }, "snowballstemmer": { "hashes": [ @@ -1358,12 +1771,12 @@ }, "types-mock": { "hashes": [ - "sha256:13ca379d5710ccb3f18f69ade5b08881874cb83383d8fb49b1d4dac9d5c5d090", - "sha256:3d116955495935b0bcba14954b38d97e507cd43eca3e3700fc1b8e4f5c6bf2c7" + "sha256:0769cb376dfc75b45215619f17a9fd6333d771cc29ce4a38937f060b1e45530f", + "sha256:7472797986d83016f96fde7f73577d129b0cd8a8d0b783487a7be330d57ba431" ], "index": "pypi", "markers": "python_version >= '3.8'", - "version": "==5.1.0.20240106" + "version": "==5.1.0.20240311" }, "types-paho-mqtt": { "hashes": [ @@ -1391,12 +1804,12 @@ }, "typing-extensions": { "hashes": [ - "sha256:23478f88c37f27d76ac8aee6c905017a143b0b1b886c3c9f66bc2fd94f9f5783", - "sha256:af72aea155e91adfc61c3ae9e0e342dbc0cba726d6cba4b6c72c1f34e47291cd" + "sha256:83f085bd5ca59c80295fc2a82ab5dac679cbe02b9f33f7d83af68e241bea51b0", + "sha256:c1f94d72897edaf4ce775bb7558d5b79d8126906a14ea5ed1635921406c0387a" ], "index": "pypi", "markers": "python_version >= '3.8'", - "version": "==4.9.0" + "version": "==4.11.0" }, "urllib3": { "hashes": [ diff --git a/robot-server/robot_server/app_setup.py b/robot-server/robot_server/app_setup.py index 80fda961119..04147753906 100644 --- a/robot-server/robot_server/app_setup.py +++ b/robot-server/robot_server/app_setup.py @@ -36,7 +36,7 @@ ) from .service.notifications import ( - initialize_notification_client, + initialize_notifications, clean_up_notification_client, ) @@ -106,7 +106,7 @@ async def on_startup() -> None: fbl_mark_persistence_init_complete ], ) - initialize_notification_client( + await initialize_notifications( app_state=app.state, ) diff --git a/robot-server/robot_server/deck_configuration/defaults.py b/robot-server/robot_server/deck_configuration/defaults.py index a591e9798df..3ed9a5ed395 100644 --- a/robot-server/robot_server/deck_configuration/defaults.py +++ b/robot-server/robot_server/deck_configuration/defaults.py @@ -7,40 +7,64 @@ _for_flex = models.DeckConfigurationRequest.construct( cutoutFixtures=[ models.CutoutFixture.construct( - cutoutId="cutoutA1", cutoutFixtureId="singleLeftSlot" + cutoutId="cutoutA1", + cutoutFixtureId="singleLeftSlot", + opentronsModuleSerialNumber=None, ), models.CutoutFixture.construct( - cutoutId="cutoutB1", cutoutFixtureId="singleLeftSlot" + cutoutId="cutoutB1", + cutoutFixtureId="singleLeftSlot", + opentronsModuleSerialNumber=None, ), models.CutoutFixture.construct( - cutoutId="cutoutC1", cutoutFixtureId="singleLeftSlot" + cutoutId="cutoutC1", + cutoutFixtureId="singleLeftSlot", + opentronsModuleSerialNumber=None, ), models.CutoutFixture.construct( - cutoutId="cutoutD1", cutoutFixtureId="singleLeftSlot" + cutoutId="cutoutD1", + cutoutFixtureId="singleLeftSlot", + opentronsModuleSerialNumber=None, ), models.CutoutFixture.construct( - cutoutId="cutoutA2", cutoutFixtureId="singleCenterSlot" + cutoutId="cutoutA2", + cutoutFixtureId="singleCenterSlot", + opentronsModuleSerialNumber=None, ), models.CutoutFixture.construct( - cutoutId="cutoutB2", cutoutFixtureId="singleCenterSlot" + cutoutId="cutoutB2", + cutoutFixtureId="singleCenterSlot", + opentronsModuleSerialNumber=None, ), models.CutoutFixture.construct( - cutoutId="cutoutC2", cutoutFixtureId="singleCenterSlot" + cutoutId="cutoutC2", + cutoutFixtureId="singleCenterSlot", + opentronsModuleSerialNumber=None, ), models.CutoutFixture.construct( - cutoutId="cutoutD2", cutoutFixtureId="singleCenterSlot" + cutoutId="cutoutD2", + cutoutFixtureId="singleCenterSlot", + opentronsModuleSerialNumber=None, ), models.CutoutFixture.construct( - cutoutId="cutoutA3", cutoutFixtureId="trashBinAdapter" + cutoutId="cutoutA3", + cutoutFixtureId="trashBinAdapter", + opentronsModuleSerialNumber=None, ), models.CutoutFixture.construct( - cutoutId="cutoutB3", cutoutFixtureId="singleRightSlot" + cutoutId="cutoutB3", + cutoutFixtureId="singleRightSlot", + opentronsModuleSerialNumber=None, ), models.CutoutFixture.construct( - cutoutId="cutoutC3", cutoutFixtureId="singleRightSlot" + cutoutId="cutoutC3", + cutoutFixtureId="singleRightSlot", + opentronsModuleSerialNumber=None, ), models.CutoutFixture.construct( - cutoutId="cutoutD3", cutoutFixtureId="singleRightSlot" + cutoutId="cutoutD3", + cutoutFixtureId="singleRightSlot", + opentronsModuleSerialNumber=None, ), ] ) @@ -49,40 +73,64 @@ _for_ot2 = models.DeckConfigurationRequest.construct( cutoutFixtures=[ models.CutoutFixture.construct( - cutoutId="cutout1", cutoutFixtureId="singleStandardSlot" + cutoutId="cutout1", + cutoutFixtureId="singleStandardSlot", + opentronsModuleSerialNumber=None, ), models.CutoutFixture.construct( - cutoutId="cutout2", cutoutFixtureId="singleStandardSlot" + cutoutId="cutout2", + cutoutFixtureId="singleStandardSlot", + opentronsModuleSerialNumber=None, ), models.CutoutFixture.construct( - cutoutId="cutout3", cutoutFixtureId="singleStandardSlot" + cutoutId="cutout3", + cutoutFixtureId="singleStandardSlot", + opentronsModuleSerialNumber=None, ), models.CutoutFixture.construct( - cutoutId="cutout4", cutoutFixtureId="singleStandardSlot" + cutoutId="cutout4", + cutoutFixtureId="singleStandardSlot", + opentronsModuleSerialNumber=None, ), models.CutoutFixture.construct( - cutoutId="cutout5", cutoutFixtureId="singleStandardSlot" + cutoutId="cutout5", + cutoutFixtureId="singleStandardSlot", + opentronsModuleSerialNumber=None, ), models.CutoutFixture.construct( - cutoutId="cutout6", cutoutFixtureId="singleStandardSlot" + cutoutId="cutout6", + cutoutFixtureId="singleStandardSlot", + opentronsModuleSerialNumber=None, ), models.CutoutFixture.construct( - cutoutId="cutout7", cutoutFixtureId="singleStandardSlot" + cutoutId="cutout7", + cutoutFixtureId="singleStandardSlot", + opentronsModuleSerialNumber=None, ), models.CutoutFixture.construct( - cutoutId="cutout8", cutoutFixtureId="singleStandardSlot" + cutoutId="cutout8", + cutoutFixtureId="singleStandardSlot", + opentronsModuleSerialNumber=None, ), models.CutoutFixture.construct( - cutoutId="cutout9", cutoutFixtureId="singleStandardSlot" + cutoutId="cutout9", + cutoutFixtureId="singleStandardSlot", + opentronsModuleSerialNumber=None, ), models.CutoutFixture.construct( - cutoutId="cutout10", cutoutFixtureId="singleStandardSlot" + cutoutId="cutout10", + cutoutFixtureId="singleStandardSlot", + opentronsModuleSerialNumber=None, ), models.CutoutFixture.construct( - cutoutId="cutout11", cutoutFixtureId="singleStandardSlot" + cutoutId="cutout11", + cutoutFixtureId="singleStandardSlot", + opentronsModuleSerialNumber=None, ), models.CutoutFixture.construct( - cutoutId="cutout12", cutoutFixtureId="fixedTrashSlot" + cutoutId="cutout12", + cutoutFixtureId="fixedTrashSlot", + opentronsModuleSerialNumber=None, ), ] ) diff --git a/robot-server/robot_server/deck_configuration/models.py b/robot-server/robot_server/deck_configuration/models.py index f0d2a7cd6bd..b84b395a667 100644 --- a/robot-server/robot_server/deck_configuration/models.py +++ b/robot-server/robot_server/deck_configuration/models.py @@ -33,6 +33,13 @@ class CutoutFixture(pydantic.BaseModel): " [deck definition](https://github.com/Opentrons/opentrons/tree/edge/shared-data/deck)." ) ) + opentronsModuleSerialNumber: Optional[str] = pydantic.Field( + description=( + "The serial number of a module loaded as a fixture." + " [deck definition](https://github.com/Opentrons/opentrons/tree/edge/shared-data/deck)." + ), + default=None, + ) class DeckConfigurationRequest(pydantic.BaseModel): diff --git a/robot-server/robot_server/deck_configuration/router.py b/robot-server/robot_server/deck_configuration/router.py index 4e00a3d707e..6e9d68d9f1b 100644 --- a/robot-server/robot_server/deck_configuration/router.py +++ b/robot-server/robot_server/deck_configuration/router.py @@ -7,7 +7,7 @@ import fastapi from starlette.status import HTTP_422_UNPROCESSABLE_ENTITY -from opentrons_shared_data.deck.dev_types import DeckDefinitionV4 +from opentrons_shared_data.deck.dev_types import DeckDefinitionV5 from robot_server.errors.error_responses import ErrorBody from robot_server.hardware import get_deck_definition @@ -64,7 +64,7 @@ async def put_deck_configuration( # noqa: D103 request_body: RequestModel[models.DeckConfigurationRequest], store: DeckConfigurationStore = fastapi.Depends(get_deck_configuration_store), now: datetime = fastapi.Depends(get_current_time), - deck_definition: DeckDefinitionV4 = fastapi.Depends(get_deck_definition), + deck_definition: DeckDefinitionV5 = fastapi.Depends(get_deck_definition), ) -> PydanticResponse[ Union[ SimpleBody[models.DeckConfigurationResponse], diff --git a/robot-server/robot_server/deck_configuration/store.py b/robot-server/robot_server/deck_configuration/store.py index feffa539ec0..e892c91f7e5 100644 --- a/robot-server/robot_server/deck_configuration/store.py +++ b/robot-server/robot_server/deck_configuration/store.py @@ -54,7 +54,9 @@ async def set( path=self._path, cutout_fixture_placements=[ calibration_storage_types.CutoutFixturePlacement( - cutout_fixture_id=e.cutoutFixtureId, cutout_id=e.cutoutId + cutout_fixture_id=e.cutoutFixtureId, + cutout_id=e.cutoutId, + opentrons_module_serial_number=e.opentronsModuleSerialNumber, ) for e in request.cutoutFixtures ], @@ -71,7 +73,8 @@ async def get_deck_configuration(self) -> DeckConfigurationType: """Get the robot's current deck configuration in an expected typing.""" to_convert = await self.get() converted = [ - (item.cutoutId, item.cutoutFixtureId) for item in to_convert.cutoutFixtures + (item.cutoutId, item.cutoutFixtureId, item.opentronsModuleSerialNumber) + for item in to_convert.cutoutFixtures ] return converted @@ -102,6 +105,7 @@ async def _get_assuming_locked(self) -> models.DeckConfigurationResponse: models.CutoutFixture.construct( cutoutFixtureId=e.cutout_fixture_id, cutoutId=e.cutout_id, + opentronsModuleSerialNumber=e.opentrons_module_serial_number, ) for e in cutout_fixtures_from_storage ] diff --git a/robot-server/robot_server/deck_configuration/validation.py b/robot-server/robot_server/deck_configuration/validation.py index 0530a4f9271..a3c043f8f51 100644 --- a/robot-server/robot_server/deck_configuration/validation.py +++ b/robot-server/robot_server/deck_configuration/validation.py @@ -3,7 +3,7 @@ from collections import defaultdict from dataclasses import dataclass -from typing import DefaultDict, FrozenSet, List, Set, Tuple, Union +from typing import DefaultDict, FrozenSet, List, Set, Tuple, Union, Optional from opentrons_shared_data.deck import dev_types as deck_types @@ -14,6 +14,7 @@ class Placement: cutout_id: str cutout_fixture_id: str + opentrons_module_serial_number: Optional[str] @dataclass(frozen=True) @@ -43,22 +44,51 @@ class InvalidLocationError: @dataclass(frozen=True) class UnrecognizedCutoutFixtureError: - """When an cutout fixture has been mounted that's not defined by the deck definition.""" + """When a cutout fixture has been mounted that's not defined by the deck definition.""" cutout_fixture_id: str allowed_cutout_fixture_ids: FrozenSet[str] +@dataclass(frozen=True) +class InvalidSerialNumberError: + """When a module cutout fixture has been mounted but not given a serial number.""" + + cutout_id: str + cutout_fixture_id: str + + +@dataclass(frozen=True) +class UnexpectedSerialNumberError: + """When a cutout fixture that is not a module has been provided a serial number.""" + + cutout_id: str + cutout_fixture_id: str + opentrons_module_serial_number: str + + +@dataclass(frozen=True) +class MissingGroupFixtureError: + """When a member of a fixture group has been mounted but other required members of that group have not.""" + + cutout_id: str + cutout_fixture_id: str + missing_fixture_id: str + + ConfigurationError = Union[ UnoccupiedCutoutError, OvercrowdedCutoutError, InvalidLocationError, UnrecognizedCutoutFixtureError, + InvalidSerialNumberError, + UnexpectedSerialNumberError, + MissingGroupFixtureError, ] -def get_configuration_errors( - deck_definition: deck_types.DeckDefinitionV4, +def get_configuration_errors( # noqa: C901 + deck_definition: deck_types.DeckDefinitionV5, placements: List[Placement], ) -> Set[ConfigurationError]: """Return all the problems with the given deck configration. @@ -98,12 +128,55 @@ def get_configuration_errors( allowed_cutout_ids=allowed_cutout_ids, ) ) + if found_cutout_fixture[ + "expectOpentronsModuleSerialNumber" + ] is False and isinstance(placement.opentrons_module_serial_number, str): + errors.add( + UnexpectedSerialNumberError( + cutout_id=placement.cutout_id, + cutout_fixture_id=placement.cutout_fixture_id, + opentrons_module_serial_number=placement.opentrons_module_serial_number, + ) + ) + elif ( + found_cutout_fixture["expectOpentronsModuleSerialNumber"] is True + and placement.opentrons_module_serial_number is None + ): + errors.add( + InvalidSerialNumberError( + cutout_id=placement.cutout_id, + cutout_fixture_id=placement.cutout_fixture_id, + ) + ) + for cutout_id in found_cutout_fixture["fixtureGroup"]: + if cutout_id == placement.cutout_id: + map = found_cutout_fixture["fixtureGroup"][cutout_id] + member_found = False + for item in map: + for group_member_cutout_id in item: + group_member_fixture_id = item[group_member_cutout_id] + for deck_item in placements: + if ( + group_member_fixture_id + == deck_item.cutout_fixture_id + and group_member_cutout_id == deck_item.cutout_id + ): + member_found = True + if member_found is False: + errors.add( + MissingGroupFixtureError( + cutout_id=placement.cutout_id, + cutout_fixture_id=placement.cutout_fixture_id, + missing_fixture_id=group_member_fixture_id, + ) + ) + member_found = False return errors def _find_cutout_fixture( - deck_definition: deck_types.DeckDefinitionV4, cutout_fixture_id: str + deck_definition: deck_types.DeckDefinitionV5, cutout_fixture_id: str ) -> Union[deck_types.CutoutFixture, UnrecognizedCutoutFixtureError]: cutout_fixtures = deck_definition["cutoutFixtures"] try: diff --git a/robot-server/robot_server/deck_configuration/validation_mapping.py b/robot-server/robot_server/deck_configuration/validation_mapping.py index 10d9b65158a..1337218075c 100644 --- a/robot-server/robot_server/deck_configuration/validation_mapping.py +++ b/robot-server/robot_server/deck_configuration/validation_mapping.py @@ -10,7 +10,11 @@ def map_in(request: models.DeckConfigurationRequest) -> List[validation.Placement]: """Map a request from HTTP to internal types that can be validated.""" return [ - validation.Placement(cutout_id=p.cutoutId, cutout_fixture_id=p.cutoutFixtureId) + validation.Placement( + cutout_id=p.cutoutId, + cutout_fixture_id=p.cutoutFixtureId, + opentrons_module_serial_number=p.opentronsModuleSerialNumber, + ) for p in request.cutoutFixtures ] diff --git a/robot-server/robot_server/hardware.py b/robot-server/robot_server/hardware.py index c72a162b1be..2994248a302 100644 --- a/robot-server/robot_server/hardware.py +++ b/robot-server/robot_server/hardware.py @@ -381,9 +381,9 @@ async def get_deck_type() -> DeckType: async def get_deck_definition( deck_type: DeckType = Depends(get_deck_type), -) -> deck.dev_types.DeckDefinitionV4: +) -> deck.dev_types.DeckDefinitionV5: """Return this robot's deck definition.""" - return deck.load(deck_type, version=4) + return deck.load(deck_type, version=5) async def _postinit_ot2_tasks( diff --git a/robot-server/robot_server/maintenance_runs/maintenance_action_models.py b/robot-server/robot_server/maintenance_runs/maintenance_action_models.py deleted file mode 100644 index 1eb34809dd5..00000000000 --- a/robot-server/robot_server/maintenance_runs/maintenance_action_models.py +++ /dev/null @@ -1,44 +0,0 @@ -"""Request and response models for controlling maintenance runs with actions.""" -from datetime import datetime -from enum import Enum -from pydantic import BaseModel, Field - -from robot_server.service.json_api import ResourceModel - - -class MaintenanceRunActionType(str, Enum): - """Types of run control actions. - - Args: - PLAY: Start or resume a protocol run. - PAUSE: Pause a run. - STOP: Stop (cancel) a run. - """ - - PLAY = "play" - PAUSE = "pause" - STOP = "stop" - - -class MaintenanceRunActionCreate(BaseModel): - """Request model for new control action creation.""" - - actionType: MaintenanceRunActionType - - -class MaintenanceRunAction(ResourceModel): - """Maintenance Run control action model. - - A MaintenanceRunAction resource represents a client-provided command to - the run in order to control the execution of the run itself. - - This is different than a run command, which represents an individual - robotic procedure to be executed. - """ - - id: str = Field(..., description="A unique identifier to reference the command.") - createdAt: datetime = Field(..., description="When the command was created.") - actionType: MaintenanceRunActionType = Field( - ..., - description="Specific type of action, which determines behavior.", - ) diff --git a/robot-server/robot_server/maintenance_runs/maintenance_engine_store.py b/robot-server/robot_server/maintenance_runs/maintenance_engine_store.py index 8e42cbf2cae..c70d2a1dd07 100644 --- a/robot-server/robot_server/maintenance_runs/maintenance_engine_store.py +++ b/robot-server/robot_server/maintenance_runs/maintenance_engine_store.py @@ -1,7 +1,10 @@ """In-memory storage of ProtocolEngine instances.""" +import asyncio +import logging from datetime import datetime -from typing import List, NamedTuple, Optional +from typing import List, NamedTuple, Optional, Callable +from opentrons.protocol_engine.errors.exceptions import EStopActivatedError from opentrons.protocol_engine.types import PostRunHardwareState from opentrons_shared_data.robot.dev_types import RobotType from opentrons_shared_data.robot.dev_types import RobotTypeEnum @@ -27,6 +30,9 @@ from opentrons.protocol_engine.types import DeckConfigurationType +_log = logging.getLogger(__name__) + + class EngineConflictError(RuntimeError): """An error raised if an active engine is already initialized. @@ -48,18 +54,47 @@ class RunnerEnginePair(NamedTuple): engine: ProtocolEngine -def get_estop_listener(engine_store: "MaintenanceEngineStore") -> HardwareEventHandler: - """Create a callback for estop events.""" +async def handle_estop_event( + engine_store: "MaintenanceEngineStore", event: HardwareEvent +) -> None: + """Handle an E-stop event from the hardware API. - def _callback(event: HardwareEvent) -> None: + This is meant to run in the engine's thread and asyncio event loop. + + This is a public function for unit-testing purposes, but it's an implementation + detail of the store. + """ + try: if isinstance(event, EstopStateNotification): if event.new_state is not EstopState.PHYSICALLY_ENGAGED: return if engine_store.current_run_id is None: return - engine_store.engine.estop(maintenance_run=True) + # todo(mm, 2024-04-17): This estop teardown sequencing belongs in the + # runner layer. + engine_store.engine.estop() + await engine_store.engine.finish(error=EStopActivatedError()) + except Exception: + # This is a background task kicked off by a hardware event, + # so there's no one to propagate this exception to. + _log.exception("Exception handling E-stop event.") + + +def _get_estop_listener(engine_store: "MaintenanceEngineStore") -> HardwareEventHandler: + """Create a callback for estop events. + + The returned callback is meant to run in the hardware API's thread. + """ + engine_loop = asyncio.get_running_loop() - return _callback + def run_handler_in_engine_thread_from_hardware_thread( + event: HardwareEvent, + ) -> None: + asyncio.run_coroutine_threadsafe( + handle_estop_event(engine_store, event), engine_loop + ) + + return run_handler_in_engine_thread_from_hardware_thread class MaintenanceEngineStore: @@ -83,15 +118,7 @@ def __init__( self._robot_type = robot_type self._deck_type = deck_type self._runner_engine_pair: Optional[RunnerEnginePair] = None - hardware_api.register_callback(get_estop_listener(self)) - - def _estop_listener(self, event: HardwareEvent) -> None: - if isinstance(event, EstopStateNotification): - if event.new_state is not EstopState.PHYSICALLY_ENGAGED: - return - if self._runner_engine_pair is None: - return - self._runner_engine_pair.engine.estop(maintenance_run=True) + hardware_api.register_callback(_get_estop_listener(self)) @property def engine(self) -> ProtocolEngine: @@ -127,6 +154,7 @@ async def create( run_id: str, created_at: datetime, labware_offsets: List[LabwareOffsetCreate], + notify_publishers: Callable[[], None], deck_configuration: Optional[DeckConfigurationType] = [], ) -> StateSummary: """Create and store a ProtocolRunner and ProtocolEngine for a given Run. @@ -135,6 +163,7 @@ async def create( run_id: The run resource the engine is assigned to. created_at: Run creation datetime labware_offsets: Labware offsets to create the engine with. + notify_publishers: Utilized by the engine to notify publishers of state changes. Returns: The initial equipment and status summary of the engine. @@ -154,6 +183,7 @@ async def create( ), ), deck_configuration=deck_configuration, + notify_publishers=notify_publishers, ) # Using LiveRunner as the runner to allow for future refactor of maintenance runs diff --git a/robot-server/robot_server/maintenance_runs/maintenance_run_data_manager.py b/robot-server/robot_server/maintenance_runs/maintenance_run_data_manager.py index 9857c50a200..084a7552a3a 100644 --- a/robot-server/robot_server/maintenance_runs/maintenance_run_data_manager.py +++ b/robot-server/robot_server/maintenance_runs/maintenance_run_data_manager.py @@ -1,6 +1,6 @@ """Manage current maintenance run data.""" from datetime import datetime -from typing import List, Optional +from typing import List, Optional, Callable from opentrons.protocol_engine import ( EngineStatus, @@ -83,6 +83,7 @@ async def create( created_at: datetime, labware_offsets: List[LabwareOffsetCreate], deck_configuration: DeckConfigurationType, + notify_publishers: Callable[[], None], ) -> MaintenanceRun: """Create a new, current maintenance run. @@ -90,6 +91,7 @@ async def create( run_id: Identifier to assign the new run. created_at: Creation datetime. labware_offsets: Labware offsets to initialize the engine with. + notify_publishers: Utilized by the engine to notify publishers of state changes. Returns: The run resource. @@ -102,6 +104,7 @@ async def create( created_at=created_at, labware_offsets=labware_offsets, deck_configuration=deck_configuration, + notify_publishers=notify_publishers, ) maintenance_run_data = _build_run( diff --git a/robot-server/robot_server/maintenance_runs/maintenance_run_models.py b/robot-server/robot_server/maintenance_runs/maintenance_run_models.py index f4d1a19dc61..00379034d9b 100644 --- a/robot-server/robot_server/maintenance_runs/maintenance_run_models.py +++ b/robot-server/robot_server/maintenance_runs/maintenance_run_models.py @@ -17,7 +17,6 @@ LabwareOffsetCreate, Liquid, ) -from robot_server.maintenance_runs.maintenance_action_models import MaintenanceRunAction from robot_server.service.json_api import ResourceModel @@ -70,9 +69,15 @@ class MaintenanceRun(ResourceModel): " There can be, at most, one current run." ), ) - actions: List[MaintenanceRunAction] = Field( + actions: List[object] = Field( ..., - description="Client-initiated run control actions.", + description=( + " This is currently always an empty list," + " and is provided for symmetry with non-maintenance runs." + " Non-maintenance runs let you issue actions with" + " `POST /runs/{id}/actions`, but there is currently no equivalent" + " endpoint for maintenance runs." + ), ) errors: List[ErrorOccurrence] = Field( ..., diff --git a/robot-server/robot_server/maintenance_runs/router/base_router.py b/robot-server/robot_server/maintenance_runs/router/base_router.py index d2eb71a5798..c115d46509f 100644 --- a/robot-server/robot_server/maintenance_runs/router/base_router.py +++ b/robot-server/robot_server/maintenance_runs/router/base_router.py @@ -5,7 +5,7 @@ import logging from datetime import datetime from textwrap import dedent -from typing import Optional +from typing import Optional, Callable from typing_extensions import Literal from fastapi import APIRouter, Depends, status @@ -39,6 +39,7 @@ get_deck_configuration_store, ) from robot_server.deck_configuration.store import DeckConfigurationStore +from robot_server.service.notifications import get_notify_publishers log = logging.getLogger(__name__) base_router = APIRouter() @@ -155,6 +156,7 @@ async def create_run( deck_configuration_store: DeckConfigurationStore = Depends( get_deck_configuration_store ), + notify_publishers: Callable[[], None] = Depends(get_notify_publishers), ) -> PydanticResponse[SimpleBody[MaintenanceRun]]: """Create a new maintenance run. @@ -166,6 +168,7 @@ async def create_run( is_ok_to_create_maintenance_run: Verify if a maintenance run may be created if a protocol run exists. check_estop: Dependency to verify the estop is in a valid state. deck_configuration_store: Dependency to fetch the deck configuration. + notify_publishers: Utilized by the engine to notify publishers of state changes. """ if not is_ok_to_create_maintenance_run: raise ProtocolRunIsActive( @@ -180,6 +183,7 @@ async def create_run( created_at=created_at, labware_offsets=offsets, deck_configuration=deck_configuration, + notify_publishers=notify_publishers, ) log.info(f'Created an empty run "{run_id}"".') diff --git a/robot-server/robot_server/persistence/_migrations/v3_to_v4.py b/robot-server/robot_server/persistence/_migrations/v3_to_v4.py new file mode 100644 index 00000000000..b67d11d34ec --- /dev/null +++ b/robot-server/robot_server/persistence/_migrations/v3_to_v4.py @@ -0,0 +1,58 @@ +"""Migrate the persistence directory from schema 3 to 4. + +Summary of changes from schema 3: + +- Adds a new "run_time_parameter_values_and_defaults" column to analysis table +- Adds a new "run_time_parameters" column to run table +""" + +from pathlib import Path +from contextlib import ExitStack +import shutil +from typing import Any + +import sqlalchemy + +from ..database import sql_engine_ctx +from ..tables import schema_4 +from .._folder_migrator import Migration + +_DB_FILE = "robot_server.db" + + +class Migration3to4(Migration): # noqa: D101 + def migrate(self, source_dir: Path, dest_dir: Path) -> None: + """Migrate the persistence directory from schema 3 to 4.""" + # Copy over all existing directories and files to new version + for item in source_dir.iterdir(): + if item.is_dir(): + shutil.copytree(src=item, dst=dest_dir / item.name) + else: + shutil.copy(src=item, dst=dest_dir / item.name) + dest_db_file = dest_dir / _DB_FILE + + # Append the new column to existing analyses in v4 database + with ExitStack() as exit_stack: + dest_engine = exit_stack.enter_context(sql_engine_ctx(dest_db_file)) + schema_4.metadata.create_all(dest_engine) + + def add_column( + engine: sqlalchemy.engine.Engine, + table_name: str, + column: Any, + ) -> None: + column_type = column.type.compile(engine.dialect) + engine.execute( + f"ALTER TABLE {table_name} ADD COLUMN {column.key} {column_type}" + ) + + add_column( + dest_engine, + schema_4.analysis_table.name, + schema_4.analysis_table.c.run_time_parameter_values_and_defaults, + ) + add_column( + dest_engine, + schema_4.run_table.name, + schema_4.run_table.c.run_time_parameters, + ) diff --git a/robot-server/robot_server/persistence/persistence_directory.py b/robot-server/robot_server/persistence/persistence_directory.py index 666d5c7998f..b7982b38555 100644 --- a/robot-server/robot_server/persistence/persistence_directory.py +++ b/robot-server/robot_server/persistence/persistence_directory.py @@ -11,7 +11,7 @@ from anyio import Path as AsyncPath, to_thread from ._folder_migrator import MigrationOrchestrator -from ._migrations import up_to_3 +from ._migrations import up_to_3, v3_to_v4 _TEMP_PERSISTENCE_DIR_PREFIX: Final = "opentrons-robot-server-" @@ -48,7 +48,10 @@ async def prepare_active_subdirectory(prepared_root: Path) -> Path: """Return the active persistence subdirectory after preparing it, if necessary.""" migration_orchestrator = MigrationOrchestrator( root=prepared_root, - migrations=[up_to_3.MigrationUpTo3(subdirectory="3")], + migrations=[ + up_to_3.MigrationUpTo3(subdirectory="3"), + v3_to_v4.Migration3to4(subdirectory="4"), + ], temp_file_prefix="temp-", ) diff --git a/robot-server/robot_server/persistence/pydantic.py b/robot-server/robot_server/persistence/pydantic.py index c3486394ad4..c56312ec166 100644 --- a/robot-server/robot_server/persistence/pydantic.py +++ b/robot-server/robot_server/persistence/pydantic.py @@ -1,7 +1,8 @@ """Store Pydantic objects in the SQL database.""" -from typing import Type, TypeVar -from pydantic import BaseModel, parse_raw_as +import json +from typing import Type, TypeVar, List, Sequence +from pydantic import BaseModel, parse_raw_as, parse_obj_as _BaseModelT = TypeVar("_BaseModelT", bound=BaseModel) @@ -17,6 +18,16 @@ def pydantic_to_json(obj: BaseModel) -> str: ) -def json_to_pydantic(model: Type[_BaseModelT], json: str) -> _BaseModelT: +def pydantic_list_to_json(obj_list: Sequence[BaseModel]) -> str: + """Serialize a list of Pydantic objects for storing in the SQL database.""" + return json.dumps([obj.dict(by_alias=True, exclude_none=True) for obj in obj_list]) + + +def json_to_pydantic(model: Type[_BaseModelT], json_str: str) -> _BaseModelT: """Parse a Pydantic object stored in the SQL database.""" - return parse_raw_as(model, json) + return parse_raw_as(model, json_str) + + +def json_to_pydantic_list(model: Type[_BaseModelT], json_str: str) -> List[_BaseModelT]: + """Parse a list of Pydantic objects stored in the SQL database.""" + return [parse_obj_as(model, obj_dict) for obj_dict in json.loads(json_str)] diff --git a/robot-server/robot_server/persistence/tables/__init__.py b/robot-server/robot_server/persistence/tables/__init__.py index 97262e73fab..0aaf869fb35 100644 --- a/robot-server/robot_server/persistence/tables/__init__.py +++ b/robot-server/robot_server/persistence/tables/__init__.py @@ -1,7 +1,7 @@ """SQL database schemas.""" # Re-export the latest schema. -from .schema_3 import ( +from .schema_4 import ( metadata, protocol_table, analysis_table, diff --git a/robot-server/robot_server/persistence/tables/schema_4.py b/robot-server/robot_server/persistence/tables/schema_4.py new file mode 100644 index 00000000000..d1662bf7adc --- /dev/null +++ b/robot-server/robot_server/persistence/tables/schema_4.py @@ -0,0 +1,137 @@ +"""v4 of our SQLite schema.""" + +import sqlalchemy + +from robot_server.persistence._utc_datetime import UTCDateTime + +metadata = sqlalchemy.MetaData() + +protocol_table = sqlalchemy.Table( + "protocol", + metadata, + sqlalchemy.Column( + "id", + sqlalchemy.String, + primary_key=True, + ), + sqlalchemy.Column( + "created_at", + UTCDateTime, + nullable=False, + ), + sqlalchemy.Column("protocol_key", sqlalchemy.String, nullable=True), +) + +analysis_table = sqlalchemy.Table( + "analysis", + metadata, + sqlalchemy.Column( + "id", + sqlalchemy.String, + primary_key=True, + ), + sqlalchemy.Column( + "protocol_id", + sqlalchemy.String, + sqlalchemy.ForeignKey("protocol.id"), + index=True, + nullable=False, + ), + sqlalchemy.Column( + "analyzer_version", + sqlalchemy.String, + nullable=False, + ), + sqlalchemy.Column( + "completed_analysis", + # Stores a JSON string. See CompletedAnalysisStore. + sqlalchemy.String, + nullable=False, + ), + # column added in schema v4 + sqlalchemy.Column( + "run_time_parameter_values_and_defaults", + sqlalchemy.String, + nullable=True, + ), +) + +run_table = sqlalchemy.Table( + "run", + metadata, + sqlalchemy.Column( + "id", + sqlalchemy.String, + primary_key=True, + ), + sqlalchemy.Column( + "created_at", + UTCDateTime, + nullable=False, + ), + sqlalchemy.Column( + "protocol_id", + sqlalchemy.String, + sqlalchemy.ForeignKey("protocol.id"), + nullable=True, + ), + # column added in schema v1 + sqlalchemy.Column( + "state_summary", + sqlalchemy.String, + nullable=True, + ), + # column added in schema v1 + sqlalchemy.Column("engine_status", sqlalchemy.String, nullable=True), + # column added in schema v1 + sqlalchemy.Column("_updated_at", UTCDateTime, nullable=True), + # column added in schema v4 + sqlalchemy.Column( + "run_time_parameters", + # Stores a JSON string. See RunStore. + sqlalchemy.String, + nullable=True, + ), +) + +action_table = sqlalchemy.Table( + "action", + metadata, + sqlalchemy.Column( + "id", + sqlalchemy.String, + primary_key=True, + ), + sqlalchemy.Column("created_at", UTCDateTime, nullable=False), + sqlalchemy.Column("action_type", sqlalchemy.String, nullable=False), + sqlalchemy.Column( + "run_id", + sqlalchemy.String, + sqlalchemy.ForeignKey("run.id"), + nullable=False, + ), +) + +run_command_table = sqlalchemy.Table( + "run_command", + metadata, + sqlalchemy.Column("row_id", sqlalchemy.Integer, primary_key=True), + sqlalchemy.Column( + "run_id", sqlalchemy.String, sqlalchemy.ForeignKey("run.id"), nullable=False + ), + sqlalchemy.Column("index_in_run", sqlalchemy.Integer, nullable=False), + sqlalchemy.Column("command_id", sqlalchemy.String, nullable=False), + sqlalchemy.Column("command", sqlalchemy.String, nullable=False), + sqlalchemy.Index( + "ix_run_run_id_command_id", # An arbitrary name for the index. + "run_id", + "command_id", + unique=True, + ), + sqlalchemy.Index( + "ix_run_run_id_index_in_run", # An arbitrary name for the index. + "run_id", + "index_in_run", + unique=True, + ), +) diff --git a/robot-server/robot_server/protocols/analysis_memcache.py b/robot-server/robot_server/protocols/analysis_memcache.py index 19280009bd5..3ba3156607f 100644 --- a/robot-server/robot_server/protocols/analysis_memcache.py +++ b/robot-server/robot_server/protocols/analysis_memcache.py @@ -63,3 +63,14 @@ def insert(self, key: K, value: V) -> None: self._pop_eldest(key) self._cache[key] = value self._cache_order.appendleft(key) + + def remove(self, key: K) -> None: + """Remove the cached element specified by the key. + + If no such element exists in cache, then simply no-op. + """ + try: + self._cache.pop(key) + self._cache_order.remove(key) # O(n) operation, use sparingly + except KeyError: + pass diff --git a/robot-server/robot_server/protocols/analysis_models.py b/robot-server/robot_server/protocols/analysis_models.py index 0a3c64c9db0..c8b11f2db25 100644 --- a/robot-server/robot_server/protocols/analysis_models.py +++ b/robot-server/robot_server/protocols/analysis_models.py @@ -2,10 +2,10 @@ # TODO(mc, 2021-08-25): add modules to simulation result from enum import Enum -from opentrons.protocol_engine.types import RunTimeParameter +from opentrons.protocol_engine.types import RunTimeParameter, RunTimeParamValuesType from opentrons_shared_data.robot.dev_types import RobotType from pydantic import BaseModel, Field -from typing import List, Optional, Union +from typing import List, Optional, Union, NamedTuple from typing_extensions import Literal from opentrons.protocol_engine import ( @@ -40,6 +40,18 @@ class AnalysisResult(str, Enum): NOT_OK = "not-ok" +class AnalysisRequest(BaseModel): + """Model for analysis request body.""" + + runTimeParameterValues: RunTimeParamValuesType = Field( + default={}, + description="Key-value pairs of run-time parameters defined in a protocol.", + ) + forceReAnalyze: bool = Field( + False, description="Whether to force start a new analysis." + ) + + class AnalysisSummary(BaseModel): """Base model for an analysis of a protocol.""" @@ -150,4 +162,11 @@ class CompletedAnalysis(BaseModel): ) +class RunTimeParameterAnalysisData(NamedTuple): + """Data from analysis of a run-time parameter.""" + + value: Union[float, bool, str] + default: Union[float, bool, str] + + ProtocolAnalysis = Union[PendingAnalysis, CompletedAnalysis] diff --git a/robot-server/robot_server/protocols/analysis_store.py b/robot-server/robot_server/protocols/analysis_store.py index d8ce780f98d..4f5b66ed4f8 100644 --- a/robot-server/robot_server/protocols/analysis_store.py +++ b/robot-server/robot_server/protocols/analysis_store.py @@ -19,6 +19,7 @@ LoadedModule, Liquid, ) +from opentrons.protocol_engine.types import RunTimeParamValuesType from .analysis_models import ( AnalysisSummary, @@ -27,6 +28,7 @@ CompletedAnalysis, AnalysisResult, AnalysisStatus, + RunTimeParameterAnalysisData, ) from .completed_analysis_store import CompletedAnalysisStore, CompletedAnalysisResource @@ -71,6 +73,14 @@ def __init__(self, analysis_id: str) -> None: super().__init__(f'Analysis "{analysis_id}" not found.') +class AnalysisIsPendingError(RuntimeError): + """Exception raised if a given analysis is still pending.""" + + def __init__(self, analysis_id: str) -> None: + """Initialize the error's message.""" + super().__init__(f'Analysis "{analysis_id}" is still pending.') + + # TODO(sf, 2023-05-05): Like for protocols and runs, there's an in-memory cache for # elements of this store. Unlike for protocols and runs, it isn't just an lru_cache # on the top-level store's access methods, because those access methods have to be @@ -93,10 +103,14 @@ class AnalysisStore: so they're only kept in-memory, and lost when the store instance is destroyed. """ - def __init__(self, sql_engine: sqlalchemy.engine.Engine) -> None: + def __init__( + self, + sql_engine: sqlalchemy.engine.Engine, + completed_store: Optional[CompletedAnalysisStore] = None, + ) -> None: """Initialize the `AnalysisStore`.""" self._pending_store = _PendingAnalysisStore() - self._completed_store = CompletedAnalysisStore( + self._completed_store = completed_store or CompletedAnalysisStore( sql_engine=sql_engine, memory_cache=MemoryCache(_CACHE_MAX_SIZE, str, CompletedAnalysisResource), current_analyzer_version=_CURRENT_ANALYZER_VERSION, @@ -115,8 +129,6 @@ def add_pending(self, protocol_id: str, analysis_id: str) -> AnalysisSummary: Returns: A summary of the just-added analysis. """ - # TODO (spp, 2024-03-19): cap the number of analyses being stored by - # auto-deleting old ones new_pending_analysis = self._pending_store.add( protocol_id=protocol_id, analysis_id=analysis_id ) @@ -180,8 +192,11 @@ async def update( protocol_id=protocol_id, analyzer_version=_CURRENT_ANALYZER_VERSION, completed_analysis=completed_analysis, + run_time_parameter_values_and_defaults=self._extract_run_time_param_values_and_defaults( + completed_analysis + ), ) - await self._completed_store.add( + await self._completed_store.make_room_and_add( completed_analysis_resource=completed_analysis_resource ) @@ -258,6 +273,88 @@ async def get_by_protocol(self, protocol_id: str) -> List[ProtocolAnalysis]: else: return completed_analyses + [pending_analysis] + @staticmethod + def _extract_run_time_param_values_and_defaults( + completed_analysis: CompletedAnalysis, + ) -> Dict[str, RunTimeParameterAnalysisData]: + """Extract the Run Time Parameters with current value and default value of each. + + We do this in order to save the RTP data separately, outside the analysis + in the database. This saves us from having to de-serialize the entire analysis + to read just the RTP values. + """ + rtp_list = completed_analysis.runTimeParameters + + rtp_values_and_defaults = {} + for param_spec in rtp_list: + rtp_values_and_defaults.update( + { + param_spec.variableName: RunTimeParameterAnalysisData( + value=param_spec.value, default=param_spec.default + ) + } + ) + return rtp_values_and_defaults + + async def matching_rtp_values_in_analysis( + self, analysis_summary: AnalysisSummary, new_rtp_values: RunTimeParamValuesType + ) -> bool: + """Return whether the last analysis of the given protocol used the mentioned RTP values. + + It is not sufficient to just check the values of provided parameters against the + corresponding parameter values in analysis because a previous request could have + composed of some extra parameters that are not in the current list. + + Similarly, it is not enough to only compare the current parameter values from + the client with the previous values from the client because a previous param + might have been assigned a default value by the client while the current request + doesn't include that param because it can rely on the API to assign the default + value to that param. + + So, we check that the Run Time Parameters in the previous analysis has params + with the values provided in the current request, and also verify that rest of the + parameters in the analysis use default values. + """ + if analysis_summary.status == AnalysisStatus.PENDING: + raise AnalysisIsPendingError(analysis_summary.id) + + rtp_values_and_defaults_in_last_analysis = ( + await self._completed_store.get_rtp_values_and_defaults_by_analysis_id( + analysis_summary.id + ) + ) + # We already make sure that the protocol has an analysis associated with before + # checking the RTP values so this assert should never raise. + # It is only added for type checking. + assert ( + rtp_values_and_defaults_in_last_analysis is not None + ), "This protocol has no analysis associated with it." + + if not set(new_rtp_values.keys()).issubset( + set(rtp_values_and_defaults_in_last_analysis.keys()) + ): + # Since the RTP keys in analysis represent all params defined in the protocol, + # if the client passes a parameter that's not present in the analysis, + # it means that the client is sending incorrect parameters. + # We will let this request trigger an analysis using the incorrect params + # and have the analysis raise an appropriate error instead of giving an + # error response to the protocols request. + # This makes the behavior of robot server consistent regardless of whether + # the client is sending a protocol for the first time or for the nth time. + return False + for ( + parameter, + prev_value_and_default, + ) in rtp_values_and_defaults_in_last_analysis.items(): + if ( + new_rtp_values.get(parameter, prev_value_and_default.default) + == prev_value_and_default.value + ): + continue + else: + return False + return True + class _PendingAnalysisStore: """An in-memory store of protocol analyses that are pending. diff --git a/robot-server/robot_server/protocols/completed_analysis_store.py b/robot-server/robot_server/protocols/completed_analysis_store.py index f4c696d0519..5f72357050b 100644 --- a/robot-server/robot_server/protocols/completed_analysis_store.py +++ b/robot-server/robot_server/protocols/completed_analysis_store.py @@ -2,23 +2,27 @@ from __future__ import annotations import asyncio +import json from typing import Dict, List, Optional from logging import getLogger from dataclasses import dataclass import sqlalchemy import anyio +from pydantic import parse_raw_as from robot_server.persistence.database import sqlite_rowid from robot_server.persistence.tables import analysis_table from robot_server.persistence.pydantic import json_to_pydantic, pydantic_to_json -from .analysis_models import CompletedAnalysis +from .analysis_models import CompletedAnalysis, RunTimeParameterAnalysisData from .analysis_memcache import MemoryCache _log = getLogger(__name__) +MAX_ANALYSES_TO_STORE = 5 + @dataclass class CompletedAnalysisResource: @@ -31,6 +35,7 @@ class CompletedAnalysisResource: protocol_id: str analyzer_version: str completed_analysis: CompletedAnalysis + run_time_parameter_values_and_defaults: Dict[str, RunTimeParameterAnalysisData] async def to_sql_values(self) -> Dict[str, object]: """Return this data as a dict that can be passed to a SQLALchemy insert. @@ -46,18 +51,25 @@ async def to_sql_values(self) -> Dict[str, object]: def serialize_completed_analysis() -> str: return pydantic_to_json(self.completed_analysis) - serialized_json = await anyio.to_thread.run_sync( + def serialize_rtp_dict() -> str: + return json.dumps(self.run_time_parameter_values_and_defaults) + + serialized_analysis = await anyio.to_thread.run_sync( serialize_completed_analysis, # Cancellation may orphan the worker thread, # but that should be harmless in this case. cancellable=True, ) - + serialized_rtp_dict = await anyio.to_thread.run_sync( + serialize_rtp_dict, + cancellable=True, + ) return { "id": self.id, "protocol_id": self.protocol_id, "analyzer_version": self.analyzer_version, - "completed_analysis": serialized_json, + "completed_analysis": serialized_analysis, + "run_time_parameter_values_and_defaults": serialized_rtp_dict, } @classmethod @@ -94,12 +106,40 @@ def parse_completed_analysis() -> CompletedAnalysis: # but that should be harmless in this case. cancellable=True, ) - + rtp_values_and_defaults = await cls.get_run_time_parameter_values_and_defaults( + sql_row + ) return cls( id=id, protocol_id=protocol_id, analyzer_version=analyzer_version, completed_analysis=completed_analysis, + run_time_parameter_values_and_defaults=rtp_values_and_defaults, + ) + + @classmethod + async def get_run_time_parameter_values_and_defaults( + cls, sql_row: sqlalchemy.engine.Row + ) -> Dict[str, RunTimeParameterAnalysisData]: + """Get the run-time parameters used in the analysis with their values & defaults.""" + + def parse_rtp_dict() -> Dict[str, RunTimeParameterAnalysisData]: + rtp_contents = sql_row.run_time_parameter_values_and_defaults + return ( + parse_raw_as( + Dict[str, RunTimeParameterAnalysisData], + sql_row.run_time_parameter_values_and_defaults, + ) + if rtp_contents + else {} + ) + + # In most cases, this parsing should be quite quick but theoretically + # there could be an unexpectedly large number of run time params. + # So we delegate the parsing of this to a cancellable thread as well. + return await anyio.to_thread.run_sync( + parse_rtp_dict, + cancellable=True, ) @@ -185,6 +225,40 @@ async def get_by_id_as_document(self, analysis_id: str) -> Optional[str]: return document + async def get_rtp_values_and_defaults_by_analysis_id( + self, analysis_id: str + ) -> Optional[Dict[str, RunTimeParameterAnalysisData]]: + """Return the dictionary of run time parameter values & defaults used in the given analysis. + + If the analysis ID doesn't exist, return None. + These RTP values are not cached in memory by themselves since we don't anticipate + that fetching the values from the database to be a time-consuming operation. + """ + async with self._memcache_lock: + try: + analysis = self._memcache.get(analysis_id) + except KeyError: + pass + else: + return analysis.run_time_parameter_values_and_defaults + + statement = sqlalchemy.select(analysis_table).where( + analysis_table.c.id == analysis_id + ) + with self._sql_engine.begin() as transaction: + try: + result = transaction.execute(statement).one() + except sqlalchemy.exc.NoResultFound: + # Since we just no-op when fetching non-existent analysis, + # do the same for non-existent RTP data + return None + + rtp_values_and_defaults = await CompletedAnalysisResource.get_run_time_parameter_values_and_defaults( + result + ) + + return rtp_values_and_defaults + async def get_by_protocol( self, protocol_id: str ) -> List[CompletedAnalysisResource]: @@ -262,13 +336,34 @@ def get_ids_by_protocol(self, protocol_id: str) -> List[str]: return result_ids - async def add(self, completed_analysis_resource: CompletedAnalysisResource) -> None: - """Add a resource to the store.""" - statement = analysis_table.insert().values( + async def make_room_and_add( + self, completed_analysis_resource: CompletedAnalysisResource + ) -> None: + """Make room and add a resource to the store. + + Removes the oldest analyses in store if the number of analyses exceed + the max allowed, and then adds the new analysis. + """ + analyses_ids = self.get_ids_by_protocol(completed_analysis_resource.protocol_id) + + # Delete all analyses exceeding max number allowed, + # plus an additional one to create room for the new one. + # Most existing databases will not have multiple extra analyses per protocol + # but there would be some internally that added multiple analyses before + # we started capping the number of analyses. + analyses_to_delete = analyses_ids[: -MAX_ANALYSES_TO_STORE + 1] + for analysis_id in analyses_to_delete: + self._memcache.remove(analysis_id) + delete_statement = analysis_table.delete().where( + analysis_table.c.id.in_(analyses_to_delete) + ) + + insert_statement = analysis_table.insert().values( await completed_analysis_resource.to_sql_values() ) with self._sql_engine.begin() as transaction: - transaction.execute(statement) + transaction.execute(delete_statement) + transaction.execute(insert_statement) self._memcache.insert( completed_analysis_resource.id, completed_analysis_resource ) diff --git a/robot-server/robot_server/protocols/router.py b/robot-server/robot_server/protocols/router.py index fb72c938def..d3375f535d4 100644 --- a/robot-server/robot_server/protocols/router.py +++ b/robot-server/robot_server/protocols/router.py @@ -4,8 +4,9 @@ from textwrap import dedent from datetime import datetime from pathlib import Path -from typing import List, Optional, Union +from typing import List, Optional, Union, Tuple +from opentrons.protocol_engine.types import RunTimeParamValuesType from opentrons_shared_data.robot import user_facing_robot_type from typing_extensions import Literal @@ -32,13 +33,14 @@ SimpleEmptyBody, MultiBodyMeta, PydanticResponse, + RequestModel, ) from .protocol_auto_deleter import ProtocolAutoDeleter from .protocol_models import Protocol, ProtocolFile, Metadata from .protocol_analyzer import ProtocolAnalyzer -from .analysis_store import AnalysisStore, AnalysisNotFoundError -from .analysis_models import ProtocolAnalysis +from .analysis_store import AnalysisStore, AnalysisNotFoundError, AnalysisIsPendingError +from .analysis_models import ProtocolAnalysis, AnalysisRequest, AnalysisSummary from .protocol_store import ( ProtocolStore, ProtocolResource, @@ -74,6 +76,13 @@ class AnalysisNotFound(ErrorDetails): title: str = "Protocol Analysis Not Found" +class LastAnalysisPending(ErrorDetails): + """An error returned when the most recent analysis of a protocol is still pending.""" + + id: Literal["LastAnalysisPending"] = "LastAnalysisPending" + title: str = "Last Analysis Still Pending." + + class ProtocolFilesInvalid(ErrorDetails): """An error returned when an uploaded protocol files are invalid.""" @@ -140,7 +149,9 @@ class ProtocolLinks(BaseModel): resource will be returned instead of creating duplicate ones. When a new protocol resource is created, an analysis is started for it. - See the `/protocols/{id}/analyses/` endpoints. + A new analysis is also started if the same protocol file is uploaded but with + a different set of run-time parameter values than the most recent request. + See the `/protocols/{id}/analyses/` endpoints for more details. """ ), status_code=status.HTTP_201_CREATED, @@ -150,6 +161,7 @@ class ProtocolLinks(BaseModel): status.HTTP_422_UNPROCESSABLE_ENTITY: { "model": ErrorBody[Union[ProtocolFilesInvalid, ProtocolRobotTypeMismatch]] }, + status.HTTP_503_SERVICE_UNAVAILABLE: {"model": ErrorBody[LastAnalysisPending]}, }, ) async def create_protocol( @@ -214,7 +226,6 @@ async def create_protocol( # TODO(mm, 2024-02-07): Investigate whether the filename can actually be None. assert file.filename is not None buffered_files = await file_reader_writer.read(files=files) # type: ignore[arg-type] - if isinstance(run_time_parameter_values, str): # We have to do this isinstance check because if `runTimeParameterValues` is # not specified in the request, then it gets assigned a Form(None) value @@ -223,36 +234,36 @@ async def create_protocol( # so we can validate the data contents and return a better error response. parsed_rtp = json.loads(run_time_parameter_values) else: - parsed_rtp = None + parsed_rtp = {} content_hash = await file_hasher.hash(buffered_files) cached_protocol_id = protocol_store.get_id_by_hash(content_hash) if cached_protocol_id is not None: - # Protocol exists in database resource = protocol_store.get(protocol_id=cached_protocol_id) - if parsed_rtp: - # This protocol exists in database but needs to be re-analyzed with the - # passed-in RTP overrides - task_runner.run( - protocol_analyzer.analyze, - protocol_resource=resource, - analysis_id=analysis_id, - run_time_param_values=parsed_rtp, - ) - analysis_store.add_pending( + + try: + analysis_summaries, _ = await _start_new_analysis_if_necessary( protocol_id=cached_protocol_id, analysis_id=analysis_id, + rtp_values=parsed_rtp, + force_reanalyze=False, + protocol_store=protocol_store, + analysis_store=analysis_store, + protocol_analyzer=protocol_analyzer, + task_runner=task_runner, ) - analyses = analysis_store.get_summaries_by_protocol( - protocol_id=cached_protocol_id - ) + except AnalysisIsPendingError as error: + raise LastAnalysisPending(detail=str(error)).as_error( + status.HTTP_503_SERVICE_UNAVAILABLE + ) from error + data = Protocol.construct( id=cached_protocol_id, createdAt=resource.created_at, protocolType=resource.source.config.protocol_type, robotType=resource.source.robot_type, metadata=Metadata.parse_obj(resource.source.metadata), - analysisSummaries=analyses, + analysisSummaries=analysis_summaries, key=resource.protocol_key, files=[ ProtocolFile(name=f.path.name, role=f.role) @@ -331,6 +342,53 @@ async def create_protocol( ) +async def _start_new_analysis_if_necessary( + protocol_id: str, + analysis_id: str, + force_reanalyze: bool, + rtp_values: RunTimeParamValuesType, + protocol_store: ProtocolStore, + analysis_store: AnalysisStore, + protocol_analyzer: ProtocolAnalyzer, + task_runner: TaskRunner, +) -> Tuple[List[AnalysisSummary], bool]: + """Check RTP values and start a new analysis if necessary. + + Returns a tuple of the latest list of analysis summaries (including any newly + started analysis) and whether a new analysis was started. + """ + resource = protocol_store.get(protocol_id=protocol_id) + analyses = analysis_store.get_summaries_by_protocol(protocol_id=protocol_id) + started_new_analysis = False + if ( + force_reanalyze + or + # Unexpected situations, like powering off the robot after a protocol upload + # but before the analysis is complete, can leave the protocol resource + # without an associated analysis. + len(analyses) == 0 + or + # The most recent analysis was done using different RTP values + not await analysis_store.matching_rtp_values_in_analysis( + analysis_summary=analyses[-1], new_rtp_values=rtp_values + ) + ): + task_runner.run( + protocol_analyzer.analyze, + protocol_resource=resource, + analysis_id=analysis_id, + run_time_param_values=rtp_values, + ) + started_new_analysis = True + analyses.append( + analysis_store.add_pending( + protocol_id=protocol_id, + analysis_id=analysis_id, + ) + ) + return analyses, started_new_analysis + + @PydanticResponse.wrap_route( protocols_router.get, path="/protocols", @@ -493,6 +551,78 @@ async def delete_protocol_by_id( ) +@PydanticResponse.wrap_route( + protocols_router.post, + path="/protocols/{protocolId}/analyses", + summary="Analyze the protocol", + description=dedent( + """ + Generate an analysis for the protocol, based on last analysis and current request data. + """ + ), + status_code=status.HTTP_201_CREATED, + responses={ + status.HTTP_200_OK: {"model": SimpleMultiBody[AnalysisSummary]}, + status.HTTP_201_CREATED: {"model": SimpleMultiBody[AnalysisSummary]}, + status.HTTP_404_NOT_FOUND: {"model": ErrorBody[ProtocolNotFound]}, + status.HTTP_503_SERVICE_UNAVAILABLE: {"model": ErrorBody[LastAnalysisPending]}, + }, +) +async def create_protocol_analysis( + protocolId: str, + request_body: Optional[RequestModel[AnalysisRequest]] = None, + protocol_store: ProtocolStore = Depends(get_protocol_store), + analysis_store: AnalysisStore = Depends(get_analysis_store), + protocol_analyzer: ProtocolAnalyzer = Depends(get_protocol_analyzer), + task_runner: TaskRunner = Depends(get_task_runner), + analysis_id: str = Depends(get_unique_id, use_cache=False), +) -> PydanticResponse[SimpleMultiBody[AnalysisSummary]]: + """Start a new analysis for the given existing protocol. + + Starts a new analysis for the protocol along with the provided run-time parameter + values (if any), and appends it to the existing analyses. + + If the last analysis in the existing analyses used the same RTP values, then a new + analysis is not created. + + If `forceAnalyze` is True, this will always start a new analysis. + + Returns: List of analysis summaries available for the protocol, ordered as + most recently started analysis last. + """ + if not protocol_store.has(protocolId): + raise ProtocolNotFound(detail=f"Protocol {protocolId} not found").as_error( + status.HTTP_404_NOT_FOUND + ) + try: + ( + analysis_summaries, + started_new_analysis, + ) = await _start_new_analysis_if_necessary( + protocol_id=protocolId, + analysis_id=analysis_id, + rtp_values=request_body.data.runTimeParameterValues if request_body else {}, + force_reanalyze=request_body.data.forceReAnalyze if request_body else False, + protocol_store=protocol_store, + analysis_store=analysis_store, + protocol_analyzer=protocol_analyzer, + task_runner=task_runner, + ) + except AnalysisIsPendingError as error: + raise LastAnalysisPending(detail=str(error)).as_error( + status.HTTP_503_SERVICE_UNAVAILABLE + ) from error + return await PydanticResponse.create( + content=SimpleMultiBody.construct( + data=analysis_summaries, + meta=MultiBodyMeta(cursor=0, totalLength=len(analysis_summaries)), + ), + status_code=status.HTTP_201_CREATED + if started_new_analysis + else status.HTTP_200_OK, + ) + + @PydanticResponse.wrap_route( protocols_router.get, path="/protocols/{protocolId}/analyses", diff --git a/robot-server/robot_server/runs/dependencies.py b/robot-server/robot_server/runs/dependencies.py index 20b8d087b66..f66ec9fdf1c 100644 --- a/robot-server/robot_server/runs/dependencies.py +++ b/robot-server/robot_server/runs/dependencies.py @@ -43,13 +43,12 @@ async def get_run_store( app_state: AppState = Depends(get_app_state), sql_engine: SQLEngine = Depends(get_sql_engine), - runs_publisher: RunsPublisher = Depends(get_runs_publisher), ) -> RunStore: """Get a singleton RunStore to keep track of created runs.""" run_store = _run_store_accessor.get_from(app_state) if run_store is None: - run_store = RunStore(sql_engine=sql_engine, runs_publisher=runs_publisher) + run_store = RunStore(sql_engine=sql_engine) _run_store_accessor.set_on(app_state, run_store) return run_store diff --git a/robot-server/robot_server/runs/engine_store.py b/robot-server/robot_server/runs/engine_store.py index aa5b26d4a77..5b6d57520a7 100644 --- a/robot-server/robot_server/runs/engine_store.py +++ b/robot-server/robot_server/runs/engine_store.py @@ -1,6 +1,9 @@ """In-memory storage of ProtocolEngine instances.""" -from typing import List, NamedTuple, Optional +import asyncio +import logging +from typing import List, NamedTuple, Optional, Callable +from opentrons.protocol_engine.errors.exceptions import EStopActivatedError from opentrons.protocol_engine.types import PostRunHardwareState from opentrons_shared_data.robot.dev_types import RobotType from opentrons_shared_data.robot.dev_types import RobotTypeEnum @@ -32,7 +35,13 @@ ) from robot_server.protocols.protocol_store import ProtocolResource -from opentrons.protocol_engine.types import DeckConfigurationType +from opentrons.protocol_engine.types import ( + DeckConfigurationType, + RunTimeParamValuesType, +) + + +_log = logging.getLogger(__name__) class EngineConflictError(RuntimeError): @@ -55,18 +64,45 @@ class RunnerEnginePair(NamedTuple): engine: ProtocolEngine -def get_estop_listener(engine_store: "EngineStore") -> HardwareEventHandler: - """Create a callback for estop events.""" +async def handle_estop_event(engine_store: "EngineStore", event: HardwareEvent) -> None: + """Handle an E-stop event from the hardware API. - def _callback(event: HardwareEvent) -> None: + This is meant to run in the engine's thread and asyncio event loop. + + This is a public function for unit-testing purposes, but it's an implementation + detail of the store. + """ + try: if isinstance(event, EstopStateNotification): if event.new_state is not EstopState.PHYSICALLY_ENGAGED: return if engine_store.current_run_id is None: return - engine_store.engine.estop(maintenance_run=False) + # todo(mm, 2024-04-17): This estop teardown sequencing belongs in the + # runner layer. + engine_store.engine.estop() + await engine_store.engine.finish(error=EStopActivatedError()) + except Exception: + # This is a background task kicked off by a hardware event, + # so there's no one to propagate this exception to. + _log.exception("Exception handling E-stop event.") + + +def _get_estop_listener(engine_store: "EngineStore") -> HardwareEventHandler: + """Create a callback for estop events. + + The returned callback is meant to run in the hardware API's thread. + """ + engine_loop = asyncio.get_running_loop() + + def run_handler_in_engine_thread_from_hardware_thread( + event: HardwareEvent, + ) -> None: + asyncio.run_coroutine_threadsafe( + handle_estop_event(engine_store, event), engine_loop + ) - return _callback + return run_handler_in_engine_thread_from_hardware_thread class EngineStore: @@ -91,7 +127,7 @@ def __init__( self._deck_type = deck_type self._default_engine: Optional[ProtocolEngine] = None self._runner_engine_pair: Optional[RunnerEnginePair] = None - hardware_api.register_callback(get_estop_listener(self)) + hardware_api.register_callback(_get_estop_listener(self)) @property def engine(self) -> ProtocolEngine: @@ -152,14 +188,19 @@ async def create( run_id: str, labware_offsets: List[LabwareOffsetCreate], deck_configuration: DeckConfigurationType, + notify_publishers: Callable[[], None], protocol: Optional[ProtocolResource], + run_time_param_values: Optional[RunTimeParamValuesType] = None, ) -> StateSummary: """Create and store a ProtocolRunner and ProtocolEngine for a given Run. Args: run_id: The run resource the engine is assigned to. labware_offsets: Labware offsets to create the engine with. + deck_configuration: A mapping of fixtures to cutout fixtures the deck will be loaded with. + notify_publishers: Utilized by the engine to notify publishers of state changes. protocol: The protocol to load the runner with, if any. + run_time_param_values: Any runtime parameter values to set. Returns: The initial equipment and status summary of the engine. @@ -184,6 +225,7 @@ async def create( ), load_fixed_trash=load_fixed_trash, deck_configuration=deck_configuration, + notify_publishers=notify_publishers, ) post_run_hardware_state = PostRunHardwareState.HOME_AND_STAY_ENGAGED @@ -214,7 +256,7 @@ async def create( # was uploaded before we added stricter validation, and that # doesn't conform to the new rules. python_parse_mode=PythonParseMode.ALLOW_LEGACY_METADATA_AND_REQUIREMENTS, - run_time_param_values=None, + run_time_param_values=run_time_param_values, ) elif isinstance(runner, JsonRunner): assert ( diff --git a/robot-server/robot_server/runs/light_control_task.py b/robot-server/robot_server/runs/light_control_task.py index ee84981359a..1cb2ad71616 100644 --- a/robot-server/robot_server/runs/light_control_task.py +++ b/robot-server/robot_server/runs/light_control_task.py @@ -32,22 +32,21 @@ def _engine_status_to_status_bar( initialization_done: bool, ) -> StatusBarState: """Convert an engine status into a status bar status.""" - if status is None: - return StatusBarState.IDLE if initialization_done else StatusBarState.OFF - - return { - EngineStatus.IDLE: StatusBarState.IDLE - if initialization_done - else StatusBarState.OFF, - EngineStatus.RUNNING: StatusBarState.RUNNING, - EngineStatus.PAUSED: StatusBarState.PAUSED, - EngineStatus.BLOCKED_BY_OPEN_DOOR: StatusBarState.PAUSED, - EngineStatus.STOP_REQUESTED: StatusBarState.UPDATING, - EngineStatus.STOPPED: StatusBarState.IDLE, - EngineStatus.FINISHING: StatusBarState.UPDATING, - EngineStatus.FAILED: StatusBarState.HARDWARE_ERROR, - EngineStatus.SUCCEEDED: StatusBarState.RUN_COMPLETED, - }[status] + match status: + case None | EngineStatus.IDLE: + return StatusBarState.IDLE if initialization_done else StatusBarState.OFF + case EngineStatus.RUNNING: + return StatusBarState.RUNNING + case EngineStatus.PAUSED | EngineStatus.AWAITING_RECOVERY | EngineStatus.BLOCKED_BY_OPEN_DOOR: + return StatusBarState.PAUSED + case EngineStatus.STOP_REQUESTED | EngineStatus.FINISHING: + return StatusBarState.UPDATING + case EngineStatus.STOPPED: + return StatusBarState.IDLE + case EngineStatus.FAILED: + return StatusBarState.HARDWARE_ERROR + case EngineStatus.SUCCEEDED: + return StatusBarState.RUN_COMPLETED def _active_updates_to_status_bar( diff --git a/robot-server/robot_server/runs/router/base_router.py b/robot-server/robot_server/runs/router/base_router.py index fc7b3f223e3..728966823fb 100644 --- a/robot-server/robot_server/runs/router/base_router.py +++ b/robot-server/robot_server/runs/router/base_router.py @@ -5,7 +5,7 @@ import logging from datetime import datetime from textwrap import dedent -from typing import Optional, Union +from typing import Optional, Union, Callable from typing_extensions import Literal from fastapi import APIRouter, Depends, status, Query @@ -45,7 +45,7 @@ get_deck_configuration_store, ) from robot_server.deck_configuration.store import DeckConfigurationStore - +from robot_server.service.notifications import get_notify_publishers log = logging.getLogger(__name__) base_router = APIRouter() @@ -144,6 +144,7 @@ async def create_run( deck_configuration_store: DeckConfigurationStore = Depends( get_deck_configuration_store ), + notify_publishers: Callable[[], None] = Depends(get_notify_publishers), ) -> PydanticResponse[SimpleBody[Union[Run, BadRun]]]: """Create a new run. @@ -157,9 +158,13 @@ async def create_run( the new run. check_estop: Dependency to verify the estop is in a valid state. deck_configuration_store: Dependency to fetch the deck configuration. + notify_publishers: Utilized by the engine to notify publishers of state changes. """ protocol_id = request_body.data.protocolId if request_body is not None else None offsets = request_body.data.labwareOffsets if request_body is not None else [] + rtp_values = ( + request_body.data.runTimeParameterValues if request_body is not None else None + ) protocol_resource = None deck_configuration = await deck_configuration_store.get_deck_configuration() @@ -183,7 +188,9 @@ async def create_run( created_at=created_at, labware_offsets=offsets, deck_configuration=deck_configuration, + run_time_param_values=rtp_values, protocol=protocol_resource, + notify_publishers=notify_publishers, ) except EngineConflictError as e: raise RunAlreadyActive(detail=str(e)).as_error(status.HTTP_409_CONFLICT) from e diff --git a/robot-server/robot_server/runs/router/commands_router.py b/robot-server/robot_server/runs/router/commands_router.py index 734d1a26066..47a64c5d800 100644 --- a/robot-server/robot_server/runs/router/commands_router.py +++ b/robot-server/robot_server/runs/router/commands_router.py @@ -56,11 +56,18 @@ class CommandNotFound(ErrorDetails): title: str = "Run Command Not Found" +class SetupCommandNotAllowed(ErrorDetails): + """An error if a given run setup command is not allowed.""" + + id: Literal["SetupCommandNotAllowed"] = "SetupCommandNotAllowed" + title: str = "Setup Command Not Allowed" + + class CommandNotAllowed(ErrorDetails): """An error if a given run command is not allowed.""" id: Literal["CommandNotAllowed"] = "CommandNotAllowed" - title: str = "Setup Command Not Allowed" + title: str = "Command Not Allowed" class CommandLinkMeta(BaseModel): @@ -128,6 +135,7 @@ async def get_current_run_engine_from_url( - Setup commands (`data.source == "setup"`) - Protocol commands (`data.source == "protocol"`) + - Fixit commands (`data.source == "fixit"`) Setup commands may be enqueued before the run has been started. You could use setup commands to prepare a module or @@ -138,6 +146,11 @@ async def get_current_run_engine_from_url( If you are running a protocol from a file(s), then you will likely not need to enqueue protocol commands using this endpoint. + Fixit commands may be enqueued while the run is `awaiting-recovery` state. + These commands are intended to fix a failed command. + They will be executed right after the failed command + and only if the run is in a `awaiting-recovery` state. + Once enqueued, setup commands will execute immediately with priority, while protocol commands will wait until a `play` action is issued. A play action may be issued while setup commands are still queued, @@ -153,8 +166,9 @@ async def get_current_run_engine_from_url( status.HTTP_201_CREATED: {"model": SimpleBody[pe_commands.Command]}, status.HTTP_404_NOT_FOUND: {"model": ErrorBody[RunNotFound]}, status.HTTP_409_CONFLICT: { - "model": ErrorBody[Union[RunStopped, CommandNotAllowed]] + "model": ErrorBody[Union[RunStopped, SetupCommandNotAllowed]] }, + status.HTTP_400_BAD_REQUEST: {"model": ErrorBody[CommandNotAllowed]}, }, ) async def create_run_command( @@ -187,6 +201,12 @@ async def create_run_command( " the default was 30 seconds, not infinite." ), ), + failedCommandId: Optional[str] = Query( + default=None, + description=( + "FIXIT command use only. Reference of the failed command id we are trying to fix." + ), + ), protocol_engine: ProtocolEngine = Depends(get_current_run_engine_from_url), check_estop: bool = Depends(require_estop_in_good_state), ) -> PydanticResponse[SimpleBody[pe_commands.Command]]: @@ -199,6 +219,8 @@ async def create_run_command( Else, return immediately. Comes from a query parameter in the URL. timeout: The maximum time, in seconds, to wait before returning. Comes from a query parameter in the URL. + failedCommandId: FIXIT command use only. + Reference of the failed command id we are trying to fix. protocol_engine: The run's `ProtocolEngine` on which the new command will be enqueued. check_estop: Dependency to verify the estop is in a valid state. @@ -207,14 +229,17 @@ async def create_run_command( # behavior is to pass through `command_intent` without overriding it command_intent = request_body.data.intent or pe_commands.CommandIntent.SETUP command_create = request_body.data.copy(update={"intent": command_intent}) - try: - command = protocol_engine.add_command(command_create) + command = protocol_engine.add_command( + request=command_create, failed_command_id=failedCommandId + ) except pe_errors.SetupCommandNotAllowedError as e: - raise CommandNotAllowed.from_exc(e).as_error(status.HTTP_409_CONFLICT) + raise SetupCommandNotAllowed.from_exc(e).as_error(status.HTTP_409_CONFLICT) except pe_errors.RunStoppedError as e: raise RunStopped.from_exc(e).as_error(status.HTTP_409_CONFLICT) + except pe_errors.CommandNotAllowedError as e: + raise CommandNotAllowed.from_exc(e).as_error(status.HTTP_400_BAD_REQUEST) if waitUntilComplete: timeout_sec = None if timeout is None else timeout / 1000.0 diff --git a/robot-server/robot_server/runs/run_controller.py b/robot-server/robot_server/runs/run_controller.py index 782754c1da6..923c9cfa64e 100644 --- a/robot-server/robot_server/runs/run_controller.py +++ b/robot-server/robot_server/runs/run_controller.py @@ -106,4 +106,5 @@ async def _run_protocol_and_insert_result( run_id=self._run_id, summary=result.state_summary, commands=result.commands, + run_time_parameters=result.parameters, ) diff --git a/robot-server/robot_server/runs/run_data_manager.py b/robot-server/robot_server/runs/run_data_manager.py index 92c7d5e12b5..8548104911b 100644 --- a/robot-server/robot_server/runs/run_data_manager.py +++ b/robot-server/robot_server/runs/run_data_manager.py @@ -1,6 +1,6 @@ """Manage current and historical run data.""" from datetime import datetime -from typing import List, Optional, Union +from typing import List, Optional, Callable, Union from opentrons_shared_data.labware.labware_definition import LabwareDefinition from opentrons_shared_data.errors.exceptions import InvalidStoredData, EnumeratedError @@ -12,6 +12,7 @@ CurrentCommand, Command, ) +from opentrons.protocol_engine.types import RunTimeParamValuesType from robot_server.protocols.protocol_store import ProtocolResource from robot_server.service.task_runner import TaskRunner @@ -21,13 +22,14 @@ from .run_store import RunResource, RunStore, BadRunResource, BadStateSummary from .run_models import Run, BadRun, RunDataError -from opentrons.protocol_engine.types import DeckConfigurationType +from opentrons.protocol_engine.types import DeckConfigurationType, RunTimeParameter def _build_run( run_resource: Union[RunResource, BadRunResource], state_summary: Union[StateSummary, BadStateSummary], current: bool, + run_time_parameters: List[RunTimeParameter], ) -> Union[Run, BadRun]: # TODO(mc, 2022-05-16): improve persistence strategy # such that this default summary object is not needed @@ -48,6 +50,7 @@ def _build_run( completedAt=state_summary.completedAt, startedAt=state_summary.startedAt, liquids=state_summary.liquids, + runTimeParameters=run_time_parameters, ) errors: List[EnumeratedError] = [] @@ -101,6 +104,7 @@ def _build_run( completedAt=state.completedAt, startedAt=state.startedAt, liquids=state.liquids, + runTimeParameters=run_time_parameters, ) @@ -142,6 +146,8 @@ async def create( created_at: datetime, labware_offsets: List[LabwareOffsetCreate], deck_configuration: DeckConfigurationType, + run_time_param_values: Optional[RunTimeParamValuesType], + notify_publishers: Callable[[], None], protocol: Optional[ProtocolResource], ) -> Union[Run, BadRun]: """Create a new, current run. @@ -150,6 +156,10 @@ async def create( run_id: Identifier to assign the new run. created_at: Creation datetime. labware_offsets: Labware offsets to initialize the engine with. + deck_configuration: A mapping of fixtures to cutout fixtures the deck will be loaded with. + notify_publishers: Utilized by the engine to notify publishers of state changes. + run_time_param_values: Any runtime parameter values to set. + protocol: The protocol to load the runner with, if any. Returns: The run resource. @@ -165,19 +175,22 @@ async def create( run_id=prev_run_id, summary=prev_run_result.state_summary, commands=prev_run_result.commands, + run_time_parameters=prev_run_result.parameters, ) state_summary = await self._engine_store.create( run_id=run_id, labware_offsets=labware_offsets, deck_configuration=deck_configuration, protocol=protocol, + run_time_param_values=run_time_param_values, + notify_publishers=notify_publishers, ) run_resource = self._run_store.insert( run_id=run_id, created_at=created_at, protocol_id=protocol.protocol_id if protocol is not None else None, ) - await self._runs_publisher.begin_polling_engine_store( + await self._runs_publisher.initialize( get_current_command=self.get_current_command, get_state_summary=self._get_good_state_summary, run_id=run_id, @@ -187,6 +200,7 @@ async def create( run_resource=run_resource, state_summary=state_summary, current=True, + run_time_parameters=[], ) def get(self, run_id: str) -> Union[Run, BadRun]: @@ -206,9 +220,10 @@ def get(self, run_id: str) -> Union[Run, BadRun]: """ run_resource = self._run_store.get(run_id=run_id) state_summary = self._get_state_summary(run_id=run_id) + parameters = self._get_run_time_parameters(run_id=run_id) current = run_id == self._engine_store.current_run_id - return _build_run(run_resource, state_summary, current) + return _build_run(run_resource, state_summary, current, parameters) def get_run_loaded_labware_definitions( self, run_id: str @@ -251,6 +266,7 @@ def get_all(self, length: Optional[int]) -> List[Union[Run, BadRun]]: run_resource=run_resource, state_summary=self._get_state_summary(run_resource.run_id), current=run_resource.run_id == self._engine_store.current_run_id, + run_time_parameters=self._get_run_time_parameters(run_resource.run_id), ) for run_resource in self._run_store.get_all(length) ] @@ -268,7 +284,8 @@ async def delete(self, run_id: str) -> None: """ if run_id == self._engine_store.current_run_id: await self._engine_store.clear() - await self._runs_publisher.stop_polling_engine_store() + + await self._runs_publisher.clean_up_run(run_id=run_id) self._run_store.remove(run_id=run_id) @@ -301,15 +318,18 @@ async def update(self, run_id: str, current: Optional[bool]) -> Union[Run, BadRu run_id=run_id, summary=state_summary, commands=commands, + run_time_parameters=parameters, ) else: state_summary = self._engine_store.engine.state_view.get_summary() + parameters = self._engine_store.runner.run_time_parameters run_resource = self._run_store.get(run_id=run_id) return _build_run( run_resource=run_resource, state_summary=state_summary, current=next_current, + run_time_parameters=parameters, ) def get_commands_slice( @@ -376,3 +396,9 @@ def _get_state_summary(self, run_id: str) -> Union[StateSummary, BadStateSummary def _get_good_state_summary(self, run_id: str) -> Optional[StateSummary]: summary = self._get_state_summary(run_id) return summary if isinstance(summary, StateSummary) else None + + def _get_run_time_parameters(self, run_id: str) -> List[RunTimeParameter]: + if run_id == self._engine_store.current_run_id: + return self._engine_store.runner.run_time_parameters + else: + return self._run_store.get_run_time_parameters(run_id=run_id) diff --git a/robot-server/robot_server/runs/run_models.py b/robot-server/robot_server/runs/run_models.py index e05cd25330c..c93049bfef4 100644 --- a/robot-server/robot_server/runs/run_models.py +++ b/robot-server/robot_server/runs/run_models.py @@ -18,6 +18,7 @@ Liquid, CommandNote, ) +from opentrons.protocol_engine.types import RunTimeParameter, RunTimeParamValuesType from opentrons_shared_data.errors import GeneralError from robot_server.service.json_api import ResourceModel from robot_server.errors.error_responses import ErrorDetails @@ -120,6 +121,15 @@ class Run(ResourceModel): ..., description="Labware offsets to apply as labware are loaded.", ) + runTimeParameters: List[RunTimeParameter] = Field( + default_factory=list, + description=( + "Run time parameters used during the run." + " These are the parameters that are defined in the protocol, with values" + " specified either in the run creation request or default values from the protocol" + " if none are specified in the request." + ), + ) protocolId: Optional[str] = Field( None, description=( @@ -184,6 +194,15 @@ class BadRun(ResourceModel): ..., description="Labware offsets to apply as labware are loaded.", ) + runTimeParameters: List[RunTimeParameter] = Field( + default_factory=list, + description=( + "Run time parameters used during the run." + " These are the parameters that are defined in the protocol, with values" + " specified either in the run creation request or default values from the protocol" + " if none are specified in the request." + ), + ) protocolId: Optional[str] = Field( None, description=( @@ -212,6 +231,10 @@ class RunCreate(BaseModel): default_factory=list, description="Labware offsets to apply as labware are loaded.", ) + runTimeParameterValues: Optional[RunTimeParamValuesType] = Field( + None, + description="Key-value pairs of run-time parameters defined in a protocol.", + ) class RunUpdate(BaseModel): diff --git a/robot-server/robot_server/runs/run_store.py b/robot-server/robot_server/runs/run_store.py index 6178e180470..b86ec8e19ea 100644 --- a/robot-server/robot_server/runs/run_store.py +++ b/robot-server/robot_server/runs/run_store.py @@ -12,6 +12,7 @@ from opentrons.util.helpers import utc_now from opentrons.protocol_engine import StateSummary, CommandSlice from opentrons.protocol_engine.commands import Command +from opentrons.protocol_engine.types import RunTimeParameter from opentrons_shared_data.errors.exceptions import ( EnumeratedError, @@ -25,9 +26,13 @@ run_command_table, action_table, ) -from robot_server.persistence.pydantic import json_to_pydantic, pydantic_to_json +from robot_server.persistence.pydantic import ( + json_to_pydantic, + pydantic_to_json, + json_to_pydantic_list, + pydantic_list_to_json, +) from robot_server.protocols.protocol_store import ProtocolNotFoundError -from robot_server.service.notifications import RunsPublisher from .action_models import RunAction, RunActionType from .run_models import RunNotFoundError @@ -94,17 +99,16 @@ class RunStore: def __init__( self, sql_engine: sqlalchemy.engine.Engine, - runs_publisher: RunsPublisher, ) -> None: """Initialize a RunStore with sql engine and notification client.""" self._sql_engine = sql_engine - self._runs_publisher = runs_publisher def update_run_state( self, run_id: str, summary: StateSummary, commands: List[Command], + run_time_parameters: List[RunTimeParameter], ) -> RunResource: """Update the run's state summary and commands list. @@ -112,6 +116,7 @@ def update_run_state( run_id: The run to update summary: The run's equipment and status summary. commands: The run's commands. + run_time_parameters: The run's run time parameters, if any. Returns: The run resource. @@ -127,6 +132,7 @@ def update_run_state( run_id=run_id, state_summary=summary, engine_status=summary.status, + run_time_parameters=run_time_parameters, ) ) ) @@ -166,7 +172,6 @@ def update_run_state( action_rows = transaction.execute(select_actions).all() self._clear_caches() - self._runs_publisher.publish_runs_advise_refetch(run_id=run_id) maybe_run_resource = _convert_row_to_run(row=run_row, action_rows=action_rows) if not maybe_run_resource.ok: raise maybe_run_resource.error @@ -192,7 +197,6 @@ def insert_action(self, run_id: str, action: RunAction) -> None: transaction.execute(insert) self._clear_caches() - self._runs_publisher.publish_runs_advise_refetch(run_id=run_id) def insert( self, @@ -235,7 +239,6 @@ def insert( raise ProtocolNotFoundError(protocol_id=run.protocol_id) self._clear_caches() - self._runs_publisher.publish_runs_advise_refetch(run_id=run_id) return run @lru_cache(maxsize=_CACHE_ENTRIES) @@ -352,6 +355,33 @@ def get_state_summary(self, run_id: str) -> Union[StateSummary, BadStateSummary] ) ) + @lru_cache(maxsize=_CACHE_ENTRIES) + def get_run_time_parameters(self, run_id: str) -> List[RunTimeParameter]: + """Get the archived run time parameters. + + This is a list of the run's parameter definitions (if any), + including the values used in the run itself, along with the default value, + constraints and associated names and descriptions. + """ + select_run_data = sqlalchemy.select(run_table.c.run_time_parameters).where( + run_table.c.id == run_id + ) + + with self._sql_engine.begin() as transaction: + row = transaction.execute(select_run_data).one() + + try: + return ( + json_to_pydantic_list(RunTimeParameter, row.run_time_parameters) # type: ignore[arg-type] + if row.run_time_parameters is not None + else [] + ) + except ValidationError: + log.warning( + f"Error retrieving run time parameters for {run_id}", exc_info=True + ) + return [] + def get_commands_slice( self, run_id: str, @@ -467,7 +497,6 @@ def remove(self, run_id: str) -> None: raise RunNotFoundError(run_id) self._clear_caches() - self._runs_publisher.publish_runs_advise_unsubscribe(run_id=run_id) def _run_exists( self, run_id: str, connection: sqlalchemy.engine.Connection @@ -483,6 +512,7 @@ def _clear_caches(self) -> None: self.get_all.cache_clear() self.get_state_summary.cache_clear() self.get_command.cache_clear() + self.get_run_time_parameters.cache_clear() # The columns that must be present in a row passed to _convert_row_to_run(). @@ -559,9 +589,11 @@ def _convert_state_to_sql_values( run_id: str, state_summary: StateSummary, engine_status: str, + run_time_parameters: List[RunTimeParameter], ) -> Dict[str, object]: return { "state_summary": pydantic_to_json(state_summary), "engine_status": engine_status, "_updated_at": utc_now(), + "run_time_parameters": pydantic_list_to_json(run_time_parameters), } diff --git a/robot-server/robot_server/service/json_api/response.py b/robot-server/robot_server/service/json_api/response.py index 9d2c2cb76b9..e1e422f255c 100644 --- a/robot-server/robot_server/service/json_api/response.py +++ b/robot-server/robot_server/service/json_api/response.py @@ -287,7 +287,7 @@ class ResponseList(BaseModel, Generic[ResponseDataT]): class NotifyRefetchBody(BaseResponseBody): """A notification response that returns a flag for refetching via HTTP.""" - refetchUsingHTTP: bool = True + refetch: bool = True class NotifyUnsubscribeBody(BaseResponseBody): diff --git a/robot-server/robot_server/service/legacy/routers/settings.py b/robot-server/robot_server/service/legacy/routers/settings.py index 16a732ff97f..5d79053d696 100644 --- a/robot-server/robot_server/service/legacy/routers/settings.py +++ b/robot-server/robot_server/service/legacy/routers/settings.py @@ -1,7 +1,7 @@ -from dataclasses import asdict +import aiohttp import logging +from dataclasses import asdict from typing import cast, Any, Dict, List, Optional, Union - from starlette import status from fastapi import APIRouter, Depends @@ -32,7 +32,6 @@ from robot_server.errors.error_responses import LegacyErrorResponse from robot_server.hardware import ( get_hardware, - get_robot_type, get_robot_type_enum, get_ot2_hardware, ) @@ -64,6 +63,17 @@ router = APIRouter() +# TODO: (ba, 2024-04-11): We should have a proper IPC mechanism to talk between +# the servers instead of one off endpoint calls like these. +async def set_oem_mode_request(enable): + """PUT request to set the OEM Mode for the system server.""" + async with aiohttp.ClientSession() as session: + async with session.put( + "http://127.0.0.1:31950/system/oem_mode/enable", json={"enable": enable} + ) as resp: + return resp.status + + @router.post( path="/settings", summary="Change a setting", @@ -78,10 +88,17 @@ async def post_settings( update: AdvancedSettingRequest, hardware: HardwareControlAPI = Depends(get_hardware), - robot_type: str = Depends(get_robot_type), + robot_type: RobotTypeEnum = Depends(get_robot_type_enum), ) -> AdvancedSettingsResponse: """Update advanced setting (feature flag)""" try: + # send request to system server if this is the enableOEMMode setting + if update.id == "enableOEMMode" and robot_type == RobotTypeEnum.FLEX: + resp = await set_oem_mode_request(update.value) + if resp != 200: + # TODO: raise correct error here + raise Exception(f"Something went wrong setting OEM Mode. err: {resp}") + await advanced_settings.set_adv_setting(update.id, update.value) hardware.hardware_feature_flags = HardwareFeatureFlags.build_from_ff() await hardware.set_status_bar_enabled(ff.status_bar_enabled()) @@ -104,21 +121,15 @@ async def post_settings( response_model_exclude_unset=True, ) async def get_settings( - robot_type: str = Depends(get_robot_type), + robot_type: RobotTypeEnum = Depends(get_robot_type_enum), ) -> AdvancedSettingsResponse: """Get advanced setting (feature flags)""" return _create_settings_response(robot_type) -def _create_settings_response(robot_type: str) -> AdvancedSettingsResponse: +def _create_settings_response(robot_type: RobotTypeEnum) -> AdvancedSettingsResponse: """Create the feature flag settings response object""" - # TODO lc(8-10-2023) We should convert the robot type function to return - # the enum value directly. - if robot_type == "OT-2 Standard": - robot_type_enum = RobotTypeEnum.OT2 - else: - robot_type_enum = RobotTypeEnum.FLEX - data = advanced_settings.get_all_adv_settings(robot_type_enum) + data = advanced_settings.get_all_adv_settings(robot_type) if advanced_settings.is_restart_required(): links = Links(restart="/server/restart") diff --git a/robot-server/robot_server/service/notifications/__init__.py b/robot-server/robot_server/service/notifications/__init__.py index 202c7fc71f1..7fd648f32aa 100644 --- a/robot-server/robot_server/service/notifications/__init__.py +++ b/robot-server/robot_server/service/notifications/__init__.py @@ -1,15 +1,20 @@ +"""Notification service creation and management.""" +from .initialize_notifications import initialize_notifications + from .notification_client import ( NotificationClient, get_notification_client, - initialize_notification_client, clean_up_notification_client, ) +from .publisher_notifier import PublisherNotifier, get_notify_publishers from .publishers import ( MaintenanceRunsPublisher, RunsPublisher, get_maintenance_runs_publisher, get_runs_publisher, ) +from .change_notifier import ChangeNotifier +from .topics import Topics __all__ = [ # main export @@ -18,10 +23,15 @@ "MaintenanceRunsPublisher", "RunsPublisher", # initialization and teardown - "initialize_notification_client", + "initialize_notifications", "clean_up_notification_client", # for use by FastAPI "get_notification_client", + "get_notify_publishers", "get_maintenance_runs_publisher", "get_runs_publisher", + # for testing + "PublisherNotifier", + "ChangeNotifier", + "Topics", ] diff --git a/robot-server/robot_server/service/notifications/change_notifier.py b/robot-server/robot_server/service/notifications/change_notifier.py new file mode 100644 index 00000000000..60c36c420af --- /dev/null +++ b/robot-server/robot_server/service/notifications/change_notifier.py @@ -0,0 +1,23 @@ +"""Simple state change notification interface.""" +import asyncio + + +class ChangeNotifier: + """An interface to emit or subscribe to state change notifications.""" + + def __init__(self) -> None: + """Initialize the ChangeNotifier with an internal Event.""" + self._event = asyncio.Event() + + def notify(self) -> None: + """Notify all `waiters` of a change.""" + self._event.set() + + async def wait(self) -> None: + """Wait until the next change notification.""" + self._event.clear() + await self._event.wait() + + def clear(self) -> None: + """Reset the internal event flag.""" + self._event.clear() diff --git a/robot-server/robot_server/service/notifications/initialize_notifications.py b/robot-server/robot_server/service/notifications/initialize_notifications.py new file mode 100644 index 00000000000..d5569d09eff --- /dev/null +++ b/robot-server/robot_server/service/notifications/initialize_notifications.py @@ -0,0 +1,11 @@ +"""Utilities for initializing the notification service.""" +from server_utils.fastapi_utils.app_state import AppState + +from .notification_client import initialize_notification_client +from .publisher_notifier import initialize_publisher_notifier + + +async def initialize_notifications(app_state: AppState) -> None: + """Initialize the notification system for the given app state.""" + initialize_notification_client(app_state) + await initialize_publisher_notifier(app_state) diff --git a/robot-server/robot_server/service/notifications/notification_client.py b/robot-server/robot_server/service/notifications/notification_client.py index 568d161cf53..f53de3bbe39 100644 --- a/robot-server/robot_server/service/notifications/notification_client.py +++ b/robot-server/robot_server/service/notifications/notification_client.py @@ -1,3 +1,4 @@ +"""An interface for managing interactions with the notification broker and relevant lifecycle utilities.""" import random import logging import paho.mqtt.client as mqtt @@ -58,24 +59,26 @@ def __init__( # MQTT is somewhat particular about the client_id format and will connect erratically # if an unexpected string is supplied. This clientId is derived from the paho-mqtt library. self._client_id: str = f"robot-server-{random.randint(0, 1000000)}" - self.client: mqtt.Client = mqtt.Client( + self._client: mqtt.Client = mqtt.Client( client_id=self._client_id, protocol=protocol_version ) - self.client.on_connect = self._on_connect - self.client.on_disconnect = self._on_disconnect + self._client.on_connect = self._on_connect + self._client.on_disconnect = self._on_disconnect def connect(self) -> None: """Connect the client to the MQTT broker.""" - self.client.on_connect = self._on_connect - self.client.on_disconnect = self._on_disconnect + self._client.on_connect = self._on_connect + self._client.on_disconnect = self._on_disconnect - self.client.connect(host=self._host, port=self._port, keepalive=self._keepalive) - self.client.loop_start() + self._client.connect( + host=self._host, port=self._port, keepalive=self._keepalive + ) + self._client.loop_start() async def disconnect(self) -> None: """Disconnect the client from the MQTT broker.""" - self.client.loop_stop() - await to_thread.run_sync(self.client.disconnect) + self._client.loop_stop() + await to_thread.run_sync(self._client.disconnect) async def publish_advise_refetch_async(self, topic: str) -> None: """Asynchronously publish a refetch message on a specific topic to the MQTT broker. @@ -104,7 +107,7 @@ def publish_advise_refetch( """ message = NotifyRefetchBody.construct() payload = message.json() - self.client.publish( + self._client.publish( topic=topic, payload=payload, qos=self._default_qos, @@ -122,7 +125,7 @@ def publish_advise_unsubscribe( """ message = NotifyUnsubscribeBody.construct() payload = message.json() - self.client.publish( + self._client.publish( topic=topic, payload=payload, qos=self._default_qos, @@ -208,7 +211,5 @@ def get_notification_client( app_state: AppState = Depends(get_app_state), ) -> Optional[NotificationClient]: """Intended to be used by endpoint functions as a FastAPI dependency.""" - notification_client: Optional[ - NotificationClient - ] = _notification_client_accessor.get_from(app_state) + notification_client = _notification_client_accessor.get_from(app_state) return notification_client diff --git a/robot-server/robot_server/service/notifications/publisher_notifier.py b/robot-server/robot_server/service/notifications/publisher_notifier.py new file mode 100644 index 00000000000..d1769ac4379 --- /dev/null +++ b/robot-server/robot_server/service/notifications/publisher_notifier.py @@ -0,0 +1,81 @@ +"""Provides an interface for alerting notification publishers to events and related lifecycle utilities.""" +import asyncio +from fastapi import Depends +from typing import Optional, Callable, List, Awaitable + +from server_utils.fastapi_utils.app_state import ( + AppState, + AppStateAccessor, + get_app_state, +) + +from .change_notifier import ChangeNotifier + + +class PublisherNotifier: + """An interface that invokes notification callbacks whenever a generic notify event occurs.""" + + def __init__( + self, + change_notifier: Optional[ChangeNotifier] = None, + ): + self._change_notifier = change_notifier or ChangeNotifier() + self._pe_notifier: Optional[asyncio.Task[None]] = None + self._callbacks: List[Callable[[], Awaitable[None]]] = [] + + def register_publish_callbacks( + self, callbacks: List[Callable[[], Awaitable[None]]] + ): + """Extend the list of callbacks with a given list of callbacks.""" + self._callbacks.extend(callbacks) + + async def _initialize(self) -> None: + """Initializes an instance of PublisherNotifier. This method should only be called once.""" + self._pe_notifier = asyncio.create_task(self._wait_for_event()) + + def _notify_publishers(self) -> None: + """A generic notifier, alerting all `waiters` of a change.""" + self._change_notifier.notify() + + async def _wait_for_event(self) -> None: + """Indefinitely wait for an event to occur, then invoke each callback.""" + while True: + await self._change_notifier.wait() + for callback in self._callbacks: + await callback() + + +_publisher_notifier_accessor: AppStateAccessor[PublisherNotifier] = AppStateAccessor[ + PublisherNotifier +]("publisher_notifier") + + +def get_publisher_notifier( + app_state: AppState = Depends(get_app_state), +) -> PublisherNotifier: + """Intended for use by various publishers only.""" + publisher_notifier = _publisher_notifier_accessor.get_from(app_state) + assert publisher_notifier is not None + + return publisher_notifier + + +def get_notify_publishers( + app_state: AppState = Depends(get_app_state), +) -> Callable[[], None]: + """Provides access to the callback used to notify publishers of changes.""" + publisher_notifier = _publisher_notifier_accessor.get_from(app_state) + assert isinstance(publisher_notifier, PublisherNotifier) + + return publisher_notifier._notify_publishers + + +async def initialize_publisher_notifier(app_state: AppState) -> None: + """Create a new `NotificationClient` and store it on `app_state`. + + Intended to be called just once, when the server starts up. + """ + publisher_notifier: PublisherNotifier = PublisherNotifier() + _publisher_notifier_accessor.set_on(app_state, publisher_notifier) + + await publisher_notifier._initialize() diff --git a/robot-server/robot_server/service/notifications/publishers/__init__.py b/robot-server/robot_server/service/notifications/publishers/__init__.py index 1dcdc43d4a9..59a30e7a135 100644 --- a/robot-server/robot_server/service/notifications/publishers/__init__.py +++ b/robot-server/robot_server/service/notifications/publishers/__init__.py @@ -1,3 +1,8 @@ +"""Publisher creation and management. + +A unique publisher is responsible for each router's related set of endpoints. The publisher conditionally determines +whether a relevant event has occurred, and if true, it publishes an appropriate message to the robot's message broker. +""" from .maintenance_runs_publisher import ( MaintenanceRunsPublisher, get_maintenance_runs_publisher, diff --git a/robot-server/robot_server/service/notifications/publishers/runs_publisher.py b/robot-server/robot_server/service/notifications/publishers/runs_publisher.py index 94aed694e8f..fef23c8a875 100644 --- a/robot-server/robot_server/service/notifications/publishers/runs_publisher.py +++ b/robot-server/robot_server/service/notifications/publishers/runs_publisher.py @@ -1,7 +1,7 @@ -from fastapi import Depends import asyncio -import logging -from typing import Union, Callable, Optional +from fastapi import Depends +from dataclasses import dataclass +from typing import Callable, Optional from opentrons.protocol_engine import CurrentCommand, StateSummary, EngineStatus @@ -11,173 +11,122 @@ get_app_state, ) from ..notification_client import NotificationClient, get_notification_client +from ..publisher_notifier import PublisherNotifier, get_publisher_notifier from ..topics import Topics -log: logging.Logger = logging.getLogger(__name__) +@dataclass +class RunHooks: + """Generated during a protocol run. Utilized by RunsPublisher.""" + + run_id: str + get_current_command: Callable[[str], Optional[CurrentCommand]] + get_state_summary: Callable[[str], Optional[StateSummary]] + + +@dataclass +class EngineStateSlice: + """Protocol Engine state relevant to RunsPublisher.""" -POLL_INTERVAL = 1 + current_command: Optional[CurrentCommand] = None + state_summary_status: Optional[EngineStatus] = None class RunsPublisher: """Publishes protocol runs topics.""" - def __init__(self, client: NotificationClient) -> None: + def __init__( + self, client: NotificationClient, publisher_notifier: PublisherNotifier + ) -> None: """Returns a configured Runs Publisher.""" self._client = client + self._publisher_notifier = publisher_notifier self._run_data_manager_polling = asyncio.Event() - self._previous_current_command: Union[CurrentCommand, None] = None - self._previous_state_summary_status: Union[EngineStatus, None] = None self._poller: Optional[asyncio.Task[None]] = None + # Variables and callbacks related to PE state changes. + self._run_hooks: Optional[RunHooks] = None + self._engine_state_slice: Optional[EngineStateSlice] = None - # TODO(jh, 2023-02-02): Instead of polling, emit current_commands directly from PE. - async def begin_polling_engine_store( - self, - get_current_command: Callable[[str], Optional[CurrentCommand]], - get_state_summary: Callable[[str], Optional[StateSummary]], - run_id: str, - ) -> None: - """Continuously poll the engine store for the current_command. - - Args: - get_current_command: Callback to get the currently executing command, if any. - get_state_summary: Callback to get the current run's state summary, if any. - run_id: ID of the current run. - """ - if self._poller is None: - self._poller = asyncio.create_task( - self._poll_engine_store( - get_current_command=get_current_command, - run_id=run_id, - get_state_summary=get_state_summary, - ) - ) - else: - await self.stop_polling_engine_store() - self._poller = asyncio.create_task( - self._poll_engine_store( - get_current_command=get_current_command, - run_id=run_id, - get_state_summary=get_state_summary, - ) - ) - - async def stop_polling_engine_store(self) -> None: - """Stops polling the engine store. Run-related topics will publish as the poller is cancelled.""" - if self._poller is not None: - self._run_data_manager_polling.set() - self._poller.cancel() - - def publish_runs_advise_refetch(self, run_id: str) -> None: - """Publishes the equivalent of GET /runs and GET /runs/:runId. - - Args: - run_id: ID of the current run. - """ - self._client.publish_advise_refetch(topic=Topics.RUNS) - self._client.publish_advise_refetch(topic=f"{Topics.RUNS}/{run_id}") - - def publish_runs_advise_unsubscribe(self, run_id: str) -> None: - """Publishes the equivalent of GET /runs and GET /runs/:runId. - - Args: - run_id: ID of the current run. - """ - self._client.publish_advise_unsubscribe(topic=Topics.RUNS) - self._client.publish_advise_unsubscribe(topic=f"{Topics.RUNS}/{run_id}") + self._publisher_notifier.register_publish_callbacks( + [self._handle_current_command_change, self._handle_engine_status_change] + ) - async def _poll_engine_store( + async def initialize( self, - get_current_command: Callable[[str], Optional[CurrentCommand]], - get_state_summary: Callable[[str], Optional[StateSummary]], run_id: str, - ) -> None: - """Asynchronously publish new current commands. - - Args: - get_current_command: Retrieves the engine store's current command. - get_state_summary: Retrieves the engine store's state summary. - run_id: ID of the current run. - """ - try: - await self._poll_for_run_id_info( - get_current_command=get_current_command, - get_state_summary=get_state_summary, - run_id=run_id, - ) - except asyncio.CancelledError: - self._clean_up_poller() - await self._publish_runs_advise_unsubscribe_async(run_id=run_id) - await self._client.publish_advise_refetch_async( - topic=Topics.RUNS_CURRENT_COMMAND - ) - except Exception as e: - log.error(f"Error within run data manager poller: {e}") - - async def _poll_for_run_id_info( - self, get_current_command: Callable[[str], Optional[CurrentCommand]], get_state_summary: Callable[[str], Optional[StateSummary]], - run_id: str, - ): - """Poll the engine store for a specific run's state while the poll is active. + ) -> None: + """Initialize RunsPublisher with necessary information derived from the current run. Args: - get_current_command: Retrieves the engine store's current command. - get_state_summary: Retrieves the engine store's state summary. run_id: ID of the current run. + get_current_command: Callback to get the currently executing command, if any. + get_state_summary: Callback to get the current run's state summary, if any. """ + self._run_hooks = RunHooks( + run_id=run_id, + get_current_command=get_current_command, + get_state_summary=get_state_summary, + ) + self._engine_state_slice = EngineStateSlice() - while not self._run_data_manager_polling.is_set(): - current_command = get_current_command(run_id) - current_state_summary = get_state_summary(run_id) - current_state_summary_status = ( - current_state_summary.status if current_state_summary else None - ) + await self._publish_runs_advise_refetch_async(run_id=run_id) - if self._previous_current_command != current_command: - await self._publish_current_command() - self._previous_current_command = current_command + async def clean_up_run(self, run_id: str) -> None: + """Publish final refetch and unsubscribe flags for the given run.""" + await self._publish_runs_advise_refetch_async(run_id=run_id) + await self._publish_runs_advise_unsubscribe_async(run_id=run_id) - if self._previous_state_summary_status != current_state_summary_status: - await self._publish_runs_advise_refetch_async(run_id=run_id) - self._previous_state_summary_status = current_state_summary_status - await asyncio.sleep(POLL_INTERVAL) - - async def _publish_current_command( - self, - ) -> None: + async def _publish_current_command(self) -> None: """Publishes the equivalent of GET /runs/:runId/commands?cursor=null&pageLength=1.""" await self._client.publish_advise_refetch_async( topic=Topics.RUNS_CURRENT_COMMAND ) async def _publish_runs_advise_refetch_async(self, run_id: str) -> None: - """Asynchronously publishes the equivalent of GET /runs and GET /runs/:runId via a refetch message. - - Args: - run_id: ID of the current run. - """ + """Publish a refetch flag for relevant runs topics.""" await self._client.publish_advise_refetch_async(topic=Topics.RUNS) - await self._client.publish_advise_refetch_async(topic=f"{Topics.RUNS}/{run_id}") - async def _publish_runs_advise_unsubscribe_async(self, run_id: str) -> None: - """Asynchronously publishes the equivalent of GET /runs and GET /runs/:runId via an unsubscribe message. + if self._run_hooks is not None: + await self._client.publish_advise_refetch_async( + topic=f"{Topics.RUNS}/{run_id}" + ) - Args: - run_id: ID of the current run. - """ - await self._client.publish_advise_unsubscribe_async(topic=Topics.RUNS) + async def _publish_runs_advise_unsubscribe_async(self, run_id: str) -> None: + """Publish an unsubscribe flag for relevant runs topics.""" await self._client.publish_advise_unsubscribe_async( topic=f"{Topics.RUNS}/{run_id}" ) - def _clean_up_poller(self) -> None: - """Cleans up the runs data manager poller.""" - self._poller = None - self._run_data_manager_polling.clear() - self._previous_current_command = None - self._previous_state_summary_status = None + async def _handle_current_command_change(self) -> None: + """Publish a refetch flag if the current command has changed.""" + if self._run_hooks is not None and self._engine_state_slice is not None: + current_command = self._run_hooks.get_current_command( + self._run_hooks.run_id + ) + if self._engine_state_slice.current_command != current_command: + await self._publish_current_command() + self._engine_state_slice.current_command = current_command + + async def _handle_engine_status_change(self) -> None: + """Publish a refetch flag if the engine status has changed.""" + if self._run_hooks is not None and self._engine_state_slice is not None: + current_state_summary = self._run_hooks.get_state_summary( + self._run_hooks.run_id + ) + + if ( + current_state_summary is not None + and self._engine_state_slice.state_summary_status + != current_state_summary.status + ): + await self._publish_runs_advise_refetch_async( + run_id=self._run_hooks.run_id + ) + self._engine_state_slice.state_summary_status = ( + current_state_summary.status + ) _runs_publisher_accessor: AppStateAccessor[RunsPublisher] = AppStateAccessor[ @@ -188,12 +137,15 @@ def _clean_up_poller(self) -> None: async def get_runs_publisher( app_state: AppState = Depends(get_app_state), notification_client: NotificationClient = Depends(get_notification_client), + publisher_notifier: PublisherNotifier = Depends(get_publisher_notifier), ) -> RunsPublisher: """Get a singleton RunsPublisher to publish runs topics.""" runs_publisher = _runs_publisher_accessor.get_from(app_state) if runs_publisher is None: - runs_publisher = RunsPublisher(client=notification_client) + runs_publisher = RunsPublisher( + client=notification_client, publisher_notifier=publisher_notifier + ) _runs_publisher_accessor.set_on(app_state, runs_publisher) return runs_publisher diff --git a/robot-server/robot_server/service/notifications/topics.py b/robot-server/robot_server/service/notifications/topics.py index 9e3d5fe0ea4..34f2fd0eea1 100644 --- a/robot-server/robot_server/service/notifications/topics.py +++ b/robot-server/robot_server/service/notifications/topics.py @@ -1,3 +1,4 @@ +"""Notification topics.""" from enum import Enum diff --git a/robot-server/simulators/test-flex.json b/robot-server/simulators/test-flex.json index d7fc860c662..adc9543fc5a 100644 --- a/robot-server/simulators/test-flex.json +++ b/robot-server/simulators/test-flex.json @@ -91,4 +91,4 @@ } ] } -} \ No newline at end of file +} diff --git a/robot-server/tests/deck_configuration/test_defaults.py b/robot-server/tests/deck_configuration/test_defaults.py index ec3bbed3c22..42aa3672f52 100644 --- a/robot-server/tests/deck_configuration/test_defaults.py +++ b/robot-server/tests/deck_configuration/test_defaults.py @@ -12,7 +12,7 @@ from robot_server.deck_configuration import validation_mapping -DECK_DEFINITION_VERSION: Final = 4 +DECK_DEFINITION_VERSION: Final = 5 @pytest.mark.parametrize( diff --git a/robot-server/tests/deck_configuration/test_validation.py b/robot-server/tests/deck_configuration/test_validation.py index 5aee74491da..24aecf9117a 100644 --- a/robot-server/tests/deck_configuration/test_validation.py +++ b/robot-server/tests/deck_configuration/test_validation.py @@ -7,22 +7,26 @@ def test_valid() -> None: """It should return an empty error list if the input is valid.""" - deck_definition = load_deck_definition("ot3_standard", version=4) + deck_definition = load_deck_definition("ot3_standard", version=5) cutout_fixtures = [ - subject.Placement(cutout_fixture_id=cutout_fixture_id, cutout_id=cutout_id) - for cutout_fixture_id, cutout_id in [ - ("singleLeftSlot", "cutoutA1"), - ("singleLeftSlot", "cutoutB1"), - ("singleLeftSlot", "cutoutC1"), - ("singleLeftSlot", "cutoutD1"), - ("singleCenterSlot", "cutoutA2"), - ("singleCenterSlot", "cutoutB2"), - ("singleCenterSlot", "cutoutC2"), - ("singleCenterSlot", "cutoutD2"), - ("stagingAreaRightSlot", "cutoutA3"), - ("singleRightSlot", "cutoutB3"), - ("stagingAreaRightSlot", "cutoutC3"), - ("singleRightSlot", "cutoutD3"), + subject.Placement( + cutout_fixture_id=cutout_fixture_id, + cutout_id=cutout_id, + opentrons_module_serial_number=opentrons_module_serial_number, + ) + for cutout_fixture_id, cutout_id, opentrons_module_serial_number in [ + ("singleLeftSlot", "cutoutA1", None), + ("singleLeftSlot", "cutoutB1", None), + ("singleLeftSlot", "cutoutC1", None), + ("singleLeftSlot", "cutoutD1", None), + ("singleCenterSlot", "cutoutA2", None), + ("singleCenterSlot", "cutoutB2", None), + ("singleCenterSlot", "cutoutC2", None), + ("singleCenterSlot", "cutoutD2", None), + ("stagingAreaRightSlot", "cutoutA3", None), + ("singleRightSlot", "cutoutB3", None), + ("stagingAreaRightSlot", "cutoutC3", None), + ("singleRightSlot", "cutoutD3", None), ] ] assert subject.get_configuration_errors(deck_definition, cutout_fixtures) == set() @@ -30,23 +34,27 @@ def test_valid() -> None: def test_invalid_empty_cutouts() -> None: """It should enforce that every cutout is occupied.""" - deck_definition = load_deck_definition("ot3_standard", version=4) + deck_definition = load_deck_definition("ot3_standard", version=5) cutout_fixtures = [ - subject.Placement(cutout_fixture_id=cutout_fixture_id, cutout_id=cutout_id) - for cutout_fixture_id, cutout_id in [ - ("singleLeftSlot", "cutoutA1"), - ("singleLeftSlot", "cutoutB1"), - ("singleLeftSlot", "cutoutC1"), - ("singleLeftSlot", "cutoutD1"), - ("singleCenterSlot", "cutoutA2"), - ("singleCenterSlot", "cutoutB2"), + subject.Placement( + cutout_fixture_id=cutout_fixture_id, + cutout_id=cutout_id, + opentrons_module_serial_number=opentrons_module_serial_number, + ) + for cutout_fixture_id, cutout_id, opentrons_module_serial_number in [ + ("singleLeftSlot", "cutoutA1", None), + ("singleLeftSlot", "cutoutB1", None), + ("singleLeftSlot", "cutoutC1", None), + ("singleLeftSlot", "cutoutD1", None), + ("singleCenterSlot", "cutoutA2", None), + ("singleCenterSlot", "cutoutB2", None), # Invalid because we haven't placed anything into cutout C2 or D2. - # ("singleCenterSlot", "cutoutC2"), - # ("singleCenterSlot", "cutoutD2"), - ("stagingAreaRightSlot", "cutoutA3"), - ("singleRightSlot", "cutoutB3"), - ("stagingAreaRightSlot", "cutoutC3"), - ("singleRightSlot", "cutoutD3"), + # ("singleCenterSlot", "cutoutC2", None), + # ("singleCenterSlot", "cutoutD2", None), + ("stagingAreaRightSlot", "cutoutA3", None), + ("singleRightSlot", "cutoutB3", None), + ("stagingAreaRightSlot", "cutoutC3", None), + ("singleRightSlot", "cutoutD3", None), ] ] assert subject.get_configuration_errors(deck_definition, cutout_fixtures) == { @@ -57,26 +65,30 @@ def test_invalid_empty_cutouts() -> None: def test_invalid_overcrowded_cutouts() -> None: """It should prevent you from putting multiple things into a single cutout.""" - deck_definition = load_deck_definition("ot3_standard", version=4) + deck_definition = load_deck_definition("ot3_standard", version=5) cutout_fixtures = [ - subject.Placement(cutout_fixture_id=cutout_fixture_id, cutout_id=cutout_id) - for cutout_fixture_id, cutout_id in [ - ("singleLeftSlot", "cutoutA1"), - ("singleLeftSlot", "cutoutB1"), - ("singleLeftSlot", "cutoutC1"), - ("singleLeftSlot", "cutoutD1"), - ("singleCenterSlot", "cutoutA2"), - ("singleCenterSlot", "cutoutB2"), - ("singleCenterSlot", "cutoutC2"), - ("singleCenterSlot", "cutoutD2"), - ("stagingAreaRightSlot", "cutoutA3"), - ("singleRightSlot", "cutoutB3"), + subject.Placement( + cutout_fixture_id=cutout_fixture_id, + cutout_id=cutout_id, + opentrons_module_serial_number=opentrons_module_serial_number, + ) + for cutout_fixture_id, cutout_id, opentrons_module_serial_number in [ + ("singleLeftSlot", "cutoutA1", None), + ("singleLeftSlot", "cutoutB1", None), + ("singleLeftSlot", "cutoutC1", None), + ("singleLeftSlot", "cutoutD1", None), + ("singleCenterSlot", "cutoutA2", None), + ("singleCenterSlot", "cutoutB2", None), + ("singleCenterSlot", "cutoutC2", None), + ("singleCenterSlot", "cutoutD2", None), + ("stagingAreaRightSlot", "cutoutA3", None), + ("singleRightSlot", "cutoutB3", None), # Invalid because we're placing two things in cutout C3... - ("stagingAreaRightSlot", "cutoutC3"), - ("stagingAreaRightSlot", "cutoutC3"), + ("stagingAreaRightSlot", "cutoutC3", None), + ("stagingAreaRightSlot", "cutoutC3", None), # ...and two things in cutout D3. - ("wasteChuteRightAdapterNoCover", "cutoutD3"), - ("singleRightSlot", "cutoutD3"), + ("wasteChuteRightAdapterNoCover", "cutoutD3", None), + ("singleRightSlot", "cutoutD3", None), ] ] assert subject.get_configuration_errors(deck_definition, cutout_fixtures) == { @@ -93,24 +105,28 @@ def test_invalid_overcrowded_cutouts() -> None: def test_invalid_cutout_for_fixture() -> None: """Each fixture must be placed in a location that's valid for that particular fixture.""" - deck_definition = load_deck_definition("ot3_standard", version=4) + deck_definition = load_deck_definition("ot3_standard", version=5) cutout_fixtures = [ - subject.Placement(cutout_fixture_id=cutout_fixture_id, cutout_id=cutout_id) - for cutout_fixture_id, cutout_id in [ - ("singleLeftSlot", "cutoutA1"), - ("singleLeftSlot", "cutoutB1"), - ("singleLeftSlot", "cutoutC1"), - ("singleLeftSlot", "cutoutD1"), - ("singleCenterSlot", "cutoutA2"), - ("singleCenterSlot", "cutoutB2"), + subject.Placement( + cutout_fixture_id=cutout_fixture_id, + cutout_id=cutout_id, + opentrons_module_serial_number=opentrons_module_serial_number, + ) + for cutout_fixture_id, cutout_id, opentrons_module_serial_number in [ + ("singleLeftSlot", "cutoutA1", None), + ("singleLeftSlot", "cutoutB1", None), + ("singleLeftSlot", "cutoutC1", None), + ("singleLeftSlot", "cutoutD1", None), + ("singleCenterSlot", "cutoutA2", None), + ("singleCenterSlot", "cutoutB2", None), # Invalid because wasteChuteRightAdapterNoCover can't be placed in cutout C2... - ("wasteChuteRightAdapterNoCover", "cutoutC2"), + ("wasteChuteRightAdapterNoCover", "cutoutC2", None), # ...nor can singleLeftSlot be placed in cutout D2. - ("singleLeftSlot", "cutoutD2"), - ("stagingAreaRightSlot", "cutoutA3"), - ("singleRightSlot", "cutoutB3"), - ("stagingAreaRightSlot", "cutoutC3"), - ("singleRightSlot", "cutoutD3"), + ("singleLeftSlot", "cutoutD2", None), + ("stagingAreaRightSlot", "cutoutA3", None), + ("singleRightSlot", "cutoutB3", None), + ("stagingAreaRightSlot", "cutoutC3", None), + ("singleRightSlot", "cutoutD3", None), ] ] assert subject.get_configuration_errors(deck_definition, cutout_fixtures) == { @@ -131,24 +147,28 @@ def test_invalid_cutout_for_fixture() -> None: def test_unrecognized_cutout() -> None: """It should raise a sensible error if you pass a totally nonexistent cutout.""" - deck_definition = load_deck_definition("ot3_standard", version=4) + deck_definition = load_deck_definition("ot3_standard", version=5) cutout_fixtures = [ - subject.Placement(cutout_fixture_id=cutout_fixture_id, cutout_id=cutout_id) - for cutout_fixture_id, cutout_id in [ - ("singleLeftSlot", "cutoutA1"), - ("singleLeftSlot", "cutoutB1"), - ("singleLeftSlot", "cutoutC1"), - ("singleLeftSlot", "cutoutD1"), - ("singleCenterSlot", "cutoutA2"), - ("singleCenterSlot", "cutoutB2"), - ("singleCenterSlot", "cutoutC2"), - ("singleCenterSlot", "cutoutD2"), - ("singleRightSlot", "cutoutA3"), - ("singleRightSlot", "cutoutB3"), - ("singleRightSlot", "cutoutC3"), - ("singleRightSlot", "cutoutD3"), + subject.Placement( + cutout_fixture_id=cutout_fixture_id, + cutout_id=cutout_id, + opentrons_module_serial_number=opentrons_module_serial_number, + ) + for cutout_fixture_id, cutout_id, opentrons_module_serial_number in [ + ("singleLeftSlot", "cutoutA1", None), + ("singleLeftSlot", "cutoutB1", None), + ("singleLeftSlot", "cutoutC1", None), + ("singleLeftSlot", "cutoutD1", None), + ("singleCenterSlot", "cutoutA2", None), + ("singleCenterSlot", "cutoutB2", None), + ("singleCenterSlot", "cutoutC2", None), + ("singleCenterSlot", "cutoutD2", None), + ("singleRightSlot", "cutoutA3", None), + ("singleRightSlot", "cutoutB3", None), + ("singleRightSlot", "cutoutC3", None), + ("singleRightSlot", "cutoutD3", None), # Invalid because "someUnrecognizedCutout" is not defined by the deck definition. - ("singleRightSlot", "someUnrecognizedCutout"), + ("singleRightSlot", "someUnrecognizedCutout", None), ] ] assert subject.get_configuration_errors(deck_definition, cutout_fixtures) == { @@ -164,23 +184,27 @@ def test_unrecognized_cutout() -> None: def test_unrecognized_cutout_fixture() -> None: """It should raise a sensible error if you pass a totally nonexistent cutout fixture.""" - deck_definition = load_deck_definition("ot3_standard", version=4) + deck_definition = load_deck_definition("ot3_standard", version=5) cutout_fixtures = [ - subject.Placement(cutout_fixture_id=cutout_fixture_id, cutout_id=cutout_id) - for cutout_fixture_id, cutout_id in [ - ("singleLeftSlot", "cutoutA1"), - ("singleLeftSlot", "cutoutB1"), - ("singleLeftSlot", "cutoutC1"), - ("singleLeftSlot", "cutoutD1"), - ("singleCenterSlot", "cutoutA2"), - ("singleCenterSlot", "cutoutB2"), - ("singleCenterSlot", "cutoutC2"), - ("singleCenterSlot", "cutoutD2"), - ("singleRightSlot", "cutoutA3"), - ("singleRightSlot", "cutoutB3"), - ("singleRightSlot", "cutoutC3"), + subject.Placement( + cutout_fixture_id=cutout_fixture_id, + cutout_id=cutout_id, + opentrons_module_serial_number=opentrons_module_serial_number, + ) + for cutout_fixture_id, cutout_id, opentrons_module_serial_number in [ + ("singleLeftSlot", "cutoutA1", None), + ("singleLeftSlot", "cutoutB1", None), + ("singleLeftSlot", "cutoutC1", None), + ("singleLeftSlot", "cutoutD1", None), + ("singleCenterSlot", "cutoutA2", None), + ("singleCenterSlot", "cutoutB2", None), + ("singleCenterSlot", "cutoutC2", None), + ("singleCenterSlot", "cutoutD2", None), + ("singleRightSlot", "cutoutA3", None), + ("singleRightSlot", "cutoutB3", None), + ("singleRightSlot", "cutoutC3", None), # Invalid because "someUnrecognizedCutoutFixture" is not defined by the deck definition. - ("someUnrecognizedCutoutFixture", "cutoutD3"), + ("someUnrecognizedCutoutFixture", "cutoutD3", None), ] ] assert subject.get_configuration_errors(deck_definition, cutout_fixtures) == { @@ -197,7 +221,115 @@ def test_unrecognized_cutout_fixture() -> None: "stagingAreaSlotWithWasteChuteRightAdapterCovered", "stagingAreaSlotWithWasteChuteRightAdapterNoCover", "trashBinAdapter", + "thermocyclerModuleV2Rear", + "thermocyclerModuleV2Front", + "heaterShakerModuleV1", + "temperatureModuleV2", + "magneticBlockV1", + "stagingAreaSlotWithMagneticBlockV1", ] ), ) } + + +def test_invalid_serial_number() -> None: + """It should raise a sensible error if you fail to provide a serial number for a fixture that requires one.""" + deck_definition = load_deck_definition("ot3_standard", version=5) + cutout_fixtures = [ + subject.Placement( + cutout_fixture_id=cutout_fixture_id, + cutout_id=cutout_id, + opentrons_module_serial_number=opentrons_module_serial_number, + ) + for cutout_fixture_id, cutout_id, opentrons_module_serial_number in [ + ("thermocyclerModuleV2Rear", "cutoutA1", "ABC"), + # Invalid, because the Thermocycler V2 Front fixture requires a serial number + ("thermocyclerModuleV2Front", "cutoutB1", None), + ("singleLeftSlot", "cutoutC1", None), + ("singleLeftSlot", "cutoutD1", None), + ("singleCenterSlot", "cutoutA2", None), + ("singleCenterSlot", "cutoutB2", None), + ("singleCenterSlot", "cutoutC2", None), + ("singleCenterSlot", "cutoutD2", None), + ("singleRightSlot", "cutoutA3", None), + ("singleRightSlot", "cutoutB3", None), + ("singleRightSlot", "cutoutC3", None), + ("singleRightSlot", "cutoutD3", None), + ] + ] + assert subject.get_configuration_errors(deck_definition, cutout_fixtures) == { + subject.InvalidSerialNumberError( + cutout_fixture_id="thermocyclerModuleV2Front", + cutout_id="cutoutB1", + ) + } + + +def test_unexpected_serial_number() -> None: + """It should raise a sensible error if you provide a serial number for a fixture that DOES NOT require one.""" + deck_definition = load_deck_definition("ot3_standard", version=5) + cutout_fixtures = [ + subject.Placement( + cutout_fixture_id=cutout_fixture_id, + cutout_id=cutout_id, + opentrons_module_serial_number=opentrons_module_serial_number, + ) + for cutout_fixture_id, cutout_id, opentrons_module_serial_number in [ + # Invalid, single slot fixtures do not have serial numbers + ("singleLeftSlot", "cutoutA1", "ABC"), + ("singleLeftSlot", "cutoutB1", None), + ("singleLeftSlot", "cutoutC1", None), + ("singleLeftSlot", "cutoutD1", None), + ("singleCenterSlot", "cutoutA2", None), + ("singleCenterSlot", "cutoutB2", None), + ("singleCenterSlot", "cutoutC2", None), + ("singleCenterSlot", "cutoutD2", None), + ("singleRightSlot", "cutoutA3", None), + ("singleRightSlot", "cutoutB3", None), + ("singleRightSlot", "cutoutC3", None), + ("singleRightSlot", "cutoutD3", None), + ] + ] + assert subject.get_configuration_errors(deck_definition, cutout_fixtures) == { + subject.UnexpectedSerialNumberError( + cutout_fixture_id="singleLeftSlot", + cutout_id="cutoutA1", + opentrons_module_serial_number="ABC", + ) + } + + +# new test to raise error if not all members of a fixture group are loaded into the deck config +def test_missing_group_fixture() -> None: + """It should raise a sensible error if you fail to provide all members of a fixture group in a deck configuration.""" + deck_definition = load_deck_definition("ot3_standard", version=5) + cutout_fixtures = [ + subject.Placement( + cutout_fixture_id=cutout_fixture_id, + cutout_id=cutout_id, + opentrons_module_serial_number=opentrons_module_serial_number, + ) + for cutout_fixture_id, cutout_id, opentrons_module_serial_number in [ + ("thermocyclerModuleV2Rear", "cutoutA1", "ABC"), + # Invalid, because the Thermocycler V2 Rear fixture above requires a Front fixture be loaded as well + ("singleLeftSlot", "cutoutB1", None), + ("singleLeftSlot", "cutoutC1", None), + ("singleLeftSlot", "cutoutD1", None), + ("singleCenterSlot", "cutoutA2", None), + ("singleCenterSlot", "cutoutB2", None), + ("singleCenterSlot", "cutoutC2", None), + ("singleCenterSlot", "cutoutD2", None), + ("singleRightSlot", "cutoutA3", None), + ("singleRightSlot", "cutoutB3", None), + ("singleRightSlot", "cutoutC3", None), + ("singleRightSlot", "cutoutD3", None), + ] + ] + assert subject.get_configuration_errors(deck_definition, cutout_fixtures) == { + subject.MissingGroupFixtureError( + cutout_fixture_id="thermocyclerModuleV2Rear", + cutout_id="cutoutA1", + missing_fixture_id="thermocyclerModuleV2Front", + ) + } diff --git a/robot-server/tests/integration/http_api/commands/test_load_module_success.tavern.yaml b/robot-server/tests/integration/http_api/commands/test_load_module_success.tavern.yaml index 8e4e99528a7..c9cc22f8e0d 100644 --- a/robot-server/tests/integration/http_api/commands/test_load_module_success.tavern.yaml +++ b/robot-server/tests/integration/http_api/commands/test_load_module_success.tavern.yaml @@ -49,7 +49,7 @@ stages: params: model: '{model}' location: - slotName: '10' + slotName: '7' response: strict: - json:off diff --git a/robot-server/tests/integration/http_api/persistence/test_reset.py b/robot-server/tests/integration/http_api/persistence/test_reset.py index c9973713802..394671bba64 100644 --- a/robot-server/tests/integration/http_api/persistence/test_reset.py +++ b/robot-server/tests/integration/http_api/persistence/test_reset.py @@ -40,9 +40,9 @@ async def _assert_reset_was_successful( all_files_and_directories = set(persistence_directory.glob("**/*")) expected_files_and_directories = { persistence_directory / "robot_server.db", - persistence_directory / "3", - persistence_directory / "3" / "protocols", - persistence_directory / "3" / "robot_server.db", + persistence_directory / "4", + persistence_directory / "4" / "protocols", + persistence_directory / "4" / "robot_server.db", } assert all_files_and_directories == expected_files_and_directories diff --git a/robot-server/tests/integration/http_api/protocols/test_analyses.tavern.yaml b/robot-server/tests/integration/http_api/protocols/test_analyses.tavern.yaml index 3634989ed3f..0451b3eebc4 100644 --- a/robot-server/tests/integration/http_api/protocols/test_analyses.tavern.yaml +++ b/robot-server/tests/integration/http_api/protocols/test_analyses.tavern.yaml @@ -85,25 +85,21 @@ stages: Content-Type: application/json json: !force_format_include '{analysis_data}' - - name: Check that uploading the same protocol with run-time parameter values triggers re-analysis - # This test must be executed after the analysis of the previous upload is completed. + + - name: Check that a new analysis is started with forceReAnalyze request: - url: '{ot2_server_base_url}/protocols' + url: '{ot2_server_base_url}/protocols/{protocol_id}/analyses' method: POST - data: - runTimeParameterValues: '{{"volume": 123, "dry_run": true, "pipette": "p10_single"}}' - files: - files: 'tests/integration/protocols/basic_transfer_standalone.py' + json: + data: + forceReAnalyze: true response: strict: - json:off - status_code: 200 + status_code: 201 json: data: - id: '{protocol_id}' - analyses: [] - analysisSummaries: - - id: '{analysis_id}' - status: completed - - id: !anystr - status: pending + - id: '{analysis_id}' + status: completed + - id: !anystr + status: pending diff --git a/robot-server/tests/integration/http_api/protocols/test_analyses_with_run_time_parameters.tavern.yaml b/robot-server/tests/integration/http_api/protocols/test_analyses_with_run_time_parameters.tavern.yaml new file mode 100644 index 00000000000..fa37eadc20c --- /dev/null +++ b/robot-server/tests/integration/http_api/protocols/test_analyses_with_run_time_parameters.tavern.yaml @@ -0,0 +1,201 @@ +test_name: Test the protocol analysis endpoints with run time parameters + +marks: + - usefixtures: + - ot2_server_base_url + +stages: + - name: Upload a protocol + request: + url: '{ot2_server_base_url}/protocols' + method: POST + files: + files: 'tests/integration/protocols/basic_transfer_with_run_time_parameters.py' + response: + save: + json: + protocol_id: data.id + analysis_id: data.analysisSummaries[0].id + strict: + - json:off + status_code: 201 + json: + data: + analyses: [] + analysisSummaries: + - id: !anystr + status: pending + + - name: Check that the analysis summary is present in /protocols/:id; retry until it says it's completed + max_retries: 5 + delay_after: 1 + request: + url: '{ot2_server_base_url}/protocols/{protocol_id}' + response: + status_code: 200 + json: + data: + analyses: [] + analysisSummaries: + - id: '{analysis_id}' + status: completed + id: !anything + protocolType: !anything + files: !anything + createdAt: !anything + robotType: !anything + metadata: !anything + links: !anything + + - name: Check that the analysis data is present in /protocols/:id/analyses/:id + request: + url: '{ot2_server_base_url}/protocols/{protocol_id}/analyses/{analysis_id}' + response: + strict: + - json:off + json: + data: + id: '{analysis_id}' + runTimeParameters: + - displayName: Sample count + variableName: sample_count + type: int + default: 6.0 + min: 1.0 + max: 12.0 + value: 6.0 + description: How many samples to process. + - displayName: Pipette volume + variableName: volume + type: float + default: 20.1 + choices: + - displayName: Low Volume + value: 10.23 + - displayName: Medium Volume + value: 20.1 + - displayName: High Volume + value: 50.5 + value: 20.1 + description: How many microliters to pipette of each sample. + - displayName: Dry Run + variableName: dry_run + type: bool + default: false + value: false + description: Skip aspirate and dispense steps. + - displayName: Pipette Name + variableName: pipette + type: str + choices: + - displayName: Single channel 50µL + value: flex_1channel_50 + - displayName: Eight Channel 50µL + value: flex_8channel_50 + default: flex_1channel_50 + value: flex_1channel_50 + description: What pipette to use during the protocol. + commands: + # Check for this command's presence as a smoke test that the analysis isn't empty. + - commandType: loadPipette + + - name: Check that uploading same protocol with new run time parameter values re-triggers analysis + # This test must be executed after the analysis of the previous upload is completed. + request: + url: '{ot2_server_base_url}/protocols' + method: POST + data: + runTimeParameterValues: '{{"sample_count": 10, "volume": 10.23, "dry_run": true}}' + files: + files: 'tests/integration/protocols/basic_transfer_with_run_time_parameters.py' + response: + save: + json: + analysis_id2: data.analysisSummaries[1].id + strict: + - json:off + status_code: 200 + json: + data: + id: '{protocol_id}' + analyses: [ ] + analysisSummaries: + - id: '{analysis_id}' + status: completed + - id: !anystr + status: pending + + - name: Check that the new analysis uses run time parameter values from client; retry until analysis is completed + max_retries: 5 + delay_after: 1 + request: + url: '{ot2_server_base_url}/protocols/{protocol_id}/analyses/{analysis_id2}' + response: + strict: + - json:off + json: + data: + id: '{analysis_id2}' + runTimeParameters: + - displayName: Sample count + variableName: sample_count + type: int + default: 6.0 + min: 1.0 + max: 12.0 + value: 10.0 + description: How many samples to process. + - displayName: Pipette volume + variableName: volume + type: float + default: 20.1 + choices: + - displayName: Low Volume + value: 10.23 + - displayName: Medium Volume + value: 20.1 + - displayName: High Volume + value: 50.5 + value: 10.23 + description: How many microliters to pipette of each sample. + - displayName: Dry Run + variableName: dry_run + type: bool + default: false + value: true + description: Skip aspirate and dispense steps. + - displayName: Pipette Name + variableName: pipette + type: str + choices: + - displayName: Single channel 50µL + value: flex_1channel_50 + - displayName: Eight Channel 50µL + value: flex_8channel_50 + default: flex_1channel_50 + value: flex_1channel_50 + description: What pipette to use during the protocol. + commands: + # Check for this command's presence as a smoke test that the analysis isn't empty. + - commandType: loadPipette + + - name: Check that a new analysis is started for the protocol because of new RTP values + request: + url: '{ot2_server_base_url}/protocols/{protocol_id}/analyses' + method: POST + json: + data: + runTimeParameterValues: + sample_count: 2 + response: + strict: + - json:off + status_code: 201 + json: + data: + - id: '{analysis_id}' + status: completed + - id: '{analysis_id2}' + status: completed + - id: !anystr + status: pending \ No newline at end of file diff --git a/robot-server/tests/integration/http_api/protocols/test_key.tavern.yaml b/robot-server/tests/integration/http_api/protocols/test_key.tavern.yaml index 7d0f4361cb3..7729ee15fa5 100644 --- a/robot-server/tests/integration/http_api/protocols/test_key.tavern.yaml +++ b/robot-server/tests/integration/http_api/protocols/test_key.tavern.yaml @@ -169,6 +169,8 @@ stages: author: engineer@opentrons.com key: duplicate_key - name: Upload basic_transfer_standalone protocol with same key + # add a delay before starting to let previous analysis complete + delay_before: 2 request: url: '{ot2_server_base_url}/protocols' method: POST diff --git a/robot-server/tests/integration/http_api/protocols/test_persistence.py b/robot-server/tests/integration/http_api/protocols/test_persistence.py index a939f5f5fda..0480accb39c 100644 --- a/robot-server/tests/integration/http_api/protocols/test_persistence.py +++ b/robot-server/tests/integration/http_api/protocols/test_persistence.py @@ -120,10 +120,10 @@ async def test_protocol_labware_files_persist() -> None: assert restarted_protocol_detail == protocol_detail four_tuberack = Path( - f"{server.persistence_directory}/3/protocols/{protocol_id}/cpx_4_tuberack_100ul.json" + f"{server.persistence_directory}/4/protocols/{protocol_id}/cpx_4_tuberack_100ul.json" ) six_tuberack = Path( - f"{server.persistence_directory}/3/protocols/{protocol_id}/cpx_6_tuberack_100ul.json" + f"{server.persistence_directory}/4/protocols/{protocol_id}/cpx_6_tuberack_100ul.json" ) assert four_tuberack.is_file() assert six_tuberack.is_file() diff --git a/robot-server/tests/integration/http_api/protocols/test_v8_json_upload_flex.tavern.yaml b/robot-server/tests/integration/http_api/protocols/test_v8_json_upload_flex.tavern.yaml index 991d88df87f..af2fc892b86 100644 --- a/robot-server/tests/integration/http_api/protocols/test_v8_json_upload_flex.tavern.yaml +++ b/robot-server/tests/integration/http_api/protocols/test_v8_json_upload_flex.tavern.yaml @@ -103,7 +103,7 @@ stages: definitionUri: opentrons/armadillo_96_wellplate_200ul_pcr_full_skirt/1 displayName: Sample Collection Plate location: - moduleId: magneticModuleId + moduleId: magneticBlockId - id: tipRackId loadName: opentrons_96_tiprack_1000ul definitionUri: opentrons/opentrons_96_tiprack_1000ul/1 @@ -117,9 +117,8 @@ stages: location: slotName: 'A3' modules: - - id: magneticModuleId - serialNumber: !anystr - model: magneticModuleV2 + - id: magneticBlockId + model: magneticBlockV1 location: slotName: 'D3' - id: temperatureModuleId @@ -159,15 +158,14 @@ stages: key: !anystr status: succeeded params: - model: magneticModuleV2 + model: magneticBlockV1 location: slotName: 'D3' - moduleId: magneticModuleId + moduleId: magneticBlockId result: - moduleId: magneticModuleId + moduleId: magneticBlockId definition: !anydict - model: magneticModuleV2 - serialNumber: !anystr + model: magneticBlockV1 notes: [] startedAt: !anystr completedAt: !anystr @@ -215,7 +213,7 @@ stages: status: succeeded params: location: - moduleId: magneticModuleId + moduleId: magneticBlockId loadName: armadillo_96_wellplate_200ul_pcr_full_skirt namespace: opentrons version: 1 @@ -365,7 +363,7 @@ stages: volume: 4.5 flowRate: 2.5 result: - position: { 'x': 341.205, 'y': 65.115, 'z': 84.3 } + position: { 'x': 342.38, 'y': 65.24, 'z': 40.05 } volume: 4.5 notes: [] startedAt: !anystr @@ -388,7 +386,7 @@ stages: radius: 1.0 speed: 42.0 result: - position: { 'x': 341.205, 'y': 65.115, 'z': 94.3 } + position: { 'x': 342.38, 'y': 65.24, 'z': 50.05 } notes: [] startedAt: !anystr completedAt: !anystr @@ -409,7 +407,7 @@ stages: z: 12.0 flowRate: 2.0 result: - position: { 'x': 341.205, 'y': 65.115, 'z': 95.3 } + position: { 'x': 342.38, 'y': 65.24, 'z': 51.05 } notes: [] startedAt: !anystr completedAt: !anystr @@ -448,7 +446,8 @@ stages: forceDirect: false speed: 12.3 result: - position: { 'x': 350.205, 'y': 65.115, 'z': 98.25 } + position: + { 'x': 351.38, 'y': 65.24, 'z': 54.0 } notes: [] startedAt: !anystr completedAt: !anystr @@ -470,7 +469,8 @@ stages: minimumZHeight: 35.0 forceDirect: true result: - position: { 'x': 352.205, 'y': 68.115, 'z': 93.3 } + position: + { 'x': 353.38, 'y': 68.24, 'z': 49.05 } notes: [] startedAt: !anystr completedAt: !anystr diff --git a/robot-server/tests/integration/http_api/runs/test_deck_slot_standardization.py b/robot-server/tests/integration/http_api/runs/test_deck_slot_standardization.py index aa39376cafe..4f065b9a59c 100644 --- a/robot-server/tests/integration/http_api/runs/test_deck_slot_standardization.py +++ b/robot-server/tests/integration/http_api/runs/test_deck_slot_standardization.py @@ -4,6 +4,7 @@ from pytest_lazyfixture import lazy_fixture # type: ignore[import-untyped] from ...robot_client import RobotClient +from ...conftest import _OT3_SESSION_SERVER_PORT @pytest.fixture @@ -58,6 +59,29 @@ async def test_deck_slot_standardization( """ module_model = "temperatureModuleV2" + deck_configuration_request = [ + {"cutoutFixtureId": "singleLeftSlot", "cutoutId": "cutoutA1"}, + {"cutoutFixtureId": "singleLeftSlot", "cutoutId": "cutoutB1"}, + {"cutoutFixtureId": "singleLeftSlot", "cutoutId": "cutoutC1"}, + { + "cutoutFixtureId": "temperatureModuleV2", + "cutoutId": "cutoutD1", + "opentronsModuleSerialNumber": "temp-1234", + }, + {"cutoutFixtureId": "singleCenterSlot", "cutoutId": "cutoutA2"}, + {"cutoutFixtureId": "singleCenterSlot", "cutoutId": "cutoutB2"}, + {"cutoutFixtureId": "singleCenterSlot", "cutoutId": "cutoutC2"}, + {"cutoutFixtureId": "singleCenterSlot", "cutoutId": "cutoutD2"}, + {"cutoutFixtureId": "singleRightSlot", "cutoutId": "cutoutA3"}, + {"cutoutFixtureId": "singleRightSlot", "cutoutId": "cutoutB3"}, + {"cutoutFixtureId": "singleRightSlot", "cutoutId": "cutoutC3"}, + {"cutoutFixtureId": "singleRightSlot", "cutoutId": "cutoutD3"}, + ] + if _OT3_SESSION_SERVER_PORT in robot_client.base_url: + await robot_client.put_deck_configuration( + req_body={"data": {"cutoutFixtures": deck_configuration_request}} + ) + labware_load_name = "armadillo_96_wellplate_200ul_pcr_full_skirt" labware_namespace = "opentrons" labware_version = 1 diff --git a/robot-server/tests/integration/http_api/runs/test_json_v6_protocol_run.tavern.yaml b/robot-server/tests/integration/http_api/runs/test_json_v6_protocol_run.tavern.yaml index 1e7d7e20be4..e7ac3483dd7 100644 --- a/robot-server/tests/integration/http_api/runs/test_json_v6_protocol_run.tavern.yaml +++ b/robot-server/tests/integration/http_api/runs/test_json_v6_protocol_run.tavern.yaml @@ -50,6 +50,7 @@ stages: displayName: Water description: Liquid H2O displayColor: '#7332a8' + runTimeParameters: [] protocolId: '{protocol_id}' - name: Execute a setup command @@ -93,10 +94,10 @@ stages: commandId: '{setup_command_id}' key: '{setup_command_key}' createdAt: '{setup_command_created_at}' - index: 14 + index: 1 meta: cursor: 0 - totalLength: 15 + totalLength: 2 data: # Initial home - id: !anystr @@ -105,184 +106,6 @@ stages: createdAt: !anystr status: queued params: {} - - id: !anystr - key: !anystr - commandType: loadPipette - createdAt: !anystr - status: queued - params: - pipetteName: p10_single - mount: left - pipetteId: pipetteId - - id: !anystr - key: !anystr - commandType: loadModule - createdAt: !anystr - status: queued - params: - model: magneticModuleV1 - location: - slotName: '3' - moduleId: magneticModuleId - - id: !anystr - key: !anystr - commandType: loadModule - createdAt: !anystr - status: queued - params: - model: temperatureModuleV2 - location: - slotName: '1' - moduleId: temperatureModuleId - - id: !anystr - key: !anystr - commandType: loadLabware - createdAt: !anystr - status: queued - params: - location: - moduleId: temperatureModuleId - loadName: foo_8_plate_33ul - namespace: example - version: 1 - labwareId: sourcePlateId - displayName: Source Plate - - id: !anystr - key: !anystr - commandType: loadLabware - createdAt: !anystr - status: queued - params: - location: - moduleId: magneticModuleId - loadName: foo_8_plate_33ul - namespace: example - version: 1 - labwareId: destPlateId - displayName: Sample Collection Plate - - id: !anystr - key: !anystr - commandType: loadLabware - createdAt: !anystr - status: queued - params: - location: - slotName: '8' - loadName: opentrons_96_tiprack_10ul - namespace: opentrons - version: 1 - labwareId: tipRackId - displayName: Opentrons 96 Tip Rack 10 µL - - id: !anystr - createdAt: !anystr - commandType: loadLiquid - key: !anystr - status: queued - params: - liquidId: 'waterId' - labwareId: 'sourcePlateId' - volumeByWell: - A1: 100 - B1: 100 - - id: !anystr - key: !anystr - commandType: pickUpTip - createdAt: !anystr - status: queued - params: - pipetteId: pipetteId - labwareId: tipRackId - wellName: B1 - wellLocation: - origin: top - offset: - x: 0 - 'y': 0 - z: 0 - - id: !anystr - key: !anystr - commandType: aspirate - createdAt: !anystr - status: queued - params: - pipetteId: pipetteId - labwareId: sourcePlateId - wellName: A1 - wellLocation: - origin: bottom - offset: - x: 0 - 'y': 0 - z: 2 - volume: 5 - flowRate: 3 - - id: !anystr - key: !anystr - commandType: dispense - createdAt: !anystr - status: queued - params: - pipetteId: pipetteId - labwareId: destPlateId - wellName: B1 - wellLocation: - origin: bottom - offset: - x: 0 - 'y': 0 - z: 1 - volume: 4.5 - flowRate: 2.5 - - id: !anystr - key: !anystr - commandType: moveToWell - createdAt: !anystr - status: queued - params: - pipetteId: pipetteId - labwareId: destPlateId - wellName: B2 - wellLocation: - origin: top - offset: - x: 0 - 'y': 0 - z: 0 - forceDirect: false - - id: !anystr - key: !anystr - commandType: moveToWell - createdAt: !anystr - status: queued - params: - pipetteId: pipetteId - labwareId: destPlateId - wellName: B2 - wellLocation: - origin: bottom - offset: - x: 2 - y: 3 - z: 10 - minimumZHeight: 35 - forceDirect: true - speed: 12.3 - - id: !anystr - key: !anystr - commandType: dropTip - createdAt: !anystr - status: queued - params: - pipetteId: pipetteId - labwareId: fixedTrash - wellName: A1 - wellLocation: - origin: default - offset: - x: 0 - y: 0 - z: 0 - alternateDropLocation: false - id: '{setup_command_id}' key: '{setup_command_key}' intent: setup @@ -352,6 +175,16 @@ stages: params: {} startedAt: !anystr completedAt: !anystr + - id: '{setup_command_id}' + key: '{setup_command_key}' + intent: setup + commandType: home + createdAt: '{setup_command_created_at}' + startedAt: '{setup_command_started_at}' + completedAt: '{setup_command_completed_at}' + status: succeeded + params: { } + notes: [] - id: !anystr key: !anystr commandType: loadPipette @@ -569,16 +402,6 @@ stages: y: 0 z: 0 alternateDropLocation: false - - id: '{setup_command_id}' - key: '{setup_command_key}' - intent: setup - commandType: home - createdAt: '{setup_command_created_at}' - startedAt: '{setup_command_started_at}' - completedAt: '{setup_command_completed_at}' - status: succeeded - notes: [] - params: {} - name: Verify commands succeeded with pageLength and cursor request: @@ -610,12 +433,12 @@ stages: notes: [] params: location: - moduleId: magneticModuleId + moduleId: temperatureModuleId loadName: foo_8_plate_33ul namespace: example version: 1 - labwareId: destPlateId - displayName: Sample Collection Plate + labwareId: sourcePlateId + displayName: Source Plate - id: !anystr key: !anystr commandType: loadLabware @@ -626,9 +449,9 @@ stages: notes: [] params: location: - slotName: '8' - loadName: opentrons_96_tiprack_10ul - namespace: opentrons + moduleId: magneticModuleId + loadName: foo_8_plate_33ul + namespace: example version: 1 - labwareId: tipRackId - displayName: Opentrons 96 Tip Rack 10 µL + labwareId: destPlateId + displayName: Sample Collection Plate diff --git a/robot-server/tests/integration/http_api/runs/test_json_v6_run_failure.tavern.yaml b/robot-server/tests/integration/http_api/runs/test_json_v6_run_failure.tavern.yaml index 46eccbae280..80c7f1b2ef5 100644 --- a/robot-server/tests/integration/http_api/runs/test_json_v6_run_failure.tavern.yaml +++ b/robot-server/tests/integration/http_api/runs/test_json_v6_run_failure.tavern.yaml @@ -86,12 +86,12 @@ stages: meta: runId: !anystr commandId: !anystr - index: 4 + index: 3 key: !anystr createdAt: !anystr meta: cursor: 3 - totalLength: 5 + totalLength: 4 data: - id: !anystr key: !anystr @@ -120,20 +120,4 @@ stages: y: 0 z: 1 flowRate: 3.78 - volume: 100 - - id: !anystr - key: !anystr - commandType: pickUpTip - createdAt: !anystr - completedAt: !anystr - status: failed - params: - pipetteId: pipetteId - labwareId: tipRackId - wellName: A1 - wellLocation: - origin: top - offset: - x: 0 - y: 0 - z: 0 + volume: 100 \ No newline at end of file diff --git a/robot-server/tests/integration/http_api/runs/test_json_v7_protocol_run.tavern.yaml b/robot-server/tests/integration/http_api/runs/test_json_v7_protocol_run.tavern.yaml index 089b5f30c03..bdc4ad4a66d 100644 --- a/robot-server/tests/integration/http_api/runs/test_json_v7_protocol_run.tavern.yaml +++ b/robot-server/tests/integration/http_api/runs/test_json_v7_protocol_run.tavern.yaml @@ -45,6 +45,7 @@ stages: definitionUri: opentrons/opentrons_1_trash_1100ml_fixed/1 location: !anydict labwareOffsets: [] + runTimeParameters: [] liquids: - id: waterId displayName: Water @@ -93,10 +94,10 @@ stages: commandId: '{setup_command_id}' key: '{setup_command_key}' createdAt: '{setup_command_created_at}' - index: 14 + index: 1 meta: cursor: 0 - totalLength: 15 + totalLength: 2 data: # Initial home - id: !anystr @@ -104,185 +105,7 @@ stages: commandType: home createdAt: !anystr status: queued - params: {} - - id: !anystr - key: !anystr - commandType: loadPipette - createdAt: !anystr - status: queued - params: - pipetteName: p10_single - mount: left - pipetteId: pipetteId - - id: !anystr - key: !anystr - commandType: loadModule - createdAt: !anystr - status: queued - params: - model: magneticModuleV1 - location: - slotName: '3' - moduleId: magneticModuleId - - id: !anystr - key: !anystr - commandType: loadModule - createdAt: !anystr - status: queued - params: - model: temperatureModuleV2 - location: - slotName: '1' - moduleId: temperatureModuleId - - id: !anystr - key: !anystr - commandType: loadLabware - createdAt: !anystr - status: queued - params: - location: - moduleId: temperatureModuleId - loadName: foo_8_plate_33ul - namespace: example - version: 1 - labwareId: sourcePlateId - displayName: Source Plate - - id: !anystr - key: !anystr - commandType: loadLabware - createdAt: !anystr - status: queued - params: - location: - moduleId: magneticModuleId - loadName: foo_8_plate_33ul - namespace: example - version: 1 - labwareId: destPlateId - displayName: Sample Collection Plate - - id: !anystr - key: !anystr - commandType: loadLabware - createdAt: !anystr - status: queued - params: - location: - slotName: '8' - loadName: opentrons_96_tiprack_10ul - namespace: opentrons - version: 1 - labwareId: tipRackId - displayName: Opentrons 96 Tip Rack 10 µL - - id: !anystr - createdAt: !anystr - commandType: loadLiquid - key: !anystr - status: queued - params: - liquidId: 'waterId' - labwareId: 'sourcePlateId' - volumeByWell: - A1: 100 - B1: 100 - - id: !anystr - key: !anystr - commandType: pickUpTip - createdAt: !anystr - status: queued - params: - pipetteId: pipetteId - labwareId: tipRackId - wellName: B1 - wellLocation: - origin: top - offset: - x: 0 - 'y': 0 - z: 0 - - id: !anystr - key: !anystr - commandType: aspirate - createdAt: !anystr - status: queued - params: - pipetteId: pipetteId - labwareId: sourcePlateId - wellName: A1 - wellLocation: - origin: bottom - offset: - x: 0 - 'y': 0 - z: 2 - volume: 5 - flowRate: 3 - - id: !anystr - key: !anystr - commandType: dispense - createdAt: !anystr - status: queued - params: - pipetteId: pipetteId - labwareId: destPlateId - wellName: B1 - wellLocation: - origin: bottom - offset: - x: 0 - 'y': 0 - z: 1 - volume: 4.5 - flowRate: 2.5 - - id: !anystr - key: !anystr - commandType: moveToWell - createdAt: !anystr - status: queued - params: - pipetteId: pipetteId - labwareId: destPlateId - wellName: B2 - wellLocation: - origin: top - offset: - x: 0 - 'y': 0 - z: 0 - forceDirect: false - - id: !anystr - key: !anystr - commandType: moveToWell - createdAt: !anystr - status: queued - params: - pipetteId: pipetteId - labwareId: destPlateId - wellName: B2 - wellLocation: - origin: bottom - offset: - x: 2 - y: 3 - z: 10 - minimumZHeight: 35 - forceDirect: true - speed: 12.3 - - id: !anystr - key: !anystr - commandType: dropTip - createdAt: !anystr - status: queued - params: - pipetteId: pipetteId - labwareId: fixedTrash - wellName: A1 - wellLocation: - origin: default - offset: - x: 0 - y: 0 - z: 0 - alternateDropLocation: false + params: { } - id: '{setup_command_id}' key: '{setup_command_key}' intent: setup @@ -350,8 +173,18 @@ stages: startedAt: !anystr completedAt: !anystr status: succeeded + params: { } + notes: [ ] + - id: '{setup_command_id}' + key: '{setup_command_key}' + intent: setup + commandType: home + createdAt: '{setup_command_created_at}' + startedAt: '{setup_command_started_at}' + completedAt: '{setup_command_completed_at}' + status: succeeded + params: { } notes: [] - params: {} - id: !anystr key: !anystr commandType: loadPipette @@ -569,13 +402,3 @@ stages: y: 0 z: 0 alternateDropLocation: false - - id: '{setup_command_id}' - key: '{setup_command_key}' - intent: setup - commandType: home - createdAt: '{setup_command_created_at}' - startedAt: '{setup_command_started_at}' - completedAt: '{setup_command_completed_at}' - status: succeeded - notes: [] - params: {} diff --git a/robot-server/tests/integration/http_api/runs/test_play_stop_papi.tavern.yaml b/robot-server/tests/integration/http_api/runs/test_play_stop_papi.tavern.yaml new file mode 100644 index 00000000000..d59b533ca67 --- /dev/null +++ b/robot-server/tests/integration/http_api/runs/test_play_stop_papi.tavern.yaml @@ -0,0 +1,128 @@ +test_name: Test python protocol run commands are failed when stopped. + +marks: + - usefixtures: + - ot2_server_base_url +stages: + - name: Upload a python protocol + request: + url: '{ot2_server_base_url}/protocols' + method: POST + files: + files: 'tests/integration/protocols/wait_for_resume_stop_papi.py' + response: + status_code: 201 + save: + json: + protocol_id: data.id + + - name: Create run from protocol + request: + url: '{ot2_server_base_url}/runs' + method: POST + json: + data: + protocolId: '{protocol_id}' + response: + status_code: 201 + save: + json: + run_id: data.id + + - name: Play the run + request: + url: '{ot2_server_base_url}/runs/{run_id}/actions' + method: POST + json: + data: + actionType: play + response: + status_code: 201 + + - name: Wait for the command to run + max_retries: 10 + delay_after: 0.2 + request: + url: '{ot2_server_base_url}/runs/{run_id}/commands' + method: GET + response: + status_code: 200 + strict: + - json:off + json: + data: + - commandType: waitForDuration + status: running + + - name: Stop the run + request: + url: '{ot2_server_base_url}/runs/{run_id}/actions' + method: POST + json: + data: + actionType: stop + response: + status_code: 201 + + - name: Wait for the run to complete + max_retries: 10 + delay_after: 0.2 + request: + url: '{ot2_server_base_url}/runs/{run_id}' + method: GET + response: + status_code: 200 + strict: + - json:off + json: + data: + status: stopped + + - name: Get run commands + request: + url: '{ot2_server_base_url}/runs/{run_id}/commands' + method: GET + response: + status_code: 200 + strict: + - json:off + json: + data: + - id: !anystr + key: !anystr + commandType: home + createdAt: !anystr + startedAt: !anystr + completedAt: !anystr + status: succeeded + params: {} + notes: [] + - id: !anystr + key: !anystr + commandType: home + createdAt: !anystr + startedAt: !anystr + completedAt: !anystr + status: succeeded + params: { } + notes: [ ] + - id: !anystr + key: !anystr + commandType: waitForDuration + createdAt: !anystr + startedAt: !anystr + completedAt: !anystr + status: failed + params: + seconds: 30 + notes: [ ] + error: + createdAt: !anystr + detail: 'Run was cancelled' + errorCode: '4000' + errorInfo: { } + errorType: 'RunStoppedError' + id: !anystr + wrappedErrors: [ ] + + diff --git a/robot-server/tests/integration/http_api/runs/test_play_stop_v6.tavern.yaml b/robot-server/tests/integration/http_api/runs/test_play_stop_v6.tavern.yaml new file mode 100644 index 00000000000..e3d6d5b659f --- /dev/null +++ b/robot-server/tests/integration/http_api/runs/test_play_stop_v6.tavern.yaml @@ -0,0 +1,128 @@ +test_name: Test a JSONv6 run can be paused and then cancelled. + +marks: + - usefixtures: + - ot2_server_base_url +stages: + - name: Upload a JSONv6 protocol + request: + url: '{ot2_server_base_url}/protocols' + method: POST + files: + files: 'tests/integration/protocols/wait_for_resume_stop_v6.json' + response: + status_code: 201 + save: + json: + protocol_id: data.id + + - name: Create run from protocol + request: + url: '{ot2_server_base_url}/runs' + method: POST + json: + data: + protocolId: '{protocol_id}' + response: + status_code: 201 + save: + json: + run_id: data.id + + - name: Play the run + request: + url: '{ot2_server_base_url}/runs/{run_id}/actions' + method: POST + json: + data: + actionType: play + response: + status_code: 201 + + - name: Wait for the command to run + max_retries: 10 + delay_after: 0.2 + request: + url: '{ot2_server_base_url}/runs/{run_id}/commands' + method: GET + response: + status_code: 200 + strict: + - json:off + json: + data: + - commandType: waitForDuration + status: running + + - name: Stop the run + request: + url: '{ot2_server_base_url}/runs/{run_id}/actions' + method: POST + json: + data: + actionType: stop + response: + status_code: 201 + + - name: Wait for the run to complete + max_retries: 10 + delay_after: 0.2 + request: + url: '{ot2_server_base_url}/runs/{run_id}' + method: GET + response: + status_code: 200 + strict: + - json:off + json: + data: + status: stopped + + - name: Get run commands + request: + url: '{ot2_server_base_url}/runs/{run_id}/commands' + method: GET + response: + status_code: 200 + strict: + - json:off + json: + data: + - id: !anystr + key: !anystr + commandType: home + createdAt: !anystr + startedAt: !anystr + completedAt: !anystr + status: succeeded + params: {} + notes: [] + - id: !anystr + key: !anystr + commandType: home + createdAt: !anystr + startedAt: !anystr + completedAt: !anystr + status: succeeded + params: { } + notes: [ ] + - id: !anystr + key: !anystr + commandType: waitForDuration + createdAt: !anystr + startedAt: !anystr + completedAt: !anystr + status: failed + params: + seconds: 30 + notes: [ ] + error: + createdAt: !anystr + detail: 'Run was cancelled' + errorCode: '4000' + errorInfo: { } + errorType: 'RunStoppedError' + id: !anystr + wrappedErrors: [ ] + + diff --git a/robot-server/tests/integration/http_api/runs/test_protocol_run.tavern.yaml b/robot-server/tests/integration/http_api/runs/test_protocol_run.tavern.yaml index 48dc570d6c9..67d1511a666 100644 --- a/robot-server/tests/integration/http_api/runs/test_protocol_run.tavern.yaml +++ b/robot-server/tests/integration/http_api/runs/test_protocol_run.tavern.yaml @@ -42,6 +42,7 @@ stages: definitionUri: opentrons/opentrons_1_trash_1100ml_fixed/1 location: !anydict labwareOffsets: [] + runTimeParameters: [] protocolId: '{protocol_id}' liquids: [] save: @@ -237,6 +238,7 @@ stages: createdAt: !re_search "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d+\\+\\d{2}:\\d{2}$" startedAt: !re_search "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d+\\+\\d{2}:\\d{2}$" liquids: [] + runTimeParameters: [] completedAt: !re_search "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d+\\+\\d{2}:\\d{2}$" errors: [] pipettes: [] diff --git a/robot-server/tests/integration/http_api/runs/test_run_queued_protocol_commands.tavern.yaml b/robot-server/tests/integration/http_api/runs/test_run_queued_protocol_commands.tavern.yaml index cc8cea69356..0d4a0010281 100644 --- a/robot-server/tests/integration/http_api/runs/test_run_queued_protocol_commands.tavern.yaml +++ b/robot-server/tests/integration/http_api/runs/test_run_queued_protocol_commands.tavern.yaml @@ -94,6 +94,7 @@ stages: labware: [] labwareOffsets: [] liquids: [] + runTimeParameters: [] modules: [] pipettes: [] status: 'idle' diff --git a/robot-server/tests/integration/http_api/runs/test_run_with_run_time_parameters.tavern.yaml b/robot-server/tests/integration/http_api/runs/test_run_with_run_time_parameters.tavern.yaml new file mode 100644 index 00000000000..d7f075b18cb --- /dev/null +++ b/robot-server/tests/integration/http_api/runs/test_run_with_run_time_parameters.tavern.yaml @@ -0,0 +1,203 @@ +test_name: Test the run endpoints with run time parameters + +marks: + - usefixtures: + - ot2_server_base_url + +stages: + - name: Upload a protocol + request: + url: '{ot2_server_base_url}/protocols' + method: POST + files: + files: 'tests/integration/protocols/basic_transfer_with_run_time_parameters.py' + response: + status_code: 201 + save: + json: + protocol_id: data.id + + - name: Create run from protocol + request: + url: '{ot2_server_base_url}/runs' + method: POST + json: + data: + protocolId: '{protocol_id}' + runTimeParameterValues: + sample_count: 4 + volume: 10.23 + dry_run: True + pipette: flex_8channel_50 + response: + status_code: 201 + save: + json: + run_id: data.id + json: + data: + id: !anystr + ok: True + createdAt: !anystr + status: idle + current: True + actions: [] + errors: [] + pipettes: [] + modules: [] + labware: [] + labwareOffsets: [] + runTimeParameters: [] + liquids: [] + protocolId: '{protocol_id}' + + - name: Play the run + request: + url: '{ot2_server_base_url}/runs/{run_id}/actions' + method: POST + json: + data: + actionType: play + response: + status_code: 201 + json: + data: + id: !anystr + actionType: play + createdAt: !anystr + + - name: Wait for the protocol to complete + max_retries: 10 + delay_after: 0.1 + request: + url: '{ot2_server_base_url}/runs/{run_id}' + method: GET + response: + status_code: 200 + strict: + - json:off + json: + data: + status: succeeded + + - name: Verify the run contains the set run time parameters + request: + url: '{ot2_server_base_url}/runs/{run_id}' + method: GET + response: + status_code: 200 + strict: + - json:off + json: + data: + id: !anystr + ok: True + createdAt: !anystr + status: succeeded + current: True + runTimeParameters: + - displayName: Sample count + variableName: sample_count + type: int + default: 6.0 + min: 1.0 + max: 12.0 + value: 4.0 + description: How many samples to process. + - displayName: Pipette volume + variableName: volume + type: float + default: 20.1 + choices: + - displayName: Low Volume + value: 10.23 + - displayName: Medium Volume + value: 20.1 + - displayName: High Volume + value: 50.5 + value: 10.23 + description: How many microliters to pipette of each sample. + - displayName: Dry Run + variableName: dry_run + type: bool + default: false + value: true + description: Skip aspirate and dispense steps. + - displayName: Pipette Name + variableName: pipette + type: str + choices: + - displayName: Single channel 50µL + value: flex_1channel_50 + - displayName: Eight Channel 50µL + value: flex_8channel_50 + default: flex_1channel_50 + value: flex_8channel_50 + description: What pipette to use during the protocol. + protocolId: '{protocol_id}' + + - name: Mark the run as not-current + request: + url: '{ot2_server_base_url}/runs/{run_id}' + method: PATCH + json: + data: + current: False + response: + status_code: 200 + + - name: Verify the archived run still contains the set run time parameters + request: + url: '{ot2_server_base_url}/runs/{run_id}' + method: GET + response: + status_code: 200 + strict: + - json:off + json: + data: + id: !anystr + ok: True + createdAt: !anystr + status: succeeded + current: False + runTimeParameters: + - displayName: Sample count + variableName: sample_count + type: int + default: 6.0 + min: 1.0 + max: 12.0 + value: 4.0 + description: How many samples to process. + - displayName: Pipette volume + variableName: volume + type: float + default: 20.1 + choices: + - displayName: Low Volume + value: 10.23 + - displayName: Medium Volume + value: 20.1 + - displayName: High Volume + value: 50.5 + value: 10.23 + description: How many microliters to pipette of each sample. + - displayName: Dry Run + variableName: dry_run + type: bool + default: false + value: true + description: Skip aspirate and dispense steps. + - displayName: Pipette Name + variableName: pipette + type: str + choices: + - displayName: Single channel 50µL + value: flex_1channel_50 + - displayName: Eight Channel 50µL + value: flex_8channel_50 + default: flex_1channel_50 + value: flex_8channel_50 + description: What pipette to use during the protocol. + protocolId: '{protocol_id}' diff --git a/robot-server/tests/integration/protocols/basic_transfer_with_run_time_parameters.py b/robot-server/tests/integration/protocols/basic_transfer_with_run_time_parameters.py new file mode 100644 index 00000000000..7fe90c65d8c --- /dev/null +++ b/robot-server/tests/integration/protocols/basic_transfer_with_run_time_parameters.py @@ -0,0 +1,57 @@ +from opentrons.protocol_api import ProtocolContext, ParameterContext + +metadata = { + "apiLevel": "2.18", + "author": "engineer@opentrons.com", + "protocolName": "basic_transfer_standalone", +} + + +def add_parameters(parameters: ParameterContext): + parameters.add_int( + display_name="Sample count", + variable_name="sample_count", + default=6, + minimum=1, + maximum=12, + description="How many samples to process.", + ) + parameters.add_float( + display_name="Pipette volume", + variable_name="volume", + default=20.1, + choices=[ + {"display_name": "Low Volume", "value": 10.23}, + {"display_name": "Medium Volume", "value": 20.1}, + {"display_name": "High Volume", "value": 50.5}, + ], + description="How many microliters to pipette of each sample.", + unit="µL", # Unit is not wired up, and it doesn't raise errors either. + ) + parameters.add_bool( + display_name="Dry Run", + variable_name="dry_run", + default=False, + description="Skip aspirate and dispense steps.", + ) + parameters.add_str( + display_name="Pipette Name", + variable_name="pipette", + choices=[ + {"display_name": "Single channel 50µL", "value": "flex_1channel_50"}, + {"display_name": "Eight Channel 50µL", "value": "flex_8channel_50"}, + ], + default="flex_1channel_50", + description="What pipette to use during the protocol.", + ) + + +def run(protocol: ProtocolContext) -> None: + plate = protocol.load_labware("corning_96_wellplate_360ul_flat", 1) + tiprack_1 = protocol.load_labware("opentrons_96_tiprack_300ul", 2) + p300 = protocol.load_instrument("p300_single", "right", tip_racks=[tiprack_1]) + + p300.pick_up_tip() + p300.aspirate(100, plate["A1"]) + p300.dispense(100, plate["B1"]) + p300.return_tip() diff --git a/robot-server/tests/integration/protocols/wait_for_resume_stop_papi.py b/robot-server/tests/integration/protocols/wait_for_resume_stop_papi.py new file mode 100644 index 00000000000..227d65cd00b --- /dev/null +++ b/robot-server/tests/integration/protocols/wait_for_resume_stop_papi.py @@ -0,0 +1,13 @@ +from opentrons.protocol_api import ProtocolContext + +metadata = { + "protocolName": "stop while waiting test", + "author": "Opentrons ", + "apiLevel": "2.15", +} + + +def run(ctx: ProtocolContext) -> None: + ctx.home() + ctx.delay(seconds=30) + ctx.set_rail_lights(on=True) diff --git a/robot-server/tests/integration/protocols/wait_for_resume_stop_v6.json b/robot-server/tests/integration/protocols/wait_for_resume_stop_v6.json new file mode 100644 index 00000000000..05101595ee7 --- /dev/null +++ b/robot-server/tests/integration/protocols/wait_for_resume_stop_v6.json @@ -0,0 +1,37 @@ +{ + "$otSharedSchema": "#/protocol/schemas/6", + "schemaVersion": 6, + "metadata": { + "protocolName": "Simple test protocol", + "author": "engineering ", + "description": "A short test protocol", + "created": 1223131231, + "tags": ["unitTest"] + }, + "robot": { + "model": "OT-2 Standard", + "deckId": "ot2_standard" + }, + "pipettes": {}, + "modules": {}, + "labware": {}, + "liquids": {}, + "labwareDefinitions": {}, + "commands": [ + { + "commandType": "home", + "params": {} + }, + { + "commandType": "waitForDuration", + "params": { + "seconds": 30 + } + }, + { + "commandType": "home", + "params": {} + } + ], + "commandAnnotations": [] +} diff --git a/robot-server/tests/integration/robot_client.py b/robot-server/tests/integration/robot_client.py index 90869cdde92..c4511f8d315 100644 --- a/robot-server/tests/integration/robot_client.py +++ b/robot-server/tests/integration/robot_client.py @@ -312,6 +312,18 @@ async def delete_session(self, session_id: str) -> Response: response.raise_for_status() return response + async def put_deck_configuration( + self, + req_body: Dict[str, object], + ) -> Response: + """PUT /deck_configuration.""" + response = await self.httpx_client.put( + url=f"{self.base_url}/deck_configuration", + json=req_body, + ) + response.raise_for_status() + return response + async def poll_until_run_completes( robot_client: RobotClient, run_id: str, poll_interval: float = _RUN_POLL_INTERVAL diff --git a/robot-server/tests/maintenance_runs/router/test_base_router.py b/robot-server/tests/maintenance_runs/router/test_base_router.py index 4e2b8b399e5..2f61afcac48 100644 --- a/robot-server/tests/maintenance_runs/router/test_base_router.py +++ b/robot-server/tests/maintenance_runs/router/test_base_router.py @@ -36,6 +36,11 @@ from robot_server.deck_configuration.store import DeckConfigurationStore +def mock_notify_publishers() -> None: + """A mock notify_publishers.""" + return None + + @pytest.fixture def labware_offset_create() -> LabwareOffsetCreate: """Get a labware offset create request value object.""" @@ -79,6 +84,7 @@ async def test_create_run( created_at=run_created_at, labware_offsets=[labware_offset_create], deck_configuration=[], + notify_publishers=mock_notify_publishers, ) ).then_return(expected_response) @@ -91,6 +97,7 @@ async def test_create_run( created_at=run_created_at, is_ok_to_create_maintenance_run=True, deck_configuration_store=mock_deck_configuration_store, + notify_publishers=mock_notify_publishers, ) assert result.content.data == expected_response diff --git a/robot-server/tests/maintenance_runs/test_engine_store.py b/robot-server/tests/maintenance_runs/test_engine_store.py index d0a3ccfc1c8..948705572ce 100644 --- a/robot-server/tests/maintenance_runs/test_engine_store.py +++ b/robot-server/tests/maintenance_runs/test_engine_store.py @@ -6,6 +6,7 @@ from opentrons_shared_data.robot.dev_types import RobotType +from opentrons.protocol_engine.errors.exceptions import EStopActivatedError from opentrons.types import DeckSlotName from opentrons.hardware_control import API from opentrons.hardware_control.types import EstopStateNotification, EstopState @@ -20,12 +21,17 @@ MaintenanceEngineStore, EngineConflictError, NoRunnerEnginePairError, - get_estop_listener, + handle_estop_event, ) +def mock_notify_publishers() -> None: + """A mock notify_publishers.""" + return None + + @pytest.fixture -def subject(decoy: Decoy) -> MaintenanceEngineStore: +async def subject(decoy: Decoy) -> MaintenanceEngineStore: """Get a MaintenanceEngineStore test subject.""" # TODO(mc, 2021-06-11): to make these test more effective and valuable, we # should pass in some sort of actual, valid HardwareAPI instead of a mock @@ -42,7 +48,10 @@ def subject(decoy: Decoy) -> MaintenanceEngineStore: async def test_create_engine(subject: MaintenanceEngineStore) -> None: """It should create an engine for a run.""" result = await subject.create( - run_id="run-id", labware_offsets=[], created_at=datetime(2023, 1, 1) + run_id="run-id", + labware_offsets=[], + created_at=datetime(2023, 1, 1), + notify_publishers=mock_notify_publishers, ) assert subject.current_run_id == "run-id" @@ -67,7 +76,10 @@ async def test_create_engine_uses_robot_and_deck_type( ) await subject.create( - run_id="run-id", labware_offsets=[], created_at=datetime(2023, 4, 1) + run_id="run-id", + labware_offsets=[], + created_at=datetime(2023, 4, 1), + notify_publishers=mock_notify_publishers, ) assert subject.engine.state_view.config.robot_type == robot_type @@ -88,6 +100,7 @@ async def test_create_engine_with_labware_offsets( run_id="run-id", labware_offsets=[labware_offset], created_at=datetime(2023, 1, 1), + notify_publishers=mock_notify_publishers, ) assert result.labwareOffsets == [ @@ -104,7 +117,10 @@ async def test_create_engine_with_labware_offsets( async def test_clear_engine(subject: MaintenanceEngineStore) -> None: """It should clear a stored engine entry.""" await subject.create( - run_id="run-id", labware_offsets=[], created_at=datetime(2023, 5, 1) + run_id="run-id", + labware_offsets=[], + created_at=datetime(2023, 5, 1), + notify_publishers=mock_notify_publishers, ) await subject.runner.run(deck_configuration=[]) result = await subject.clear() @@ -124,7 +140,10 @@ async def test_clear_engine_not_stopped_or_idle( ) -> None: """It should raise a conflict if the engine is not stopped.""" await subject.create( - run_id="run-id", labware_offsets=[], created_at=datetime(2023, 6, 1) + run_id="run-id", + labware_offsets=[], + created_at=datetime(2023, 6, 1), + notify_publishers=mock_notify_publishers, ) subject.runner.play() @@ -135,7 +154,10 @@ async def test_clear_engine_not_stopped_or_idle( async def test_clear_idle_engine(subject: MaintenanceEngineStore) -> None: """It should successfully clear engine if idle (not started).""" await subject.create( - run_id="run-id", labware_offsets=[], created_at=datetime(2023, 7, 1) + run_id="run-id", + labware_offsets=[], + created_at=datetime(2023, 7, 1), + notify_publishers=mock_notify_publishers, ) assert subject.engine is not None assert subject.runner is not None @@ -155,22 +177,30 @@ async def test_estop_callback( """The callback should stop an active engine.""" engine_store = decoy.mock(cls=MaintenanceEngineStore) - subject = get_estop_listener(engine_store=engine_store) - - decoy.when(engine_store.current_run_id).then_return(None, "fake_run_id") - disengage_event = EstopStateNotification( old_state=EstopState.PHYSICALLY_ENGAGED, new_state=EstopState.LOGICALLY_ENGAGED ) - - subject(disengage_event) - engage_event = EstopStateNotification( old_state=EstopState.LOGICALLY_ENGAGED, new_state=EstopState.PHYSICALLY_ENGAGED ) - subject(engage_event) - - subject(engage_event) + decoy.when(engine_store.current_run_id).then_return(None) + await handle_estop_event(engine_store, disengage_event) + decoy.verify( + engine_store.engine.estop(), + ignore_extra_args=True, + times=0, + ) + decoy.verify( + await engine_store.engine.finish(), + ignore_extra_args=True, + times=0, + ) - decoy.verify(engine_store.engine.estop(maintenance_run=True), times=1) + decoy.when(engine_store.current_run_id).then_return("fake-run-id") + await handle_estop_event(engine_store, engage_event) + decoy.verify( + engine_store.engine.estop(), + await engine_store.engine.finish(error=matchers.IsA(EStopActivatedError)), + times=1, + ) diff --git a/robot-server/tests/maintenance_runs/test_run_data_manager.py b/robot-server/tests/maintenance_runs/test_run_data_manager.py index f0e63809d68..0046b3098db 100644 --- a/robot-server/tests/maintenance_runs/test_run_data_manager.py +++ b/robot-server/tests/maintenance_runs/test_run_data_manager.py @@ -35,6 +35,11 @@ from opentrons.protocol_engine import Liquid +def mock_notify_publishers() -> None: + """A mock notify_publishers.""" + return None + + @pytest.fixture def mock_maintenance_engine_store(decoy: Decoy) -> MaintenanceEngineStore: """Get a mock MaintenanceEngineStore.""" @@ -104,6 +109,7 @@ async def test_create( labware_offsets=[], created_at=created_at, deck_configuration=[], + notify_publishers=mock_notify_publishers, ) ).then_return(engine_state_summary) decoy.when(mock_maintenance_engine_store.current_run_created_at).then_return( @@ -114,6 +120,7 @@ async def test_create( created_at=created_at, labware_offsets=[], deck_configuration=[], + notify_publishers=mock_notify_publishers, ) assert result == MaintenanceRun( @@ -153,6 +160,7 @@ async def test_create_with_options( labware_offsets=[labware_offset], created_at=created_at, deck_configuration=[], + notify_publishers=mock_notify_publishers, ) ).then_return(engine_state_summary) decoy.when(mock_maintenance_engine_store.current_run_created_at).then_return( @@ -164,6 +172,7 @@ async def test_create_with_options( created_at=created_at, labware_offsets=[labware_offset], deck_configuration=[], + notify_publishers=mock_notify_publishers, ) assert result == MaintenanceRun( @@ -196,6 +205,7 @@ async def test_create_engine_error( labware_offsets=[], created_at=created_at, deck_configuration=[], + notify_publishers=mock_notify_publishers, ) ).then_raise(EngineConflictError("oh no")) decoy.when(mock_maintenance_engine_store.current_run_created_at).then_return( @@ -208,6 +218,7 @@ async def test_create_engine_error( created_at=created_at, labware_offsets=[], deck_configuration=[], + notify_publishers=mock_notify_publishers, ) diff --git a/robot-server/tests/persistence/test_tables.py b/robot-server/tests/persistence/test_tables.py index ca0bca5c2d5..5f3c45adcaa 100644 --- a/robot-server/tests/persistence/test_tables.py +++ b/robot-server/tests/persistence/test_tables.py @@ -10,6 +10,7 @@ metadata as latest_metadata, schema_3, schema_2, + schema_4, ) # The statements that we expect to emit when we create a fresh database. @@ -39,6 +40,7 @@ protocol_id VARCHAR NOT NULL, analyzer_version VARCHAR NOT NULL, completed_analysis VARCHAR NOT NULL, + run_time_parameter_values_and_defaults VARCHAR, PRIMARY KEY (id), FOREIGN KEY(protocol_id) REFERENCES protocol (id) ) @@ -54,6 +56,7 @@ state_summary VARCHAR, engine_status VARCHAR, _updated_at DATETIME, + run_time_parameters VARCHAR, PRIMARY KEY (id), FOREIGN KEY(protocol_id) REFERENCES protocol (id) ) @@ -87,8 +90,70 @@ """, ] +EXPECTED_STATEMENTS_V4 = EXPECTED_STATEMENTS_LATEST -EXPECTED_STATEMENTS_V3 = EXPECTED_STATEMENTS_LATEST +EXPECTED_STATEMENTS_V3 = [ + """ + CREATE TABLE protocol ( + id VARCHAR NOT NULL, + created_at DATETIME NOT NULL, + protocol_key VARCHAR, + PRIMARY KEY (id) + ) + """, + """ + CREATE TABLE analysis ( + id VARCHAR NOT NULL, + protocol_id VARCHAR NOT NULL, + analyzer_version VARCHAR NOT NULL, + completed_analysis VARCHAR NOT NULL, + PRIMARY KEY (id), + FOREIGN KEY(protocol_id) REFERENCES protocol (id) + ) + """, + """ + CREATE INDEX ix_analysis_protocol_id ON analysis (protocol_id) + """, + """ + CREATE TABLE run ( + id VARCHAR NOT NULL, + created_at DATETIME NOT NULL, + protocol_id VARCHAR, + state_summary VARCHAR, + engine_status VARCHAR, + _updated_at DATETIME, + PRIMARY KEY (id), + FOREIGN KEY(protocol_id) REFERENCES protocol (id) + ) + """, + """ + CREATE TABLE action ( + id VARCHAR NOT NULL, + created_at DATETIME NOT NULL, + action_type VARCHAR NOT NULL, + run_id VARCHAR NOT NULL, + PRIMARY KEY (id), + FOREIGN KEY(run_id) REFERENCES run (id) + ) + """, + """ + CREATE TABLE run_command ( + row_id INTEGER NOT NULL, + run_id VARCHAR NOT NULL, + index_in_run INTEGER NOT NULL, + command_id VARCHAR NOT NULL, + command VARCHAR NOT NULL, + PRIMARY KEY (row_id), + FOREIGN KEY(run_id) REFERENCES run (id) + ) + """, + """ + CREATE UNIQUE INDEX ix_run_run_id_command_id ON run_command (run_id, command_id) + """, + """ + CREATE UNIQUE INDEX ix_run_run_id_index_in_run ON run_command (run_id, index_in_run) + """, +] EXPECTED_STATEMENTS_V2 = [ @@ -165,6 +230,7 @@ def _normalize_statement(statement: str) -> str: ("metadata", "expected_statements"), [ (latest_metadata, EXPECTED_STATEMENTS_LATEST), + (schema_4.metadata, EXPECTED_STATEMENTS_V4), (schema_3.metadata, EXPECTED_STATEMENTS_V3), (schema_2.metadata, EXPECTED_STATEMENTS_V2), ], @@ -172,7 +238,7 @@ def _normalize_statement(statement: str) -> str: def test_creating_tables_emits_expected_statements( metadata: sqlalchemy.MetaData, expected_statements: List[str] ) -> None: - """Test that fresh databases are created with with the expected statements. + """Test that fresh databases are created with the expected statements. This is a snapshot test to help catch accidental changes to our SQL schema. diff --git a/robot-server/tests/protocols/test_analysis_store.py b/robot-server/tests/protocols/test_analysis_store.py index b9c2dcccdac..090cb680dfe 100644 --- a/robot-server/tests/protocols/test_analysis_store.py +++ b/robot-server/tests/protocols/test_analysis_store.py @@ -6,6 +6,8 @@ from typing import List, NamedTuple import pytest +from decoy import Decoy +from opentrons.protocol_engine.types import RunTimeParamValuesType from sqlalchemy.engine import Engine as SQLEngine @@ -28,10 +30,17 @@ AnalysisSummary, PendingAnalysis, CompletedAnalysis, + RunTimeParameterAnalysisData, ) from robot_server.protocols.analysis_store import ( AnalysisStore, AnalysisNotFoundError, + AnalysisIsPendingError, + _CURRENT_ANALYZER_VERSION, +) +from robot_server.protocols.completed_analysis_store import ( + CompletedAnalysisStore, + CompletedAnalysisResource, ) from robot_server.protocols.protocol_store import ( ProtocolStore, @@ -171,12 +180,20 @@ async def test_update_adds_details_and_completes_analysis( pipetteName=PipetteNameType.P300_SINGLE, mount=MountType.LEFT, ) - + run_time_param = pe_types.NumberParameter( + displayName="My parameter", + variableName="cool_param", + type="int", + min=1, + max=5, + value=2.0, + default=3.0, + ) subject.add_pending(protocol_id="protocol-id", analysis_id="analysis-id") await subject.update( analysis_id="analysis-id", robot_type="OT-2 Standard", - run_time_parameters=[], + run_time_parameters=[run_time_param], labware=[labware], pipettes=[pipette], # TODO(mm, 2022-10-21): Give the subject some commands, errors, and liquids here @@ -195,7 +212,7 @@ async def test_update_adds_details_and_completes_analysis( status=AnalysisStatus.COMPLETED, result=AnalysisResult.OK, robotType="OT-2 Standard", - runTimeParameters=[], + runTimeParameters=[run_time_param], labware=[labware], pipettes=[pipette], modules=[], @@ -209,7 +226,17 @@ async def test_update_adds_details_and_completes_analysis( "result": "ok", "status": "completed", "robotType": "OT-2 Standard", - "runTimeParameters": [], + "runTimeParameters": [ + { + "displayName": "My parameter", + "variableName": "cool_param", + "type": "int", + "min": 1, + "max": 5, + "value": 2.0, + "default": 3.0, + } + ], "labware": [ { "id": "labware-id", @@ -228,6 +255,76 @@ async def test_update_adds_details_and_completes_analysis( } +async def test_update_adds_rtp_values_and_defaults_to_completed_store( + decoy: Decoy, sql_engine: SQLEngine, protocol_store: ProtocolStore +) -> None: + """It should add RTP values and defaults to completed analysis store.""" + number_param = pe_types.NumberParameter( + displayName="My parameter", + variableName="cool_param", + type="int", + min=1, + max=5, + value=2.0, + default=3.0, + ) + string_param = pe_types.EnumParameter( + displayName="A choiced param", + variableName="cooler_param", + type="str", + choices=[ + pe_types.EnumChoice(displayName="FOOOO", value="foo"), + pe_types.EnumChoice(displayName="BARRR", value="bar"), + ], + value="baz", + default="blah", + ) + expected_completed_analysis_resource = CompletedAnalysisResource( + id="analysis-id", + protocol_id="protocol-id", + analyzer_version=_CURRENT_ANALYZER_VERSION, + completed_analysis=CompletedAnalysis( + id="analysis-id", + status=AnalysisStatus.COMPLETED, + result=AnalysisResult.OK, + robotType="OT-2 Standard", + runTimeParameters=[number_param, string_param], + labware=[], + pipettes=[], + modules=[], + commands=[], + errors=[], + liquids=[], + ), + run_time_parameter_values_and_defaults={ + "cool_param": RunTimeParameterAnalysisData(value=2.0, default=3.0), + "cooler_param": RunTimeParameterAnalysisData(value="baz", default="blah"), + }, + ) + + mock_completed_store = decoy.mock(cls=CompletedAnalysisStore) + subject = AnalysisStore(sql_engine=sql_engine, completed_store=mock_completed_store) + protocol_store.insert(make_dummy_protocol_resource(protocol_id="protocol-id")) + + subject.add_pending(protocol_id="protocol-id", analysis_id="analysis-id") + await subject.update( + analysis_id="analysis-id", + robot_type="OT-2 Standard", + run_time_parameters=[number_param, string_param], + labware=[], + pipettes=[], + modules=[], + commands=[], + errors=[], + liquids=[], + ) + decoy.verify( + await mock_completed_store.make_room_and_add( + completed_analysis_resource=expected_completed_analysis_resource + ) + ) + + class AnalysisResultSpec(NamedTuple): """Spec data for analysis result tests.""" @@ -291,3 +388,101 @@ async def test_update_infers_status_from_errors( analysis = (await subject.get_by_protocol("protocol-id"))[0] assert isinstance(analysis, CompletedAnalysis) assert analysis.result == expected_result + + +@pytest.mark.parametrize( + argnames=["rtp_values_from_client", "expected_match"], + argvalues=[ + ({"cool_param": 2.0, "cooler_param": "baz", "uncool_param": 5}, True), + ( + {"cool_param": 2, "cooler_param": "baz"}, + True, + ), + ( + {"cool_param": 2, "cooler_param": "buzzzzzzz"}, + False, + ), + ( + {"cool_param": 2.0, "cooler_param": "baz", "weird_param": 5}, + False, + ), + ({}, False), + ], +) +async def test_matching_rtp_values_in_analysis( + decoy: Decoy, + sql_engine: SQLEngine, + protocol_store: ProtocolStore, + rtp_values_from_client: RunTimeParamValuesType, + expected_match: bool, +) -> None: + """It should return whether the client's RTP values match with those in the last analysis of protocol.""" + mock_completed_store = decoy.mock(cls=CompletedAnalysisStore) + subject = AnalysisStore(sql_engine=sql_engine, completed_store=mock_completed_store) + protocol_store.insert(make_dummy_protocol_resource(protocol_id="protocol-id")) + + decoy.when( + await mock_completed_store.get_rtp_values_and_defaults_by_analysis_id( + "analysis-2" + ) + ).then_return( + { + "cool_param": RunTimeParameterAnalysisData(value=2.0, default=3.0), + "cooler_param": RunTimeParameterAnalysisData( + value="baz", default="very cool" + ), + "uncool_param": RunTimeParameterAnalysisData(value=5, default=5), + } + ) + assert ( + await subject.matching_rtp_values_in_analysis( + analysis_summary=AnalysisSummary( + id="analysis-2", status=AnalysisStatus.COMPLETED + ), + new_rtp_values=rtp_values_from_client, + ) + == expected_match + ) + + +async def test_matching_default_rtp_values_in_analysis_with_no_client_rtp_values( + decoy: Decoy, + sql_engine: SQLEngine, + protocol_store: ProtocolStore, +) -> None: + """It should return a match when client sends no RTP values and last analysis used all default values.""" + params_with_only_default_values = { + "cool_param": RunTimeParameterAnalysisData(value=2.0, default=2.0), + "cooler_param": RunTimeParameterAnalysisData( + value="very cool", default="very cool" + ), + "uncool_param": RunTimeParameterAnalysisData(value=True, default=True), + } + mock_completed_store = decoy.mock(cls=CompletedAnalysisStore) + subject = AnalysisStore(sql_engine=sql_engine, completed_store=mock_completed_store) + protocol_store.insert(make_dummy_protocol_resource(protocol_id="protocol-id")) + + decoy.when( + await mock_completed_store.get_rtp_values_and_defaults_by_analysis_id( + "analysis-2" + ) + ).then_return(params_with_only_default_values) + assert ( + await subject.matching_rtp_values_in_analysis( + analysis_summary=AnalysisSummary( + id="analysis-2", status=AnalysisStatus.COMPLETED + ), + new_rtp_values={}, + ) + is True + ) + + +async def test_matching_default_rtp_values_in_analysis_with_pending_analysis( + subject: AnalysisStore, protocol_store: ProtocolStore +) -> None: + """It should raise an error if analysis is pending.""" + with pytest.raises(AnalysisIsPendingError): + await subject.matching_rtp_values_in_analysis( + AnalysisSummary(id="analysis-id", status=AnalysisStatus.PENDING), {} + ) diff --git a/robot-server/tests/protocols/test_completed_analysis_store.py b/robot-server/tests/protocols/test_completed_analysis_store.py index 8339460cf66..1cac25fb4e1 100644 --- a/robot-server/tests/protocols/test_completed_analysis_store.py +++ b/robot-server/tests/protocols/test_completed_analysis_store.py @@ -2,11 +2,13 @@ import json from datetime import datetime, timezone from pathlib import Path +from typing import Optional, Dict, List import pytest from sqlalchemy.engine import Engine from decoy import Decoy +from robot_server.persistence.tables import analysis_table from robot_server.protocols.completed_analysis_store import ( CompletedAnalysisResource, CompletedAnalysisStore, @@ -20,6 +22,7 @@ CompletedAnalysis, AnalysisResult, AnalysisStatus, + RunTimeParameterAnalysisData, ) from robot_server.protocols.protocol_store import ( ProtocolStore, @@ -76,7 +79,9 @@ def make_dummy_protocol_resource(protocol_id: str) -> ProtocolResource: def _completed_analysis_resource( - analysis_id: str, protocol_id: str + analysis_id: str, + protocol_id: str, + rtp_values_and_defaults: Optional[Dict[str, RunTimeParameterAnalysisData]] = None, ) -> CompletedAnalysisResource: return CompletedAnalysisResource( analysis_id, @@ -93,6 +98,7 @@ def _completed_analysis_resource( errors=[], liquids=[], ), + run_time_parameter_values_and_defaults=rtp_values_and_defaults or {}, ) @@ -120,7 +126,7 @@ async def test_get_by_analysis_id_falls_back_to_sql( """It should return analyses from sql if they are not cached.""" resource = _completed_analysis_resource("analysis-id", "protocol-id") protocol_store.insert(make_dummy_protocol_resource("protocol-id")) - await subject.add(resource) + await subject.make_room_and_add(resource) # the analysis is not cached decoy.when(memcache.get("analysis-id")).then_raise(KeyError()) analysis_from_sql = await subject.get_by_id("analysis-id") @@ -137,7 +143,7 @@ async def test_get_by_analysis_id_stores_results_in_cache( """It should cache successful fetches from sql.""" resource = _completed_analysis_resource("analysis-id", "protocol-id") protocol_store.insert(make_dummy_protocol_resource("protocol-id")) - await subject.add(resource) + await subject.make_room_and_add(resource) # the analysis is not cached decoy.when(memcache.get("analysis-id")).then_raise(KeyError()) from_sql = await subject.get_by_id("analysis-id") @@ -152,7 +158,7 @@ async def test_get_by_analysis_id_as_document( """It should return the analysis serialized as a JSON string.""" resource = _completed_analysis_resource("analysis-id", "protocol-id") protocol_store.insert(make_dummy_protocol_resource("protocol-id")) - await subject.add(resource) + await subject.make_room_and_add(resource) result = await subject.get_by_id_as_document("analysis-id") assert result is not None assert json.loads(result) == { @@ -178,9 +184,9 @@ async def test_get_ids_by_protocol( resource_3 = _completed_analysis_resource("analysis-id-3", "protocol-id-2") protocol_store.insert(make_dummy_protocol_resource("protocol-id-1")) protocol_store.insert(make_dummy_protocol_resource("protocol-id-2")) - await subject.add(resource_1) - await subject.add(resource_2) - await subject.add(resource_3) + await subject.make_room_and_add(resource_1) + await subject.make_room_and_add(resource_2) + await subject.make_room_and_add(resource_3) assert subject.get_ids_by_protocol("protocol-id-1") == [ "analysis-id-1", "analysis-id-2", @@ -202,9 +208,9 @@ async def test_get_by_protocol( decoy.when(memcache.insert("analysis-id-1", resource_1)).then_return(None) decoy.when(memcache.insert("analysis-id-2", resource_2)).then_return(None) decoy.when(memcache.insert("analysis-id-3", resource_3)).then_return(None) - await subject.add(resource_1) - await subject.add(resource_2) - await subject.add(resource_3) + await subject.make_room_and_add(resource_1) + await subject.make_room_and_add(resource_2) + await subject.make_room_and_add(resource_3) decoy.when(memcache.get("analysis-id-1")).then_raise(KeyError()) decoy.when(memcache.get("analysis-id-2")).then_return(resource_2) decoy.when(memcache.contains("analysis-id-1")).then_return(False) @@ -212,3 +218,143 @@ async def test_get_by_protocol( decoy.when(memcache.insert("analysis-id-1", resource_1)).then_return(None) resources = await subject.get_by_protocol("protocol-id-1") assert resources == [resource_1, resource_2] + + +async def test_get_rtp_values_and_defaults_by_analysis_id_prefers_memcache( + subject: CompletedAnalysisStore, + memcache: MemoryCache[str, CompletedAnalysisResource], + protocol_store: ProtocolStore, + decoy: Decoy, +) -> None: + """It should return RTP values and defaults dict from memcache.""" + resource = _completed_analysis_resource( + analysis_id="analysis-id", + protocol_id="protocol-id", + rtp_values_and_defaults={ + "abc": RunTimeParameterAnalysisData(value=123, default=234) + }, + ) + protocol_store.insert(make_dummy_protocol_resource("protocol-id")) + # When we retrieve a resource via its id we should see it query the cache, and it should + # return the identity-same resource + decoy.when(memcache.get("analysis-id")).then_return(resource) + result = await subject.get_rtp_values_and_defaults_by_analysis_id("analysis-id") + assert result == resource.run_time_parameter_values_and_defaults + + +async def test_get_rtp_values_and_defaults_by_analysis_from_db( + subject: CompletedAnalysisStore, + memcache: MemoryCache[str, CompletedAnalysisResource], + protocol_store: ProtocolStore, + decoy: Decoy, +) -> None: + """It should fetch the RTP values and defaults dict from database if not present in cache.""" + resource = _completed_analysis_resource( + analysis_id="analysis-id", + protocol_id="protocol-id", + rtp_values_and_defaults={ + "xyz": RunTimeParameterAnalysisData(value=123, default=234) + }, + ) + protocol_store.insert(make_dummy_protocol_resource("protocol-id")) + await subject.make_room_and_add(resource) + # Not in memcache + decoy.when(memcache.get("analysis-id")).then_raise(KeyError()) + result = await subject.get_rtp_values_and_defaults_by_analysis_id("analysis-id") + assert result == resource.run_time_parameter_values_and_defaults + + +@pytest.mark.parametrize( + argnames=["existing_analysis_ids", "expected_analyses_ids_after_making_room"], + argvalues=[ + ( + [f"analysis-id-{num}" for num in range(8)], + [ + "analysis-id-4", + "analysis-id-5", + "analysis-id-6", + "analysis-id-7", + "new-analysis-id", + ], + ), + ( + [f"analysis-id-{num}" for num in range(5)], + [ + "analysis-id-1", + "analysis-id-2", + "analysis-id-3", + "analysis-id-4", + "new-analysis-id", + ], + ), + ( + [f"analysis-id-{num}" for num in range(4)], + [ + "analysis-id-0", + "analysis-id-1", + "analysis-id-2", + "analysis-id-3", + "new-analysis-id", + ], + ), + ( + [f"analysis-id-{num}" for num in range(3)], + [ + "analysis-id-0", + "analysis-id-1", + "analysis-id-2", + "new-analysis-id", + ], + ), + ( + [f"analysis-id-{num}" for num in range(2)], + ["analysis-id-0", "analysis-id-1", "new-analysis-id"], + ), + (["analysis-id-0"], ["analysis-id-0", "new-analysis-id"]), + ([], ["new-analysis-id"]), + ], +) +async def test_add_makes_room_for_new_analysis( + subject: CompletedAnalysisStore, + memcache: MemoryCache[str, CompletedAnalysisResource], + protocol_store: ProtocolStore, + existing_analysis_ids: List[str], + expected_analyses_ids_after_making_room: List[str], + decoy: Decoy, + sql_engine: Engine, +) -> None: + """It should delete old analyses and make room for new analysis.""" + protocol_store.insert(make_dummy_protocol_resource("protocol-id")) + + # Set up the database with existing analyses + resources = [ + _completed_analysis_resource( + analysis_id=analysis_id, + protocol_id="protocol-id", + ) + for analysis_id in existing_analysis_ids + ] + for resource in resources: + statement = analysis_table.insert().values(await resource.to_sql_values()) + with sql_engine.begin() as transaction: + transaction.execute(statement) + + assert subject.get_ids_by_protocol("protocol-id") == existing_analysis_ids + await subject.make_room_and_add( + _completed_analysis_resource( + analysis_id="new-analysis-id", + protocol_id="protocol-id", + ) + ) + assert ( + subject.get_ids_by_protocol("protocol-id") + == expected_analyses_ids_after_making_room + ) + + removed_ids = [ + analysis_id + for analysis_id in existing_analysis_ids + if analysis_id not in expected_analyses_ids_after_making_room + ] + for analysis_id in removed_ids: + decoy.verify(memcache.remove(analysis_id)) diff --git a/robot-server/tests/protocols/test_memcache.py b/robot-server/tests/protocols/test_memcache.py index ce485d8984f..80acb184f20 100644 --- a/robot-server/tests/protocols/test_memcache.py +++ b/robot-server/tests/protocols/test_memcache.py @@ -22,3 +22,19 @@ def test_cache_retains_new_values() -> None: for val in range(1, 4): assert subject.contains(f"key-{val}") assert subject.get(f"key-{val}") == f"value-{val}" + + +def test_cache_removes_values_by_key() -> None: + """It should eject values when asked for it.""" + subject = MemoryCache(3, str, str) + for val in range(3): + subject.insert(f"key-{val}", f"value-{val}") + subject.remove("key-1") + assert not subject.contains("key-1") + + # Make sure cache order is updated + assert subject.contains("key-0") and subject.contains("key-2") + subject.insert("key-4", "value-4") + assert subject.contains("key-0") + subject.insert("key-5", "value-5") + assert not subject.contains("key-0") diff --git a/robot-server/tests/protocols/test_protocol_store.py b/robot-server/tests/protocols/test_protocol_store.py index bd6655e4c10..d75212fd2fe 100644 --- a/robot-server/tests/protocols/test_protocol_store.py +++ b/robot-server/tests/protocols/test_protocol_store.py @@ -50,7 +50,7 @@ def mock_runs_publisher(decoy: Decoy) -> RunsPublisher: @pytest.fixture def run_store(sql_engine: SQLEngine, mock_runs_publisher: RunsPublisher) -> RunStore: """Get a RunStore linked to the same database as the subject ProtocolStore.""" - return RunStore(sql_engine=sql_engine, runs_publisher=mock_runs_publisher) + return RunStore(sql_engine=sql_engine) async def test_insert_and_get_protocol( diff --git a/robot-server/tests/protocols/test_protocols_router.py b/robot-server/tests/protocols/test_protocols_router.py index dbdad50c3bd..88605f81a3b 100644 --- a/robot-server/tests/protocols/test_protocols_router.py +++ b/robot-server/tests/protocols/test_protocols_router.py @@ -1,11 +1,13 @@ """Tests for the /protocols router.""" import io + import pytest from datetime import datetime from decoy import Decoy, matchers from fastapi import UploadFile from pathlib import Path +from opentrons.protocol_engine.types import RunTimeParamValuesType from opentrons.protocols.api_support.types import APIVersion from opentrons.protocol_reader import ( @@ -22,9 +24,13 @@ ) from robot_server.errors.error_responses import ApiError -from robot_server.service.json_api import SimpleEmptyBody, MultiBodyMeta +from robot_server.service.json_api import SimpleEmptyBody, MultiBodyMeta, RequestModel from robot_server.service.task_runner import TaskRunner -from robot_server.protocols.analysis_store import AnalysisStore, AnalysisNotFoundError +from robot_server.protocols.analysis_store import ( + AnalysisStore, + AnalysisNotFoundError, + AnalysisIsPendingError, +) from robot_server.protocols.protocol_analyzer import ProtocolAnalyzer from robot_server.protocols.protocol_auto_deleter import ProtocolAutoDeleter from robot_server.protocols.analysis_models import ( @@ -33,6 +39,7 @@ CompletedAnalysis, PendingAnalysis, AnalysisResult, + AnalysisRequest, ) from robot_server.protocols.protocol_models import ( @@ -51,6 +58,7 @@ from robot_server.protocols.router import ( ProtocolLinks, create_protocol, + create_protocol_analysis, get_protocols, get_protocol_ids, get_protocol_by_id, @@ -373,6 +381,11 @@ async def test_create_existing_protocol( decoy.when( analysis_store.get_summaries_by_protocol(protocol_id="the-og-proto-id") ).then_return([completed_analysis]) + decoy.when( + await analysis_store.matching_rtp_values_in_analysis( + analysis_summary=completed_analysis, new_rtp_values={} + ) + ).then_return(True) result = await create_protocol( files=[protocol_file], @@ -513,12 +526,12 @@ async def test_create_protocol( protocol_analyzer.analyze, analysis_id="analysis-id", protocol_resource=protocol_resource, - run_time_param_values=None, + run_time_param_values={}, ), ) -async def test_create_protocol_with_run_time_params( +async def test_create_new_protocol_with_run_time_params( decoy: Decoy, protocol_store: ProtocolStore, analysis_store: AnalysisStore, @@ -620,7 +633,240 @@ async def test_create_protocol_with_run_time_params( ) -async def test_create_existing_protocol_with_run_time_params( +async def test_create_existing_protocol_with_no_previous_analysis( + decoy: Decoy, + protocol_store: ProtocolStore, + analysis_store: AnalysisStore, + protocol_reader: ProtocolReader, + file_reader_writer: FileReaderWriter, + file_hasher: FileHasher, + protocol_analyzer: ProtocolAnalyzer, + task_runner: TaskRunner, + protocol_auto_deleter: ProtocolAutoDeleter, +) -> None: + """It should re-trigger analysis of the existing protocol resource.""" + protocol_directory = Path("/dev/null") + content = bytes("some_content", encoding="utf-8") + uploaded_file = io.BytesIO(content) + + protocol_file = UploadFile(filename="foo.json", file=uploaded_file) + buffered_file = BufferedFile(name="blah", contents=content, path=None) + + protocol_source = ProtocolSource( + directory=Path("/dev/null"), + main_file=Path("/dev/null/foo.json"), + files=[ + ProtocolSourceFile( + path=Path("/dev/null/foo.json"), + role=ProtocolFileRole.MAIN, + ) + ], + metadata={"this_is_fake_metadata": True}, + robot_type="OT-2 Standard", + config=JsonProtocolConfig(schema_version=123), + content_hash="a_b_c", + ) + + stored_protocol_resource = ProtocolResource( + protocol_id="protocol-id", + created_at=datetime(year=2020, month=1, day=1), + source=protocol_source, + protocol_key="dummy-key-222", + ) + pending_analysis = AnalysisSummary( + id="analysis-id", + status=AnalysisStatus.PENDING, + ) + decoy.when( + await file_reader_writer.read( + # TODO(mm, 2024-02-07): Recent FastAPI upgrades mean protocol_file.filename + # is typed as possibly None. Investigate whether that can actually happen in + # practice and whether we need to account for it. + files=[protocol_file] # type: ignore[list-item] + ) + ).then_return([buffered_file]) + + decoy.when(await file_hasher.hash(files=[buffered_file])).then_return("a_b_c") + decoy.when(protocol_store.get_id_by_hash("a_b_c")).then_return("the-og-proto-id") + decoy.when(protocol_store.get(protocol_id="the-og-proto-id")).then_return( + stored_protocol_resource + ) + decoy.when( + analysis_store.get_summaries_by_protocol(protocol_id="the-og-proto-id") + ).then_return([]) + decoy.when( + analysis_store.add_pending( + protocol_id="the-og-proto-id", analysis_id="analysis-id" + ) + ).then_return(pending_analysis) + + result = await create_protocol( + files=[protocol_file], + key="dummy-key-111", + run_time_parameter_values='{"vol": 123, "dry_run": true, "mount": "left"}', + protocol_directory=protocol_directory, + protocol_store=protocol_store, + analysis_store=analysis_store, + file_reader_writer=file_reader_writer, + protocol_reader=protocol_reader, + file_hasher=file_hasher, + protocol_analyzer=protocol_analyzer, + task_runner=task_runner, + protocol_auto_deleter=protocol_auto_deleter, + robot_type="OT-2 Standard", + protocol_id="protocol-id", + analysis_id="analysis-id", + created_at=datetime(year=2021, month=1, day=1), + ) + + assert result.content.data == Protocol( + id="the-og-proto-id", + createdAt=datetime(year=2020, month=1, day=1), + protocolType=ProtocolType.JSON, + metadata=Metadata(this_is_fake_metadata=True), # type: ignore[call-arg] + robotType="OT-2 Standard", + analysisSummaries=[pending_analysis], + files=[ProtocolFile(name="foo.json", role=ProtocolFileRole.MAIN)], + key="dummy-key-222", + ) + assert result.status_code == 200 + decoy.verify( + task_runner.run( + protocol_analyzer.analyze, + analysis_id="analysis-id", + protocol_resource=stored_protocol_resource, + run_time_param_values={"vol": 123, "dry_run": True, "mount": "left"}, + ), + analysis_store.add_pending( + protocol_id="the-og-proto-id", + analysis_id="analysis-id", + ), + ) + + +async def test_create_existing_protocol_with_different_run_time_params( + decoy: Decoy, + protocol_store: ProtocolStore, + analysis_store: AnalysisStore, + protocol_reader: ProtocolReader, + file_reader_writer: FileReaderWriter, + file_hasher: FileHasher, + protocol_analyzer: ProtocolAnalyzer, + task_runner: TaskRunner, + protocol_auto_deleter: ProtocolAutoDeleter, +) -> None: + """It should re-trigger analysis of the existing protocol resource.""" + protocol_directory = Path("/dev/null") + content = bytes("some_content", encoding="utf-8") + uploaded_file = io.BytesIO(content) + + protocol_file = UploadFile(filename="foo.json", file=uploaded_file) + buffered_file = BufferedFile(name="blah", contents=content, path=None) + + protocol_source = ProtocolSource( + directory=Path("/dev/null"), + main_file=Path("/dev/null/foo.json"), + files=[ + ProtocolSourceFile( + path=Path("/dev/null/foo.json"), + role=ProtocolFileRole.MAIN, + ) + ], + metadata={"this_is_fake_metadata": True}, + robot_type="OT-2 Standard", + config=JsonProtocolConfig(schema_version=123), + content_hash="a_b_c", + ) + + stored_protocol_resource = ProtocolResource( + protocol_id="protocol-id", + created_at=datetime(year=2020, month=1, day=1), + source=protocol_source, + protocol_key="dummy-key-222", + ) + + completed_summary = AnalysisSummary( + id="analysis-id", + status=AnalysisStatus.COMPLETED, + ) + + pending_summary = AnalysisSummary( + id="analysis-id", + status=AnalysisStatus.PENDING, + ) + decoy.when( + await file_reader_writer.read( + # TODO(mm, 2024-02-07): Recent FastAPI upgrades mean protocol_file.filename + # is typed as possibly None. Investigate whether that can actually happen in + # practice and whether we need to account for it. + files=[protocol_file] # type: ignore[list-item] + ) + ).then_return([buffered_file]) + + decoy.when(await file_hasher.hash(files=[buffered_file])).then_return("a_b_c") + decoy.when(protocol_store.get_id_by_hash("a_b_c")).then_return("the-og-proto-id") + decoy.when(protocol_store.get(protocol_id="the-og-proto-id")).then_return( + stored_protocol_resource + ) + decoy.when( + analysis_store.get_summaries_by_protocol(protocol_id="the-og-proto-id") + ).then_return([completed_summary]) + decoy.when( + await analysis_store.matching_rtp_values_in_analysis( + completed_summary, {"vol": 123, "dry_run": True, "mount": "left"} + ) + ).then_return(False) + decoy.when( + analysis_store.add_pending( + protocol_id="the-og-proto-id", analysis_id="analysis-id" + ) + ).then_return(pending_summary) + + result = await create_protocol( + files=[protocol_file], + key="dummy-key-111", + run_time_parameter_values='{"vol": 123, "dry_run": true, "mount": "left"}', + protocol_directory=protocol_directory, + protocol_store=protocol_store, + analysis_store=analysis_store, + file_reader_writer=file_reader_writer, + protocol_reader=protocol_reader, + file_hasher=file_hasher, + protocol_analyzer=protocol_analyzer, + task_runner=task_runner, + protocol_auto_deleter=protocol_auto_deleter, + robot_type="OT-2 Standard", + protocol_id="protocol-id", + analysis_id="analysis-id", + created_at=datetime(year=2021, month=1, day=1), + ) + + assert result.content.data == Protocol( + id="the-og-proto-id", + createdAt=datetime(year=2020, month=1, day=1), + protocolType=ProtocolType.JSON, + metadata=Metadata(this_is_fake_metadata=True), # type: ignore[call-arg] + robotType="OT-2 Standard", + analysisSummaries=[completed_summary, pending_summary], + files=[ProtocolFile(name="foo.json", role=ProtocolFileRole.MAIN)], + key="dummy-key-222", + ) + assert result.status_code == 200 + decoy.verify( + task_runner.run( + protocol_analyzer.analyze, + analysis_id="analysis-id", + protocol_resource=stored_protocol_resource, + run_time_param_values={"vol": 123, "dry_run": True, "mount": "left"}, + ), + analysis_store.add_pending( + protocol_id="the-og-proto-id", + analysis_id="analysis-id", + ), + ) + + +async def test_create_existing_protocol_with_same_run_time_params( decoy: Decoy, protocol_store: ProtocolStore, analysis_store: AnalysisStore, @@ -666,10 +912,6 @@ async def test_create_existing_protocol_with_run_time_params( id="analysis-id", status=AnalysisStatus.COMPLETED, ), - AnalysisSummary( - id="analysis-id", - status=AnalysisStatus.PENDING, - ), ] decoy.when( @@ -689,6 +931,11 @@ async def test_create_existing_protocol_with_run_time_params( decoy.when( analysis_store.get_summaries_by_protocol(protocol_id="the-og-proto-id") ).then_return(analysis_summaries) + decoy.when( + await analysis_store.matching_rtp_values_in_analysis( + analysis_summaries[-1], {"vol": 123, "dry_run": True, "mount": "left"} + ) + ).then_return(True) result = await create_protocol( files=[protocol_file], @@ -727,12 +974,111 @@ async def test_create_existing_protocol_with_run_time_params( protocol_resource=stored_protocol_resource, run_time_param_values={"vol": 123, "dry_run": True, "mount": "left"}, ), + times=0, + ) + decoy.verify( analysis_store.add_pending( protocol_id="the-og-proto-id", analysis_id="analysis-id", ), + times=0, + ) + + +async def test_create_existing_protocol_with_pending_analysis_raises( + decoy: Decoy, + protocol_store: ProtocolStore, + analysis_store: AnalysisStore, + protocol_reader: ProtocolReader, + file_reader_writer: FileReaderWriter, + file_hasher: FileHasher, + protocol_analyzer: ProtocolAnalyzer, + task_runner: TaskRunner, + protocol_auto_deleter: ProtocolAutoDeleter, +) -> None: + """It should raise an error if protocol has existing pending analysis.""" + protocol_directory = Path("/dev/null") + content = bytes("some_content", encoding="utf-8") + uploaded_file = io.BytesIO(content) + + protocol_file = UploadFile(filename="foo.json", file=uploaded_file) + buffered_file = BufferedFile(name="blah", contents=content, path=None) + + protocol_source = ProtocolSource( + directory=Path("/dev/null"), + main_file=Path("/dev/null/foo.json"), + files=[ + ProtocolSourceFile( + path=Path("/dev/null/foo.json"), + role=ProtocolFileRole.MAIN, + ) + ], + metadata={"this_is_fake_metadata": True}, + robot_type="OT-2 Standard", + config=JsonProtocolConfig(schema_version=123), + content_hash="a_b_c", + ) + + stored_protocol_resource = ProtocolResource( + protocol_id="protocol-id", + created_at=datetime(year=2020, month=1, day=1), + source=protocol_source, + protocol_key="dummy-key-222", ) + analysis_summaries = [ + AnalysisSummary( + id="analysis-id", + status=AnalysisStatus.PENDING, + ), + ] + + decoy.when( + await file_reader_writer.read( + # TODO(mm, 2024-02-07): Recent FastAPI upgrades mean protocol_file.filename + # is typed as possibly None. Investigate whether that can actually happen in + # practice and whether we need to account for it. + files=[protocol_file] # type: ignore[list-item] + ) + ).then_return([buffered_file]) + + decoy.when(await file_hasher.hash(files=[buffered_file])).then_return("a_b_c") + decoy.when(protocol_store.get_id_by_hash("a_b_c")).then_return("the-og-proto-id") + decoy.when(protocol_store.get(protocol_id="the-og-proto-id")).then_return( + stored_protocol_resource + ) + decoy.when( + analysis_store.get_summaries_by_protocol(protocol_id="the-og-proto-id") + ).then_return(analysis_summaries) + decoy.when( + await analysis_store.matching_rtp_values_in_analysis( + analysis_summaries[-1], {"vol": 123, "dry_run": True, "mount": "left"} + ) + ).then_raise(AnalysisIsPendingError("a-id")) + + with pytest.raises(ApiError) as exc_info: + await create_protocol( + files=[protocol_file], + key="dummy-key-111", + run_time_parameter_values='{"vol": 123, "dry_run": true, "mount": "left"}', + protocol_directory=protocol_directory, + protocol_store=protocol_store, + analysis_store=analysis_store, + file_reader_writer=file_reader_writer, + protocol_reader=protocol_reader, + file_hasher=file_hasher, + protocol_analyzer=protocol_analyzer, + task_runner=task_runner, + protocol_auto_deleter=protocol_auto_deleter, + robot_type="OT-2 Standard", + protocol_id="protocol-id", + analysis_id="analysis-id", + created_at=datetime(year=2021, month=1, day=1), + ) + + assert exc_info.value.status_code == 503 + assert exc_info.value.content["errors"][0]["id"] == "LastAnalysisPending" + async def test_create_protocol_not_readable( decoy: Decoy, @@ -1050,3 +1396,130 @@ async def test_get_protocol_analysis_as_document_analysis_not_found( assert exc_info.value.status_code == 404 assert exc_info.value.content["errors"][0]["id"] == "AnalysisNotFound" + + +async def test_create_protocol_analyses_with_same_rtp_values( + decoy: Decoy, + protocol_store: ProtocolStore, + analysis_store: AnalysisStore, + protocol_analyzer: ProtocolAnalyzer, + task_runner: TaskRunner, +) -> None: + """It should not start a new analysis for the new rtp values.""" + rtp_values: RunTimeParamValuesType = {"vol": 123, "dry_run": True, "mount": "left"} + analysis_summaries = [ + AnalysisSummary( + id="analysis-id", + status=AnalysisStatus.COMPLETED, + ), + ] + decoy.when(protocol_store.has(protocol_id="protocol-id")).then_return(True) + decoy.when( + analysis_store.get_summaries_by_protocol(protocol_id="protocol-id") + ).then_return(analysis_summaries) + decoy.when( + await analysis_store.matching_rtp_values_in_analysis( + analysis_summaries[-1], rtp_values + ) + ).then_return(True) + + result = await create_protocol_analysis( + protocolId="protocol-id", + request_body=RequestModel( + data=AnalysisRequest(runTimeParameterValues=rtp_values) + ), + protocol_store=protocol_store, + analysis_store=analysis_store, + protocol_analyzer=protocol_analyzer, + task_runner=task_runner, + analysis_id="analysis-id-2", + ) + assert result.content.data == analysis_summaries + assert result.status_code == 200 + + +async def test_update_protocol_analyses_with_new_rtp_values( + decoy: Decoy, + protocol_store: ProtocolStore, + analysis_store: AnalysisStore, + protocol_analyzer: ProtocolAnalyzer, + task_runner: TaskRunner, +) -> None: + """It should start a new analysis for the new rtp values.""" + rtp_values: RunTimeParamValuesType = {"vol": 123, "dry_run": True, "mount": "left"} + analysis_summaries = [ + AnalysisSummary( + id="analysis-id", + status=AnalysisStatus.COMPLETED, + ), + ] + decoy.when(protocol_store.has(protocol_id="protocol-id")).then_return(True) + decoy.when( + analysis_store.get_summaries_by_protocol(protocol_id="protocol-id") + ).then_return(analysis_summaries) + decoy.when( + await analysis_store.matching_rtp_values_in_analysis( + analysis_summaries[-1], rtp_values + ) + ).then_return(False) + decoy.when(analysis_store.add_pending("protocol-id", "analysis-id-2")).then_return( + AnalysisSummary(id="analysis-id-2", status=AnalysisStatus.PENDING) + ) + result = await create_protocol_analysis( + protocolId="protocol-id", + request_body=RequestModel( + data=AnalysisRequest(runTimeParameterValues=rtp_values) + ), + protocol_store=protocol_store, + analysis_store=analysis_store, + protocol_analyzer=protocol_analyzer, + task_runner=task_runner, + analysis_id="analysis-id-2", + ) + assert result.content.data == [ + AnalysisSummary(id="analysis-id", status=AnalysisStatus.COMPLETED), + AnalysisSummary(id="analysis-id-2", status=AnalysisStatus.PENDING), + ] + assert result.status_code == 201 + + +async def test_update_protocol_analyses_with_forced_reanalysis( + decoy: Decoy, + protocol_store: ProtocolStore, + analysis_store: AnalysisStore, + protocol_analyzer: ProtocolAnalyzer, + task_runner: TaskRunner, +) -> None: + """It should start a new analysis for the protocol, regardless of rtp values.""" + analysis_summaries = [ + AnalysisSummary( + id="analysis-id", + status=AnalysisStatus.COMPLETED, + ), + ] + decoy.when(protocol_store.has(protocol_id="protocol-id")).then_return(True) + decoy.when( + analysis_store.get_summaries_by_protocol(protocol_id="protocol-id") + ).then_return(analysis_summaries) + decoy.when( + await analysis_store.matching_rtp_values_in_analysis( + analysis_summary=analysis_summaries[-1], new_rtp_values={} + ) + ).then_return(True) + decoy.when(analysis_store.add_pending("protocol-id", "analysis-id-2")).then_return( + AnalysisSummary(id="analysis-id-2", status=AnalysisStatus.PENDING) + ) + result = await create_protocol_analysis( + protocolId="protocol-id", + request_body=RequestModel(data=AnalysisRequest(forceReAnalyze=True)), + protocol_store=protocol_store, + analysis_store=analysis_store, + protocol_analyzer=protocol_analyzer, + task_runner=task_runner, + analysis_id="analysis-id-2", + ) + assert result.content.data == [ + AnalysisSummary(id="analysis-id", status=AnalysisStatus.COMPLETED), + AnalysisSummary(id="analysis-id-2", status=AnalysisStatus.PENDING), + ] + assert result.status_code == 201 diff --git a/robot-server/tests/runs/router/test_base_router.py b/robot-server/tests/runs/router/test_base_router.py index 1fd754f224a..5763935cc39 100644 --- a/robot-server/tests/runs/router/test_base_router.py +++ b/robot-server/tests/runs/router/test_base_router.py @@ -42,6 +42,11 @@ from robot_server.deck_configuration.store import DeckConfigurationStore +def mock_notify_publishers() -> None: + """A mock notify_publishers.""" + return None + + @pytest.fixture def labware_offset_create() -> LabwareOffsetCreate: """Get a labware offset create request value object.""" @@ -87,6 +92,8 @@ async def test_create_run( labware_offsets=[labware_offset_create], deck_configuration=[], protocol=None, + run_time_param_values=None, + notify_publishers=mock_notify_publishers, ) ).then_return(expected_response) @@ -99,6 +106,7 @@ async def test_create_run( created_at=run_created_at, run_auto_deleter=mock_run_auto_deleter, deck_configuration_store=mock_deck_configuration_store, + notify_publishers=mock_notify_publishers, ) assert result.content.data == expected_response @@ -162,17 +170,24 @@ async def test_create_protocol_run( labware_offsets=[], deck_configuration=[], protocol=protocol_resource, + run_time_param_values={"foo": "bar"}, + notify_publishers=mock_notify_publishers, ) ).then_return(expected_response) result = await create_run( - request_body=RequestModel(data=RunCreate(protocolId="protocol-id")), + request_body=RequestModel( + data=RunCreate( + protocolId="protocol-id", runTimeParameterValues={"foo": "bar"} + ) + ), protocol_store=mock_protocol_store, run_data_manager=mock_run_data_manager, run_id=run_id, created_at=run_created_at, run_auto_deleter=mock_run_auto_deleter, deck_configuration_store=mock_deck_configuration_store, + notify_publishers=mock_notify_publishers, ) assert result.content.data == expected_response @@ -223,6 +238,8 @@ async def test_create_run_conflict( labware_offsets=[], deck_configuration=[], protocol=None, + run_time_param_values=None, + notify_publishers=mock_notify_publishers, ) ).then_raise(EngineConflictError("oh no")) @@ -234,6 +251,7 @@ async def test_create_run_conflict( run_data_manager=mock_run_data_manager, run_auto_deleter=mock_run_auto_deleter, deck_configuration_store=mock_deck_configuration_store, + notify_publishers=mock_notify_publishers, ) assert exc_info.value.status_code == 409 diff --git a/robot-server/tests/runs/router/test_commands_router.py b/robot-server/tests/runs/router/test_commands_router.py index fa5e47ada9a..93adb46fa53 100644 --- a/robot-server/tests/runs/router/test_commands_router.py +++ b/robot-server/tests/runs/router/test_commands_router.py @@ -114,10 +114,11 @@ def _stub_queued_command_state(*_a: object, **_k: object) -> pe_commands.Command decoy.when( mock_protocol_engine.add_command( - pe_commands.WaitForResumeCreate( + request=pe_commands.WaitForResumeCreate( params=pe_commands.WaitForResumeParams(message="Hello"), intent=pe_commands.CommandIntent.SETUP, - ) + ), + failed_command_id=None, ) ).then_do(_stub_queued_command_state) @@ -125,6 +126,7 @@ def _stub_queued_command_state(*_a: object, **_k: object) -> pe_commands.Command request_body=RequestModelWithCommandCreate(data=command_request), waitUntilComplete=False, protocol_engine=mock_protocol_engine, + failedCommandId=None, ) assert result.content.data == command_once_added @@ -132,6 +134,33 @@ def _stub_queued_command_state(*_a: object, **_k: object) -> pe_commands.Command decoy.verify(await mock_protocol_engine.wait_for_command("command-id"), times=0) +async def test_create_command_with_failed_command_raises( + decoy: Decoy, + mock_protocol_engine: ProtocolEngine, +) -> None: + """It should return 400 bad request.""" + command_create = pe_commands.HomeCreate(params=pe_commands.HomeParams()) + + decoy.when( + mock_protocol_engine.add_command( + pe_commands.HomeCreate( + params=pe_commands.HomeParams(), + intent=pe_commands.CommandIntent.SETUP, + ), + failed_command_id="123", + ) + ).then_raise(pe_errors.CommandNotAllowedError()) + + with pytest.raises(ApiError): + await create_run_command( + RequestModelWithCommandCreate(data=command_create), + waitUntilComplete=False, + timeout=42, + protocol_engine=mock_protocol_engine, + failedCommandId="123", + ) + + async def test_create_run_command_blocking_completion( decoy: Decoy, mock_protocol_engine: ProtocolEngine, @@ -171,7 +200,7 @@ def _stub_completed_command_state(*_a: object, **_k: object) -> None: mock_protocol_engine.state_view.commands.get("command-id") ).then_return(command_once_completed) - decoy.when(mock_protocol_engine.add_command(command_request)).then_do( + decoy.when(mock_protocol_engine.add_command(command_request, None)).then_do( _stub_queued_command_state ) @@ -184,6 +213,7 @@ def _stub_completed_command_state(*_a: object, **_k: object) -> None: waitUntilComplete=True, timeout=999, protocol_engine=mock_protocol_engine, + failedCommandId=None, ) assert result.content.data == command_once_completed @@ -200,7 +230,7 @@ async def test_add_conflicting_setup_command( intent=pe_commands.CommandIntent.SETUP, ) - decoy.when(mock_protocol_engine.add_command(command_request)).then_raise( + decoy.when(mock_protocol_engine.add_command(command_request, None)).then_raise( pe_errors.SetupCommandNotAllowedError("oh no") ) @@ -209,6 +239,7 @@ async def test_add_conflicting_setup_command( request_body=RequestModelWithCommandCreate(data=command_request), waitUntilComplete=False, protocol_engine=mock_protocol_engine, + failedCommandId=None, ) assert exc_info.value.status_code == 409 @@ -228,7 +259,7 @@ async def test_add_command_to_stopped_engine( intent=pe_commands.CommandIntent.SETUP, ) - decoy.when(mock_protocol_engine.add_command(command_request)).then_raise( + decoy.when(mock_protocol_engine.add_command(command_request, None)).then_raise( pe_errors.RunStoppedError("oh no") ) @@ -237,6 +268,7 @@ async def test_add_command_to_stopped_engine( request_body=RequestModelWithCommandCreate(data=command_request), waitUntilComplete=False, protocol_engine=mock_protocol_engine, + failedCommandId=None, ) assert exc_info.value.status_code == 409 diff --git a/robot-server/tests/runs/test_engine_store.py b/robot-server/tests/runs/test_engine_store.py index 1bf74632139..330e974be9c 100644 --- a/robot-server/tests/runs/test_engine_store.py +++ b/robot-server/tests/runs/test_engine_store.py @@ -1,12 +1,12 @@ """Tests for the EngineStore interface.""" from datetime import datetime -from pathlib import Path import pytest from decoy import Decoy, matchers from opentrons_shared_data import get_shared_data_root from opentrons_shared_data.robot.dev_types import RobotType +from opentrons.protocol_engine.errors.exceptions import EStopActivatedError from opentrons.types import DeckSlotName from opentrons.hardware_control import HardwareControlAPI, API from opentrons.hardware_control.types import EstopStateNotification, EstopState @@ -23,12 +23,17 @@ EngineStore, EngineConflictError, NoRunnerEnginePairError, - get_estop_listener, + handle_estop_event, ) +def mock_notify_publishers() -> None: + """A mock notify_publishers.""" + return None + + @pytest.fixture -def subject(decoy: Decoy, hardware_api: HardwareControlAPI) -> EngineStore: +async def subject(decoy: Decoy, hardware_api: HardwareControlAPI) -> EngineStore: """Get a EngineStore test subject.""" return EngineStore( hardware_api=hardware_api, @@ -40,7 +45,7 @@ def subject(decoy: Decoy, hardware_api: HardwareControlAPI) -> EngineStore: @pytest.fixture -async def json_protocol_source(tmp_path: Path) -> ProtocolSource: +async def json_protocol_source() -> ProtocolSource: """Get a protocol source fixture.""" simple_protocol = ( get_shared_data_root() / "protocol" / "fixtures" / "6" / "simpleV6.json" @@ -51,7 +56,11 @@ async def json_protocol_source(tmp_path: Path) -> ProtocolSource: async def test_create_engine(subject: EngineStore) -> None: """It should create an engine for a run.""" result = await subject.create( - run_id="run-id", labware_offsets=[], protocol=None, deck_configuration=[] + run_id="run-id", + labware_offsets=[], + protocol=None, + deck_configuration=[], + notify_publishers=mock_notify_publishers, ) assert subject.current_run_id == "run-id" @@ -61,7 +70,6 @@ async def test_create_engine(subject: EngineStore) -> None: async def test_create_engine_with_protocol( - decoy: Decoy, subject: EngineStore, json_protocol_source: ProtocolSource, ) -> None: @@ -82,6 +90,7 @@ async def test_create_engine_with_protocol( labware_offsets=[], deck_configuration=[], protocol=protocol, + notify_publishers=mock_notify_publishers, ) assert subject.current_run_id == "run-id" assert isinstance(result, StateSummary) @@ -103,7 +112,11 @@ async def test_create_engine_uses_robot_type( ) await subject.create( - run_id="run-id", labware_offsets=[], deck_configuration=[], protocol=None + run_id="run-id", + labware_offsets=[], + deck_configuration=[], + protocol=None, + notify_publishers=mock_notify_publishers, ) assert subject.engine.state_view.config.robot_type == robot_type @@ -122,6 +135,7 @@ async def test_create_engine_with_labware_offsets(subject: EngineStore) -> None: labware_offsets=[labware_offset], deck_configuration=[], protocol=None, + notify_publishers=mock_notify_publishers, ) assert result.labwareOffsets == [ @@ -138,12 +152,20 @@ async def test_create_engine_with_labware_offsets(subject: EngineStore) -> None: async def test_archives_state_if_engine_already_exists(subject: EngineStore) -> None: """It should not create more than one engine / runner pair.""" await subject.create( - run_id="run-id-1", labware_offsets=[], deck_configuration=[], protocol=None + run_id="run-id-1", + labware_offsets=[], + deck_configuration=[], + protocol=None, + notify_publishers=mock_notify_publishers, ) with pytest.raises(EngineConflictError): await subject.create( - run_id="run-id-2", labware_offsets=[], deck_configuration=[], protocol=None + run_id="run-id-2", + labware_offsets=[], + deck_configuration=[], + protocol=None, + notify_publishers=mock_notify_publishers, ) assert subject.current_run_id == "run-id-1" @@ -152,7 +174,11 @@ async def test_archives_state_if_engine_already_exists(subject: EngineStore) -> async def test_clear_engine(subject: EngineStore) -> None: """It should clear a stored engine entry.""" await subject.create( - run_id="run-id", labware_offsets=[], deck_configuration=[], protocol=None + run_id="run-id", + labware_offsets=[], + deck_configuration=[], + protocol=None, + notify_publishers=mock_notify_publishers, ) await subject.runner.run(deck_configuration=[]) result = await subject.clear() @@ -172,7 +198,11 @@ async def test_clear_engine_not_stopped_or_idle( ) -> None: """It should raise a conflict if the engine is not stopped.""" await subject.create( - run_id="run-id", labware_offsets=[], deck_configuration=[], protocol=None + run_id="run-id", + labware_offsets=[], + deck_configuration=[], + protocol=None, + notify_publishers=mock_notify_publishers, ) subject.runner.play(deck_configuration=[]) @@ -183,7 +213,11 @@ async def test_clear_engine_not_stopped_or_idle( async def test_clear_idle_engine(subject: EngineStore) -> None: """It should successfully clear engine if idle (not started).""" await subject.create( - run_id="run-id", labware_offsets=[], deck_configuration=[], protocol=None + run_id="run-id", + labware_offsets=[], + deck_configuration=[], + protocol=None, + notify_publishers=mock_notify_publishers, ) assert subject.engine is not None assert subject.runner is not None @@ -216,7 +250,9 @@ async def test_get_default_engine_robot_type( # should pass in some sort of actual, valid HardwareAPI instead of a mock hardware_api = decoy.mock(cls=API) subject = EngineStore( - hardware_api=hardware_api, robot_type=robot_type, deck_type=deck_type + hardware_api=hardware_api, + robot_type=robot_type, + deck_type=deck_type, ) result = await subject.get_default_engine() @@ -227,7 +263,11 @@ async def test_get_default_engine_robot_type( async def test_get_default_engine_current_unstarted(subject: EngineStore) -> None: """It should allow a default engine if another engine current but unstarted.""" await subject.create( - run_id="run-id", labware_offsets=[], deck_configuration=[], protocol=None + run_id="run-id", + labware_offsets=[], + deck_configuration=[], + protocol=None, + notify_publishers=mock_notify_publishers, ) result = await subject.get_default_engine() @@ -237,7 +277,11 @@ async def test_get_default_engine_current_unstarted(subject: EngineStore) -> Non async def test_get_default_engine_conflict(subject: EngineStore) -> None: """It should not allow a default engine if another engine is executing commands.""" await subject.create( - run_id="run-id", labware_offsets=[], deck_configuration=[], protocol=None + run_id="run-id", + labware_offsets=[], + deck_configuration=[], + protocol=None, + notify_publishers=mock_notify_publishers, ) subject.engine.play() @@ -248,7 +292,11 @@ async def test_get_default_engine_conflict(subject: EngineStore) -> None: async def test_get_default_engine_run_stopped(subject: EngineStore) -> None: """It allow a default engine if another engine is terminal.""" await subject.create( - run_id="run-id", labware_offsets=[], deck_configuration=[], protocol=None + run_id="run-id", + labware_offsets=[], + deck_configuration=[], + protocol=None, + notify_publishers=mock_notify_publishers, ) await subject.engine.finish() @@ -262,22 +310,30 @@ async def test_estop_callback( """The callback should stop an active engine.""" engine_store = decoy.mock(cls=EngineStore) - subject = get_estop_listener(engine_store=engine_store) - - decoy.when(engine_store.current_run_id).then_return(None, "fake_run_id") - disengage_event = EstopStateNotification( old_state=EstopState.PHYSICALLY_ENGAGED, new_state=EstopState.LOGICALLY_ENGAGED ) - - subject(disengage_event) - engage_event = EstopStateNotification( old_state=EstopState.LOGICALLY_ENGAGED, new_state=EstopState.PHYSICALLY_ENGAGED ) - subject(engage_event) - - subject(engage_event) + decoy.when(engine_store.current_run_id).then_return(None) + await handle_estop_event(engine_store, disengage_event) + decoy.verify( + engine_store.engine.estop(), + ignore_extra_args=True, + times=0, + ) + decoy.verify( + await engine_store.engine.finish(), + ignore_extra_args=True, + times=0, + ) - decoy.verify(engine_store.engine.estop(maintenance_run=False), times=1) + decoy.when(engine_store.current_run_id).then_return("fake-run-id") + await handle_estop_event(engine_store, engage_event) + decoy.verify( + engine_store.engine.estop(), + await engine_store.engine.finish(error=matchers.IsA(EStopActivatedError)), + times=1, + ) diff --git a/robot-server/tests/runs/test_run_controller.py b/robot-server/tests/runs/test_run_controller.py index 5bf5778c486..a844cdcc6d5 100644 --- a/robot-server/tests/runs/test_run_controller.py +++ b/robot-server/tests/runs/test_run_controller.py @@ -11,6 +11,7 @@ commands as pe_commands, errors as pe_errors, ) +from opentrons.protocol_engine.types import RunTimeParameter, BooleanParameter from opentrons.protocol_runner import RunResult, JsonRunner, PythonAndLegacyRunner from robot_server.service.task_runner import TaskRunner @@ -60,6 +61,19 @@ def engine_state_summary() -> StateSummary: ) +@pytest.fixture() +def run_time_parameters() -> List[RunTimeParameter]: + """Get a RunTimeParameter list.""" + return [ + BooleanParameter( + displayName="Display Name", + variableName="variable_name", + value=False, + default=True, + ) + ] + + @pytest.fixture def protocol_commands() -> List[pe_commands.Command]: """Get a StateSummary value object.""" @@ -122,6 +136,7 @@ async def test_create_play_action_to_start( mock_run_store: RunStore, mock_task_runner: TaskRunner, engine_state_summary: StateSummary, + run_time_parameters: List[RunTimeParameter], protocol_commands: List[pe_commands.Command], run_id: str, subject: RunController, @@ -153,7 +168,7 @@ async def test_create_play_action_to_start( RunResult( commands=protocol_commands, state_summary=engine_state_summary, - parameters=[], + parameters=run_time_parameters, ) ) @@ -164,6 +179,7 @@ async def test_create_play_action_to_start( run_id=run_id, summary=engine_state_summary, commands=protocol_commands, + run_time_parameters=run_time_parameters, ), times=1, ) diff --git a/robot-server/tests/runs/test_run_data_manager.py b/robot-server/tests/runs/test_run_data_manager.py index 92152eb3940..547ec0a7b74 100644 --- a/robot-server/tests/runs/test_run_data_manager.py +++ b/robot-server/tests/runs/test_run_data_manager.py @@ -1,5 +1,5 @@ """Tests for RunDataManager.""" -from typing import Optional +from typing import Optional, List import pytest from datetime import datetime @@ -40,6 +40,11 @@ from opentrons_shared_data.errors.exceptions import InvalidStoredData +def mock_notify_publishers() -> None: + """A mock notify_publishers.""" + return None + + @pytest.fixture def mock_engine_store(decoy: Decoy) -> EngineStore: """Get a mock EngineStore.""" @@ -80,6 +85,19 @@ def engine_state_summary() -> StateSummary: ) +@pytest.fixture() +def run_time_parameters() -> List[pe_types.RunTimeParameter]: + """Get a RunTimeParameter list.""" + return [ + pe_types.BooleanParameter( + displayName="Display Name", + variableName="variable_name", + value=False, + default=True, + ) + ] + + @pytest.fixture def run_resource() -> RunResource: """Get a StateSummary value object.""" @@ -138,6 +156,8 @@ async def test_create( labware_offsets=[], protocol=None, deck_configuration=[], + run_time_param_values=None, + notify_publishers=mock_notify_publishers, ) ).then_return(engine_state_summary) decoy.when( @@ -154,6 +174,8 @@ async def test_create( labware_offsets=[], protocol=None, deck_configuration=[], + run_time_param_values=None, + notify_publishers=mock_notify_publishers, ) assert result == Run( @@ -180,7 +202,7 @@ async def test_create_with_options( engine_state_summary: StateSummary, run_resource: RunResource, ) -> None: - """It should handle creation with a protocol and labware offsets.""" + """It should handle creation with a protocol, labware offsets and parameters.""" run_id = "hello world" created_at = datetime(year=2021, month=1, day=1) @@ -203,6 +225,8 @@ async def test_create_with_options( labware_offsets=[labware_offset], protocol=protocol, deck_configuration=[], + run_time_param_values={"foo": "bar"}, + notify_publishers=mock_notify_publishers, ) ).then_return(engine_state_summary) @@ -220,6 +244,8 @@ async def test_create_with_options( labware_offsets=[labware_offset], protocol=protocol, deck_configuration=[], + run_time_param_values={"foo": "bar"}, + notify_publishers=mock_notify_publishers, ) assert result == Run( @@ -254,6 +280,8 @@ async def test_create_engine_error( labware_offsets=[], protocol=None, deck_configuration=[], + run_time_param_values=None, + notify_publishers=mock_notify_publishers, ) ).then_raise(EngineConflictError("oh no")) @@ -264,6 +292,8 @@ async def test_create_engine_error( labware_offsets=[], protocol=None, deck_configuration=[], + run_time_param_values=None, + notify_publishers=mock_notify_publishers, ) decoy.verify( @@ -282,6 +312,7 @@ async def test_get_current_run( mock_run_store: RunStore, subject: RunDataManager, engine_state_summary: StateSummary, + run_time_parameters: List[pe_types.RunTimeParameter], run_resource: RunResource, ) -> None: """It should get the current run from the engine.""" @@ -292,6 +323,9 @@ async def test_get_current_run( decoy.when(mock_engine_store.engine.state_view.get_summary()).then_return( engine_state_summary ) + decoy.when(mock_engine_store.runner.run_time_parameters).then_return( + run_time_parameters + ) result = subject.get(run_id=run_id) @@ -308,6 +342,7 @@ async def test_get_current_run( pipettes=engine_state_summary.pipettes, modules=engine_state_summary.modules, liquids=engine_state_summary.liquids, + runTimeParameters=run_time_parameters, ) assert subject.current_run_id == run_id @@ -318,6 +353,7 @@ async def test_get_historical_run( mock_run_store: RunStore, subject: RunDataManager, engine_state_summary: StateSummary, + run_time_parameters: List[pe_types.RunTimeParameter], run_resource: RunResource, ) -> None: """It should get a historical run from the store.""" @@ -327,6 +363,9 @@ async def test_get_historical_run( decoy.when(mock_run_store.get_state_summary(run_id=run_id)).then_return( engine_state_summary ) + decoy.when(mock_run_store.get_run_time_parameters(run_id=run_id)).then_return( + run_time_parameters + ) decoy.when(mock_engine_store.current_run_id).then_return("some other id") result = subject.get(run_id=run_id) @@ -344,6 +383,7 @@ async def test_get_historical_run( pipettes=engine_state_summary.pipettes, modules=engine_state_summary.modules, liquids=engine_state_summary.liquids, + runTimeParameters=run_time_parameters, ) @@ -353,6 +393,7 @@ async def test_get_historical_run_no_data( mock_run_store: RunStore, subject: RunDataManager, run_resource: RunResource, + run_time_parameters: List[pe_types.RunTimeParameter], ) -> None: """It should get a historical run from the store.""" run_id = "hello world" @@ -363,6 +404,9 @@ async def test_get_historical_run_no_data( decoy.when(mock_run_store.get_state_summary(run_id=run_id)).then_return( BadStateSummary(dataError=state_exc) ) + decoy.when(mock_run_store.get_run_time_parameters(run_id=run_id)).then_return( + run_time_parameters + ) decoy.when(mock_engine_store.current_run_id).then_return("some other id") result = subject.get(run_id=run_id) @@ -381,6 +425,7 @@ async def test_get_historical_run_no_data( pipettes=[], modules=[], liquids=[], + runTimeParameters=run_time_parameters, ) @@ -400,6 +445,14 @@ async def test_get_all_runs( modules=[LoadedModule.construct(id="current-module-id")], # type: ignore[call-arg] liquids=[Liquid(id="some-liquid-id", displayName="liquid", description="desc")], ) + current_run_time_parameters: List[pe_types.RunTimeParameter] = [ + pe_types.BooleanParameter( + displayName="Current Bool", + variableName="current bool", + value=False, + default=True, + ) + ] historical_run_data = StateSummary( status=EngineStatus.STOPPED, @@ -410,6 +463,14 @@ async def test_get_all_runs( modules=[LoadedModule.construct(id="old-module-id")], # type: ignore[call-arg] liquids=[], ) + historical_run_time_parameters: List[pe_types.RunTimeParameter] = [ + pe_types.BooleanParameter( + displayName="Old Bool", + variableName="Old bool", + value=True, + default=False, + ) + ] current_run_resource = RunResource( ok=True, @@ -431,9 +492,15 @@ async def test_get_all_runs( decoy.when(mock_engine_store.engine.state_view.get_summary()).then_return( current_run_data ) + decoy.when(mock_engine_store.runner.run_time_parameters).then_return( + current_run_time_parameters + ) decoy.when(mock_run_store.get_state_summary("historical-run")).then_return( historical_run_data ) + decoy.when(mock_run_store.get_run_time_parameters("historical-run")).then_return( + historical_run_time_parameters + ) decoy.when(mock_run_store.get_all(length=20)).then_return( [historical_run_resource, current_run_resource] ) @@ -454,6 +521,7 @@ async def test_get_all_runs( pipettes=historical_run_data.pipettes, modules=historical_run_data.modules, liquids=historical_run_data.liquids, + runTimeParameters=historical_run_time_parameters, ), Run( current=True, @@ -468,6 +536,7 @@ async def test_get_all_runs( pipettes=current_run_data.pipettes, modules=current_run_data.modules, liquids=current_run_data.liquids, + runTimeParameters=current_run_time_parameters, ), ] @@ -509,6 +578,7 @@ async def test_delete_historical_run( async def test_update_current( decoy: Decoy, engine_state_summary: StateSummary, + run_time_parameters: List[pe_types.RunTimeParameter], run_resource: RunResource, run_command: commands.Command, mock_engine_store: EngineStore, @@ -520,7 +590,9 @@ async def test_update_current( decoy.when(mock_engine_store.current_run_id).then_return(run_id) decoy.when(await mock_engine_store.clear()).then_return( RunResult( - commands=[run_command], state_summary=engine_state_summary, parameters=[] + commands=[run_command], + state_summary=engine_state_summary, + parameters=run_time_parameters, ) ) @@ -529,6 +601,7 @@ async def test_update_current( run_id=run_id, summary=engine_state_summary, commands=[run_command], + run_time_parameters=run_time_parameters, ) ).then_return(run_resource) @@ -547,6 +620,7 @@ async def test_update_current( pipettes=engine_state_summary.pipettes, modules=engine_state_summary.modules, liquids=engine_state_summary.liquids, + runTimeParameters=run_time_parameters, ) @@ -554,6 +628,7 @@ async def test_update_current( async def test_update_current_noop( decoy: Decoy, engine_state_summary: StateSummary, + run_time_parameters: List[pe_types.RunTimeParameter], run_resource: RunResource, run_command: commands.Command, mock_engine_store: EngineStore, @@ -567,6 +642,9 @@ async def test_update_current_noop( decoy.when(mock_engine_store.engine.state_view.get_summary()).then_return( engine_state_summary ) + decoy.when(mock_engine_store.runner.run_time_parameters).then_return( + run_time_parameters + ) decoy.when(mock_run_store.get(run_id=run_id)).then_return(run_resource) result = await subject.update(run_id=run_id, current=current) @@ -577,6 +655,7 @@ async def test_update_current_noop( run_id=run_id, summary=matchers.Anything(), commands=matchers.Anything(), + run_time_parameters=matchers.Anything(), ), times=0, ) @@ -594,6 +673,7 @@ async def test_update_current_noop( pipettes=engine_state_summary.pipettes, modules=engine_state_summary.modules, liquids=engine_state_summary.liquids, + runTimeParameters=run_time_parameters, ) @@ -617,6 +697,7 @@ async def test_update_current_not_allowed( async def test_create_archives_existing( decoy: Decoy, engine_state_summary: StateSummary, + run_time_parameters: List[pe_types.RunTimeParameter], run_resource: RunResource, run_command: commands.Command, mock_engine_store: EngineStore, @@ -630,7 +711,9 @@ async def test_create_archives_existing( decoy.when(mock_engine_store.current_run_id).then_return(run_id_old) decoy.when(await mock_engine_store.clear()).then_return( RunResult( - commands=[run_command], state_summary=engine_state_summary, parameters=[] + commands=[run_command], + state_summary=engine_state_summary, + parameters=run_time_parameters, ) ) @@ -640,6 +723,8 @@ async def test_create_archives_existing( labware_offsets=[], protocol=None, deck_configuration=[], + run_time_param_values=None, + notify_publishers=mock_notify_publishers, ) ).then_return(engine_state_summary) @@ -657,6 +742,8 @@ async def test_create_archives_existing( labware_offsets=[], protocol=None, deck_configuration=[], + run_time_param_values=None, + notify_publishers=mock_notify_publishers, ) decoy.verify( @@ -664,6 +751,7 @@ async def test_create_archives_existing( run_id=run_id_old, summary=engine_state_summary, commands=[run_command], + run_time_parameters=run_time_parameters, ) ) diff --git a/robot-server/tests/runs/test_run_store.py b/robot-server/tests/runs/test_run_store.py index bb089d4b40a..c6108cf5407 100644 --- a/robot-server/tests/runs/test_run_store.py +++ b/robot-server/tests/runs/test_run_store.py @@ -47,7 +47,6 @@ def subject( """Get a ProtocolStore test subject.""" return RunStore( sql_engine=sql_engine, - runs_publisher=mock_runs_publisher, ) @@ -121,6 +120,41 @@ def state_summary() -> StateSummary: ) +@pytest.fixture() +def run_time_parameters() -> List[pe_types.RunTimeParameter]: + """Get a RunTimeParameter list.""" + return [ + pe_types.BooleanParameter( + displayName="Display Name 1", + variableName="variable_name_1", + value=False, + default=True, + ), + pe_types.NumberParameter( + displayName="Display Name 2", + variableName="variable_name_2", + type="int", + min=123.0, + max=456.0, + value=333.0, + default=222.0, + ), + pe_types.EnumParameter( + displayName="Display Name 3", + variableName="variable_name_3", + type="str", + choices=[ + pe_types.EnumChoice( + displayName="Choice Name", + value="cool choice", + ) + ], + default="cooler choice", + value="coolest choice", + ), + ] + + @pytest.fixture def invalid_state_summary() -> StateSummary: """Should fail pydantic validation.""" @@ -165,6 +199,7 @@ def test_update_run_state( subject: RunStore, state_summary: StateSummary, protocol_commands: List[pe_commands.Command], + run_time_parameters: List[pe_types.RunTimeParameter], mock_runs_publisher: mock.Mock, ) -> None: """It should be able to update a run state to the store.""" @@ -185,8 +220,10 @@ def test_update_run_state( run_id="run-id", summary=state_summary, commands=protocol_commands, + run_time_parameters=run_time_parameters, ) run_summary_result = subject.get_state_summary(run_id="run-id") + parameters_result = subject.get_run_time_parameters(run_id="run-id") commands_result = subject.get_commands_slice( run_id="run-id", length=len(protocol_commands), @@ -201,6 +238,7 @@ def test_update_run_state( actions=[action], ) assert run_summary_result == state_summary + assert parameters_result == run_time_parameters assert commands_result.commands == protocol_commands mock_runs_publisher.publish_runs_advise_refetch.assert_called_once_with( run_id="run-id" @@ -218,6 +256,7 @@ def test_update_state_run_not_found( run_id="run-not-found", summary=state_summary, commands=protocol_commands, + run_time_parameters=[], ) @@ -437,7 +476,9 @@ def test_get_state_summary( protocol_id=None, created_at=datetime(year=2021, month=1, day=1, tzinfo=timezone.utc), ) - subject.update_run_state(run_id="run-id", summary=state_summary, commands=[]) + subject.update_run_state( + run_id="run-id", summary=state_summary, commands=[], run_time_parameters=[] + ) result = subject.get_state_summary(run_id="run-id") assert result == state_summary mock_runs_publisher.publish_runs_advise_refetch.assert_called_once_with( @@ -455,7 +496,10 @@ def test_get_state_summary_failure( created_at=datetime(year=2021, month=1, day=1, tzinfo=timezone.utc), ) subject.update_run_state( - run_id="run-id", summary=invalid_state_summary, commands=[] + run_id="run-id", + summary=invalid_state_summary, + commands=[], + run_time_parameters=[], ) result = subject.get_state_summary(run_id="run-id") assert isinstance(result, BadStateSummary) @@ -474,6 +518,62 @@ def test_get_state_summary_none(subject: RunStore) -> None: assert result.dataError.code == ErrorCodes.INVALID_STORED_DATA +def test_get_run_time_parameters( + subject: RunStore, + state_summary: StateSummary, + run_time_parameters: List[pe_types.RunTimeParameter], +) -> None: + """It should be able to get store run time parameters.""" + subject.insert( + run_id="run-id", + protocol_id=None, + created_at=datetime(year=2021, month=1, day=1, tzinfo=timezone.utc), + ) + subject.update_run_state( + run_id="run-id", + summary=state_summary, + commands=[], + run_time_parameters=run_time_parameters, + ) + result = subject.get_run_time_parameters(run_id="run-id") + assert result == run_time_parameters + + +def test_get_run_time_parameters_invalid( + subject: RunStore, + state_summary: StateSummary, +) -> None: + """It should return an empty list if there invalid parameters.""" + bad_parameters = [pe_types.BooleanParameter.construct(foo="bar")] # type: ignore[call-arg] + subject.insert( + run_id="run-id", + protocol_id=None, + created_at=datetime(year=2021, month=1, day=1, tzinfo=timezone.utc), + ) + subject.update_run_state( + run_id="run-id", + summary=state_summary, + commands=[], + run_time_parameters=bad_parameters, # type: ignore[arg-type] + ) + result = subject.get_run_time_parameters(run_id="run-id") + assert result == [] + + +def test_get_run_time_parameters_none( + subject: RunStore, + state_summary: StateSummary, +) -> None: + """It should return an empty list if there are no run time parameters associated.""" + subject.insert( + run_id="run-id", + protocol_id=None, + created_at=datetime(year=2021, month=1, day=1, tzinfo=timezone.utc), + ) + result = subject.get_run_time_parameters(run_id="run-id") + assert result == [] + + def test_has_run_id(subject: RunStore) -> None: """It should tell us if a given ID is in the store.""" subject.insert( @@ -504,6 +604,7 @@ def test_get_command( run_id="run-id", summary=state_summary, commands=protocol_commands, + run_time_parameters=[], ) result = subject.get_command(run_id="run-id", command_id="pause-2") @@ -533,6 +634,7 @@ def test_get_command_raise_exception( run_id="run-id", summary=state_summary, commands=protocol_commands, + run_time_parameters=[], ) with pytest.raises(expected_exception): subject.get_command(run_id=input_run_id, command_id=input_command_id) @@ -553,6 +655,7 @@ def test_get_command_slice( run_id="run-id", summary=state_summary, commands=protocol_commands, + run_time_parameters=[], ) result = subject.get_commands_slice( run_id="run-id", cursor=0, length=len(protocol_commands) @@ -599,6 +702,7 @@ def test_get_commands_slice_clamping( run_id="run-id", summary=state_summary, commands=protocol_commands, + run_time_parameters=[], ) result = subject.get_commands_slice( run_id="run-id", cursor=input_cursor, length=input_length diff --git a/robot-server/tests/service/json_api/test_response.py b/robot-server/tests/service/json_api/test_response.py index 1429d88b5e0..6952468229b 100644 --- a/robot-server/tests/service/json_api/test_response.py +++ b/robot-server/tests/service/json_api/test_response.py @@ -116,7 +116,7 @@ class ResponseSpec(NamedTuple): "links": {"sibling": {"href": "/bar", "meta": None}}, }, ), - ResponseSpec(subject=NotifyRefetchBody(), expected={"refetchUsingHTTP": True}), + ResponseSpec(subject=NotifyRefetchBody(), expected={"refetch": True}), ResponseSpec( subject=NotifyUnsubscribeBody(), expected={"unsubscribe": True}, diff --git a/robot-server/tests/service/notifications/__init__.py b/robot-server/tests/service/notifications/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/robot-server/tests/service/notifications/publishers/__init__.py b/robot-server/tests/service/notifications/publishers/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/robot-server/tests/service/notifications/publishers/test_maintenance_runs_publisher.py b/robot-server/tests/service/notifications/publishers/test_maintenance_runs_publisher.py new file mode 100644 index 00000000000..8a0cb6a1832 --- /dev/null +++ b/robot-server/tests/service/notifications/publishers/test_maintenance_runs_publisher.py @@ -0,0 +1,30 @@ +"""Tests for the maintenance runs publisher.""" +import pytest +from unittest.mock import AsyncMock + +from robot_server.service.notifications import MaintenanceRunsPublisher, Topics + + +@pytest.fixture +def notification_client() -> AsyncMock: + """Mocked notification client.""" + return AsyncMock() + + +@pytest.fixture +def maintenance_runs_publisher( + notification_client: AsyncMock, +) -> MaintenanceRunsPublisher: + """Instantiate MaintenanceRunsPublisher.""" + return MaintenanceRunsPublisher(notification_client) + + +@pytest.mark.asyncio +async def test_publish_current_maintenance_run( + notification_client: AsyncMock, maintenance_runs_publisher: MaintenanceRunsPublisher +) -> None: + """It should publish a notify flag for maintenance runs.""" + await maintenance_runs_publisher.publish_current_maintenance_run() + notification_client.publish_advise_refetch_async.assert_awaited_once_with( + topic=Topics.MAINTENANCE_RUNS_CURRENT_RUN + ) diff --git a/robot-server/tests/service/notifications/publishers/test_runs_publisher.py b/robot-server/tests/service/notifications/publishers/test_runs_publisher.py new file mode 100644 index 00000000000..a889664cbee --- /dev/null +++ b/robot-server/tests/service/notifications/publishers/test_runs_publisher.py @@ -0,0 +1,145 @@ +"""Tests for runs publisher.""" +import pytest +from datetime import datetime +from unittest.mock import MagicMock, AsyncMock + +from robot_server.service.notifications import RunsPublisher, Topics +from opentrons.protocol_engine import CurrentCommand, EngineStatus + + +def mock_curent_command(command_id: str) -> CurrentCommand: + """Create a mock CurrentCommand.""" + return CurrentCommand( + command_id=command_id, + command_key="1", + index=0, + created_at=datetime(year=2021, month=1, day=1), + ) + + +@pytest.fixture +def notification_client() -> AsyncMock: + """Mocked notification client.""" + return AsyncMock() + + +@pytest.fixture +def publisher_notifier() -> AsyncMock: + """Mocked publisher notifier.""" + return AsyncMock() + + +@pytest.fixture +async def runs_publisher( + notification_client: AsyncMock, publisher_notifier: AsyncMock +) -> RunsPublisher: + """Instantiate RunsPublisher.""" + return RunsPublisher( + client=notification_client, publisher_notifier=publisher_notifier + ) + + +@pytest.mark.asyncio +async def test_initialize( + runs_publisher: RunsPublisher, notification_client: AsyncMock +) -> None: + """It should initialize the runs_publisher with required parameters and callbacks.""" + run_id = "1234" + get_current_command = AsyncMock() + get_state_summary = AsyncMock() + + await runs_publisher.initialize(run_id, get_current_command, get_state_summary) + + assert runs_publisher._run_hooks + assert runs_publisher._run_hooks.run_id == run_id + assert runs_publisher._run_hooks.get_current_command == get_current_command + assert runs_publisher._run_hooks.get_state_summary == get_state_summary + assert runs_publisher._engine_state_slice + assert runs_publisher._engine_state_slice.current_command is None + assert runs_publisher._engine_state_slice.state_summary_status is None + + notification_client.publish_advise_refetch_async.assert_any_await(topic=Topics.RUNS) + notification_client.publish_advise_refetch_async.assert_any_await( + topic=f"{Topics.RUNS}/1234" + ) + + +@pytest.mark.asyncio +async def test_clean_up_current_run( + runs_publisher: RunsPublisher, notification_client: AsyncMock +) -> None: + """It should publish to appropriate topics at the end of a run.""" + await runs_publisher.initialize("1234", AsyncMock(), AsyncMock()) + + await runs_publisher.clean_up_run(run_id="1234") + + notification_client.publish_advise_refetch_async.assert_any_await(topic=Topics.RUNS) + notification_client.publish_advise_refetch_async.assert_any_await( + topic=f"{Topics.RUNS}/1234" + ) + notification_client.publish_advise_unsubscribe_async.assert_any_await( + topic=f"{Topics.RUNS}/1234" + ) + + +@pytest.mark.asyncio +async def test_handle_current_command_change( + runs_publisher: RunsPublisher, notification_client: AsyncMock +) -> None: + """It should handle command changes appropriately.""" + await runs_publisher.initialize( + "1234", lambda _: mock_curent_command("command1"), AsyncMock() + ) + + assert runs_publisher._run_hooks + assert runs_publisher._engine_state_slice + + runs_publisher._engine_state_slice.current_command = mock_curent_command("command1") + + await runs_publisher._handle_current_command_change() + + assert notification_client.publish_advise_refetch_async.call_count == 2 + + runs_publisher._run_hooks.get_current_command = lambda _: mock_curent_command( + "command2" + ) + + await runs_publisher._handle_current_command_change() + + notification_client.publish_advise_refetch_async.assert_any_await( + topic=Topics.RUNS_CURRENT_COMMAND + ) + + +@pytest.mark.asyncio +async def test_handle_engine_status_change( + runs_publisher: RunsPublisher, notification_client: AsyncMock +) -> None: + """It should handle engine status changes appropriately.""" + await runs_publisher.initialize( + "1234", lambda _: mock_curent_command("command1"), AsyncMock() + ) + + assert runs_publisher._run_hooks + assert runs_publisher._engine_state_slice + + runs_publisher._run_hooks.run_id = "1234" + runs_publisher._run_hooks.get_state_summary = MagicMock( + return_value=MagicMock(status=EngineStatus.IDLE) + ) + runs_publisher._engine_state_slice.state_summary_status = EngineStatus.IDLE + + await runs_publisher._handle_engine_status_change() + + assert notification_client.publish_advise_refetch_async.call_count == 2 + + runs_publisher._run_hooks.get_state_summary.return_value = MagicMock( + status=EngineStatus.RUNNING + ) + + await runs_publisher._handle_engine_status_change() + + notification_client.publish_advise_refetch_async.assert_any_await(topic=Topics.RUNS) + notification_client.publish_advise_refetch_async.assert_any_await( + topic=f"{Topics.RUNS}/1234" + ) diff --git a/robot-server/tests/service/notifications/test_change_notifier.py b/robot-server/tests/service/notifications/test_change_notifier.py new file mode 100644 index 00000000000..4967e6d254e --- /dev/null +++ b/robot-server/tests/service/notifications/test_change_notifier.py @@ -0,0 +1,56 @@ +"""Tests for the ChangeNotifier interface.""" +import asyncio +import pytest +from opentrons.protocol_engine.state.change_notifier import ChangeNotifier + + +async def test_single_subscriber() -> None: + """Test that a single subscriber can wait for a notification.""" + subject = ChangeNotifier() + result = asyncio.create_task(subject.wait()) + + # ensure that the wait actually waits by delaying and + # checking that the task has not resolved + await asyncio.sleep(0.1) + assert result.done() is False + + asyncio.get_running_loop().call_soon(subject.notify) + + await result + + +@pytest.mark.parametrize("_test_repetition", range(10)) +async def test_multiple_subscribers(_test_repetition: int) -> None: + """Test that multiple subscribers can wait for a notification. + + This test checks that the subscribers are awoken in the order they + subscribed. This may or may not be guarenteed according to the + implementations of both ChangeNotifier and the event loop. + This test functions as a canary, given that our code may relies + on this ordering for determinism. + + This test runs multiple times to check for flakyness. + """ + subject = ChangeNotifier() + results = [] + + async def _do_task_1() -> None: + await subject.wait() + results.append(1) + + async def _do_task_2() -> None: + await subject.wait() + results.append(2) + + async def _do_task_3() -> None: + await subject.wait() + results.append(3) + + task_1 = asyncio.create_task(_do_task_1()) + task_2 = asyncio.create_task(_do_task_2()) + task_3 = asyncio.create_task(_do_task_3()) + + asyncio.get_running_loop().call_soon(subject.notify) + await asyncio.gather(task_1, task_2, task_3) + + assert results == [1, 2, 3] diff --git a/robot-server/tests/service/notifications/test_publisher_notifier.py b/robot-server/tests/service/notifications/test_publisher_notifier.py new file mode 100644 index 00000000000..125cfdd1806 --- /dev/null +++ b/robot-server/tests/service/notifications/test_publisher_notifier.py @@ -0,0 +1,74 @@ +import asyncio +from unittest.mock import Mock, MagicMock + +from robot_server.service.notifications import ( + PublisherNotifier, + ChangeNotifier, +) + + +async def test_initialize() -> None: + """It should create a new task.""" + publisher_notifier = PublisherNotifier() + + await publisher_notifier._initialize() + + assert asyncio.get_running_loop() + + +def test_notify_publishers() -> None: + """Invoke the change notifier's notify method.""" + change_notifier = MagicMock() + publisher_notifier = PublisherNotifier(change_notifier) + + publisher_notifier._notify_publishers() + + change_notifier.notify.assert_called_once() + + +def test_register_publish_callbacks() -> None: + """It should extend the list of callbacks within a given list of callbacks.""" + publisher_notifier = PublisherNotifier() + callback1 = Mock() + callback2 = Mock() + + publisher_notifier.register_publish_callbacks([callback1, callback2]) + + assert len(publisher_notifier._callbacks) == 2 + assert publisher_notifier._callbacks[0] == callback1 + assert publisher_notifier._callbacks[1] == callback2 + + +async def test_wait_for_event() -> None: + """It should wait for an event to occur, then invoke each callback.""" + change_notifier = ChangeNotifier() + publisher_notifier = PublisherNotifier(change_notifier) + + callback_called = False + callback_2_called = False + + async def callback() -> None: + """Mock callback.""" + nonlocal callback_called + callback_called = True + + async def callback_2() -> None: + """Mock callback.""" + nonlocal callback_2_called + callback_2_called = True + + publisher_notifier.register_publish_callbacks([callback, callback_2]) + + async def trigger_callbacks() -> None: + """Mock trigger for callbacks.""" + await asyncio.sleep(0.1) + change_notifier.notify() + + task = asyncio.create_task(publisher_notifier._initialize()) + + await asyncio.gather(trigger_callbacks(), task) + + assert callback_called + assert callback_2_called + + task.cancel() diff --git a/scripts/deploy/create-release.js b/scripts/deploy/create-release.js index eb4db62bd2a..3b804506a2e 100644 --- a/scripts/deploy/create-release.js +++ b/scripts/deploy/create-release.js @@ -22,12 +22,6 @@ const parseArgs = require('./lib/parseArgs') const conventionalChangelog = require('conventional-changelog') const semver = require('semver') const { Octokit } = require('@octokit/rest') -const { - detailsFromTag, - tagFromDetails, - prefixForProject, - monorepoGit, -} = require('../git-version') const USAGE = '\nUsage:\n node ./scripts/deploy/create-release [--deploy] [--allow-old]' @@ -81,9 +75,35 @@ function versionPrevious(currentVersion, previousVersions) { return releasesOfGEQKind.length === 0 ? null : releasesOfGEQKind[0] } +async function gitVersion() { + let imported + if (imported === undefined) { + imported = await import('../git-version.mjs') + } + return imported +} + +async function monorepoGit() { + return await (await gitVersion()).monorepoGit() +} + +async function detailsFromTag(tag) { + return await (await gitVersion()).detailsFromTag(tag) +} + +async function tagFromDetails(project, version) { + return (await gitVersion()).tagFromDetails(project, version) +} + +async function prefixForProject(project) { + return (await gitVersion()).prefixForProject(project) +} + async function versionDetailsFromGit(tag, allowOld) { if (!allowOld) { - const last100 = await monorepoGit().log({ from: 'HEAD~100', to: 'HEAD' }) + const git = await monorepoGit() + const last100 = await git.log({ from: 'HEAD~100', to: 'HEAD' }) + if (!last100.all.some(commit => commit.refs.includes('tag: ' + tag))) { throw new Error( `Cannot find tag ${tag} in last 100 commits. You must run this script from a ref with ` + @@ -94,9 +114,8 @@ async function versionDetailsFromGit(tag, allowOld) { } const [project, currentVersion] = detailsFromTag(tag) - - const allTags = (await monorepoGit().tags([prefixForProject(project) + '*'])) - .all + const prefix = await prefixForProject(project) + const allTags = (await monorepoGit().tags([prefix + '*'])).all if (!allTags.includes(tag)) { throw new Error( `Tag ${tag} does not exist - create it before running this script` @@ -123,14 +142,15 @@ async function buildChangelog(project, currentVersion, previousVersion) { `## ${currentVersion}` + `\nFirst release for ${titleForProject(project)}` ) } - const previousTag = tagFromDetails(project, previousVersion) - + const previousTag = await tagFromDetails(project, previousVersion) + const currentTag = await tagFromDetails(project, currentVersion) + const prefix = await prefixForProject(Project) const changelogStream = conventionalChangelog( - { preset: 'angular', tagPrefix: prefixForProject(project) }, + { preset: 'angular', tagPrefix: prefix }, { version: currentVersion, - currentTag: tagFromDetails(project, currentVersion), - previousTag: previousTag, + currentTag, + previousTag, host: 'https://github.com', owner: REPO_DETAILS.owner, repository: REPO_DETAILS.repo, @@ -203,6 +223,7 @@ async function main() { currentVersion, previousVersion, ] = await versionDetailsFromGit(tag, allowOld) + const prefix = await prefixForProject(project) const changelog = await buildChangelog( project, currentVersion, @@ -211,8 +232,8 @@ async function main() { const truncatedChangelog = truncateAndAnnotate( changelog, 10000, - prefixForProject(project) + previousVersion, - prefixForProject(project) + currentVersion + prefix + previousVersion, + prefix + currentVersion ) return await createRelease( token, diff --git a/scripts/git-version.js b/scripts/git-version.mjs similarity index 79% rename from scripts/git-version.js rename to scripts/git-version.mjs index a2dab912f23..7b4d364d0da 100644 --- a/scripts/git-version.js +++ b/scripts/git-version.mjs @@ -15,23 +15,24 @@ // What that all boils down to is that we need, and this module provides, an interface to get the version of a // given project that currently exists in the monorepo. -const git = require('simple-git') -const { dirname } = require('path') -const REPO_BASE = dirname(__dirname) +import git from 'simple-git' +import { dirname } from 'path' +import { fileURLToPath } from 'url' +const REPO_BASE = dirname(dirname(fileURLToPath(import.meta.url))) -function monorepoGit() { +export function monorepoGit() { return git({ baseDir: REPO_BASE }) } -const detailsFromTag = tag => +export const detailsFromTag = tag => tag.includes('@') ? tag.split('@') : ['robot-stack', tag.substring(1)] -function tagFromDetails(project, version) { +export function tagFromDetails(project, version) { const prefix = prefixForProject(project) return `${prefix}${version}` } -function prefixForProject(project) { +export function prefixForProject(project) { if (project === 'robot-stack') { return 'v' } else { @@ -39,7 +40,7 @@ function prefixForProject(project) { } } -async function latestTagForProject(project) { +export async function latestTagForProject(project) { return ( await monorepoGit().raw([ 'describe', @@ -50,7 +51,7 @@ async function latestTagForProject(project) { ).trim() } -async function versionForProject(project) { +export async function versionForProject(project) { return latestTagForProject(project) .then(tag => detailsFromTag(tag)[1]) .catch(error => { @@ -60,12 +61,3 @@ async function versionForProject(project) { return '0.0.0-dev' }) } - -module.exports = { - detailsFromTag, - tagFromDetails, - prefixForProject, - latestTagForProject, - versionForProject, - monorepoGit, -} diff --git a/scripts/update-releases-json.js b/scripts/update-releases-json.js index 3286256c42b..d7aa9b0ca21 100644 --- a/scripts/update-releases-json.js +++ b/scripts/update-releases-json.js @@ -4,8 +4,6 @@ const fs = require('fs/promises') // Updates a releases historical manifest with a release's version. -const versionFinder = require('./git-version') - const parseArgs = require('./deploy/lib/parseArgs') const USAGE = '\nUsage:\n node ./scripts/update-releases-json ' @@ -63,6 +61,7 @@ async function main() { } console.log(`Updating ${releasesPath} with artifacts from ${artifactDirPath}`) const releasesData = await readOrDefaultReleases(releasesPath) + const versionFinder = await import('./git-version.mjs') const version = await versionFinder.versionForProject(project) console.log(`Adding data for ${version}`) releasesData.production[version] = { diff --git a/setup-vitest.ts b/setup-vitest.ts index 07bd135137d..bf9d07a6ba7 100644 --- a/setup-vitest.ts +++ b/setup-vitest.ts @@ -10,6 +10,9 @@ vi.mock('./app/src/redux/shell/remote') process.env.OT_PD_VERSION = 'fake_PD_version' global._PKG_VERSION_ = 'test environment' +global._OPENTRONS_PROJECT_ = 'robotics' +global._PKG_PRODUCT_NAME_ = 'test product' +global._PKG_BUGS_URL_ = 'http://bugs.contoso.com' afterEach(() => { cleanup() diff --git a/shared-data/command/schemas/8.json b/shared-data/command/schemas/8.json index a17be9ee690..f3c5bb38b27 100644 --- a/shared-data/command/schemas/8.json +++ b/shared-data/command/schemas/8.json @@ -339,7 +339,7 @@ "CommandIntent": { "title": "CommandIntent", "description": "Run intent for a given command.\n\nProps:\n PROTOCOL: the command is part of the protocol run itself.\n SETUP: the command is part of the setup phase of a run.", - "enum": ["protocol", "setup"], + "enum": ["protocol", "setup", "fixit"], "type": "string" }, "AspirateCreate": { diff --git a/shared-data/deck/definitions/5/ot2_short_trash.json b/shared-data/deck/definitions/5/ot2_short_trash.json new file mode 100644 index 00000000000..7d00f8d5773 --- /dev/null +++ b/shared-data/deck/definitions/5/ot2_short_trash.json @@ -0,0 +1,409 @@ +{ + "otId": "ot2_short_trash", + "schemaVersion": 5, + "cornerOffsetFromOrigin": [-115.65, -68.03, 0], + "dimensions": [624.3, 565.2, 0], + "metadata": { + "displayName": "OT-2 Short-Trash Deck", + "tags": ["ot2", "12 slots", "short trash"] + }, + "robot": { + "model": "OT-2 Standard" + }, + "locations": { + "addressableAreas": [ + { + "id": "1", + "areaType": "slot", + "offsetFromCutoutFixture": [0.0, 0.0, 0.0], + "matingSurfaceUnitVector": [-1, 1, -1], + "boundingBox": { + "xDimension": 128.0, + "yDimension": 86.0, + "zDimension": 0 + }, + "displayName": "Slot 1", + "compatibleModuleTypes": [ + "magneticModuleType", + "temperatureModuleType", + "heaterShakerModuleType" + ] + }, + { + "id": "2", + "areaType": "slot", + "offsetFromCutoutFixture": [0.0, 0.0, 0.0], + "matingSurfaceUnitVector": [-1, 1, -1], + "boundingBox": { + "xDimension": 128.0, + "yDimension": 86.0, + "zDimension": 0 + }, + "displayName": "Slot 2", + "compatibleModuleTypes": [ + "magneticModuleType", + "temperatureModuleType", + "heaterShakerModuleType" + ] + }, + { + "id": "3", + "areaType": "slot", + "offsetFromCutoutFixture": [0.0, 0.0, 0.0], + "matingSurfaceUnitVector": [-1, 1, -1], + "boundingBox": { + "xDimension": 128.0, + "yDimension": 86.0, + "zDimension": 0 + }, + "displayName": "Slot 3", + "compatibleModuleTypes": [ + "magneticModuleType", + "temperatureModuleType", + "heaterShakerModuleType" + ] + }, + { + "id": "4", + "areaType": "slot", + "offsetFromCutoutFixture": [0.0, 0.0, 0.0], + "matingSurfaceUnitVector": [-1, 1, -1], + "boundingBox": { + "xDimension": 128.0, + "yDimension": 86.0, + "zDimension": 0 + }, + "displayName": "Slot 4", + "compatibleModuleTypes": [ + "magneticModuleType", + "temperatureModuleType", + "heaterShakerModuleType" + ] + }, + { + "id": "5", + "areaType": "slot", + "offsetFromCutoutFixture": [0.0, 0.0, 0.0], + "matingSurfaceUnitVector": [-1, 1, -1], + "boundingBox": { + "xDimension": 128.0, + "yDimension": 86.0, + "zDimension": 0 + }, + "displayName": "Slot 5", + "compatibleModuleTypes": [ + "magneticModuleType", + "temperatureModuleType", + "heaterShakerModuleType" + ] + }, + { + "id": "6", + "areaType": "slot", + "offsetFromCutoutFixture": [0.0, 0.0, 0.0], + "matingSurfaceUnitVector": [-1, 1, -1], + "boundingBox": { + "xDimension": 128.0, + "yDimension": 86.0, + "zDimension": 0 + }, + "displayName": "Slot 6", + "compatibleModuleTypes": [ + "magneticModuleType", + "temperatureModuleType", + "heaterShakerModuleType" + ] + }, + { + "id": "7", + "areaType": "slot", + "offsetFromCutoutFixture": [0.0, 0.0, 0.0], + "matingSurfaceUnitVector": [-1, 1, -1], + "boundingBox": { + "xDimension": 128.0, + "yDimension": 86.0, + "zDimension": 0 + }, + "displayName": "Slot 7", + "compatibleModuleTypes": [ + "magneticModuleType", + "temperatureModuleType", + "thermocyclerModuleType", + "heaterShakerModuleType" + ] + }, + { + "id": "8", + "areaType": "slot", + "offsetFromCutoutFixture": [0.0, 0.0, 0.0], + "matingSurfaceUnitVector": [-1, 1, -1], + "boundingBox": { + "xDimension": 128.0, + "yDimension": 86.0, + "zDimension": 0 + }, + "displayName": "Slot 8", + "compatibleModuleTypes": [ + "magneticModuleType", + "temperatureModuleType", + "heaterShakerModuleType" + ] + }, + { + "id": "9", + "areaType": "slot", + "offsetFromCutoutFixture": [0.0, 0.0, 0.0], + "matingSurfaceUnitVector": [-1, 1, -1], + "boundingBox": { + "xDimension": 128.0, + "yDimension": 86.0, + "zDimension": 0 + }, + "displayName": "Slot 9", + "compatibleModuleTypes": [ + "magneticModuleType", + "temperatureModuleType", + "heaterShakerModuleType" + ] + }, + { + "id": "10", + "areaType": "slot", + "offsetFromCutoutFixture": [0.0, 0.0, 0.0], + "matingSurfaceUnitVector": [-1, 1, -1], + "boundingBox": { + "xDimension": 128.0, + "yDimension": 86.0, + "zDimension": 0 + }, + "displayName": "Slot 10", + "compatibleModuleTypes": [ + "magneticModuleType", + "temperatureModuleType", + "heaterShakerModuleType" + ] + }, + { + "id": "11", + "areaType": "slot", + "offsetFromCutoutFixture": [0.0, 0.0, 0.0], + "matingSurfaceUnitVector": [-1, 1, -1], + "boundingBox": { + "xDimension": 128.0, + "yDimension": 86.0, + "zDimension": 0 + }, + "displayName": "Slot 11", + "compatibleModuleTypes": [ + "magneticModuleType", + "temperatureModuleType", + "heaterShakerModuleType" + ] + }, + { + "id": "12", + "areaType": "slot", + "offsetFromCutoutFixture": [0.0, 0.0, 0.0], + "boundingBox": { + "xDimension": 128.0, + "yDimension": 86.0, + "zDimension": 0 + }, + "displayName": "Slot 12", + "compatibleModuleTypes": [] + }, + { + "id": "shortFixedTrash", + "areaType": "fixedTrash", + "offsetFromCutoutFixture": [29.285, -2.835, 0], + "boundingBox": { + "xDimension": 107.11, + "yDimension": 165.67, + "zDimension": 58 + }, + "displayName": "Slot 12/Short Fixed Trash", + "ableToDropTips": true + } + ], + "cutouts": [ + { + "id": "cutout1", + "position": [0.0, 0.0, 0.0], + "displayName": "Cutout 1" + }, + { + "id": "cutout2", + "position": [132.5, 0.0, 0.0], + "displayName": "Cutout 2" + }, + { + "id": "cutout3", + "position": [265.0, 0.0, 0.0], + "displayName": "Cutout 3" + }, + { + "id": "cutout4", + "position": [0.0, 90.5, 0.0], + "displayName": "Cutout 4" + }, + { + "id": "cutout5", + "position": [132.5, 90.5, 0.0], + "displayName": "Cutout 5" + }, + { + "id": "cutout6", + "position": [265.0, 90.5, 0.0], + "displayName": "Cutout 6" + }, + { + "id": "cutout7", + "position": [0.0, 181.0, 0.0], + "displayName": "Cutout 7" + }, + { + "id": "cutout8", + "position": [132.5, 181.0, 0.0], + "displayName": "Cutout 8" + }, + { + "id": "cutout9", + "position": [265.0, 181.0, 0.0], + "displayName": "Cutout 9" + }, + { + "id": "cutout10", + "position": [0.0, 271.5, 0.0], + "displayName": "Slot 10" + }, + { + "id": "cutout11", + "position": [132.5, 271.5, 0.0], + "displayName": "Cutout 11" + }, + { + "id": "cutout12", + "position": [265.0, 271.5, 0.0], + "displayName": "Cutout 12" + } + ], + "calibrationPoints": [ + { + "id": "1BLC", + "position": [12.13, 9.0, 0.0], + "displayName": "Slot 1 Bottom Left Cross" + }, + { + "id": "3BRC", + "position": [380.87, 9.0, 0.0], + "displayName": "Slot 3 Bottom Right Cross" + }, + { + "id": "7TLC", + "position": [12.13, 258.0, 0.0], + "displayName": "Slot 7 Top Left Cross" + }, + { + "id": "9TRC", + "position": [380.87, 258.0, 0.0], + "displayName": "Slot 9 Top Right Cross" + }, + { + "id": "10TLC", + "position": [12.13, 348.5, 0.0], + "displayName": "Slot 10 Top Left Cross" + }, + { + "id": "11TRC", + "position": [248.37, 348.5, 0.0], + "displayName": "Slot 11 Top Right Cross" + }, + { + "id": "1BLD", + "position": [12.13, 6.0, 0.0], + "displayName": "Slot 1 Bottom Left Dot" + }, + { + "id": "3BRD", + "position": [380.87, 6.0, 0.0], + "displayName": "Slot 3 Bottom Right Dot" + }, + { + "id": "7TLD", + "position": [12.13, 261.0, 0.0], + "displayName": "Slot 7 Top Left Dot" + }, + { + "id": "9TRD", + "position": [380.87, 261.0, 0.0], + "displayName": "Slot 9 Top Right Dot" + }, + { + "id": "10TLD", + "position": [12.13, 351.5, 0.0], + "displayName": "Slot 10 Top Left Dot" + }, + { + "id": "11TRD", + "position": [248.37, 351.5, 0.0], + "displayName": "Slot 11 Top Right Dot" + } + ], + "legacyFixtures": [ + { + "id": "fixedTrash", + "slot": "12", + "labware": "opentrons_1_trash_850ml_fixed", + "displayName": "Fixed Trash" + } + ] + }, + "cutoutFixtures": [ + { + "id": "singleStandardSlot", + "expectOpentronsModuleSerialNumber": false, + "mayMountTo": [ + "cutout1", + "cutout2", + "cutout3", + "cutout4", + "cutout5", + "cutout6", + "cutout7", + "cutout8", + "cutout9", + "cutout10", + "cutout11", + "cutout12" + ], + "displayName": "Standard Slot", + "providesAddressableAreas": { + "cutout1": ["1"], + "cutout2": ["2"], + "cutout3": ["3"], + "cutout4": ["4"], + "cutout5": ["5"], + "cutout6": ["6"], + "cutout7": ["7"], + "cutout8": ["8"], + "cutout9": ["9"], + "cutout10": ["10"], + "cutout11": ["11"], + "cutout12": ["12"] + }, + "fixtureGroup": {}, + "height": 0 + }, + { + "id": "fixedTrashSlot", + "expectOpentronsModuleSerialNumber": false, + "mayMountTo": ["cutout12"], + "displayName": "Fixed Trash", + "providesAddressableAreas": { + "cutout12": ["shortFixedTrash"] + }, + "fixtureGroup": {}, + "height": 58 + } + ] +} diff --git a/shared-data/deck/definitions/5/ot2_standard.json b/shared-data/deck/definitions/5/ot2_standard.json new file mode 100644 index 00000000000..0ccc1997c3e --- /dev/null +++ b/shared-data/deck/definitions/5/ot2_standard.json @@ -0,0 +1,409 @@ +{ + "otId": "ot2_standard", + "schemaVersion": 5, + "cornerOffsetFromOrigin": [-115.65, -68.03, 0], + "dimensions": [624.3, 565.2, 0], + "metadata": { + "displayName": "OT-2 Standard Deck", + "tags": ["ot2", "12 slots", "standard"] + }, + "robot": { + "model": "OT-2 Standard" + }, + "locations": { + "addressableAreas": [ + { + "id": "1", + "areaType": "slot", + "offsetFromCutoutFixture": [0.0, 0.0, 0.0], + "matingSurfaceUnitVector": [-1, 1, -1], + "boundingBox": { + "xDimension": 128.0, + "yDimension": 86.0, + "zDimension": 0 + }, + "displayName": "Slot 1", + "compatibleModuleTypes": [ + "magneticModuleType", + "temperatureModuleType", + "heaterShakerModuleType" + ] + }, + { + "id": "2", + "areaType": "slot", + "offsetFromCutoutFixture": [0.0, 0.0, 0.0], + "matingSurfaceUnitVector": [-1, 1, -1], + "boundingBox": { + "xDimension": 128.0, + "yDimension": 86.0, + "zDimension": 0 + }, + "displayName": "Slot 2", + "compatibleModuleTypes": [ + "magneticModuleType", + "temperatureModuleType", + "heaterShakerModuleType" + ] + }, + { + "id": "3", + "areaType": "slot", + "offsetFromCutoutFixture": [0.0, 0.0, 0.0], + "matingSurfaceUnitVector": [-1, 1, -1], + "boundingBox": { + "xDimension": 128.0, + "yDimension": 86.0, + "zDimension": 0 + }, + "displayName": "Slot 3", + "compatibleModuleTypes": [ + "magneticModuleType", + "temperatureModuleType", + "heaterShakerModuleType" + ] + }, + { + "id": "4", + "areaType": "slot", + "offsetFromCutoutFixture": [0.0, 0.0, 0.0], + "matingSurfaceUnitVector": [-1, 1, -1], + "boundingBox": { + "xDimension": 128.0, + "yDimension": 86.0, + "zDimension": 0 + }, + "displayName": "Slot 4", + "compatibleModuleTypes": [ + "magneticModuleType", + "temperatureModuleType", + "heaterShakerModuleType" + ] + }, + { + "id": "5", + "areaType": "slot", + "offsetFromCutoutFixture": [0.0, 0.0, 0.0], + "matingSurfaceUnitVector": [-1, 1, -1], + "boundingBox": { + "xDimension": 128.0, + "yDimension": 86.0, + "zDimension": 0 + }, + "displayName": "Slot 5", + "compatibleModuleTypes": [ + "magneticModuleType", + "temperatureModuleType", + "heaterShakerModuleType" + ] + }, + { + "id": "6", + "areaType": "slot", + "offsetFromCutoutFixture": [0.0, 0.0, 0.0], + "matingSurfaceUnitVector": [-1, 1, -1], + "boundingBox": { + "xDimension": 128.0, + "yDimension": 86.0, + "zDimension": 0 + }, + "displayName": "Slot 6", + "compatibleModuleTypes": [ + "magneticModuleType", + "temperatureModuleType", + "heaterShakerModuleType" + ] + }, + { + "id": "7", + "areaType": "slot", + "offsetFromCutoutFixture": [0.0, 0.0, 0.0], + "matingSurfaceUnitVector": [-1, 1, -1], + "boundingBox": { + "xDimension": 128.0, + "yDimension": 86.0, + "zDimension": 0 + }, + "displayName": "Slot 7", + "compatibleModuleTypes": [ + "magneticModuleType", + "temperatureModuleType", + "thermocyclerModuleType", + "heaterShakerModuleType" + ] + }, + { + "id": "8", + "areaType": "slot", + "offsetFromCutoutFixture": [0.0, 0.0, 0.0], + "matingSurfaceUnitVector": [-1, 1, -1], + "boundingBox": { + "xDimension": 128.0, + "yDimension": 86.0, + "zDimension": 0 + }, + "displayName": "Slot 8", + "compatibleModuleTypes": [ + "magneticModuleType", + "temperatureModuleType", + "heaterShakerModuleType" + ] + }, + { + "id": "9", + "areaType": "slot", + "offsetFromCutoutFixture": [0.0, 0.0, 0.0], + "matingSurfaceUnitVector": [-1, 1, -1], + "boundingBox": { + "xDimension": 128.0, + "yDimension": 86.0, + "zDimension": 0 + }, + "displayName": "Slot 9", + "compatibleModuleTypes": [ + "magneticModuleType", + "temperatureModuleType", + "heaterShakerModuleType" + ] + }, + { + "id": "10", + "areaType": "slot", + "offsetFromCutoutFixture": [0.0, 0.0, 0.0], + "matingSurfaceUnitVector": [-1, 1, -1], + "boundingBox": { + "xDimension": 128.0, + "yDimension": 86.0, + "zDimension": 0 + }, + "displayName": "Slot 10", + "compatibleModuleTypes": [ + "magneticModuleType", + "temperatureModuleType", + "heaterShakerModuleType" + ] + }, + { + "id": "11", + "areaType": "slot", + "offsetFromCutoutFixture": [0.0, 0.0, 0.0], + "matingSurfaceUnitVector": [-1, 1, -1], + "boundingBox": { + "xDimension": 128.0, + "yDimension": 86.0, + "zDimension": 0 + }, + "displayName": "Slot 11", + "compatibleModuleTypes": [ + "magneticModuleType", + "temperatureModuleType", + "heaterShakerModuleType" + ] + }, + { + "id": "12", + "areaType": "slot", + "offsetFromCutoutFixture": [0.0, 0.0, 0.0], + "boundingBox": { + "xDimension": 128.0, + "yDimension": 86.0, + "zDimension": 0 + }, + "displayName": "Slot 12", + "compatibleModuleTypes": [] + }, + { + "id": "fixedTrash", + "areaType": "fixedTrash", + "offsetFromCutoutFixture": [29.285, -2.835, 0], + "boundingBox": { + "xDimension": 107.11, + "yDimension": 165.67, + "zDimension": 82 + }, + "displayName": "Slot 12/Fixed Trash", + "ableToDropTips": true + } + ], + "cutouts": [ + { + "id": "cutout1", + "position": [0.0, 0.0, 0.0], + "displayName": "Cutout 1" + }, + { + "id": "cutout2", + "position": [132.5, 0.0, 0.0], + "displayName": "Cutout 2" + }, + { + "id": "cutout3", + "position": [265.0, 0.0, 0.0], + "displayName": "Cutout 3" + }, + { + "id": "cutout4", + "position": [0.0, 90.5, 0.0], + "displayName": "Cutout 4" + }, + { + "id": "cutout5", + "position": [132.5, 90.5, 0.0], + "displayName": "Cutout 5" + }, + { + "id": "cutout6", + "position": [265.0, 90.5, 0.0], + "displayName": "Cutout 6" + }, + { + "id": "cutout7", + "position": [0.0, 181.0, 0.0], + "displayName": "Cutout 7" + }, + { + "id": "cutout8", + "position": [132.5, 181.0, 0.0], + "displayName": "Cutout 8" + }, + { + "id": "cutout9", + "position": [265.0, 181.0, 0.0], + "displayName": "Cutout 9" + }, + { + "id": "cutout10", + "position": [0.0, 271.5, 0.0], + "displayName": "Slot 10" + }, + { + "id": "cutout11", + "position": [132.5, 271.5, 0.0], + "displayName": "Cutout 11" + }, + { + "id": "cutout12", + "position": [265.0, 271.5, 0.0], + "displayName": "Cutout 12" + } + ], + "calibrationPoints": [ + { + "id": "1BLC", + "position": [12.13, 9.0, 0.0], + "displayName": "Slot 1 Bottom Left Cross" + }, + { + "id": "3BRC", + "position": [380.87, 9.0, 0.0], + "displayName": "Slot 3 Bottom Right Cross" + }, + { + "id": "7TLC", + "position": [12.13, 258.0, 0.0], + "displayName": "Slot 7 Top Left Cross" + }, + { + "id": "9TRC", + "position": [380.87, 258.0, 0.0], + "displayName": "Slot 9 Top Right Cross" + }, + { + "id": "10TLC", + "position": [12.13, 348.5, 0.0], + "displayName": "Slot 10 Top Left Cross" + }, + { + "id": "11TRC", + "position": [248.37, 348.5, 0.0], + "displayName": "Slot 11 Top Right Cross" + }, + { + "id": "1BLD", + "position": [12.13, 6.0, 0.0], + "displayName": "Slot 1 Bottom Left Dot" + }, + { + "id": "3BRD", + "position": [380.87, 6.0, 0.0], + "displayName": "Slot 3 Bottom Right Dot" + }, + { + "id": "7TLD", + "position": [12.13, 261.0, 0.0], + "displayName": "Slot 7 Top Left Dot" + }, + { + "id": "9TRD", + "position": [380.87, 261.0, 0.0], + "displayName": "Slot 9 Top Right Dot" + }, + { + "id": "10TLD", + "position": [12.13, 351.5, 0.0], + "displayName": "Slot 10 Top Left Dot" + }, + { + "id": "11TRD", + "position": [248.37, 351.5, 0.0], + "displayName": "Slot 11 Top Right Dot" + } + ], + "legacyFixtures": [ + { + "id": "fixedTrash", + "slot": "12", + "labware": "opentrons_1_trash_1100ml_fixed", + "displayName": "Fixed Trash" + } + ] + }, + "cutoutFixtures": [ + { + "id": "singleStandardSlot", + "expectOpentronsModuleSerialNumber": false, + "mayMountTo": [ + "cutout1", + "cutout2", + "cutout3", + "cutout4", + "cutout5", + "cutout6", + "cutout7", + "cutout8", + "cutout9", + "cutout10", + "cutout11", + "cutout12" + ], + "displayName": "Standard Slot", + "providesAddressableAreas": { + "cutout1": ["1"], + "cutout2": ["2"], + "cutout3": ["3"], + "cutout4": ["4"], + "cutout5": ["5"], + "cutout6": ["6"], + "cutout7": ["7"], + "cutout8": ["8"], + "cutout9": ["9"], + "cutout10": ["10"], + "cutout11": ["11"], + "cutout12": ["12"] + }, + "fixtureGroup": {}, + "height": 0 + }, + { + "id": "fixedTrashSlot", + "expectOpentronsModuleSerialNumber": false, + "mayMountTo": ["cutout12"], + "displayName": "Fixed Trash", + "providesAddressableAreas": { + "cutout12": ["fixedTrash"] + }, + "fixtureGroup": {}, + "height": 82 + } + ] +} diff --git a/shared-data/deck/definitions/5/ot3_standard.json b/shared-data/deck/definitions/5/ot3_standard.json new file mode 100644 index 00000000000..85dcbf64792 --- /dev/null +++ b/shared-data/deck/definitions/5/ot3_standard.json @@ -0,0 +1,1042 @@ +{ + "otId": "ot3_standard", + "schemaVersion": 5, + "cornerOffsetFromOrigin": [-204.31, -76.59, 0], + "dimensions": [854.995, 581.74, 0], + "metadata": { + "displayName": "OT-3 Standard Deck", + "tags": ["ot3", "12 slots", "standard"] + }, + "robot": { + "model": "OT-3 Standard" + }, + "locations": { + "addressableAreas": [ + { + "id": "D1", + "areaType": "slot", + "matingSurfaceUnitVector": [-1, 1, -1], + "offsetFromCutoutFixture": [0.0, 0.0, 0.0], + "boundingBox": { + "xDimension": 128.0, + "yDimension": 86.0, + "zDimension": 0 + }, + "displayName": "Slot D1" + }, + { + "id": "D2", + "areaType": "slot", + "matingSurfaceUnitVector": [-1, 1, -1], + "offsetFromCutoutFixture": [0.0, 0.0, 0.0], + "boundingBox": { + "xDimension": 128.0, + "yDimension": 86.0, + "zDimension": 0 + }, + "displayName": "Slot D2" + }, + { + "id": "D3", + "areaType": "slot", + "offsetFromCutoutFixture": [0.0, 0.0, 0.0], + "matingSurfaceUnitVector": [-1, 1, -1], + "boundingBox": { + "xDimension": 128.0, + "yDimension": 86.0, + "zDimension": 0 + }, + "displayName": "Slot D3" + }, + { + "id": "C1", + "areaType": "slot", + "offsetFromCutoutFixture": [0.0, 0.0, 0.0], + "matingSurfaceUnitVector": [-1, 1, -1], + "boundingBox": { + "xDimension": 128.0, + "yDimension": 86.0, + "zDimension": 0 + }, + "displayName": "Slot C1" + }, + { + "id": "C2", + "areaType": "slot", + "offsetFromCutoutFixture": [0.0, 0.0, 0.0], + "matingSurfaceUnitVector": [-1, 1, -1], + "boundingBox": { + "xDimension": 128.0, + "yDimension": 86.0, + "zDimension": 0 + }, + "displayName": "Slot C2" + }, + { + "id": "C3", + "areaType": "slot", + "offsetFromCutoutFixture": [0.0, 0.0, 0.0], + "matingSurfaceUnitVector": [-1, 1, -1], + "boundingBox": { + "xDimension": 128.0, + "yDimension": 86.0, + "zDimension": 0 + }, + "displayName": "Slot C3" + }, + { + "id": "B1", + "areaType": "slot", + "offsetFromCutoutFixture": [0.0, 0, 0.0], + "matingSurfaceUnitVector": [-1, 1, -1], + "boundingBox": { + "xDimension": 128.0, + "yDimension": 86.0, + "zDimension": 0 + }, + "displayName": "Slot B1" + }, + { + "id": "B2", + "areaType": "slot", + "offsetFromCutoutFixture": [0.0, 0.0, 0.0], + "matingSurfaceUnitVector": [-1, 1, -1], + "boundingBox": { + "xDimension": 128.0, + "yDimension": 86.0, + "zDimension": 0 + }, + "displayName": "Slot B2" + }, + { + "id": "B3", + "areaType": "slot", + "offsetFromCutoutFixture": [0.0, 0.0, 0.0], + "matingSurfaceUnitVector": [-1, 1, -1], + "boundingBox": { + "xDimension": 128.0, + "yDimension": 86.0, + "zDimension": 0 + }, + "displayName": "Slot B3" + }, + { + "id": "A1", + "areaType": "slot", + "offsetFromCutoutFixture": [0.0, 0.0, 0.0], + "matingSurfaceUnitVector": [-1, 1, -1], + "boundingBox": { + "xDimension": 128.0, + "yDimension": 86.0, + "zDimension": 0 + }, + "displayName": "Slot A1" + }, + { + "id": "A2", + "areaType": "slot", + "offsetFromCutoutFixture": [0.0, 0.0, 0.0], + "matingSurfaceUnitVector": [-1, 1, -1], + "boundingBox": { + "xDimension": 128.0, + "yDimension": 86.0, + "zDimension": 0 + }, + "displayName": "Slot A2", + "compatibleModuleTypes": [] + }, + { + "id": "A3", + "areaType": "slot", + "offsetFromCutoutFixture": [0.0, 0.0, 0.0], + "boundingBox": { + "xDimension": 128.0, + "yDimension": 86.0, + "zDimension": 0 + }, + "displayName": "Slot A3", + "compatibleModuleTypes": [] + }, + { + "id": "A4", + "areaType": "stagingSlot", + "offsetFromCutoutFixture": [164.0, 0.0, 14.5], + "matingSurfaceUnitVector": [-1, 1, -1], + "boundingBox": { + "xDimension": 128.0, + "yDimension": 86.0, + "zDimension": 0 + }, + "displayName": "Slot A4", + "compatibleModuleTypes": [] + }, + { + "id": "B4", + "areaType": "stagingSlot", + "offsetFromCutoutFixture": [164.0, 0.0, 14.5], + "matingSurfaceUnitVector": [-1, 1, -1], + "boundingBox": { + "xDimension": 128.0, + "yDimension": 86.0, + "zDimension": 0 + }, + "displayName": "Slot B4", + "compatibleModuleTypes": [] + }, + { + "id": "C4", + "areaType": "stagingSlot", + "offsetFromCutoutFixture": [164.0, 0.0, 14.5], + "matingSurfaceUnitVector": [-1, 1, -1], + "boundingBox": { + "xDimension": 128.0, + "yDimension": 86.0, + "zDimension": 0 + }, + "displayName": "Slot C4", + "compatibleModuleTypes": [] + }, + { + "id": "D4", + "areaType": "stagingSlot", + "offsetFromCutoutFixture": [164.0, 0.0, 14.5], + "matingSurfaceUnitVector": [-1, 1, -1], + "boundingBox": { + "xDimension": 128.0, + "yDimension": 86.0, + "zDimension": 0 + }, + "displayName": "Slot D4", + "compatibleModuleTypes": [] + }, + { + "id": "movableTrashD1", + "areaType": "movableTrash", + "offsetFromCutoutFixture": [-90.25, 4, 0.0], + "boundingBox": { + "xDimension": 225, + "yDimension": 78, + "zDimension": 40 + }, + "displayName": "Trash Bin in D1", + "ableToDropTips": true + }, + { + "id": "movableTrashC1", + "areaType": "movableTrash", + "offsetFromCutoutFixture": [-90.25, 4, 0.0], + "boundingBox": { + "xDimension": 225, + "yDimension": 78, + "zDimension": 40 + }, + "displayName": "Trash Bin in C1", + "ableToDropTips": true + }, + { + "id": "movableTrashB1", + "areaType": "movableTrash", + "offsetFromCutoutFixture": [-90.25, 4, 0.0], + "boundingBox": { + "xDimension": 225, + "yDimension": 78, + "zDimension": 40 + }, + "displayName": "Trash Bin in B1", + "ableToDropTips": true + }, + { + "id": "movableTrashA1", + "areaType": "movableTrash", + "offsetFromCutoutFixture": [-90.25, 4, 0.0], + "boundingBox": { + "xDimension": 225, + "yDimension": 78, + "zDimension": 40 + }, + "displayName": "Trash Bin in A1", + "ableToDropTips": true + }, + { + "id": "movableTrashD3", + "areaType": "movableTrash", + "offsetFromCutoutFixture": [-6.25, 4, 0.0], + "boundingBox": { + "xDimension": 225, + "yDimension": 78, + "zDimension": 40 + }, + "displayName": "Trash Bin in D3", + "ableToDropTips": true + }, + { + "id": "movableTrashC3", + "areaType": "movableTrash", + "offsetFromCutoutFixture": [-6.25, 4, 0.0], + "boundingBox": { + "xDimension": 225, + "yDimension": 78, + "zDimension": 40 + }, + "displayName": "Trash Bin in C3", + "ableToDropTips": true + }, + { + "id": "movableTrashB3", + "areaType": "movableTrash", + "offsetFromCutoutFixture": [-6.25, 4, 0.0], + "boundingBox": { + "xDimension": 225, + "yDimension": 78, + "zDimension": 40 + }, + "displayName": "Trash Bin in B3", + "ableToDropTips": true + }, + { + "id": "movableTrashA3", + "areaType": "movableTrash", + "offsetFromCutoutFixture": [-6.25, 4, 0.0], + "boundingBox": { + "xDimension": 225, + "yDimension": 78, + "zDimension": 40 + }, + "displayName": "Trash Bin in A3", + "ableToDropTips": true + }, + { + "id": "1ChannelWasteChute", + "areaType": "wasteChute", + "offsetFromCutoutFixture": [64, 36, 114.5], + "boundingBox": { + "xDimension": 0, + "yDimension": 0, + "zDimension": 0 + }, + "displayName": "Waste Chute", + "ableToDropTips": true + }, + { + "id": "8ChannelWasteChute", + "areaType": "wasteChute", + "offsetFromCutoutFixture": [64, -27, 114.5], + "boundingBox": { + "xDimension": 0, + "yDimension": 63, + "zDimension": 0 + }, + "displayName": "Waste Chute", + "ableToDropTips": true + }, + { + "id": "96ChannelWasteChute", + "areaType": "wasteChute", + "offsetFromCutoutFixture": [14.445, -20.915, 114.5], + "boundingBox": { + "xDimension": 99, + "yDimension": 63, + "zDimension": 0 + }, + "displayName": "Waste Chute", + "ableToDropTips": true + }, + { + "id": "gripperWasteChute", + "areaType": "wasteChute", + "offsetFromCutoutFixture": [64, 29, 136.5], + "boundingBox": { + "xDimension": 0, + "yDimension": 0, + "zDimension": 0 + }, + "displayName": "Waste Chute", + "ableToDropLabware": true + }, + { + "id": "thermocyclerModuleV2", + "areaType": "thermocycler", + "offsetFromCutoutFixture": [-20.005, 67.96, 10.96], + "matingSurfaceUnitVector": [-1, 1, -1], + "boundingBox": { + "xDimension": 128.0, + "yDimension": 86.0, + "zDimension": 0 + }, + "displayName": "Thermocycler Module Slot" + }, + { + "id": "heaterShakerV1D1", + "areaType": "heaterShaker", + "offsetFromCutoutFixture": [0, 0, 18.95], + "boundingBox": { + "xDimension": 128.0, + "yDimension": 86.0, + "zDimension": 0 + }, + "displayName": "Heater Shaker in D1" + }, + { + "id": "heaterShakerV1C1", + "areaType": "heaterShaker", + "offsetFromCutoutFixture": [0.0, 0.0, 18.95], + "boundingBox": { + "xDimension": 128.0, + "yDimension": 86.0, + "zDimension": 0 + }, + "displayName": "Heater Shaker in C1" + }, + { + "id": "heaterShakerV1B1", + "areaType": "heaterShaker", + "offsetFromCutoutFixture": [0.0, 0.0, 18.95], + "boundingBox": { + "xDimension": 128.0, + "yDimension": 86.0, + "zDimension": 0 + }, + "displayName": "Heater Shaker in B1" + }, + { + "id": "heaterShakerV1A1", + "areaType": "heaterShaker", + "offsetFromCutoutFixture": [0.0, 0.0, 18.95], + "boundingBox": { + "xDimension": 128.0, + "yDimension": 86.0, + "zDimension": 0 + }, + "displayName": "Heater Shaker in A1" + }, + { + "id": "heaterShakerV1D3", + "areaType": "heaterShaker", + "offsetFromCutoutFixture": [0.0, 0.0, 18.95], + "boundingBox": { + "xDimension": 128.0, + "yDimension": 86.0, + "zDimension": 0 + }, + "displayName": "Heater Shaker in D3" + }, + { + "id": "heaterShakerV1C3", + "areaType": "heaterShaker", + "offsetFromCutoutFixture": [0.0, 0.0, 18.95], + "boundingBox": { + "xDimension": 128.0, + "yDimension": 86.0, + "zDimension": 0 + }, + "displayName": "Heater Shaker in C3" + }, + { + "id": "heaterShakerV1B3", + "areaType": "heaterShaker", + "offsetFromCutoutFixture": [0.0, 0.0, 18.95], + "boundingBox": { + "xDimension": 128.0, + "yDimension": 86.0, + "zDimension": 0 + }, + "displayName": "Heater Shaker in B3" + }, + { + "id": "heaterShakerV1A3", + "areaType": "heaterShaker", + "offsetFromCutoutFixture": [0.0, 0.0, 18.95], + "boundingBox": { + "xDimension": 128.0, + "yDimension": 86.0, + "zDimension": 0 + }, + "displayName": "Heater Shaker in A3" + }, + { + "id": "temperatureModuleV2D1", + "areaType": "temperatureModule", + "offsetFromCutoutFixture": [0.0, 0.0, 9.0], + "boundingBox": { + "xDimension": 128.0, + "yDimension": 86.0, + "zDimension": 0 + }, + "displayName": "Temperature Module in D1" + }, + { + "id": "temperatureModuleV2C1", + "areaType": "temperatureModule", + "offsetFromCutoutFixture": [0.0, 0.0, 9.0], + "boundingBox": { + "xDimension": 128.0, + "yDimension": 86.0, + "zDimension": 0 + }, + "displayName": "Temperature Module in C1" + }, + { + "id": "temperatureModuleV2B1", + "areaType": "temperatureModule", + "offsetFromCutoutFixture": [0.0, 0.0, 9.0], + "boundingBox": { + "xDimension": 128.0, + "yDimension": 86.0, + "zDimension": 0 + }, + "displayName": "Temperature Module in B1" + }, + { + "id": "temperatureModuleV2A1", + "areaType": "temperatureModule", + "offsetFromCutoutFixture": [0.0, 0.0, 9.0], + "boundingBox": { + "xDimension": 128.0, + "yDimension": 86.0, + "zDimension": 0 + }, + "displayName": "Temperature Module in A1" + }, + { + "id": "temperatureModuleV2D3", + "areaType": "temperatureModule", + "offsetFromCutoutFixture": [0.0, 0.0, 9.0], + "boundingBox": { + "xDimension": 128.0, + "yDimension": 86.0, + "zDimension": 0 + }, + "displayName": "Temperature Module in D3" + }, + { + "id": "temperatureModuleV2C3", + "areaType": "temperatureModule", + "offsetFromCutoutFixture": [0.0, 0.0, 9.0], + "boundingBox": { + "xDimension": 128.0, + "yDimension": 86.0, + "zDimension": 0 + }, + "displayName": "Temperature Module in C3" + }, + { + "id": "temperatureModuleV2B3", + "areaType": "temperatureModule", + "offsetFromCutoutFixture": [0.0, 0.0, 9.0], + "boundingBox": { + "xDimension": 128.0, + "yDimension": 86.0, + "zDimension": 0 + }, + "displayName": "Temperature Module in B3" + }, + { + "id": "temperatureModuleV2A3", + "areaType": "temperatureModule", + "offsetFromCutoutFixture": [0.0, 0.0, 9.0], + "boundingBox": { + "xDimension": 128.0, + "yDimension": 86.0, + "zDimension": 0 + }, + "displayName": "Temperature Module in A3" + }, + { + "id": "magneticBlockV1D1", + "areaType": "magneticBlock", + "offsetFromCutoutFixture": [0.0, 0.0, 38], + "boundingBox": { + "xDimension": 128.0, + "yDimension": 86.0, + "zDimension": 0 + }, + "displayName": "Magnetic Block in D1" + }, + { + "id": "magneticBlockV1C1", + "areaType": "magneticBlock", + "offsetFromCutoutFixture": [0.0, 0.0, 38], + "boundingBox": { + "xDimension": 128.0, + "yDimension": 86.0, + "zDimension": 0 + }, + "displayName": "Magnetic Block in C1" + }, + { + "id": "magneticBlockV1B1", + "areaType": "magneticBlock", + "offsetFromCutoutFixture": [0.0, 0.0, 38], + "boundingBox": { + "xDimension": 128.0, + "yDimension": 86.0, + "zDimension": 0 + }, + "displayName": "Magnetic Block in B1" + }, + { + "id": "magneticBlockV1A1", + "areaType": "magneticBlock", + "offsetFromCutoutFixture": [0.0, 0.0, 38], + "boundingBox": { + "xDimension": 128.0, + "yDimension": 86.0, + "zDimension": 0 + }, + "displayName": "Magnetic Block in A1" + }, + { + "id": "magneticBlockV1D2", + "areaType": "magneticBlock", + "offsetFromCutoutFixture": [0.0, 0.0, 38], + "boundingBox": { + "xDimension": 128.0, + "yDimension": 86.0, + "zDimension": 0 + }, + "displayName": "Magnetic Block in D2" + }, + { + "id": "magneticBlockV1C2", + "areaType": "magneticBlock", + "offsetFromCutoutFixture": [0.0, 0.0, 38], + "boundingBox": { + "xDimension": 128.0, + "yDimension": 86.0, + "zDimension": 0 + }, + "displayName": "Magnetic Block in C2" + }, + { + "id": "magneticBlockV1B2", + "areaType": "magneticBlock", + "offsetFromCutoutFixture": [0.0, 0.0, 38], + "boundingBox": { + "xDimension": 128.0, + "yDimension": 86.0, + "zDimension": 0 + }, + "displayName": "Magnetic Block in B2" + }, + { + "id": "magneticBlockV1A2", + "areaType": "magneticBlock", + "offsetFromCutoutFixture": [0.0, 0.0, 38], + "boundingBox": { + "xDimension": 128.0, + "yDimension": 86.0, + "zDimension": 0 + }, + "displayName": "Magnetic Block in A2" + }, + { + "id": "magneticBlockV1D3", + "areaType": "magneticBlock", + "offsetFromCutoutFixture": [0.0, 0.0, 38], + "boundingBox": { + "xDimension": 128.0, + "yDimension": 86.0, + "zDimension": 0 + }, + "displayName": "Magnetic Block in D3" + }, + { + "id": "magneticBlockV1C3", + "areaType": "magneticBlock", + "offsetFromCutoutFixture": [0.0, 0.0, 38], + "boundingBox": { + "xDimension": 128.0, + "yDimension": 86.0, + "zDimension": 0 + }, + "displayName": "Magnetic Block in C3" + }, + { + "id": "magneticBlockV1B3", + "areaType": "magneticBlock", + "offsetFromCutoutFixture": [0.0, 0.0, 38], + "boundingBox": { + "xDimension": 128.0, + "yDimension": 86.0, + "zDimension": 0 + }, + "displayName": "Magnetic Block in B3" + }, + { + "id": "magneticBlockV1A3", + "areaType": "magneticBlock", + "offsetFromCutoutFixture": [0.0, 0.0, 38], + "boundingBox": { + "xDimension": 128.0, + "yDimension": 86.0, + "zDimension": 0 + }, + "displayName": "Magnetic Block in A3" + } + ], + "cutouts": [ + { + "id": "cutoutD1", + "position": [0.0, 0.0, 0.0], + "displayName": "Cutout D1" + }, + { + "id": "cutoutD2", + "position": [164.0, 0.0, 0.0], + "displayName": "Cutout D2" + }, + { + "id": "cutoutD3", + "position": [328.0, 0.0, 0.0], + "displayName": "Cutout D3" + }, + { + "id": "cutoutC1", + "position": [0.0, 107, 0.0], + "displayName": "Cutout C1" + }, + { + "id": "cutoutC2", + "position": [164.0, 107, 0.0], + "displayName": "Cutout C2" + }, + { + "id": "cutoutC3", + "position": [328.0, 107, 0.0], + "displayName": "Cutout C3" + }, + { + "id": "cutoutB1", + "position": [0.0, 214.0, 0.0], + "displayName": "Cutout B1" + }, + { + "id": "cutoutB2", + "position": [164.0, 214.0, 0.0], + "displayName": "Cutout B2" + }, + { + "id": "cutoutB3", + "position": [328.0, 214.0, 0.0], + "displayName": "Cutout B3" + }, + { + "id": "cutoutA1", + "position": [0.0, 321.0, 0.0], + "displayName": "Cutout A1" + }, + { + "id": "cutoutA2", + "position": [164.0, 321.0, 0.0], + "displayName": "Cutout A2" + }, + { + "id": "cutoutA3", + "position": [328.0, 321.0, 0.0], + "displayName": "Cutout A3" + } + ], + "calibrationPoints": [], + "legacyFixtures": [ + { + "id": "fixedTrash", + "slot": "A3", + "labware": "opentrons_1_trash_3200ml_fixed", + "displayName": "Fixed Trash" + } + ] + }, + "cutoutFixtures": [ + { + "id": "singleLeftSlot", + "expectOpentronsModuleSerialNumber": false, + "mayMountTo": ["cutoutD1", "cutoutC1", "cutoutB1", "cutoutA1"], + "displayName": "Standard Slot Left", + "providesAddressableAreas": { + "cutoutD1": ["D1"], + "cutoutC1": ["C1"], + "cutoutB1": ["B1"], + "cutoutA1": ["A1"] + }, + "fixtureGroup": {}, + "height": 0 + }, + { + "id": "singleCenterSlot", + "expectOpentronsModuleSerialNumber": false, + "mayMountTo": ["cutoutD2", "cutoutC2", "cutoutB2", "cutoutA2"], + "displayName": "Standard Slot Center", + "providesAddressableAreas": { + "cutoutD2": ["D2"], + "cutoutC2": ["C2"], + "cutoutB2": ["B2"], + "cutoutA2": ["A2"] + }, + "fixtureGroup": {}, + "height": 0 + }, + { + "id": "singleRightSlot", + "expectOpentronsModuleSerialNumber": false, + "mayMountTo": ["cutoutD3", "cutoutC3", "cutoutB3", "cutoutA3"], + "displayName": "Standard Slot Right", + "providesAddressableAreas": { + "cutoutD3": ["D3"], + "cutoutC3": ["C3"], + "cutoutB3": ["B3"], + "cutoutA3": ["A3"] + }, + "fixtureGroup": {}, + "height": 0 + }, + { + "id": "stagingAreaRightSlot", + "expectOpentronsModuleSerialNumber": false, + "mayMountTo": ["cutoutD3", "cutoutC3", "cutoutB3", "cutoutA3"], + "displayName": "Staging Area Slot", + "providesAddressableAreas": { + "cutoutD3": ["D3", "D4"], + "cutoutC3": ["C3", "C4"], + "cutoutB3": ["B3", "B4"], + "cutoutA3": ["A3", "A4"] + }, + "fixtureGroup": {}, + "height": 0 + }, + { + "id": "trashBinAdapter", + "expectOpentronsModuleSerialNumber": false, + "mayMountTo": [ + "cutoutD1", + "cutoutC1", + "cutoutB1", + "cutoutA1", + "cutoutD3", + "cutoutC3", + "cutoutB3", + "cutoutA3" + ], + "displayName": "Slot With Movable Trash", + "providesAddressableAreas": { + "cutoutD1": ["movableTrashD1"], + "cutoutC1": ["movableTrashC1"], + "cutoutB1": ["movableTrashB1"], + "cutoutA1": ["movableTrashA1"], + "cutoutD3": ["movableTrashD3"], + "cutoutC3": ["movableTrashC3"], + "cutoutB3": ["movableTrashB3"], + "cutoutA3": ["movableTrashA3"] + }, + "fixtureGroup": {}, + "height": 40 + }, + { + "id": "wasteChuteRightAdapterCovered", + "expectOpentronsModuleSerialNumber": false, + "mayMountTo": ["cutoutD3"], + "displayName": "Waste Chute Adapter for 1 or 8 Channel Pipettes", + "providesAddressableAreas": { + "cutoutD3": ["1ChannelWasteChute", "8ChannelWasteChute"] + }, + "fixtureGroup": {}, + "height": 124.5 + }, + { + "id": "wasteChuteRightAdapterNoCover", + "expectOpentronsModuleSerialNumber": false, + "mayMountTo": ["cutoutD3"], + "displayName": "Waste Chute Adapter for 96 Channel Pipette or Gripper", + "providesAddressableAreas": { + "cutoutD3": [ + "1ChannelWasteChute", + "8ChannelWasteChute", + "96ChannelWasteChute", + "gripperWasteChute" + ] + }, + "fixtureGroup": {}, + "height": 124.5 + }, + { + "id": "stagingAreaSlotWithWasteChuteRightAdapterCovered", + "expectOpentronsModuleSerialNumber": false, + "mayMountTo": ["cutoutD3"], + "displayName": "Staging Slot With Waste Chute Adapter for 96 Channel Pipette or Gripper", + "providesAddressableAreas": { + "cutoutD3": ["1ChannelWasteChute", "8ChannelWasteChute", "D4"] + }, + "fixtureGroup": {}, + "height": 124.5 + }, + { + "id": "stagingAreaSlotWithWasteChuteRightAdapterNoCover", + "expectOpentronsModuleSerialNumber": false, + "mayMountTo": ["cutoutD3"], + "displayName": "Staging Slot With Waste Chute Adapter and Staging Area Slot", + "providesAddressableAreas": { + "cutoutD3": [ + "1ChannelWasteChute", + "8ChannelWasteChute", + "96ChannelWasteChute", + "gripperWasteChute", + "D4" + ] + }, + "fixtureGroup": {}, + "height": 124.5 + }, + { + "id": "thermocyclerModuleV2Rear", + "expectOpentronsModuleSerialNumber": true, + "mayMountTo": ["cutoutA1"], + "displayName": "Rear Slot portion of the Thermocycler Moduler", + "providesAddressableAreas": { + "cutoutA1": [] + }, + "fixtureGroup": { + "cutoutA1": [ + { + "cutoutA1": "thermocyclerModuleV2Rear", + "cutoutB1": "thermocyclerModuleV2Front" + } + ] + }, + "height": 72.35 + }, + { + "id": "thermocyclerModuleV2Front", + "expectOpentronsModuleSerialNumber": true, + "mayMountTo": ["cutoutB1"], + "displayName": "Front Slot portion of the Thermocycler Moduler", + "providesAddressableAreas": { + "cutoutB1": ["thermocyclerModuleV2"] + }, + "fixtureGroup": { + "cutoutB1": [ + { + "cutoutA1": "thermocyclerModuleV2Rear", + "cutoutB1": "thermocyclerModuleV2Front" + } + ] + }, + "height": 72.35 + }, + { + "id": "heaterShakerModuleV1", + "expectOpentronsModuleSerialNumber": true, + "mayMountTo": [ + "cutoutD1", + "cutoutC1", + "cutoutB1", + "cutoutA1", + "cutoutD3", + "cutoutC3", + "cutoutB3", + "cutoutA3" + ], + "displayName": "Slot With a Heater Shaker", + "providesAddressableAreas": { + "cutoutD1": ["heaterShakerV1D1"], + "cutoutC1": ["heaterShakerV1C1"], + "cutoutB1": ["heaterShakerV1B1"], + "cutoutA1": ["heaterShakerV1A1"], + "cutoutD3": ["heaterShakerV1D3"], + "cutoutC3": ["heaterShakerV1C3"], + "cutoutB3": ["heaterShakerV1B3"], + "cutoutA3": ["heaterShakerV1A3"] + }, + "fixtureGroup": {}, + "height": 18.95 + }, + { + "id": "temperatureModuleV2", + "expectOpentronsModuleSerialNumber": true, + "mayMountTo": [ + "cutoutD1", + "cutoutC1", + "cutoutB1", + "cutoutA1", + "cutoutD3", + "cutoutC3", + "cutoutB3", + "cutoutA3" + ], + "displayName": "Slot With a Temperature Module", + "providesAddressableAreas": { + "cutoutD1": ["temperatureModuleV2D1"], + "cutoutC1": ["temperatureModuleV2C1"], + "cutoutB1": ["temperatureModuleV2B1"], + "cutoutA1": ["temperatureModuleV2A1"], + "cutoutD3": ["temperatureModuleV2D3"], + "cutoutC3": ["temperatureModuleV2C3"], + "cutoutB3": ["temperatureModuleV2B3"], + "cutoutA3": ["temperatureModuleV2A3"] + }, + "fixtureGroup": {}, + "height": 9.0 + }, + { + "id": "magneticBlockV1", + "expectOpentronsModuleSerialNumber": false, + "mayMountTo": [ + "cutoutD1", + "cutoutC1", + "cutoutB1", + "cutoutA1", + "cutoutD2", + "cutoutC2", + "cutoutB2", + "cutoutA2", + "cutoutD3", + "cutoutC3", + "cutoutB3", + "cutoutA3" + ], + "displayName": "Slot With a Magnetic Block", + "providesAddressableAreas": { + "cutoutD1": ["magneticBlockV1D1"], + "cutoutC1": ["magneticBlockV1C1"], + "cutoutB1": ["magneticBlockV1B1"], + "cutoutA1": ["magneticBlockV1A1"], + "cutoutD2": ["magneticBlockV1D2"], + "cutoutC2": ["magneticBlockV1C2"], + "cutoutB2": ["magneticBlockV1B2"], + "cutoutA2": ["magneticBlockV1A2"], + "cutoutD3": ["magneticBlockV1D3"], + "cutoutC3": ["magneticBlockV1C3"], + "cutoutB3": ["magneticBlockV1B3"], + "cutoutA3": ["magneticBlockV1A3"] + }, + "fixtureGroup": {}, + "height": 38.0 + }, + { + "id": "stagingAreaSlotWithMagneticBlockV1", + "expectOpentronsModuleSerialNumber": false, + "mayMountTo": ["cutoutD3", "cutoutC3", "cutoutB3", "cutoutA3"], + "displayName": "Fixture that provides a Magnetic Block and a Staging Area Slot", + "providesAddressableAreas": { + "cutoutD3": ["magneticBlockV1D3", "D4"], + "cutoutC3": ["magneticBlockV1C3", "C4"], + "cutoutB3": ["magneticBlockV1B3", "B4"], + "cutoutA3": ["magneticBlockV1A3", "A4"] + }, + "fixtureGroup": {}, + "height": 38.0 + } + ], + "gripperOffsets": { + "default": { + "pickUpOffset": { + "x": 0, + "y": 0, + "z": 0 + }, + "dropOffset": { + "x": 0, + "y": 0, + "z": -0.75 + } + } + } +} diff --git a/shared-data/deck/index.ts b/shared-data/deck/index.ts index e308d7a17ad..7d68bdeebd9 100644 --- a/shared-data/deck/index.ts +++ b/shared-data/deck/index.ts @@ -8,11 +8,16 @@ import ot2StandardDeckV4 from './definitions/4/ot2_standard.json' import ot2ShortFixedTrashDeckV4 from './definitions/4/ot2_short_trash.json' import ot3StandardDeckV4 from './definitions/4/ot3_standard.json' +// v5 deck defs +import ot2StandardDeckV5 from './definitions/5/ot2_standard.json' +import ot2ShortFixedTrashDeckV5 from './definitions/5/ot2_short_trash.json' +import ot3StandardDeckV5 from './definitions/5/ot3_standard.json' + import deckExample from './fixtures/3/deckExample.json' import type { DeckDefinition } from '../js/types' -export * from './types/schemaV4' +export * from './types/schemaV5' export { ot2StandardDeckV3, @@ -21,13 +26,16 @@ export { ot2StandardDeckV4, ot2ShortFixedTrashDeckV4, ot3StandardDeckV4, + ot2StandardDeckV5, + ot2ShortFixedTrashDeckV5, + ot3StandardDeckV5, deckExample, } const latestDeckDefinitions = { - ot2StandardDeckV4, - ot2ShortFixedTrashDeckV4, - ot3StandardDeckV4, + ot2StandardDeckV5, + ot2ShortFixedTrashDeckV5, + ot3StandardDeckV5, } export function getDeckDefinitions(): Record { diff --git a/shared-data/deck/schemas/5.json b/shared-data/deck/schemas/5.json new file mode 100644 index 00000000000..da77152da13 --- /dev/null +++ b/shared-data/deck/schemas/5.json @@ -0,0 +1,338 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "opentronsDeckSchemaV5", + "definitions": { + "positiveNumber": { + "type": "number", + "minimum": 0 + }, + "xyzArray": { + "type": "array", + "description": "Array of 3 numbers, [x, y, z]", + "items": { "type": "number" }, + "minItems": 3, + "maxItems": 3 + }, + "coordinates": { + "type": "object", + "additionalProperties": false, + "required": ["x", "y", "z"], + "properties": { + "x": { + "type": "number" + }, + "y": { + "type": "number" + }, + "z": { + "type": "number" + } + } + }, + "unitVector": { + "type": "array", + "description": "Array of 3 unit directions, [x, y, z]", + "items": { + "type": "number", + "enum": [1, -1] + }, + "minItems": 3, + "maxItems": 3 + }, + "boundingBox": { + "type": "object", + "required": ["xDimension", "yDimension", "zDimension"], + "properties": { + "xDimension": { "$ref": "#/definitions/positiveNumber" }, + "yDimension": { "$ref": "#/definitions/positiveNumber" }, + "zDimension": { "$ref": "#/definitions/positiveNumber" } + } + } + }, + "description": "Deck specifications, where x,y,z (0,0,0) is at front the bottom left corner.", + "type": "object", + "additionalProperties": false, + "required": [ + "otId", + "schemaVersion", + "cornerOffsetFromOrigin", + "dimensions", + "metadata", + "robot", + "locations", + "cutoutFixtures" + ], + "properties": { + "otId": { + "description": "Unique internal ID generated using UUID", + "type": "string" + }, + "schemaVersion": { + "description": "Schema version of a deck is a single integer", + "enum": [5] + }, + "cornerOffsetFromOrigin": { + "$ref": "#/definitions/xyzArray", + "description": "Position of left-front-bottom corner of entire deck to robot coordinate system origin" + }, + "dimensions": { + "$ref": "#/definitions/xyzArray", + "description": "Outer dimensions of a deck bounding box" + }, + "metadata": { + "description": "Optional metadata about the Deck", + "type": "object", + "properties": { + "displayName": { + "description": "A short, human-readable name for the deck", + "type": "string" + }, + "tags": { + "description": "Tags to be used in searching for this deck", + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "robot": { + "type": "object", + "required": ["model"], + "properties": { + "model": { + "description": "Model of the robot", + "type": "string", + "enum": ["OT-2 Standard", "OT-3 Standard"] + } + } + }, + "locations": { + "type": "object", + "required": [ + "addressableAreas", + "calibrationPoints", + "cutouts", + "legacyFixtures" + ], + "properties": { + "addressableAreas": { + "type": "array", + "items": { + "type": "object", + "description": "An addressable area is a named area in 3D space that the robot can interact with--for example, as a place to drop tips, or hold a labware.", + "required": [ + "id", + "areaType", + "offsetFromCutoutFixture", + "boundingBox", + "displayName" + ], + "properties": { + "id": { + "description": "Unique identifier for slot", + "type": "string" + }, + "areaType": { + "description": "The type of addressable area, defining allowed behavior.", + "type": "string", + "enum": [ + "slot", + "stagingSlot", + "movableTrash", + "fixedTrash", + "wasteChute" + ] + }, + "offsetFromCutoutFixture": { + "$ref": "#/definitions/xyzArray", + "description": "The offset from the origin of the cutout fixture that's providing this addressable area (which is currently identical to the position of the underlying cutout), to the -x, -y, -z corner of this addressable area's bounding box." + }, + "matingSurfaceUnitVector": { + "$ref": "#/definitions/unitVector", + "description": "An optional diagonal direction of force, defined by spring location, which governs the mating surface of objects placed in this addressable area. Meant to be used when this addressable area is a slot." + }, + "boundingBox": { + "description": "The active area (both pipettes can reach) of this addressable area.", + "$ref": "#/definitions/boundingBox" + }, + "displayName": { + "description": "A human-readable nickname for this area e.g. \"Slot A1\" or \"Trash Bin in A1\"", + "type": "string" + }, + "compatibleModuleTypes": { + "description": "OT-2 Only parameter. An array of module types that can be placed in this area. The module type names can be found in the moduleType field of a module definition.", + "type": "array", + "items": { + "type": "string" + } + }, + "ableToDropTips": { + "description": "Whether tips are allowed to be dropped into this area. If `true`, the top-center of the `boundingBox` should be a good location for the bottom-center of all the tips when they're dropped.", + "type": "boolean" + }, + "ableToDropLabware": { + "description": "Whether labware is allowed to be dropped (different from being placed) into this area. If `true`, the top-center of the `boundingBox` should be a good location for the bottom-center of the labware when it's dropped.", + "type": "boolean" + } + } + } + }, + "calibrationPoints": { + "type": "array", + "description": "Key points for deck calibration", + "items": { + "type": "object", + "required": ["id", "position", "displayName"], + "properties": { + "id": { + "description": "Unique identifier for calibration point", + "type": "string" + }, + "position": { + "$ref": "#/definitions/xyzArray" + }, + "displayName": { + "description": "An optional human-readable nickname for this point Eg \"Slot 3 Cross\" or \"Slot 1 Dot\"", + "type": "string" + } + } + } + }, + "cutouts": { + "type": "array", + "description": "The machined cutout slots on the deck surface.", + "items": { + "type": "object", + "required": ["id", "position", "displayName"], + "properties": { + "id": { + "description": "Unique identifier for the cutout", + "type": "string" + }, + "position": { + "description": "Absolute position of the cutout", + "$ref": "#/definitions/xyzArray" + }, + "displayName": { + "description": "An optional human-readable nickname for this cutout e.g. \"Cutout A1\"", + "type": "string" + } + } + } + }, + "legacyFixtures": { + "type": "array", + "description": "Fixed position objects on the deck.", + "items": { + "type": "object", + "required": ["id", "displayName"], + "properties": { + "id": { + "description": "Unique identifier for fixed object", + "type": "string" + }, + "labware": { + "description": "Valid labware loadName for fixed object", + "type": "string" + }, + "slot": { + "description": "Slot location of the fixed object", + "type": "string" + }, + "displayName": { + "description": "An optional human-readable nickname for this fixture Eg \"Tall Fixed Trash\" or \"Short Fixed Trash\"", + "type": "string" + } + } + } + } + } + }, + "cutoutFixtures": { + "type": "array", + "items": { + "description": "A cutout fixture is a physical thing that can be mounted onto one of the deck cutouts.", + "type": "object", + "required": [ + "id", + "expectOpentronsModuleSerialNumber", + "mayMountTo", + "displayName", + "providesAddressableAreas", + "fixtureGroup", + "height" + ], + "properties": { + "id": { + "description": "Unique identifier for the cutout fixture.", + "type": "string" + }, + "expectOpentronsModuleSerialNumber": { + "description": "Determines whether or not a fixture expects a serial number for a connected Opentrons Module.", + "type": "boolean" + }, + "mayMountTo": { + "description": "A list of compatible cutouts this fixture may be mounted to. These must match `id`s in `cutouts`.", + "type": "array", + "items": { + "type": "string" + } + }, + "displayName": { + "description": "A human-readable nickname for this area e.g. \"Standard Right Slot\" or \"Slot With Movable Trash\"", + "type": "string" + }, + "providesAddressableAreas": { + "description": "The addressable areas that this cutout fixture provides, when it's mounted. It can provide different addressable areas depending on where it's mounted. Keys must match values from this object's `mayMountTo`. Values must match `id`s from `addressableAreas`.", + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "fixtureGroup": { + "description": "The map of fixtures that must exist in the deck configuration if this fixture exists, with the mounting location acting as a key to determine the location of the rest of the group.", + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + }, + "height": { + "description": "The vertical distance (mm) from the cutout fixture's origin to its tallest physical feature that an instrument could collide with.", + "type": "number" + } + } + } + }, + "gripperOffsets": { + "type": "object", + "description": "Offsets to be added when calculating the coordinates a gripper should go to when picking up or dropping a labware on this deck.", + "properties": { + "default": { + "type": "object", + "properties": { + "pickUpOffset": { + "$ref": "#/definitions/coordinates", + "description": "Offset added to calculate pick-up coordinates of a labware placed on this deck." + }, + "dropOffset": { + "$ref": "#/definitions/coordinates", + "description": "Offset added to calculate drop coordinates of a labware placed on this deck." + } + }, + "required": ["pickUpOffset", "dropOffset"] + } + }, + "required": ["default"] + } + } +} diff --git a/shared-data/deck/types/schemaV5.ts b/shared-data/deck/types/schemaV5.ts new file mode 100644 index 00000000000..e763b893bde --- /dev/null +++ b/shared-data/deck/types/schemaV5.ts @@ -0,0 +1,141 @@ +export type FlexAddressableAreaName = + | 'A1' + | 'B1' + | 'C1' + | 'D1' + | 'A2' + | 'B2' + | 'C2' + | 'D2' + | 'A3' + | 'B3' + | 'C3' + | 'D3' + | 'A4' + | 'B4' + | 'C4' + | 'D4' + | 'movableTrashA1' + | 'movableTrashB1' + | 'movableTrashC1' + | 'movableTrashD1' + | 'movableTrashA3' + | 'movableTrashB3' + | 'movableTrashC3' + | 'movableTrashD3' + | '1ChannelWasteChute' + | '8ChannelWasteChute' + | '96ChannelWasteChute' + | 'gripperWasteChute' + | 'thermocyclerModuleV2' + | 'heaterShakerV1A1' + | 'heaterShakerV1B1' + | 'heaterShakerV1C1' + | 'heaterShakerV1D1' + | 'heaterShakerV1A3' + | 'heaterShakerV1B3' + | 'heaterShakerV1C3' + | 'heaterShakerV1D3' + | 'temperatureModuleV2A1' + | 'temperatureModuleV2B1' + | 'temperatureModuleV2C1' + | 'temperatureModuleV2D1' + | 'temperatureModuleV2A3' + | 'temperatureModuleV2B3' + | 'temperatureModuleV2C3' + | 'temperatureModuleV2D3' + | 'magneticBlockV1A1' + | 'magneticBlockV1B1' + | 'magneticBlockV1C1' + | 'magneticBlockV1D1' + | 'magneticBlockV1A2' + | 'magneticBlockV1B2' + | 'magneticBlockV1C2' + | 'magneticBlockV1D2' + | 'magneticBlockV1A3' + | 'magneticBlockV1B3' + | 'magneticBlockV1C3' + | 'magneticBlockV1D3' + +export type OT2AddressableAreaName = + | '1' + | '2' + | '3' + | '4' + | '5' + | '6' + | '7' + | '8' + | '9' + | '10' + | '11' + | '12' + | 'fixedTrash' + +export type AddressableAreaName = + | FlexAddressableAreaName + | OT2AddressableAreaName + +export type CutoutId = + | 'cutoutD1' + | 'cutoutD2' + | 'cutoutD3' + | 'cutoutC1' + | 'cutoutC2' + | 'cutoutC3' + | 'cutoutB1' + | 'cutoutB2' + | 'cutoutB3' + | 'cutoutA1' + | 'cutoutA2' + | 'cutoutA3' + +export type OT2CutoutId = + | 'cutout1' + | 'cutout2' + | 'cutout3' + | 'cutout4' + | 'cutout5' + | 'cutout6' + | 'cutout7' + | 'cutout8' + | 'cutout9' + | 'cutout10' + | 'cutout11' + | 'cutout12' + +export type SingleSlotCutoutFixtureId = + | 'singleLeftSlot' + | 'singleCenterSlot' + | 'singleRightSlot' + +export type StagingAreaRightSlotFixtureId = 'stagingAreaRightSlot' + +export type TrashBinAdapterCutoutFixtureId = 'trashBinAdapter' + +export type WasteChuteCutoutFixtureId = + | 'wasteChuteRightAdapterCovered' + | 'wasteChuteRightAdapterNoCover' + | 'stagingAreaSlotWithWasteChuteRightAdapterCovered' + | 'stagingAreaSlotWithWasteChuteRightAdapterNoCover' + +export type FlexModuleCutoutFixtureId = + | 'heaterShakerModuleV1' + | 'temperatureModuleV2' + | 'magneticBlockV1' + | 'stagingAreaSlotWithMagneticBlockV1' + | 'thermocyclerModuleV2Rear' + | 'thermocyclerModuleV2Front' + +export type OT2SingleStandardSlot = 'singleStandardSlot' + +export type OT2FixedTrashSlot = 'fixedTrashSlot' + +export type CutoutFixtureId = + | SingleSlotCutoutFixtureId + | StagingAreaRightSlotFixtureId + | TrashBinAdapterCutoutFixtureId + | WasteChuteCutoutFixtureId + | FlexModuleCutoutFixtureId + | OT2SingleStandardSlot + | OT2FixedTrashSlot diff --git a/shared-data/js/__tests__/pipettes.test.ts b/shared-data/js/__tests__/pipettes.test.ts index 6eae38eba66..15c72cd9882 100644 --- a/shared-data/js/__tests__/pipettes.test.ts +++ b/shared-data/js/__tests__/pipettes.test.ts @@ -158,6 +158,7 @@ describe('pipette data accessors', () => { minVolume: 5, supportedTips: { t50: { + uiMaxFlowRate: 47, aspirate: { default: { 1: expect.anything(), @@ -205,27 +206,28 @@ describe('pipette data accessors', () => { minVolume: 1, supportedTips: { t50: { + uiMaxFlowRate: 26.7, aspirate: { default: { 1: expect.anything(), }, }, defaultAspirateFlowRate: { - default: 35, + default: 26.7, valuesByApiLevel: { - 2.14: 35, + 2.14: 26.7, }, }, defaultBlowOutFlowRate: { - default: 57, + default: 26.7, valuesByApiLevel: { - 2.14: 57, + 2.14: 26.7, }, }, defaultDispenseFlowRate: { - default: 57, + default: 26.7, valuesByApiLevel: { - 2.14: 57, + 2.14: 26.7, }, }, defaultFlowAcceleration: 1200, diff --git a/shared-data/js/constants.ts b/shared-data/js/constants.ts index 1b944418e0e..aaef2eb2430 100644 --- a/shared-data/js/constants.ts +++ b/shared-data/js/constants.ts @@ -1,5 +1,5 @@ import type { CutoutFixtureId, CutoutId, AddressableAreaName } from '../deck' -import type { ModuleType } from './types' +import type { ModuleModel, ModuleType } from './types' // constants for dealing with robot coordinate system (eg in labwareTools) export const SLOT_LENGTH_MM = 127.76 // along X axis in robot coordinate system @@ -230,6 +230,16 @@ export const STAGING_AREA_CUTOUTS: CutoutId[] = [ 'cutoutD3', ] +export const TEMPERATURE_MODULE_CUTOUTS: CutoutId[] = [ + ...SINGLE_RIGHT_CUTOUTS, + ...SINGLE_LEFT_CUTOUTS, +] +export const HEATER_SHAKER_CUTOUTS: CutoutId[] = [ + ...SINGLE_RIGHT_CUTOUTS, + ...SINGLE_LEFT_CUTOUTS, +] +export const THERMOCYCLER_MODULE_CUTOUTS: CutoutId[] = ['cutoutA1', 'cutoutB1'] + export const WASTE_CHUTE_CUTOUT: 'cutoutD3' = 'cutoutD3' export const A1_ADDRESSABLE_AREA: 'A1' = 'A1' @@ -275,6 +285,98 @@ export const NINETY_SIX_CHANNEL_WASTE_CHUTE_ADDRESSABLE_AREA: '96ChannelWasteChu export const GRIPPER_WASTE_CHUTE_ADDRESSABLE_AREA: 'gripperWasteChute' = 'gripperWasteChute' +export const THERMOCYCLER_ADDRESSABLE_AREA: 'thermocyclerModuleV2' = + 'thermocyclerModuleV2' +export const HEATERSHAKER_A1_ADDRESSABLE_AREA: 'heaterShakerV1A1' = + 'heaterShakerV1A1' +export const HEATERSHAKER_B1_ADDRESSABLE_AREA: 'heaterShakerV1B1' = + 'heaterShakerV1B1' +export const HEATERSHAKER_C1_ADDRESSABLE_AREA: 'heaterShakerV1C1' = + 'heaterShakerV1C1' +export const HEATERSHAKER_D1_ADDRESSABLE_AREA: 'heaterShakerV1D1' = + 'heaterShakerV1D1' +export const HEATERSHAKER_A3_ADDRESSABLE_AREA: 'heaterShakerV1A3' = + 'heaterShakerV1A3' +export const HEATERSHAKER_B3_ADDRESSABLE_AREA: 'heaterShakerV1B3' = + 'heaterShakerV1B3' +export const HEATERSHAKER_C3_ADDRESSABLE_AREA: 'heaterShakerV1C3' = + 'heaterShakerV1C3' +export const HEATERSHAKER_D3_ADDRESSABLE_AREA: 'heaterShakerV1D3' = + 'heaterShakerV1D3' +export const TEMPERATURE_MODULE_A1_ADDRESSABLE_AREA: 'temperatureModuleV2A1' = + 'temperatureModuleV2A1' +export const TEMPERATURE_MODULE_B1_ADDRESSABLE_AREA: 'temperatureModuleV2B1' = + 'temperatureModuleV2B1' +export const TEMPERATURE_MODULE_C1_ADDRESSABLE_AREA: 'temperatureModuleV2C1' = + 'temperatureModuleV2C1' +export const TEMPERATURE_MODULE_D1_ADDRESSABLE_AREA: 'temperatureModuleV2D1' = + 'temperatureModuleV2D1' +export const TEMPERATURE_MODULE_A3_ADDRESSABLE_AREA: 'temperatureModuleV2A3' = + 'temperatureModuleV2A3' +export const TEMPERATURE_MODULE_B3_ADDRESSABLE_AREA: 'temperatureModuleV2B3' = + 'temperatureModuleV2B3' +export const TEMPERATURE_MODULE_C3_ADDRESSABLE_AREA: 'temperatureModuleV2C3' = + 'temperatureModuleV2C3' +export const TEMPERATURE_MODULE_D3_ADDRESSABLE_AREA: 'temperatureModuleV2D3' = + 'temperatureModuleV2D3' + +export const MAGNETIC_BLOCK_A1_ADDRESSABLE_AREA: 'magneticBlockV1A1' = + 'magneticBlockV1A1' +export const MAGNETIC_BLOCK_B1_ADDRESSABLE_AREA: 'magneticBlockV1B1' = + 'magneticBlockV1B1' +export const MAGNETIC_BLOCK_C1_ADDRESSABLE_AREA: 'magneticBlockV1C1' = + 'magneticBlockV1C1' +export const MAGNETIC_BLOCK_D1_ADDRESSABLE_AREA: 'magneticBlockV1D1' = + 'magneticBlockV1D1' +export const MAGNETIC_BLOCK_A2_ADDRESSABLE_AREA: 'magneticBlockV1A2' = + 'magneticBlockV1A2' +export const MAGNETIC_BLOCK_B2_ADDRESSABLE_AREA: 'magneticBlockV1B2' = + 'magneticBlockV1B2' +export const MAGNETIC_BLOCK_C2_ADDRESSABLE_AREA: 'magneticBlockV1C2' = + 'magneticBlockV1C2' +export const MAGNETIC_BLOCK_D2_ADDRESSABLE_AREA: 'magneticBlockV1D2' = + 'magneticBlockV1D2' +export const MAGNETIC_BLOCK_A3_ADDRESSABLE_AREA: 'magneticBlockV1A3' = + 'magneticBlockV1A3' +export const MAGNETIC_BLOCK_B3_ADDRESSABLE_AREA: 'magneticBlockV1B3' = + 'magneticBlockV1B3' +export const MAGNETIC_BLOCK_C3_ADDRESSABLE_AREA: 'magneticBlockV1C3' = + 'magneticBlockV1C3' +export const MAGNETIC_BLOCK_D3_ADDRESSABLE_AREA: 'magneticBlockV1D3' = + 'magneticBlockV1D3' + +export const FLEX_MODULE_ADDRESSABLE_AREAS: AddressableAreaName[] = [ + THERMOCYCLER_ADDRESSABLE_AREA, + HEATERSHAKER_A1_ADDRESSABLE_AREA, + HEATERSHAKER_B1_ADDRESSABLE_AREA, + HEATERSHAKER_C1_ADDRESSABLE_AREA, + HEATERSHAKER_D1_ADDRESSABLE_AREA, + HEATERSHAKER_A3_ADDRESSABLE_AREA, + HEATERSHAKER_B3_ADDRESSABLE_AREA, + HEATERSHAKER_C3_ADDRESSABLE_AREA, + HEATERSHAKER_D3_ADDRESSABLE_AREA, + TEMPERATURE_MODULE_A1_ADDRESSABLE_AREA, + TEMPERATURE_MODULE_B1_ADDRESSABLE_AREA, + TEMPERATURE_MODULE_C1_ADDRESSABLE_AREA, + TEMPERATURE_MODULE_D1_ADDRESSABLE_AREA, + TEMPERATURE_MODULE_A3_ADDRESSABLE_AREA, + TEMPERATURE_MODULE_B3_ADDRESSABLE_AREA, + TEMPERATURE_MODULE_C3_ADDRESSABLE_AREA, + TEMPERATURE_MODULE_D3_ADDRESSABLE_AREA, + MAGNETIC_BLOCK_A1_ADDRESSABLE_AREA, + MAGNETIC_BLOCK_B1_ADDRESSABLE_AREA, + MAGNETIC_BLOCK_C1_ADDRESSABLE_AREA, + MAGNETIC_BLOCK_D1_ADDRESSABLE_AREA, + MAGNETIC_BLOCK_A2_ADDRESSABLE_AREA, + MAGNETIC_BLOCK_B2_ADDRESSABLE_AREA, + MAGNETIC_BLOCK_C2_ADDRESSABLE_AREA, + MAGNETIC_BLOCK_D2_ADDRESSABLE_AREA, + MAGNETIC_BLOCK_A3_ADDRESSABLE_AREA, + MAGNETIC_BLOCK_B3_ADDRESSABLE_AREA, + MAGNETIC_BLOCK_C3_ADDRESSABLE_AREA, + MAGNETIC_BLOCK_D3_ADDRESSABLE_AREA, +] + export const ADDRESSABLE_AREA_1: '1' = '1' export const ADDRESSABLE_AREA_2: '2' = '2' export const ADDRESSABLE_AREA_3: '3' = '3' @@ -359,6 +461,30 @@ export const STAGING_AREA_SLOT_WITH_WASTE_CHUTE_RIGHT_ADAPTER_COVERED_FIXTURE: ' export const STAGING_AREA_SLOT_WITH_WASTE_CHUTE_RIGHT_ADAPTER_NO_COVER_FIXTURE: 'stagingAreaSlotWithWasteChuteRightAdapterNoCover' = 'stagingAreaSlotWithWasteChuteRightAdapterNoCover' +export const HEATERSHAKER_MODULE_V1_FIXTURE: 'heaterShakerModuleV1' = + 'heaterShakerModuleV1' +export const TEMPERATURE_MODULE_V2_FIXTURE: 'temperatureModuleV2' = + 'temperatureModuleV2' +export const MAGNETIC_BLOCK_V1_FIXTURE: 'magneticBlockV1' = 'magneticBlockV1' +export const STAGING_AREA_SLOT_WITH_MAGNETIC_BLOCK_V1_FIXTURE: 'stagingAreaSlotWithMagneticBlockV1' = + 'stagingAreaSlotWithMagneticBlockV1' +export const THERMOCYCLER_V2_REAR_FIXTURE: 'thermocyclerModuleV2Rear' = + 'thermocyclerModuleV2Rear' +export const THERMOCYCLER_V2_FRONT_FIXTURE: 'thermocyclerModuleV2Front' = + 'thermocyclerModuleV2Front' + +export const MODULE_FIXTURES_BY_MODEL: { + [moduleModel in ModuleModel]?: CutoutFixtureId[] +} = { + [HEATERSHAKER_MODULE_V1]: [HEATERSHAKER_MODULE_V1_FIXTURE], + [TEMPERATURE_MODULE_V2]: [TEMPERATURE_MODULE_V2_FIXTURE], + [MAGNETIC_BLOCK_V1]: [MAGNETIC_BLOCK_V1_FIXTURE], + [THERMOCYCLER_MODULE_V2]: [ + THERMOCYCLER_V2_REAR_FIXTURE, + THERMOCYCLER_V2_FRONT_FIXTURE, + ], +} + export const SINGLE_SLOT_FIXTURES: CutoutFixtureId[] = [ SINGLE_LEFT_SLOT_FIXTURE, SINGLE_CENTER_SLOT_FIXTURE, diff --git a/shared-data/js/deck/index.ts b/shared-data/js/deck/index.ts index 786325be5a7..fce6ffb05fe 100644 --- a/shared-data/js/deck/index.ts +++ b/shared-data/js/deck/index.ts @@ -1,5 +1,5 @@ -import flexDeckDefV4 from '../../deck/definitions/4/ot3_standard.json' -import ot2DeckDefV4 from '../../deck/definitions/4/ot2_standard.json' -import ot2DeckDefShortFixedTrashV4 from '../../deck/definitions/4/ot2_short_trash.json' +import flexDeckDefV5 from '../../deck/definitions/5/ot3_standard.json' +import ot2DeckDefV5 from '../../deck/definitions/5/ot2_standard.json' +import ot2DeckDefShortFixedTrashV5 from '../../deck/definitions/5/ot2_short_trash.json' -export { ot2DeckDefV4, ot2DeckDefShortFixedTrashV4, flexDeckDefV4 } +export { ot2DeckDefV5, ot2DeckDefShortFixedTrashV5, flexDeckDefV5 } diff --git a/shared-data/js/fixtures.ts b/shared-data/js/fixtures.ts index 7e2f117bca8..057bce01503 100644 --- a/shared-data/js/fixtures.ts +++ b/shared-data/js/fixtures.ts @@ -6,9 +6,57 @@ import { WASTE_CHUTE_RIGHT_ADAPTER_NO_COVER_FIXTURE, STAGING_AREA_SLOT_WITH_WASTE_CHUTE_RIGHT_ADAPTER_COVERED_FIXTURE, STAGING_AREA_SLOT_WITH_WASTE_CHUTE_RIGHT_ADAPTER_NO_COVER_FIXTURE, + A1_ADDRESSABLE_AREA, + A2_ADDRESSABLE_AREA, + A3_ADDRESSABLE_AREA, + B1_ADDRESSABLE_AREA, + B2_ADDRESSABLE_AREA, + B3_ADDRESSABLE_AREA, + C1_ADDRESSABLE_AREA, + C2_ADDRESSABLE_AREA, + C3_ADDRESSABLE_AREA, + D1_ADDRESSABLE_AREA, + D2_ADDRESSABLE_AREA, + D3_ADDRESSABLE_AREA, + ADDRESSABLE_AREA_1, + ADDRESSABLE_AREA_2, + ADDRESSABLE_AREA_3, + ADDRESSABLE_AREA_4, + ADDRESSABLE_AREA_5, + ADDRESSABLE_AREA_6, + ADDRESSABLE_AREA_7, + ADDRESSABLE_AREA_8, + ADDRESSABLE_AREA_9, + ADDRESSABLE_AREA_10, + ADDRESSABLE_AREA_11, + HEATERSHAKER_MODULE_V1_FIXTURE, + HEATERSHAKER_MODULE_V1, + TEMPERATURE_MODULE_V2_FIXTURE, + TEMPERATURE_MODULE_V2, + MAGNETIC_BLOCK_V1_FIXTURE, + MAGNETIC_BLOCK_V1, + THERMOCYCLER_V2_REAR_FIXTURE, + THERMOCYCLER_MODULE_V2, + THERMOCYCLER_V2_FRONT_FIXTURE, + MODULE_FIXTURES_BY_MODEL, + STAGING_AREA_SLOT_WITH_MAGNETIC_BLOCK_V1_FIXTURE, } from './constants' -import type { CutoutFixtureId, CutoutId, OT2CutoutId } from '../deck' -import type { AddressableArea, CoordinateTuple, DeckDefinition } from './types' +import { getModuleDisplayName } from './modules' +import { getCutoutIdForSlotName } from './helpers' +import type { + AddressableAreaName, + CutoutFixtureId, + CutoutId, + OT2CutoutId, +} from '../deck' +import type { + AddressableArea, + CoordinateTuple, + CutoutFixture, + DeckDefinition, + ModuleModel, +} from './types' +import type { ModuleLocation } from '../command' export function getCutoutDisplayName(cutout: CutoutId): string { return cutout.replace('cutout', '') @@ -107,66 +155,172 @@ export function getAddressableAreaFromSlotId( ) } +export function getCutoutFixtureIdsForModuleModel( + moduleModel: ModuleModel +): CutoutFixtureId[] { + const moduleFixtures = MODULE_FIXTURES_BY_MODEL[moduleModel] + return moduleFixtures ?? [] +} + +export function getCutoutFixturesForModuleModel( + moduleModel: ModuleModel, + deckDef: DeckDefinition +): CutoutFixture[] { + const moduleFixtureIds = getCutoutFixtureIdsForModuleModel(moduleModel) + return moduleFixtureIds.reduce((acc, id) => { + const moduleFixture = deckDef.cutoutFixtures.find(cf => cf.id === id) + return moduleFixture != null ? [...acc, moduleFixture] : acc + }, []) +} + +export function getFixtureIdByCutoutIdFromModuleAnchorCutoutId( + anchorCutoutId: CutoutId | null, + moduleFixtures: CutoutFixture[] // cutout fixtures for a specific module model +): { [cutoutId in CutoutId]?: CutoutFixtureId } { + // find the first fixture for this specific module model that may mount to the cutout implied by the slotName + const anchorFixture = moduleFixtures.find(fixture => + fixture.mayMountTo.some(cutoutId => cutoutId === anchorCutoutId) + ) + if (anchorCutoutId != null && anchorFixture != null) { + const groupedFixtures = anchorFixture.fixtureGroup[anchorCutoutId] + return groupedFixtures?.[0] ?? { [anchorCutoutId]: anchorFixture.id } + } + return {} +} + +export function getFixtureIdByCutoutIdFromModuleSlotName( + slotName: string, + moduleFixtures: CutoutFixture[], // cutout fixtures for a specific module model + deckDef: DeckDefinition +): { [cutoutId in CutoutId]?: CutoutFixtureId } { + const anchorCutoutId = getCutoutIdForSlotName(slotName, deckDef) + return getFixtureIdByCutoutIdFromModuleAnchorCutoutId( + anchorCutoutId, + moduleFixtures + ) +} + +export function getCutoutIdsFromModuleSlotName( + slotName: string, + moduleFixtures: CutoutFixture[], // cutout fixtures for a specific module model + deckDef: DeckDefinition +): CutoutId[] { + const fixtureIdByCutoutId = getFixtureIdByCutoutIdFromModuleSlotName( + slotName, + moduleFixtures, + deckDef + ) + return Object.keys(fixtureIdByCutoutId) as CutoutId[] +} + +export function getAddressableAreaNamesFromLoadedModule( + moduleModel: ModuleModel, + slotName: ModuleLocation['slotName'], + deckDef: DeckDefinition +): AddressableAreaName[] { + const moduleFixtures = getCutoutFixturesForModuleModel(moduleModel, deckDef) + const cutoutIds = getCutoutIdsFromModuleSlotName( + slotName, + moduleFixtures, + deckDef + ) + return moduleFixtures.reduce((acc, cutoutFixture) => { + const providedAddressableAreas = cutoutIds.reduce( + (innerAcc, cutoutId) => { + const newAddressableAreas = + cutoutFixture?.providesAddressableAreas[cutoutId] ?? [] + return [...innerAcc, ...newAddressableAreas] + }, + [] + ) + return [...acc, ...providedAddressableAreas] + }, []) +} + export function getFixtureDisplayName( - cutoutFixtureId: CutoutFixtureId | null + cutoutFixtureId: CutoutFixtureId | null, + usbPortNumber?: number ): string { - if (cutoutFixtureId === STAGING_AREA_RIGHT_SLOT_FIXTURE) { - return 'Staging area slot' - } else if (cutoutFixtureId === TRASH_BIN_ADAPTER_FIXTURE) { - return 'Trash bin' - } else if (cutoutFixtureId === WASTE_CHUTE_RIGHT_ADAPTER_NO_COVER_FIXTURE) { - return 'Waste chute only' - } else if (cutoutFixtureId === WASTE_CHUTE_RIGHT_ADAPTER_COVERED_FIXTURE) { - return 'Waste chute only with cover' - } else if ( - cutoutFixtureId === - STAGING_AREA_SLOT_WITH_WASTE_CHUTE_RIGHT_ADAPTER_NO_COVER_FIXTURE - ) { - return 'Waste chute with staging area slot' - } else if ( - cutoutFixtureId === - STAGING_AREA_SLOT_WITH_WASTE_CHUTE_RIGHT_ADAPTER_COVERED_FIXTURE - ) { - return 'Waste chute with staging area slot and cover' - } else { - return 'Slot' + switch (cutoutFixtureId) { + case STAGING_AREA_RIGHT_SLOT_FIXTURE: + return 'Staging area slot' + case TRASH_BIN_ADAPTER_FIXTURE: + return 'Trash bin' + case WASTE_CHUTE_RIGHT_ADAPTER_NO_COVER_FIXTURE: + return 'Waste chute only' + case WASTE_CHUTE_RIGHT_ADAPTER_COVERED_FIXTURE: + return 'Waste chute only with cover' + case STAGING_AREA_SLOT_WITH_WASTE_CHUTE_RIGHT_ADAPTER_NO_COVER_FIXTURE: + return 'Waste chute with staging area slot' + case STAGING_AREA_SLOT_WITH_WASTE_CHUTE_RIGHT_ADAPTER_COVERED_FIXTURE: + return 'Waste chute with staging area slot and cover' + case HEATERSHAKER_MODULE_V1_FIXTURE: + return usbPortNumber != null + ? `${getModuleDisplayName( + HEATERSHAKER_MODULE_V1 + )} in USB-${usbPortNumber}` + : getModuleDisplayName(HEATERSHAKER_MODULE_V1) + case TEMPERATURE_MODULE_V2_FIXTURE: + return usbPortNumber != null + ? `${getModuleDisplayName( + TEMPERATURE_MODULE_V2 + )} in USB-${usbPortNumber}` + : getModuleDisplayName(TEMPERATURE_MODULE_V2) + case MAGNETIC_BLOCK_V1_FIXTURE: + return `${getModuleDisplayName(MAGNETIC_BLOCK_V1)}` + case STAGING_AREA_SLOT_WITH_MAGNETIC_BLOCK_V1_FIXTURE: + return `${getModuleDisplayName(MAGNETIC_BLOCK_V1)} with staging area slot` + case THERMOCYCLER_V2_REAR_FIXTURE: + return usbPortNumber != null + ? `${getModuleDisplayName( + THERMOCYCLER_MODULE_V2 + )} in USB-${usbPortNumber}` + : getModuleDisplayName(THERMOCYCLER_MODULE_V2) + case THERMOCYCLER_V2_FRONT_FIXTURE: + return usbPortNumber != null + ? `${getModuleDisplayName( + THERMOCYCLER_MODULE_V2 + )} in USB-${usbPortNumber}` + : getModuleDisplayName(THERMOCYCLER_MODULE_V2) + default: + return 'Slot' } } -const STANDARD_OT2_SLOTS = [ - '1', - '2', - '3', - '4', - '5', - '6', - '7', - '8', - '9', - '10', - '11', +const STANDARD_OT2_SLOTS: AddressableAreaName[] = [ + ADDRESSABLE_AREA_1, + ADDRESSABLE_AREA_2, + ADDRESSABLE_AREA_3, + ADDRESSABLE_AREA_4, + ADDRESSABLE_AREA_5, + ADDRESSABLE_AREA_6, + ADDRESSABLE_AREA_7, + ADDRESSABLE_AREA_8, + ADDRESSABLE_AREA_9, + ADDRESSABLE_AREA_10, + ADDRESSABLE_AREA_11, ] -const STANDARD_FLEX_SLOTS = [ - 'A1', - 'A2', - 'A3', - 'B1', - 'B2', - 'B3', - 'C1', - 'C2', - 'C3', - 'D1', - 'D2', - 'D3', +const STANDARD_FLEX_SLOTS: AddressableAreaName[] = [ + A1_ADDRESSABLE_AREA, + A2_ADDRESSABLE_AREA, + A3_ADDRESSABLE_AREA, + B1_ADDRESSABLE_AREA, + B2_ADDRESSABLE_AREA, + B3_ADDRESSABLE_AREA, + C1_ADDRESSABLE_AREA, + C2_ADDRESSABLE_AREA, + C3_ADDRESSABLE_AREA, + D1_ADDRESSABLE_AREA, + D2_ADDRESSABLE_AREA, + D3_ADDRESSABLE_AREA, ] export const isAddressableAreaStandardSlot = ( - addressableAreaId: string, + addressableAreaName: AddressableAreaName, deckDef: DeckDefinition ): boolean => (deckDef.robot.model === FLEX_ROBOT_TYPE ? STANDARD_FLEX_SLOTS : STANDARD_OT2_SLOTS - ).includes(addressableAreaId) + ).includes(addressableAreaName) diff --git a/shared-data/js/helpers/__tests__/formatRunTimeParameterDefaultValue.test.ts b/shared-data/js/helpers/__tests__/formatRunTimeParameterDefaultValue.test.ts new file mode 100644 index 00000000000..d83239e3ec9 --- /dev/null +++ b/shared-data/js/helpers/__tests__/formatRunTimeParameterDefaultValue.test.ts @@ -0,0 +1,145 @@ +import { describe, it, expect, vi } from 'vitest' +import { formatRunTimeParameterDefaultValue } from '../formatRunTimeParameterDefaultValue' + +import type { RunTimeParameter } from '../../types' + +const capitalizeFirstLetter = (str: string): string => { + return str.charAt(0).toUpperCase() + str.slice(1) +} + +const mockTFunction = vi.fn(str => capitalizeFirstLetter(str)) + +describe('formatRunTimeParameterDefaultValue', () => { + it('should return value with suffix when type is int', () => { + const mockData = { + value: 6, + displayName: 'PCR Cycles', + variableName: 'PCR_CYCLES', + description: 'number of PCR cycles on a thermocycler', + type: 'int', + min: 1, + max: 10, + default: 6, + suffix: 'samples', + } as RunTimeParameter + const result = formatRunTimeParameterDefaultValue(mockData, mockTFunction) + expect(result).toEqual('6 samples') + }) + + it('should return value with suffix when type is float', () => { + const mockData = { + value: 6.5, + displayName: 'EtoH Volume', + variableName: 'ETOH_VOLUME', + description: '70% ethanol volume', + type: 'float', + suffix: 'mL', + min: 1.5, + max: 10.0, + default: 6.5, + } as RunTimeParameter + const result = formatRunTimeParameterDefaultValue(mockData, mockTFunction) + expect(result).toEqual('6.5 mL') + }) + + it('should return value when type is str', () => { + const mockData = { + value: 'left', + displayName: 'pipette mount', + variableName: 'mount', + description: 'pipette mount', + type: 'str', + choices: [ + { + displayName: 'Left', + value: 'left', + }, + { + displayName: 'Right', + value: 'right', + }, + ], + default: 'left', + } as RunTimeParameter + const result = formatRunTimeParameterDefaultValue(mockData, mockTFunction) + expect(result).toEqual('Left') + }) + + it('should return value when type is int choice with suffix', () => { + const mockData = { + value: 5, + displayName: 'num', + variableName: 'number', + description: 'its just number', + type: 'int', + suffix: 'mL', + min: 1, + max: 10, + choices: [ + { + displayName: 'one', + value: 1, + }, + { + displayName: 'six', + value: 6, + }, + ], + default: 5, + } as RunTimeParameter + const result = formatRunTimeParameterDefaultValue(mockData, mockTFunction) + expect(result).toEqual('5 mL') + }) + + it('should return value when type is float choice with suffix', () => { + const mockData = { + value: 5.0, + displayName: 'num', + variableName: 'number', + description: 'its just number', + type: 'float', + suffix: 'mL', + min: 1.0, + max: 10.0, + choices: [ + { + displayName: 'one', + value: 1.0, + }, + { + displayName: 'six', + value: 6.0, + }, + ], + default: 5.0, + } as RunTimeParameter + const result = formatRunTimeParameterDefaultValue(mockData, mockTFunction) + expect(result).toEqual('5 mL') + }) + + it('should return value when type is boolean true', () => { + const mockData = { + value: true, + displayName: 'Deactivate Temperatures', + variableName: 'DEACTIVATE_TEMP', + description: 'deactivate temperature on the module', + type: 'bool', + default: true, + } as RunTimeParameter + const result = formatRunTimeParameterDefaultValue(mockData, mockTFunction) + expect(result).toEqual('On') + }) + + it('should return value when type is boolean false', () => { + const mockData = { + value: false, + displayName: 'Dry Run', + variableName: 'DRYRUN', + description: 'Is this a dry or wet run? Wet is true, dry is false', + type: 'bool', + default: false, + } as RunTimeParameter + const result = formatRunTimeParameterDefaultValue(mockData, mockTFunction) + expect(result).toEqual('Off') + }) +}) diff --git a/shared-data/js/helpers/__tests__/formatRunTimeParameterMinMax.test.tsx b/shared-data/js/helpers/__tests__/formatRunTimeParameterMinMax.test.tsx new file mode 100644 index 00000000000..07190fac23e --- /dev/null +++ b/shared-data/js/helpers/__tests__/formatRunTimeParameterMinMax.test.tsx @@ -0,0 +1,37 @@ +import { describe, it, expect } from 'vitest' +import { formatRunTimeParameterMinMax } from '../formatRunTimeParameterMinMax' + +import type { RunTimeParameter } from '../../types' + +describe('utils-formatRunTimeParameterMinMax', () => { + it('should return int min and max', () => { + const mockData = { + value: 6, + displayName: 'PCR Cycles', + variableName: 'PCR_CYCLES', + description: 'number of PCR cycles on a thermocycler', + type: 'int', + min: 1, + max: 10, + default: 6, + } as RunTimeParameter + const result = formatRunTimeParameterMinMax(mockData) + expect(result).toEqual('1-10') + }) + + it('should return value with suffix when type is float', () => { + const mockData = { + value: 6.5, + displayName: 'EtoH Volume', + variableName: 'ETOH_VOLUME', + description: '70% ethanol volume', + type: 'float', + suffix: 'mL', + min: 1.5, + max: 10.0, + default: 6.5, + } as RunTimeParameter + const result = formatRunTimeParameterMinMax(mockData) + expect(result).toEqual('1.5-10.0') + }) +}) diff --git a/shared-data/js/helpers/__tests__/formatRunTimeParameterValue.test.ts b/shared-data/js/helpers/__tests__/formatRunTimeParameterValue.test.ts index bfdad493913..8e228cb6dbc 100644 --- a/shared-data/js/helpers/__tests__/formatRunTimeParameterValue.test.ts +++ b/shared-data/js/helpers/__tests__/formatRunTimeParameterValue.test.ts @@ -9,7 +9,7 @@ const capitalizeFirstLetter = (str: string): string => { const mockTFunction = vi.fn(str => capitalizeFirstLetter(str)) -describe('utils-formatRunTimeParameterValue', () => { +describe('utils-formatRunTimeParameterDefaultValue', () => { it('should return value with suffix when type is int', () => { const mockData = { value: 6, @@ -41,11 +41,11 @@ describe('utils-formatRunTimeParameterValue', () => { expect(result).toEqual('6.5 mL') }) - it('should return value with suffix when type is str', () => { + it('should return value when type is str', () => { const mockData = { value: 'left', displayName: 'pipette mount', - variableName: 'mont', + variableName: 'mount', description: 'pipette mount', type: 'str', choices: [ @@ -64,26 +64,78 @@ describe('utils-formatRunTimeParameterValue', () => { expect(result).toEqual('Left') }) - it('should return value with suffix when type is boolean true', () => { + it('should return value when type is int choice with suffix', () => { + const mockData = { + value: 5, + displayName: 'num', + variableName: 'number', + description: 'its just number', + type: 'int', + suffix: 'mL', + min: 1, + max: 10, + choices: [ + { + displayName: 'one', + value: 1, + }, + { + displayName: 'six', + value: 6, + }, + ], + default: 5, + } as RunTimeParameter + const result = formatRunTimeParameterValue(mockData, mockTFunction) + expect(result).toEqual('5 mL') + }) + + it('should return value when type is float choice with suffix', () => { + const mockData = { + value: 5.0, + displayName: 'num', + variableName: 'number', + description: 'its just number', + type: 'float', + suffix: 'mL', + min: 1.0, + max: 10.0, + choices: [ + { + displayName: 'one', + value: 1.0, + }, + { + displayName: 'six', + value: 6.0, + }, + ], + default: 5.0, + } as RunTimeParameter + const result = formatRunTimeParameterValue(mockData, mockTFunction) + expect(result).toEqual('5 mL') + }) + + it('should return value when type is boolean true', () => { const mockData = { value: true, displayName: 'Deactivate Temperatures', variableName: 'DEACTIVATE_TEMP', description: 'deactivate temperature on the module', - type: 'boolean', + type: 'bool', default: true, } as RunTimeParameter const result = formatRunTimeParameterValue(mockData, mockTFunction) expect(result).toEqual('On') }) - it('should return value with suffix when type is boolean false', () => { + it('should return value when type is boolean false', () => { const mockData = { value: false, displayName: 'Dry Run', variableName: 'DRYRUN', description: 'Is this a dry or wet run? Wet is true, dry is false', - type: 'boolean', + type: 'bool', default: false, } as RunTimeParameter const result = formatRunTimeParameterValue(mockData, mockTFunction) diff --git a/shared-data/js/helpers/__tests__/getDeckDefFromLoadedLabware.test.ts b/shared-data/js/helpers/__tests__/getDeckDefFromLoadedLabware.test.ts index 9c7a1318e06..8e34261756b 100644 --- a/shared-data/js/helpers/__tests__/getDeckDefFromLoadedLabware.test.ts +++ b/shared-data/js/helpers/__tests__/getDeckDefFromLoadedLabware.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest' -import ot2DeckDef from '../../../deck/definitions/4/ot2_standard.json' -import ot3DeckDef from '../../../deck/definitions/4/ot3_standard.json' +import ot2DeckDef from '../../../deck/definitions/5/ot2_standard.json' +import ot3DeckDef from '../../../deck/definitions/5/ot3_standard.json' import { getDeckDefFromRobotType } from '..' describe('getDeckDefFromRobotType', () => { diff --git a/shared-data/js/helpers/__tests__/orderRuntimeParameterRangeOptions.test.ts b/shared-data/js/helpers/__tests__/orderRuntimeParameterRangeOptions.test.ts new file mode 100644 index 00000000000..2a5b62b265d --- /dev/null +++ b/shared-data/js/helpers/__tests__/orderRuntimeParameterRangeOptions.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect } from 'vitest' + +import { + isNumeric, + orderRuntimeParameterRangeOptions, +} from '../orderRuntimeParameterRangeOptions' + +import type { Choice } from '../../types' + +describe('isNumeric', () => { + it('should return true when input is "2"', () => { + const result = isNumeric('2') + expect(result).toBeTruthy() + }) + + it('should return false when input is "opentrons"', () => { + const result = isNumeric('opentrons') + expect(result).toBeFalsy() + }) +}) + +describe('orderRuntimeParameterRangeOptions', () => { + it('should return numerical order when choices are number', () => { + const mockChoices: Choice[] = [ + { displayName: '20', value: 20 }, + { displayName: '16', value: 16 }, + ] + const result = orderRuntimeParameterRangeOptions(mockChoices) + expect(result).toEqual('16, 20') + }) + + it('should return alphabetical order when choices are number', () => { + const mockChoices: Choice[] = [ + { displayName: 'Single channel 50µL', value: 'flex_1channel_50' }, + { displayName: 'Eight Channel 50µL', value: 'flex_8channel_50' }, + ] + const result = orderRuntimeParameterRangeOptions(mockChoices) + expect(result).toEqual('Eight Channel 50µL, Single channel 50µL') + }) + + it('should return empty string choices > 3', () => { + const mockChoices: Choice[] = [ + { displayName: '20', value: 20 }, + { displayName: '16', value: 16 }, + { displayName: '18', value: 18 }, + ] + const result = orderRuntimeParameterRangeOptions(mockChoices) + expect(result).toEqual('') + }) +}) diff --git a/shared-data/js/helpers/formatRunTimeParameterDefaultValue.ts b/shared-data/js/helpers/formatRunTimeParameterDefaultValue.ts new file mode 100644 index 00000000000..3ac5cda5bfa --- /dev/null +++ b/shared-data/js/helpers/formatRunTimeParameterDefaultValue.ts @@ -0,0 +1,50 @@ +import type { RunTimeParameter } from '../types' + +/** + * Formats the runtime parameter's default value. + * + * @param {RunTimeParameter} runTimeParameter - The runtime parameter whose default value is to be formatted. + * @param {Function} [t] - An optional function for localization. + * + * @returns {string} The formatted default value of the runtime parameter. + * + */ + +export const formatRunTimeParameterDefaultValue = ( + runTimeParameter: RunTimeParameter, + t?: any +): string => { + const { type, default: defaultValue } = runTimeParameter + const suffix = + 'suffix' in runTimeParameter && runTimeParameter.suffix != null + ? runTimeParameter.suffix + : null + + if ('choices' in runTimeParameter && runTimeParameter.choices != null) { + const choice = runTimeParameter.choices.find( + choice => choice.value === defaultValue + ) + if (choice != null) { + return suffix != null + ? `${choice.displayName} ${suffix}` + : choice.displayName + } + } + + switch (type) { + case 'int': + case 'float': + return suffix !== null + ? `${defaultValue.toString()} ${suffix}` + : defaultValue.toString() + case 'bool': + if (t != null) { + return Boolean(defaultValue) ? t('on') : t('off') + } else { + return Boolean(defaultValue) ? 'On' : 'Off' + } + default: + break + } + return '' +} diff --git a/shared-data/js/helpers/formatRunTimeParameterMinMax.ts b/shared-data/js/helpers/formatRunTimeParameterMinMax.ts new file mode 100644 index 00000000000..632dec5c020 --- /dev/null +++ b/shared-data/js/helpers/formatRunTimeParameterMinMax.ts @@ -0,0 +1,32 @@ +import type { RunTimeParameter } from '../types' +/** + * Formats the runtime parameter's minimum and maximum values. + * + * @param {RunTimeParameter} runTimeParameter - The runtime parameter whose min and max values are to be formatted. + * + * @returns {string} The formatted min-max value of the runtime parameter. + * + * @example + * const runTimeParameter = { + * value: 6.5, + * displayName: 'EtoH Volume', + * variableName: 'ETOH_VOLUME', + * description: '70% ethanol volume', + * type: 'float', + * suffix: 'mL', + * min: 1.5, + * max: 10.0, + * default: 6.5, + * } + * console.log(formatRunTimeParameterMinMax(runTimeParameter)); // "1.5-10.0" + */ + +export const formatRunTimeParameterMinMax = ( + runTimeParameter: RunTimeParameter +): string => { + const min = 'min' in runTimeParameter ? runTimeParameter.min : 0 + const max = 'max' in runTimeParameter ? runTimeParameter.max : 0 + return runTimeParameter.type === 'int' + ? `${min}-${max}` + : `${min.toFixed(1)}-${max.toFixed(1)}` +} diff --git a/shared-data/js/helpers/formatRunTimeParameterValue.ts b/shared-data/js/helpers/formatRunTimeParameterValue.ts index ffbab087849..a6a3ad4d7ec 100644 --- a/shared-data/js/helpers/formatRunTimeParameterValue.ts +++ b/shared-data/js/helpers/formatRunTimeParameterValue.ts @@ -1,35 +1,45 @@ -import { RunTimeParameter } from '../types' +import type { RunTimeParameter } from '../types' + +/** + * Formats the runtime parameter value. + * + * @param {RunTimeParameter} runTimeParameter - The runtime parameter to be formatted. + * @param {Function} t - A function for localization. + * + * @returns {string} The formatted runtime parameter value. + * + */ export const formatRunTimeParameterValue = ( runTimeParameter: RunTimeParameter, - t?: any + t: any ): string => { - const { type, default: defaultValue } = runTimeParameter + const { type, value } = runTimeParameter const suffix = 'suffix' in runTimeParameter && runTimeParameter.suffix != null ? runTimeParameter.suffix : null + + if ('choices' in runTimeParameter && runTimeParameter.choices != null) { + const choice = runTimeParameter.choices.find( + choice => choice.value === value + ) + if (choice != null) { + return suffix != null + ? `${choice.displayName} ${suffix}` + : choice.displayName + } + } switch (type) { case 'int': case 'float': return suffix !== null - ? `${defaultValue.toString()} ${suffix}` - : defaultValue.toString() - case 'boolean': - if (t != null) { - return Boolean(defaultValue) ? t('on') : t('off') - } else { - return Boolean(defaultValue) ? 'On' : 'Off' - } - case 'str': - if ('choices' in runTimeParameter && runTimeParameter.choices != null) { - const choice = runTimeParameter.choices.find( - choice => choice.value === defaultValue - ) - if (choice != null) { - return choice.displayName - } - } + ? `${value.toString()} ${suffix}` + : value.toString() + case 'bool': { + return Boolean(value) ? t('on') : t('off') + } + default: break } return '' diff --git a/shared-data/js/helpers/getAddressableAreasInProtocol.ts b/shared-data/js/helpers/getAddressableAreasInProtocol.ts index 1ca3013930a..81222777db2 100644 --- a/shared-data/js/helpers/getAddressableAreasInProtocol.ts +++ b/shared-data/js/helpers/getAddressableAreasInProtocol.ts @@ -1,5 +1,8 @@ import { MOVABLE_TRASH_A3_ADDRESSABLE_AREA } from '../constants' -import { getAddressableAreaFromSlotId } from '../fixtures' +import { + getAddressableAreaNamesFromLoadedModule, + getAddressableAreaFromSlotId, +} from '../fixtures' import type { AddressableAreaName } from '../../deck' import type { ProtocolAnalysisOutput } from '../../protocol' import type { CompletedProtocolAnalysis, DeckDefinition } from '../types' @@ -12,16 +15,15 @@ export function getAddressableAreasInProtocol( const addressableAreasFromCommands = commands.reduce( (acc, command) => { + const { commandType, params } = command if ( - command.commandType === 'moveLabware' && - command.params.newLocation !== 'offDeck' && - 'slotName' in command.params.newLocation && - !acc.includes( - command.params.newLocation.slotName as AddressableAreaName - ) + commandType === 'moveLabware' && + params.newLocation !== 'offDeck' && + 'slotName' in params.newLocation && + !acc.includes(params.newLocation.slotName as AddressableAreaName) ) { const addressableAreaName = getAddressableAreaFromSlotId( - command.params.newLocation.slotName, + params.newLocation.slotName, deckDef )?.id @@ -31,51 +33,61 @@ export function getAddressableAreasInProtocol( return [...acc, addressableAreaName] } } else if ( - command.commandType === 'moveLabware' && - command.params.newLocation !== 'offDeck' && - 'addressableAreaName' in command.params.newLocation && - !acc.includes(command.params.newLocation.addressableAreaName) + commandType === 'moveLabware' && + params.newLocation !== 'offDeck' && + 'addressableAreaName' in params.newLocation && + !acc.includes(params.newLocation.addressableAreaName) ) { - return [...acc, command.params.newLocation.addressableAreaName] + return [...acc, params.newLocation.addressableAreaName] } else if ( - (command.commandType === 'loadLabware' || - command.commandType === 'loadModule') && - command.params.location !== 'offDeck' && - 'slotName' in command.params.location && - !acc.includes(command.params.location.slotName as AddressableAreaName) + commandType === 'loadLabware' && + params.location !== 'offDeck' && + 'slotName' in params.location && + !acc.includes(params.location.slotName as AddressableAreaName) ) { const addressableAreaName = getAddressableAreaFromSlotId( - command.params.location.slotName, + params.location.slotName, deckDef )?.id // do not add addressable area name for legacy trash labware if ( addressableAreaName == null || - ('loadName' in command.params && - command.params.loadName === 'opentrons_1_trash_3200ml_fixed') + ('loadName' in params && + params.loadName === 'opentrons_1_trash_3200ml_fixed') ) { return acc } else { return [...acc, addressableAreaName] } } else if ( - command.commandType === 'loadLabware' && - command.params.location !== 'offDeck' && - 'addressableAreaName' in command.params.location && - !acc.includes(command.params.location.addressableAreaName) + commandType === 'loadModule' && + !acc.includes(params.location.slotName as AddressableAreaName) + ) { + const addressableAreaNames = getAddressableAreaNamesFromLoadedModule( + params.model, + params.location.slotName, + deckDef + ) + + return [...acc, ...addressableAreaNames] + } else if ( + commandType === 'loadLabware' && + params.location !== 'offDeck' && + 'addressableAreaName' in params.location && + !acc.includes(params.location.addressableAreaName) ) { - return [...acc, command.params.location.addressableAreaName] + return [...acc, params.location.addressableAreaName] } else if ( - command.commandType === 'moveToAddressableArea' && - !acc.includes(command.params.addressableAreaName) + commandType === 'moveToAddressableArea' && + !acc.includes(params.addressableAreaName) ) { - return [...acc, command.params.addressableAreaName] + return [...acc, params.addressableAreaName] } else if ( - command.commandType === 'moveToAddressableAreaForDropTip' && - !acc.includes(command.params.addressableAreaName) + commandType === 'moveToAddressableAreaForDropTip' && + !acc.includes(params.addressableAreaName) ) { - return [...acc, command.params.addressableAreaName] + return [...acc, params.addressableAreaName] } else { return acc } diff --git a/shared-data/js/helpers/getSimplestFlexDeckConfig.ts b/shared-data/js/helpers/getSimplestFlexDeckConfig.ts index e4017199156..65c4ccac3e5 100644 --- a/shared-data/js/helpers/getSimplestFlexDeckConfig.ts +++ b/shared-data/js/helpers/getSimplestFlexDeckConfig.ts @@ -2,7 +2,7 @@ import { FLEX_ROBOT_TYPE } from '../constants' import { getAddressableAreaFromSlotId } from '../fixtures' import { getAddressableAreasInProtocol, getDeckDefFromRobotType } from '.' -import type { AddressableAreaName, CutoutId } from '../../deck' +import type { AddressableAreaName, CutoutFixtureId, CutoutId } from '../../deck' import type { ProtocolAnalysisOutput } from '../../protocol' import type { CutoutConfig, @@ -10,6 +10,7 @@ import type { DeckDefinition, DeckConfiguration, CompletedProtocolAnalysis, + CutoutFixtureGroup, } from '../types' export interface CutoutConfigProtocolSpec extends CutoutConfig { @@ -111,7 +112,6 @@ export function getSimplestDeckConfigForProtocol( } return acc }, FLEX_SIMPLEST_DECK_CONFIG_PROTOCOL_SPEC) - return simplestDeckConfig } @@ -151,6 +151,15 @@ export function getCutoutIdForSlotName( return cutoutIdForSlotName } +export function getFixtureGroupForCutoutFixture( + cutoutFixtureId: CutoutFixtureId, + cutoutFixtures: CutoutFixture[] +): CutoutFixtureGroup { + return ( + cutoutFixtures.find(cf => cf.id === cutoutFixtureId)?.fixtureGroup ?? {} + ) +} + export function getCutoutIdForAddressableArea( addressableArea: AddressableAreaName, cutoutFixtures: CutoutFixture[] diff --git a/shared-data/js/helpers/index.ts b/shared-data/js/helpers/index.ts index 2d78f16ca1f..a07d10472f6 100644 --- a/shared-data/js/helpers/index.ts +++ b/shared-data/js/helpers/index.ts @@ -1,8 +1,8 @@ import uniq from 'lodash/uniq' import { OPENTRONS_LABWARE_NAMESPACE } from '../constants' -import standardOt2DeckDef from '../../deck/definitions/4/ot2_standard.json' -import standardFlexDeckDef from '../../deck/definitions/4/ot3_standard.json' +import standardOt2DeckDef from '../../deck/definitions/5/ot2_standard.json' +import standardFlexDeckDef from '../../deck/definitions/5/ot3_standard.json' import type { DeckDefinition, LabwareDefinition2, @@ -28,7 +28,10 @@ export * from './getOccludedSlotCountForModule' export * from './labwareInference' export * from './getAddressableAreasInProtocol' export * from './getSimplestFlexDeckConfig' +export * from './formatRunTimeParameterDefaultValue' export * from './formatRunTimeParameterValue' +export * from './formatRunTimeParameterMinMax' +export * from './orderRuntimeParameterRangeOptions' export const getLabwareDefIsStandard = (def: LabwareDefinition2): boolean => def?.namespace === OPENTRONS_LABWARE_NAMESPACE @@ -201,6 +204,23 @@ export const getWellsDepth = ( return offsets[0] } +export const getWellDimension = ( + labwareDef: LabwareDefinition2, + wells: string[], + position: 'x' | 'y' +): number => { + const offsets = wells.map(well => { + const labwareWell = labwareDef.wells[well] + const shape = labwareWell.shape + if (shape === 'circular') { + return labwareWell.diameter + } else { + return position === 'x' ? labwareWell.xDimension : labwareWell.yDimension + } + }) + return offsets[0] +} + export const getSlotHasMatingSurfaceUnitVector = ( deckDef: DeckDefinition, addressableAreaName: string diff --git a/shared-data/js/helpers/orderRuntimeParameterRangeOptions.ts b/shared-data/js/helpers/orderRuntimeParameterRangeOptions.ts new file mode 100644 index 00000000000..826fc958dd1 --- /dev/null +++ b/shared-data/js/helpers/orderRuntimeParameterRangeOptions.ts @@ -0,0 +1,46 @@ +import type { Choice } from '../types' + +export const isNumeric = (str: string): boolean => { + return !isNaN(Number(str)) +} + +/** + * This function sorts an array of strings in numerical and alphabetical order. + * @param {Choice[]} - The array of Choice + * Choice is an object like {displayName: 'Single channel 50µL', value: 'flex_1channel_50' } + * @returns {string} The ordered string with "," + * + * @example + * const numChoices = [ + * { displayName: '20', value: 20 }, + * { displayName: '16', value: 16 }, + * ] + * console.log(orderRuntimeParameterRangeOptions(numChoices) // 16,20 + * + * const strChoices = [ + * { displayName: 'Single channel 50µL', value: 'flex_1channel_50' }, + * { displayName: 'Eight Channel 50µL', value: 'flex_8channel_50' }, + * ] + * console.log(orderRuntimeParameterRangeOptions(strChoices) // Eight Channel 50µL, Single channel 50µL + */ +export const orderRuntimeParameterRangeOptions = ( + choices: Choice[] +): string => { + // when this function is called, the array length is always 2 + if (choices.length > 2) { + console.error(`expected to have length 2 but has length ${choices.length}`) + return '' + } + const displayNames = [choices[0].displayName, choices[1].displayName] + if (isNumeric(displayNames[0])) { + return displayNames + .sort((a, b) => { + const numA = Number(a) + const numB = Number(b) + return numA - numB + }) + .join(', ') + } else { + return displayNames.sort().join(', ') + } +} diff --git a/shared-data/js/types.ts b/shared-data/js/types.ts index 12e991ce7f3..4d51f992f22 100644 --- a/shared-data/js/types.ts +++ b/shared-data/js/types.ts @@ -270,11 +270,17 @@ export interface DeckCalibrationPoint { displayName: string } +export type CutoutFixtureGroup = { + [cutoutId in CutoutId]?: Array<{ [cutoutId in CutoutId]?: CutoutFixtureId }> +} + export interface CutoutFixture { id: CutoutFixtureId mayMountTo: CutoutId[] displayName: string providesAddressableAreas: Record + expectOpentronsModuleSerialNumber: boolean + fixtureGroup: CutoutFixtureGroup height: number } @@ -486,6 +492,7 @@ export interface SupportedTip { } defaultReturnTipHeight?: number defaultFlowAcceleration?: number + uiMaxFlowRate?: number } export interface SupportedTips { @@ -590,38 +597,37 @@ export interface AnalysisError { createdAt: string } -export interface NumberParameter { +export interface NumberParameter extends BaseRunTimeParameter { type: NumberParameterType min: number max: number default: number } -interface Choice { +export interface Choice { displayName: string value: number | boolean | string } -interface ChoiceParameter { +interface ChoiceParameter extends BaseRunTimeParameter { type: RunTimeParameterType choices: Choice[] default: number | boolean | string } -interface BooleanParameter { +interface BooleanParameter extends BaseRunTimeParameter { type: BooleanParameterType default: boolean } type NumberParameterType = 'int' | 'float' -type BooleanParameterType = 'boolean' +type BooleanParameterType = 'bool' type StringParameterType = 'str' type RunTimeParameterType = | NumberParameter | BooleanParameterType | StringParameterType -type ParameterType = NumberParameter | ChoiceParameter | BooleanParameter interface BaseRunTimeParameter { displayName: string variableName: string @@ -630,7 +636,10 @@ interface BaseRunTimeParameter { suffix?: string } -export type RunTimeParameter = BaseRunTimeParameter & ParameterType +export type RunTimeParameter = + | BooleanParameter + | ChoiceParameter + | NumberParameter // TODO(BC, 10/25/2023): this type (and others in this file) probably belong in api-client, not here export interface CompletedProtocolAnalysis { @@ -720,7 +729,8 @@ export type StatusBarAnimations = StatusBarAnimation[] export interface CutoutConfig { cutoutId: CutoutId - cutoutFixtureId: CutoutFixtureId | null + cutoutFixtureId: CutoutFixtureId + opentronsModuleSerialNumber?: string } export type DeckConfiguration = CutoutConfig[] diff --git a/shared-data/pipette/definitions/1/pipetteModelSpecs.json b/shared-data/pipette/definitions/1/pipetteModelSpecs.json index 7a039c0e33f..c6367e851b4 100644 --- a/shared-data/pipette/definitions/1/pipetteModelSpecs.json +++ b/shared-data/pipette/definitions/1/pipetteModelSpecs.json @@ -10304,6 +10304,175 @@ "quirks": [], "returnTipHeight": 0.83, "idleCurrent": 0.3 + }, + "p1000_96_v3.6": { + "name": "p1000_96", + "backCompatNames": [], + "top": { + "value": 0.5, + "min": 0, + "max": 45, + "units": "mm", + "type": "float" + }, + "bottom": { + "value": 71.5, + "min": 55, + "max": 80, + "type": "float", + "units": "mm" + }, + "blowout": { + "value": 76.5, + "min": 60, + "max": 85, + "units": "mm", + "type": "float" + }, + "dropTip": { + "value": 92.5, + "min": 78, + "max": 119, + "units": "mm", + "type": "float" + }, + "pickUpCurrent": { + "value": 0.5, + "min": 0.05, + "max": 2.0, + "units": "amps", + "type": "float" + }, + "pickUpDistance": { + "value": 13, + "min": 1, + "max": 30, + "units": "mm", + "type": "float" + }, + "pickUpIncrement": { + "value": 0.0, + "min": 0.0, + "max": 10.0, + "units": "mm", + "type": "float" + }, + "pickUpPresses": { + "value": 1, + "min": 0, + "max": 10, + "units": "presses", + "type": "int" + }, + "pickUpSpeed": { + "value": 10, + "min": 1, + "max": 30, + "units": "mm/s", + "type": "float" + }, + "nozzleOffset": [-8.0, -16.0, -259.15], + "modelOffset": [0.0, 0.0, 25.14], + "ulPerMm": [ + { + "aspirate": [ + [0.6464, 0.4817, 0.0427], + [1.0889, 0.2539, 0.1591], + [1.5136, 0.1624, 0.2587], + [1.9108, 0.1042, 0.3467], + [2.2941, 0.0719, 0.4085], + [2.9978, 0.037, 0.4886], + [3.7731, 0.0378, 0.4863], + [4.7575, 0.0516, 0.4342], + [5.5024, 0.011, 0.6275], + [6.2686, 0.0114, 0.6253], + [7.005, 0.0054, 0.6625], + [8.5207, 0.0063, 0.6563], + [10.0034, 0.003, 0.6844], + [11.5075, 0.0031, 0.6833], + [13.0327, 0.0032, 0.6829], + [14.5356, 0.0018, 0.7003], + [17.5447, 0.0014, 0.7063], + [20.5576, 0.0011, 0.7126], + [23.5624, 0.0007, 0.7197], + [26.5785, 0.0007, 0.721], + [29.593, 0.0005, 0.7248], + [32.6109, 0.0004, 0.7268], + [35.6384, 0.0004, 0.727], + [38.6439, 0.0002, 0.7343], + [41.6815, 0.0004, 0.7284], + [44.6895, 0.0002, 0.7372], + [47.6926, 0.0001, 0.7393], + [51.4567, 0.0001, 0.7382] + ], + + "dispense": [ + [0.6464, 0.4817, 0.0427], + [1.0889, 0.2539, 0.1591], + [1.5136, 0.1624, 0.2587], + [1.9108, 0.1042, 0.3467], + [2.2941, 0.0719, 0.4085], + [2.9978, 0.037, 0.4886], + [3.7731, 0.0378, 0.4863], + [4.7575, 0.0516, 0.4342], + [5.5024, 0.011, 0.6275], + [6.2686, 0.0114, 0.6253], + [7.005, 0.0054, 0.6625], + [8.5207, 0.0063, 0.6563], + [10.0034, 0.003, 0.6844], + [11.5075, 0.0031, 0.6833], + [13.0327, 0.0032, 0.6829], + [14.5356, 0.0018, 0.7003], + [17.5447, 0.0014, 0.7063], + [20.5576, 0.0011, 0.7126], + [23.5624, 0.0007, 0.7197], + [26.5785, 0.0007, 0.721], + [29.593, 0.0005, 0.7248], + [32.6109, 0.0004, 0.7268], + [35.6384, 0.0004, 0.727], + [38.6439, 0.0002, 0.7343], + [41.6815, 0.0004, 0.7284], + [44.6895, 0.0002, 0.7372], + [47.6926, 0.0001, 0.7393], + [51.4567, 0.0001, 0.7382] + ] + } + ], + "plungerCurrent": { + "value": 1, + "min": 0.1, + "max": 1.5, + "units": "amps", + "type": "float" + }, + "dropTipCurrent": { + "value": 1, + "min": 0.1, + "max": 1.25, + "units": "amps", + "type": "float" + }, + "dropTipSpeed": { + "value": 10, + "min": 0.001, + "max": 30, + "units": "mm/sec", + "type": "float" + }, + "tipOverlap": { + "default": 10.5, + "opentrons/opentrons_96_tiprack_50ul/1": 10.5 + }, + "tipLength": { + "value": 78.3, + "units": "mm", + "type": "float", + "min": 0, + "max": 100 + }, + "quirks": [], + "returnTipHeight": 0.83, + "idleCurrent": 0.3 } }, "mutableConfigs": [ diff --git a/shared-data/pipette/definitions/2/general/ninety_six_channel/p1000/3_6.json b/shared-data/pipette/definitions/2/general/ninety_six_channel/p1000/3_6.json new file mode 100644 index 00000000000..c59dfce42ab --- /dev/null +++ b/shared-data/pipette/definitions/2/general/ninety_six_channel/p1000/3_6.json @@ -0,0 +1,114 @@ +{ + "$otSharedSchema": "#/pipette/schemas/2/pipettePropertiesSchema.json", + "displayName": "Flex 96-Channel 1000 μL", + "model": "p1000", + "displayCategory": "FLEX", + "pickUpTipConfigurations": { + "pressFit": { + "presses": 1, + "increment": 0.0, + "speedByTipCount": { + "1": 10.0, + "2": 10.0, + "3": 10.0, + "4": 10.0, + "5": 10.0, + "6": 10.0, + "7": 10.0, + "8": 10.0, + "12": 10.0, + "16": 10.0, + "24": 10.0, + "48": 10.0 + }, + "distanceByTipCount": { + "1": 13.0, + "2": 13.0, + "3": 13.0, + "4": 13.0, + "5": 13.0, + "6": 13.0, + "7": 13.0, + "8": 13.0, + "12": 13.0, + "16": 13.0, + "24": 13.0, + "48": 13.0 + }, + + "currentByTipCount": { + "1": 0.2, + "2": 0.25, + "3": 0.3, + "4": 0.35, + "5": 0.4, + "6": 0.45, + "7": 0.5, + "8": 0.55, + "12": 0.19, + "16": 0.25, + "24": 0.38, + "48": 0.75 + } + }, + "camAction": { + "speed": 5.5, + "distance": 10.0, + "prep_move_distance": 8.25, + "prep_move_speed": 10.0, + "connectTiprackDistanceMM": 7.0, + "currentByTipCount": { + "96": 1.5 + } + } + }, + "dropTipConfigurations": { + "camAction": { + "current": 1.5, + "speed": 5.5, + "distance": 10.8, + "prep_move_distance": 19.0, + "prep_move_speed": 10.0 + } + }, + "plungerMotorConfigurations": { + "idle": 0.3, + "run": 0.8 + }, + "plungerPositionsConfigurations": { + "default": { + "top": 0.5, + "bottom": 68.5, + "blowout": 73.5, + "drop": 80 + } + }, + "availableSensors": { + "sensors": ["pressure", "capacitive", "environment"], + "pressure": { + "count": 2 + }, + "capacitive": { + "count": 2 + }, + "environment": { + "count": 1 + } + }, + "partialTipConfigurations": { + "partialTipSupported": true, + "availableConfigurations": [1, 8, 12, 16, 24, 48, 96] + }, + "backCompatNames": [], + "channels": 96, + "shaftDiameter": 4.5, + "shaftULperMM": 15.904, + "backlashDistance": 3.0, + "quirks": [], + "plungerHomingConfigurations": { + "current": 0.8, + "speed": 5 + }, + "tipPresenceCheckDistanceMM": 8.0, + "endTipActionRetractDistanceMM": 2.0 +} diff --git a/shared-data/pipette/definitions/2/geometry/ninety_six_channel/p1000/3_6.json b/shared-data/pipette/definitions/2/geometry/ninety_six_channel/p1000/3_6.json new file mode 100644 index 00000000000..da209a72907 --- /dev/null +++ b/shared-data/pipette/definitions/2/geometry/ninety_six_channel/p1000/3_6.json @@ -0,0 +1,295 @@ +{ + "$otSharedSchema": "#/pipette/schemas/2/pipetteGeometrySchema.json", + "pathTo3D": "pipette/definitions/2/geometry/ninety_six_channel/p1000/placeholder.gltf", + "nozzleOffset": [-36.0, -25.5, -259.15], + "pipetteBoundingBoxOffsets": { + "backLeftCorner": [-67.0, -3.5, -259.15], + "frontRightCorner": [94.0, -113.0, -259.15] + }, + "orderedRows": [ + { + "key": "A", + "orderedNozzles": [ + "A1", + "A2", + "A3", + "A4", + "A5", + "A6", + "A7", + "A8", + "A9", + "A10", + "A11", + "A12" + ] + }, + { + "key": "B", + "orderedNozzles": [ + "B1", + "B2", + "B3", + "B4", + "B5", + "B6", + "B7", + "B8", + "B9", + "B10", + "B11", + "B12" + ] + }, + { + "key": "C", + "orderedNozzles": [ + "C1", + "C2", + "C3", + "C4", + "C5", + "C6", + "C7", + "C8", + "C9", + "C10", + "C11", + "C12" + ] + }, + { + "key": "D", + "orderedNozzles": [ + "D1", + "D2", + "D3", + "D4", + "D5", + "D6", + "D7", + "D8", + "D9", + "D10", + "D11", + "D12" + ] + }, + { + "key": "E", + "orderedNozzles": [ + "E1", + "E2", + "E3", + "E4", + "E5", + "E6", + "E7", + "E8", + "E9", + "E10", + "E11", + "E12" + ] + }, + { + "key": "F", + "orderedNozzles": [ + "F1", + "F2", + "F3", + "F4", + "F5", + "F6", + "F7", + "F8", + "F9", + "F10", + "F11", + "F12" + ] + }, + { + "key": "G", + "orderedNozzles": [ + "G1", + "G2", + "G3", + "G4", + "G5", + "G6", + "G7", + "G8", + "G9", + "G10", + "G11", + "G12" + ] + }, + { + "key": "H", + "orderedNozzles": [ + "H1", + "H2", + "H3", + "H4", + "H5", + "H6", + "H7", + "H8", + "H9", + "H10", + "H11", + "H12" + ] + } + ], + "orderedColumns": [ + { + "key": "1", + "orderedNozzles": ["A1", "B1", "C1", "D1", "E1", "F1", "G1", "H1"] + }, + { + "key": "2", + "orderedNozzles": ["A2", "B2", "C2", "D2", "E2", "F2", "G2", "H2"] + }, + { + "key": "3", + "orderedNozzles": ["A3", "B3", "C3", "D3", "E3", "F3", "G3", "H3"] + }, + { + "key": "4", + "orderedNozzles": ["A4", "B4", "C4", "D4", "E4", "F4", "G4", "H4"] + }, + { + "key": "5", + "orderedNozzles": ["A5", "B5", "C5", "D5", "E5", "F5", "G5", "H5"] + }, + { + "key": "6", + "orderedNozzles": ["A6", "B6", "C6", "D6", "E6", "F6", "G6", "H6"] + }, + { + "key": "7", + "orderedNozzles": ["A7", "B7", "C7", "D7", "E7", "F7", "G7", "H7"] + }, + { + "key": "8", + "orderedNozzles": ["A8", "B8", "C8", "D8", "E8", "F8", "G8", "H8"] + }, + { + "key": "9", + "orderedNozzles": ["A9", "B9", "C9", "D9", "E9", "F9", "G9", "H9"] + }, + { + "key": "10", + "orderedNozzles": ["A10", "B10", "C10", "D10", "E10", "F10", "G10", "H10"] + }, + { + "key": "11", + "orderedNozzles": ["A11", "B11", "C11", "D11", "E11", "F11", "G11", "H11"] + }, + { + "key": "12", + "orderedNozzles": ["A12", "B12", "C12", "D12", "E12", "F12", "G12", "H12"] + } + ], + "nozzleMap": { + "A1": [-36.0, -25.5, -259.15], + "A2": [-27.0, -25.5, -259.15], + "A3": [-18.0, -25.5, -259.15], + "A4": [-9.0, -25.5, -259.15], + "A5": [0.0, -25.5, -259.15], + "A6": [9.0, -25.5, -259.15], + "A7": [18.0, -25.5, -259.15], + "A8": [27.0, -25.5, -259.15], + "A9": [36.0, -25.5, -259.15], + "A10": [45.0, -25.5, -259.15], + "A11": [54.0, -25.5, -259.15], + "A12": [63.0, -25.5, -259.15], + "B1": [-36.0, -34.5, -259.15], + "B2": [-27.0, -34.5, -259.15], + "B3": [-18.0, -34.5, -259.15], + "B4": [-9.0, -34.5, -259.15], + "B5": [0.0, -34.5, -259.15], + "B6": [9.0, -34.5, -259.15], + "B7": [18.0, -34.5, -259.15], + "B8": [27.0, -34.5, -259.15], + "B9": [36.0, -34.5, -259.15], + "B10": [45.0, -34.5, -259.15], + "B11": [54.0, -34.5, -259.15], + "B12": [63.0, -34.5, -259.15], + "C1": [-36.0, -43.5, -259.15], + "C2": [-27.0, -43.5, -259.15], + "C3": [-18.0, -43.5, -259.15], + "C4": [-9.0, -43.5, -259.15], + "C5": [0.0, -43.5, -259.15], + "C6": [9.0, -43.5, -259.15], + "C7": [18.0, -43.5, -259.15], + "C8": [27.0, -43.5, -259.15], + "C9": [36.0, -43.5, -259.15], + "C10": [45.0, -43.5, -259.15], + "C11": [54.0, -43.5, -259.15], + "C12": [63.0, -43.5, -259.15], + "D1": [-36.0, -52.5, -259.15], + "D2": [-27.0, -52.5, -259.15], + "D3": [-18.0, -52.5, -259.15], + "D4": [-9.0, -52.5, -259.15], + "D5": [0.0, -52.5, -259.15], + "D6": [9.0, -52.5, -259.15], + "D7": [18.0, -52.5, -259.15], + "D8": [27.0, -52.5, -259.15], + "D9": [36.0, -52.5, -259.15], + "D10": [45.0, -52.5, -259.15], + "D11": [54.0, -52.5, -259.15], + "D12": [63.0, -52.5, -259.15], + "E1": [-36.0, -61.5, -259.15], + "E2": [-27.0, -61.5, -259.15], + "E3": [-18.0, -61.5, -259.15], + "E4": [-9.0, -61.5, -259.15], + "E5": [0.0, -61.5, -259.15], + "E6": [9.0, -61.5, -259.15], + "E7": [18.0, -61.5, -259.15], + "E8": [27.0, -61.5, -259.15], + "E9": [36.0, -61.5, -259.15], + "E10": [45.0, -61.5, -259.15], + "E11": [54.0, -61.5, -259.15], + "E12": [63.0, -61.5, -259.15], + "F1": [-36.0, -70.5, -259.15], + "F2": [-27.0, -70.5, -259.15], + "F3": [-18.0, -70.5, -259.15], + "F4": [-9.0, -70.5, -259.15], + "F5": [0.0, -70.5, -259.15], + "F6": [9.0, -70.5, -259.15], + "F7": [18.0, -70.5, -259.15], + "F8": [27.0, -70.5, -259.15], + "F9": [36.0, -70.5, -259.15], + "F10": [45.0, -70.5, -259.15], + "F11": [54.0, -70.5, -259.15], + "F12": [63.0, -70.5, -259.15], + "G1": [-36.0, -79.5, -259.15], + "G2": [-27.0, -79.5, -259.15], + "G3": [-18.0, -79.5, -259.15], + "G4": [-9.0, -79.5, -259.15], + "G5": [0.0, -79.5, -259.15], + "G6": [9.0, -79.5, -259.15], + "G7": [18.0, -79.5, -259.15], + "G8": [27.0, -79.5, -259.15], + "G9": [36.0, -79.5, -259.15], + "G10": [45.0, -79.5, -259.15], + "G11": [54.0, -79.5, -259.15], + "G12": [63.0, -79.5, -259.15], + "H1": [-36.0, -88.5, -259.15], + "H2": [-27.0, -88.5, -259.15], + "H3": [-18.0, -88.5, -259.15], + "H4": [-9.0, -88.5, -259.15], + "H5": [0.0, -88.5, -259.15], + "H6": [9.0, -88.5, -259.15], + "H7": [18.0, -88.5, -259.15], + "H8": [27.0, -88.5, -259.15], + "H9": [36.0, -88.5, -259.15], + "H10": [45.0, -88.5, -259.15], + "H11": [54.0, -88.5, -259.15], + "H12": [63.0, -88.5, -259.15] + } +} diff --git a/shared-data/pipette/definitions/2/liquid/eight_channel/p1000/default/3_3.json b/shared-data/pipette/definitions/2/liquid/eight_channel/p1000/default/3_3.json index 12736030d8e..fd4f29a83bb 100644 --- a/shared-data/pipette/definitions/2/liquid/eight_channel/p1000/default/3_3.json +++ b/shared-data/pipette/definitions/2/liquid/eight_channel/p1000/default/3_3.json @@ -2,6 +2,7 @@ "$otSharedSchema": "#/pipette/schemas/2/pipetteLiquidPropertiesSchema.json", "supportedTips": { "t50": { + "uiMaxFlowRate": 808.3, "defaultAspirateFlowRate": { "default": 6, "valuesByApiLevel": { "2.14": 6 } @@ -116,6 +117,7 @@ "defaultPushOutVolume": 7 }, "t200": { + "uiMaxFlowRate": 905.7, "defaultAspirateFlowRate": { "default": 80, "valuesByApiLevel": { "2.14": 80 } @@ -228,6 +230,7 @@ "defaultPushOutVolume": 5 }, "t1000": { + "uiMaxFlowRate": 787.7, "defaultAspirateFlowRate": { "default": 160, "valuesByApiLevel": { "2.14": 160 } diff --git a/shared-data/pipette/definitions/2/liquid/eight_channel/p1000/default/3_4.json b/shared-data/pipette/definitions/2/liquid/eight_channel/p1000/default/3_4.json index ae95738fb09..dcc9d533490 100644 --- a/shared-data/pipette/definitions/2/liquid/eight_channel/p1000/default/3_4.json +++ b/shared-data/pipette/definitions/2/liquid/eight_channel/p1000/default/3_4.json @@ -2,6 +2,7 @@ "$otSharedSchema": "#/pipette/schemas/2/pipetteLiquidPropertiesSchema.json", "supportedTips": { "t50": { + "uiMaxFlowRate": 808.3, "defaultAspirateFlowRate": { "default": 478, "valuesByApiLevel": { "2.14": 478 } @@ -116,6 +117,7 @@ "defaultPushOutVolume": 7 }, "t200": { + "uiMaxFlowRate": 905.7, "defaultAspirateFlowRate": { "default": 716, "valuesByApiLevel": { "2.14": 716 } @@ -228,6 +230,7 @@ "defaultPushOutVolume": 5 }, "t1000": { + "uiMaxFlowRate": 787.7, "defaultAspirateFlowRate": { "default": 716, "valuesByApiLevel": { "2.14": 716 } diff --git a/shared-data/pipette/definitions/2/liquid/eight_channel/p1000/default/3_5.json b/shared-data/pipette/definitions/2/liquid/eight_channel/p1000/default/3_5.json index 1906adc8372..83026842153 100644 --- a/shared-data/pipette/definitions/2/liquid/eight_channel/p1000/default/3_5.json +++ b/shared-data/pipette/definitions/2/liquid/eight_channel/p1000/default/3_5.json @@ -2,6 +2,7 @@ "$otSharedSchema": "#/pipette/schemas/2/pipetteLiquidPropertiesSchema.json", "supportedTips": { "t50": { + "uiMaxFlowRate": 802.9, "defaultAspirateFlowRate": { "default": 478, "valuesByApiLevel": { "2.14": 478 } @@ -82,6 +83,7 @@ "defaultPushOutVolume": 7 }, "t200": { + "uiMaxFlowRate": 847.9, "defaultAspirateFlowRate": { "default": 716, "valuesByApiLevel": { "2.14": 716 } @@ -160,6 +162,7 @@ "defaultPushOutVolume": 5 }, "t1000": { + "uiMaxFlowRate": 744.6, "defaultAspirateFlowRate": { "default": 716, "valuesByApiLevel": { "2.14": 716 } diff --git a/shared-data/pipette/definitions/2/liquid/eight_channel/p20/default/2_1.json b/shared-data/pipette/definitions/2/liquid/eight_channel/p20/default/2_1.json index 22950b76875..aa83a2e5bda 100644 --- a/shared-data/pipette/definitions/2/liquid/eight_channel/p20/default/2_1.json +++ b/shared-data/pipette/definitions/2/liquid/eight_channel/p20/default/2_1.json @@ -2,6 +2,7 @@ "$otSharedSchema": "#/pipette/schemas/2/pipetteLiquidPropertiesSchema.json", "supportedTips": { "t20": { + "uiMaxFlowRate": 25, "defaultAspirateFlowRate": { "default": 7.6, "valuesByApiLevel": { "2.0": 7.6 } @@ -86,6 +87,7 @@ "defaultPushOutVolume": 0 }, "t10": { + "uiMaxFlowRate": 23, "defaultAspirateFlowRate": { "default": 7.6, "valuesByApiLevel": { "2.0": 7.6 } diff --git a/shared-data/pipette/definitions/2/liquid/eight_channel/p300/default/2_1.json b/shared-data/pipette/definitions/2/liquid/eight_channel/p300/default/2_1.json index a7d91165db7..4fee623f602 100644 --- a/shared-data/pipette/definitions/2/liquid/eight_channel/p300/default/2_1.json +++ b/shared-data/pipette/definitions/2/liquid/eight_channel/p300/default/2_1.json @@ -2,6 +2,7 @@ "$otSharedSchema": "#/pipette/schemas/2/pipetteLiquidPropertiesSchema.json", "supportedTips": { "t200": { + "uiMaxFlowRate": 335.3, "defaultAspirateFlowRate": { "default": 94, "valuesByApiLevel": { "2.0": 94 } @@ -89,6 +90,7 @@ "defaultPushOutVolume": 0 }, "t300": { + "uiMaxFlowRate": 335.3, "defaultAspirateFlowRate": { "default": 94, "valuesByApiLevel": { "2.0": 94 } diff --git a/shared-data/pipette/definitions/2/liquid/eight_channel/p50/default/3_3.json b/shared-data/pipette/definitions/2/liquid/eight_channel/p50/default/3_3.json index ac12e0bea1e..38a4b01df80 100644 --- a/shared-data/pipette/definitions/2/liquid/eight_channel/p50/default/3_3.json +++ b/shared-data/pipette/definitions/2/liquid/eight_channel/p50/default/3_3.json @@ -2,6 +2,7 @@ "$otSharedSchema": "#/pipette/schemas/2/pipetteLiquidPropertiesSchema.json", "supportedTips": { "t50": { + "uiMaxFlowRate": 46.8, "defaultAspirateFlowRate": { "default": 8, "valuesByApiLevel": { "2.14": 8 } diff --git a/shared-data/pipette/definitions/2/liquid/eight_channel/p50/default/3_4.json b/shared-data/pipette/definitions/2/liquid/eight_channel/p50/default/3_4.json index 352f61bae30..32131ee1982 100644 --- a/shared-data/pipette/definitions/2/liquid/eight_channel/p50/default/3_4.json +++ b/shared-data/pipette/definitions/2/liquid/eight_channel/p50/default/3_4.json @@ -2,6 +2,7 @@ "$otSharedSchema": "#/pipette/schemas/2/pipetteLiquidPropertiesSchema.json", "supportedTips": { "t50": { + "uiMaxFlowRate": 46.8, "defaultAspirateFlowRate": { "default": 35, "valuesByApiLevel": { "2.14": 35 } diff --git a/shared-data/pipette/definitions/2/liquid/eight_channel/p50/default/3_5.json b/shared-data/pipette/definitions/2/liquid/eight_channel/p50/default/3_5.json index 49b2a7b549d..ca2a48db274 100644 --- a/shared-data/pipette/definitions/2/liquid/eight_channel/p50/default/3_5.json +++ b/shared-data/pipette/definitions/2/liquid/eight_channel/p50/default/3_5.json @@ -2,6 +2,7 @@ "$otSharedSchema": "#/pipette/schemas/2/pipetteLiquidPropertiesSchema.json", "supportedTips": { "t50": { + "uiMaxFlowRate": 46.7, "defaultAspirateFlowRate": { "default": 35, "valuesByApiLevel": { "2.14": 35 } diff --git a/shared-data/pipette/definitions/2/liquid/eight_channel/p50/lowVolumeDefault/3_3.json b/shared-data/pipette/definitions/2/liquid/eight_channel/p50/lowVolumeDefault/3_3.json index 4e83eee5d81..cc629f28316 100644 --- a/shared-data/pipette/definitions/2/liquid/eight_channel/p50/lowVolumeDefault/3_3.json +++ b/shared-data/pipette/definitions/2/liquid/eight_channel/p50/lowVolumeDefault/3_3.json @@ -2,6 +2,7 @@ "$otSharedSchema": "#/pipette/schemas/2/pipetteLiquidPropertiesSchema.json", "supportedTips": { "t50": { + "uiMaxFlowRate": 32.6, "defaultAspirateFlowRate": { "default": 8, "valuesByApiLevel": { "2.14": 8 } diff --git a/shared-data/pipette/definitions/2/liquid/eight_channel/p50/lowVolumeDefault/3_4.json b/shared-data/pipette/definitions/2/liquid/eight_channel/p50/lowVolumeDefault/3_4.json index 881e9583aa5..0e9284b04e6 100644 --- a/shared-data/pipette/definitions/2/liquid/eight_channel/p50/lowVolumeDefault/3_4.json +++ b/shared-data/pipette/definitions/2/liquid/eight_channel/p50/lowVolumeDefault/3_4.json @@ -2,17 +2,18 @@ "$otSharedSchema": "#/pipette/schemas/2/pipetteLiquidPropertiesSchema.json", "supportedTips": { "t50": { + "uiMaxFlowRate": 32.6, "defaultAspirateFlowRate": { - "default": 35, - "valuesByApiLevel": { "2.14": 35 } + "default": 32.6, + "valuesByApiLevel": { "2.14": 32.6 } }, "defaultDispenseFlowRate": { - "default": 57, - "valuesByApiLevel": { "2.14": 57 } + "default": 32.6, + "valuesByApiLevel": { "2.14": 32.6 } }, "defaultBlowOutFlowRate": { - "default": 57, - "valuesByApiLevel": { "2.14": 57 } + "default": 32.6, + "valuesByApiLevel": { "2.14": 32.6 } }, "defaultFlowAcceleration": 1200.0, "defaultTipLength": 57.9, diff --git a/shared-data/pipette/definitions/2/liquid/eight_channel/p50/lowVolumeDefault/3_5.json b/shared-data/pipette/definitions/2/liquid/eight_channel/p50/lowVolumeDefault/3_5.json index 881e9583aa5..0e9284b04e6 100644 --- a/shared-data/pipette/definitions/2/liquid/eight_channel/p50/lowVolumeDefault/3_5.json +++ b/shared-data/pipette/definitions/2/liquid/eight_channel/p50/lowVolumeDefault/3_5.json @@ -2,17 +2,18 @@ "$otSharedSchema": "#/pipette/schemas/2/pipetteLiquidPropertiesSchema.json", "supportedTips": { "t50": { + "uiMaxFlowRate": 32.6, "defaultAspirateFlowRate": { - "default": 35, - "valuesByApiLevel": { "2.14": 35 } + "default": 32.6, + "valuesByApiLevel": { "2.14": 32.6 } }, "defaultDispenseFlowRate": { - "default": 57, - "valuesByApiLevel": { "2.14": 57 } + "default": 32.6, + "valuesByApiLevel": { "2.14": 32.6 } }, "defaultBlowOutFlowRate": { - "default": 57, - "valuesByApiLevel": { "2.14": 57 } + "default": 32.6, + "valuesByApiLevel": { "2.14": 32.6 } }, "defaultFlowAcceleration": 1200.0, "defaultTipLength": 57.9, diff --git a/shared-data/pipette/definitions/2/liquid/ninety_six_channel/p1000/default/3_3.json b/shared-data/pipette/definitions/2/liquid/ninety_six_channel/p1000/default/3_3.json index 0f3f56f6494..899d08aeaee 100644 --- a/shared-data/pipette/definitions/2/liquid/ninety_six_channel/p1000/default/3_3.json +++ b/shared-data/pipette/definitions/2/liquid/ninety_six_channel/p1000/default/3_3.json @@ -2,6 +2,7 @@ "$otSharedSchema": "#/pipette/schemas/2/pipetteLiquidPropertiesSchema.json", "supportedTips": { "t50": { + "uiMaxFlowRate": 189.1, "defaultAspirateFlowRate": { "default": 6, "valuesByApiLevel": { "2.14": 6 } @@ -56,6 +57,7 @@ "defaultPushOutVolume": 7 }, "t200": { + "uiMaxFlowRate": 185.1, "defaultAspirateFlowRate": { "default": 80, "valuesByApiLevel": { "2.14": 80 } @@ -108,6 +110,7 @@ "defaultPushOutVolume": 5 }, "t1000": { + "uiMaxFlowRate": 184.8, "defaultAspirateFlowRate": { "default": 160, "valuesByApiLevel": { "2.14": 160 } diff --git a/shared-data/pipette/definitions/2/liquid/ninety_six_channel/p1000/default/3_4.json b/shared-data/pipette/definitions/2/liquid/ninety_six_channel/p1000/default/3_4.json index 0f3f56f6494..899d08aeaee 100644 --- a/shared-data/pipette/definitions/2/liquid/ninety_six_channel/p1000/default/3_4.json +++ b/shared-data/pipette/definitions/2/liquid/ninety_six_channel/p1000/default/3_4.json @@ -2,6 +2,7 @@ "$otSharedSchema": "#/pipette/schemas/2/pipetteLiquidPropertiesSchema.json", "supportedTips": { "t50": { + "uiMaxFlowRate": 189.1, "defaultAspirateFlowRate": { "default": 6, "valuesByApiLevel": { "2.14": 6 } @@ -56,6 +57,7 @@ "defaultPushOutVolume": 7 }, "t200": { + "uiMaxFlowRate": 185.1, "defaultAspirateFlowRate": { "default": 80, "valuesByApiLevel": { "2.14": 80 } @@ -108,6 +110,7 @@ "defaultPushOutVolume": 5 }, "t1000": { + "uiMaxFlowRate": 184.8, "defaultAspirateFlowRate": { "default": 160, "valuesByApiLevel": { "2.14": 160 } diff --git a/shared-data/pipette/definitions/2/liquid/ninety_six_channel/p1000/default/3_5.json b/shared-data/pipette/definitions/2/liquid/ninety_six_channel/p1000/default/3_5.json index 0f3f56f6494..1b9c88edf92 100644 --- a/shared-data/pipette/definitions/2/liquid/ninety_six_channel/p1000/default/3_5.json +++ b/shared-data/pipette/definitions/2/liquid/ninety_six_channel/p1000/default/3_5.json @@ -2,6 +2,7 @@ "$otSharedSchema": "#/pipette/schemas/2/pipetteLiquidPropertiesSchema.json", "supportedTips": { "t50": { + "uiMaxFlowRate": 189.1, "defaultAspirateFlowRate": { "default": 6, "valuesByApiLevel": { "2.14": 6 } @@ -56,6 +57,7 @@ "defaultPushOutVolume": 7 }, "t200": { + "uiMaxFlowRate": 185.1, "defaultAspirateFlowRate": { "default": 80, "valuesByApiLevel": { "2.14": 80 } @@ -108,6 +110,7 @@ "defaultPushOutVolume": 5 }, "t1000": { + "uiMaxFlowRate": 185.1, "defaultAspirateFlowRate": { "default": 160, "valuesByApiLevel": { "2.14": 160 } diff --git a/shared-data/pipette/definitions/2/liquid/ninety_six_channel/p1000/default/3_6.json b/shared-data/pipette/definitions/2/liquid/ninety_six_channel/p1000/default/3_6.json new file mode 100644 index 00000000000..cd27abd81f0 --- /dev/null +++ b/shared-data/pipette/definitions/2/liquid/ninety_six_channel/p1000/default/3_6.json @@ -0,0 +1,191 @@ +{ + "$otSharedSchema": "#/pipette/schemas/2/pipetteLiquidPropertiesSchema.json", + "supportedTips": { + "t50": { + "uiMaxFlowRate": 194, + "defaultAspirateFlowRate": { + "default": 6, + "valuesByApiLevel": { "2.14": 6 } + }, + "defaultDispenseFlowRate": { + "default": 6, + "valuesByApiLevel": { "2.14": 6 } + }, + "defaultBlowOutFlowRate": { + "default": 80, + "valuesByApiLevel": { "2.14": 80 } + }, + "defaultFlowAcceleration": 16000.0, + "defaultTipLength": 57.9, + "defaultReturnTipHeight": 0.2, + "aspirate": { + "default": { + "1": [ + [1.9733, 2.7039, 5.1258], + [2.88, 1.0915, 8.3077], + [3.7642, 0.5906, 9.7502], + [4.9783, 1.0072, 8.1822], + [5.9342, 0.2998, 11.7038], + [6.8708, 0.1887, 12.3626], + [7.8092, 0.1497, 12.631], + [8.7525, 0.1275, 12.804], + [13.4575, 0.0741, 13.2718], + [22.8675, 0.0296, 13.87], + [37.0442, 0.0128, 14.2551], + [55.4792, -0.0013, 14.7754] + ] + } + }, + "dispense": { + "default": { + "1": [ + [1.9733, 2.7039, 5.1258], + [2.88, 1.0915, 8.3077], + [3.7642, 0.5906, 9.7502], + [4.9783, 1.0072, 8.1822], + [5.9342, 0.2998, 11.7038], + [6.8708, 0.1887, 12.3626], + [7.8092, 0.1497, 12.631], + [8.7525, 0.1275, 12.804], + [13.4575, 0.0741, 13.2718], + [22.8675, 0.0296, 13.87], + [37.0442, 0.0128, 14.2551], + [55.4792, -0.0013, 14.7754] + ] + } + }, + "defaultPushOutVolume": 7 + }, + "t200": { + "uiMaxFlowRate": 194, + "defaultAspirateFlowRate": { + "default": 80, + "valuesByApiLevel": { "2.14": 80 } + }, + "defaultDispenseFlowRate": { + "default": 80, + "valuesByApiLevel": { "2.14": 80 } + }, + "defaultBlowOutFlowRate": { + "default": 80, + "valuesByApiLevel": { "2.14": 80 } + }, + "defaultFlowAcceleration": 16000.0, + "defaultTipLength": 58.35, + "defaultReturnTipHeight": 0.2, + "aspirate": { + "default": { + "1": [ + [1.9331, 3.4604, 3.5588], + [2.9808, 1.5307, 7.2892], + [3.9869, 0.825, 9.3926], + [4.9762, 0.5141, 10.6323], + [5.9431, 0.3232, 11.5819], + [6.9223, 0.2644, 11.9317], + [7.8877, 0.1832, 12.4935], + [8.8562, 0.1512, 12.7463], + [47.7169, 0.0281, 13.836], + [95.63, 0.0007, 15.147], + [211.1169, 0.0005, 15.1655] + ] + } + }, + "dispense": { + "default": { + "1": [ + [1.9331, 3.4604, 3.5588], + [2.9808, 1.5307, 7.2892], + [3.9869, 0.825, 9.3926], + [4.9762, 0.5141, 10.6323], + [5.9431, 0.3232, 11.5819], + [6.9223, 0.2644, 11.9317], + [7.8877, 0.1832, 12.4935], + [8.8562, 0.1512, 12.7463], + [47.7169, 0.0281, 13.836], + [95.63, 0.0007, 15.147], + [211.1169, 0.0005, 15.1655] + ] + } + }, + "defaultPushOutVolume": 5 + }, + "t1000": { + "uiMaxFlowRate": 187.2, + "defaultAspirateFlowRate": { + "default": 160, + "valuesByApiLevel": { "2.14": 160 } + }, + "defaultDispenseFlowRate": { + "default": 160, + "valuesByApiLevel": { "2.14": 160 } + }, + "defaultBlowOutFlowRate": { + "default": 80, + "valuesByApiLevel": { "2.14": 80 } + }, + "defaultFlowAcceleration": 16000.0, + "defaultTipLength": 95.6, + "defaultReturnTipHeight": 0.2, + "aspirate": { + "default": { + "1": [ + [3.9, 1.789, 5.4283], + [5.6991, 0.3019, 11.2278], + [8.5155, 0.2111, 11.7453], + [13.1482, 0.0858, 12.8124], + [17.8909, 0.0604, 13.1472], + [46.0982, 0.0155, 13.9505], + [93.5618, 0.0046, 14.4523], + [112.5991, 0.0023, 14.6687], + [189.5555, 0.002, 14.7035], + [305.5891, 0.001, 14.887], + [431.2836, 0.0004, 15.055], + [625.0209, 0.0003, 15.1309], + [818.6909, 0.0001, 15.2112], + [963.9909, 0.0001, 15.2445], + [992.0791, -0.0005, 15.7723], + [1012.2118, 0.0007, 14.6701], + [1037.1873, 0.0005, 14.8072] + ] + } + }, + "dispense": { + "default": { + "1": [ + [3.9, 1.789, 5.4283], + [5.6991, 0.3019, 11.2278], + [8.5155, 0.2111, 11.7453], + [13.1482, 0.0858, 12.8124], + [17.8909, 0.0604, 13.1472], + [46.0982, 0.0155, 13.9505], + [93.5618, 0.0046, 14.4523], + [112.5991, 0.0023, 14.6687], + [189.5555, 0.002, 14.7035], + [305.5891, 0.001, 14.887], + [431.2836, 0.0004, 15.055], + [625.0209, 0.0003, 15.1309], + [818.6909, 0.0001, 15.2112], + [963.9909, 0.0001, 15.2445], + [992.0791, -0.0005, 15.7723], + [1012.2118, 0.0007, 14.6701], + [1037.1873, 0.0005, 14.8072] + ] + } + }, + "defaultPushOutVolume": 20 + } + }, + "defaultTipOverlapDictionary": { + "default": 10.5, + "opentrons/opentrons_flex_96_tiprack_50ul/1": 10.5, + "opentrons/opentrons_flex_96_tiprack_1000ul/1": 10.5, + "opentrons/opentrons_flex_96_tiprack_200ul/1": 10.5 + }, + "maxVolume": 1000, + "minVolume": 5, + "defaultTipracks": [ + "opentrons/opentrons_flex_96_tiprack_1000ul/1", + "opentrons/opentrons_flex_96_tiprack_200ul/1", + "opentrons/opentrons_flex_96_tiprack_50ul/1" + ] +} diff --git a/shared-data/pipette/definitions/2/liquid/single_channel/p1000/default/2_1.json b/shared-data/pipette/definitions/2/liquid/single_channel/p1000/default/2_1.json index 9a281ac618c..46563177001 100644 --- a/shared-data/pipette/definitions/2/liquid/single_channel/p1000/default/2_1.json +++ b/shared-data/pipette/definitions/2/liquid/single_channel/p1000/default/2_1.json @@ -2,6 +2,7 @@ "$otSharedSchema": "#/pipette/schemas/2/pipetteLiquidPropertiesSchema.json", "supportedTips": { "t1000": { + "uiMaxFlowRate": 1018.6, "defaultAspirateFlowRate": { "default": 274.7, "valuesByApiLevel": { "2.0": 137.35, "2.6": 274.7 } diff --git a/shared-data/pipette/definitions/2/liquid/single_channel/p1000/default/2_2.json b/shared-data/pipette/definitions/2/liquid/single_channel/p1000/default/2_2.json index bc51e5751d9..bfb9c6e83e8 100644 --- a/shared-data/pipette/definitions/2/liquid/single_channel/p1000/default/2_2.json +++ b/shared-data/pipette/definitions/2/liquid/single_channel/p1000/default/2_2.json @@ -2,6 +2,7 @@ "$otSharedSchema": "#/pipette/schemas/2/pipetteLiquidPropertiesSchema.json", "supportedTips": { "t1000": { + "uiMaxFlowRate": 1020.7, "defaultAspirateFlowRate": { "default": 274.7, "valuesByApiLevel": { "2.0": 137.35, "2.6": 274.7 } diff --git a/shared-data/pipette/definitions/2/liquid/single_channel/p1000/default/3_3.json b/shared-data/pipette/definitions/2/liquid/single_channel/p1000/default/3_3.json index 476cb96cc69..e4e765c999c 100644 --- a/shared-data/pipette/definitions/2/liquid/single_channel/p1000/default/3_3.json +++ b/shared-data/pipette/definitions/2/liquid/single_channel/p1000/default/3_3.json @@ -2,6 +2,7 @@ "$otSharedSchema": "#/pipette/schemas/2/pipetteLiquidPropertiesSchema.json", "supportedTips": { "t50": { + "uiMaxFlowRate": 808.3, "defaultAspirateFlowRate": { "default": 6, "valuesByApiLevel": { "2.14": 6 } @@ -116,6 +117,7 @@ "defaultPushOutVolume": 7 }, "t200": { + "uiMaxFlowRate": 905.7, "defaultAspirateFlowRate": { "default": 80, "valuesByApiLevel": { "2.14": 80 } @@ -228,6 +230,7 @@ "defaultPushOutVolume": 5 }, "t1000": { + "uiMaxFlowRate": 787.7, "defaultAspirateFlowRate": { "default": 160, "valuesByApiLevel": { "2.14": 160 } diff --git a/shared-data/pipette/definitions/2/liquid/single_channel/p1000/default/3_4.json b/shared-data/pipette/definitions/2/liquid/single_channel/p1000/default/3_4.json index 28226b82e4d..f48e41f37f2 100644 --- a/shared-data/pipette/definitions/2/liquid/single_channel/p1000/default/3_4.json +++ b/shared-data/pipette/definitions/2/liquid/single_channel/p1000/default/3_4.json @@ -2,6 +2,7 @@ "$otSharedSchema": "#/pipette/schemas/2/pipetteLiquidPropertiesSchema.json", "supportedTips": { "t50": { + "uiMaxFlowRate": 762.1, "defaultAspirateFlowRate": { "default": 478, "valuesByApiLevel": { "2.14": 478 } @@ -90,6 +91,7 @@ "defaultPushOutVolume": 7 }, "t200": { + "uiMaxFlowRate": 745, "defaultAspirateFlowRate": { "default": 716, "valuesByApiLevel": { "2.14": 716 } @@ -178,6 +180,7 @@ "defaultPushOutVolume": 5 }, "t1000": { + "uiMaxFlowRate": 763.3, "defaultAspirateFlowRate": { "default": 716, "valuesByApiLevel": { "2.14": 716 } diff --git a/shared-data/pipette/definitions/2/liquid/single_channel/p1000/default/3_5.json b/shared-data/pipette/definitions/2/liquid/single_channel/p1000/default/3_5.json index 65456da3a9d..9c939ba9c7c 100644 --- a/shared-data/pipette/definitions/2/liquid/single_channel/p1000/default/3_5.json +++ b/shared-data/pipette/definitions/2/liquid/single_channel/p1000/default/3_5.json @@ -2,6 +2,7 @@ "$otSharedSchema": "#/pipette/schemas/2/pipetteLiquidPropertiesSchema.json", "supportedTips": { "t50": { + "uiMaxFlowRate": 785.2, "defaultAspirateFlowRate": { "default": 478, "valuesByApiLevel": { "2.14": 478 } @@ -70,6 +71,7 @@ "defaultPushOutVolume": 7 }, "t200": { + "uiMaxFlowRate": 802.5, "defaultAspirateFlowRate": { "default": 716, "valuesByApiLevel": { "2.14": 716 } @@ -128,6 +130,7 @@ "defaultPushOutVolume": 5 }, "t1000": { + "uiMaxFlowRate": 753.5, "defaultAspirateFlowRate": { "default": 716, "valuesByApiLevel": { "2.14": 716 } diff --git a/shared-data/pipette/definitions/2/liquid/single_channel/p1000/default/3_6.json b/shared-data/pipette/definitions/2/liquid/single_channel/p1000/default/3_6.json index 29caae1b15b..72187981c26 100644 --- a/shared-data/pipette/definitions/2/liquid/single_channel/p1000/default/3_6.json +++ b/shared-data/pipette/definitions/2/liquid/single_channel/p1000/default/3_6.json @@ -2,6 +2,7 @@ "$otSharedSchema": "#/pipette/schemas/2/pipetteLiquidPropertiesSchema.json", "supportedTips": { "t50": { + "uiMaxFlowRate": 785.2, "defaultAspirateFlowRate": { "default": 478, "valuesByApiLevel": { "2.14": 478 } @@ -70,6 +71,7 @@ "defaultPushOutVolume": 7 }, "t200": { + "uiMaxFlowRate": 802.5, "defaultAspirateFlowRate": { "default": 716, "valuesByApiLevel": { "2.14": 716 } @@ -128,6 +130,7 @@ "defaultPushOutVolume": 5 }, "t1000": { + "uiMaxFlowRate": 727.3, "defaultAspirateFlowRate": { "default": 716, "valuesByApiLevel": { "2.14": 716 } diff --git a/shared-data/pipette/definitions/2/liquid/single_channel/p20/default/2_1.json b/shared-data/pipette/definitions/2/liquid/single_channel/p20/default/2_1.json index 8acb156a2af..e10b9ba735d 100644 --- a/shared-data/pipette/definitions/2/liquid/single_channel/p20/default/2_1.json +++ b/shared-data/pipette/definitions/2/liquid/single_channel/p20/default/2_1.json @@ -2,6 +2,7 @@ "$otSharedSchema": "#/pipette/schemas/2/pipetteLiquidPropertiesSchema.json", "supportedTips": { "t20": { + "uiMaxFlowRate": 25.3, "defaultAspirateFlowRate": { "default": 3.78, "valuesByApiLevel": { "2.0": 3.78, "2.6": 7.56 } @@ -146,6 +147,7 @@ "defaultPushOutVolume": 0 }, "t10": { + "uiMaxFlowRate": 25.3, "defaultAspirateFlowRate": { "default": 3.78, "valuesByApiLevel": { "2.0": 3.78, "2.6": 7.56 } diff --git a/shared-data/pipette/definitions/2/liquid/single_channel/p20/default/2_2.json b/shared-data/pipette/definitions/2/liquid/single_channel/p20/default/2_2.json index 907d4546520..eba2ed05cb1 100644 --- a/shared-data/pipette/definitions/2/liquid/single_channel/p20/default/2_2.json +++ b/shared-data/pipette/definitions/2/liquid/single_channel/p20/default/2_2.json @@ -2,6 +2,7 @@ "$otSharedSchema": "#/pipette/schemas/2/pipetteLiquidPropertiesSchema.json", "supportedTips": { "t20": { + "uiMaxFlowRate": 25, "defaultAspirateFlowRate": { "default": 3.78, "valuesByApiLevel": { "2.0": 3.78, "2.6": 7.56 } @@ -146,6 +147,7 @@ "defaultPushOutVolume": 0 }, "t10": { + "uiMaxFlowRate": 25, "defaultAspirateFlowRate": { "default": 3.78, "valuesByApiLevel": { "2.0": 3.78, "2.6": 7.56 } diff --git a/shared-data/pipette/definitions/2/liquid/single_channel/p300/default/2_1.json b/shared-data/pipette/definitions/2/liquid/single_channel/p300/default/2_1.json index 14b514edf8d..0478ea9c0e5 100644 --- a/shared-data/pipette/definitions/2/liquid/single_channel/p300/default/2_1.json +++ b/shared-data/pipette/definitions/2/liquid/single_channel/p300/default/2_1.json @@ -2,6 +2,7 @@ "$otSharedSchema": "#/pipette/schemas/2/pipetteLiquidPropertiesSchema.json", "supportedTips": { "t200": { + "uiMaxFlowRate": 329.3, "defaultAspirateFlowRate": { "default": 46.43, "valuesByApiLevel": { "2.0": 46.43, "2.6": 92.86 } @@ -88,6 +89,7 @@ "defaultPushOutVolume": 0 }, "t300": { + "uiMaxFlowRate": 329.3, "defaultAspirateFlowRate": { "default": 46.43, "valuesByApiLevel": { "2.0": 46.43, "2.6": 92.86 } diff --git a/shared-data/pipette/definitions/2/liquid/single_channel/p50/default/3_3.json b/shared-data/pipette/definitions/2/liquid/single_channel/p50/default/3_3.json index f5492d8809a..a5d87c485ba 100644 --- a/shared-data/pipette/definitions/2/liquid/single_channel/p50/default/3_3.json +++ b/shared-data/pipette/definitions/2/liquid/single_channel/p50/default/3_3.json @@ -2,6 +2,7 @@ "$otSharedSchema": "#/pipette/schemas/2/pipetteLiquidPropertiesSchema.json", "supportedTips": { "t50": { + "uiMaxFlowRate": 46.8, "defaultAspirateFlowRate": { "default": 8, "valuesByApiLevel": { "2.14": 8 } diff --git a/shared-data/pipette/definitions/2/liquid/single_channel/p50/default/3_4.json b/shared-data/pipette/definitions/2/liquid/single_channel/p50/default/3_4.json index df9fc3d784b..464eb213798 100644 --- a/shared-data/pipette/definitions/2/liquid/single_channel/p50/default/3_4.json +++ b/shared-data/pipette/definitions/2/liquid/single_channel/p50/default/3_4.json @@ -2,6 +2,7 @@ "$otSharedSchema": "#/pipette/schemas/2/pipetteLiquidPropertiesSchema.json", "supportedTips": { "t50": { + "uiMaxFlowRate": 46.3, "defaultAspirateFlowRate": { "default": 35, "valuesByApiLevel": { "2.14": 35 } diff --git a/shared-data/pipette/definitions/2/liquid/single_channel/p50/default/3_5.json b/shared-data/pipette/definitions/2/liquid/single_channel/p50/default/3_5.json index c798ce421a6..2fca659b070 100644 --- a/shared-data/pipette/definitions/2/liquid/single_channel/p50/default/3_5.json +++ b/shared-data/pipette/definitions/2/liquid/single_channel/p50/default/3_5.json @@ -2,6 +2,7 @@ "$otSharedSchema": "#/pipette/schemas/2/pipetteLiquidPropertiesSchema.json", "supportedTips": { "t50": { + "uiMaxFlowRate": 47, "defaultAspirateFlowRate": { "default": 35, "valuesByApiLevel": { "2.14": 35 } diff --git a/shared-data/pipette/definitions/2/liquid/single_channel/p50/lowVolumeDefault/3_3.json b/shared-data/pipette/definitions/2/liquid/single_channel/p50/lowVolumeDefault/3_3.json index 2a292477578..deae3998fe9 100644 --- a/shared-data/pipette/definitions/2/liquid/single_channel/p50/lowVolumeDefault/3_3.json +++ b/shared-data/pipette/definitions/2/liquid/single_channel/p50/lowVolumeDefault/3_3.json @@ -2,6 +2,7 @@ "$otSharedSchema": "#/pipette/schemas/2/pipetteLiquidPropertiesSchema.json", "supportedTips": { "t50": { + "uiMaxFlowRate": 31.8, "defaultAspirateFlowRate": { "default": 8, "valuesByApiLevel": { "2.14": 8 } diff --git a/shared-data/pipette/definitions/2/liquid/single_channel/p50/lowVolumeDefault/3_4.json b/shared-data/pipette/definitions/2/liquid/single_channel/p50/lowVolumeDefault/3_4.json index 771ff88cf22..397dc63b230 100644 --- a/shared-data/pipette/definitions/2/liquid/single_channel/p50/lowVolumeDefault/3_4.json +++ b/shared-data/pipette/definitions/2/liquid/single_channel/p50/lowVolumeDefault/3_4.json @@ -2,17 +2,18 @@ "$otSharedSchema": "#/pipette/schemas/2/pipetteLiquidPropertiesSchema.json", "supportedTips": { "t50": { + "uiMaxFlowRate": 31.8, "defaultAspirateFlowRate": { - "default": 35, - "valuesByApiLevel": { "2.14": 35 } + "default": 31.8, + "valuesByApiLevel": { "2.14": 31.8 } }, "defaultDispenseFlowRate": { - "default": 57, - "valuesByApiLevel": { "2.14": 57 } + "default": 31.8, + "valuesByApiLevel": { "2.14": 31.8 } }, "defaultBlowOutFlowRate": { - "default": 57, - "valuesByApiLevel": { "2.14": 57 } + "default": 31.8, + "valuesByApiLevel": { "2.14": 31.8 } }, "defaultFlowAcceleration": 1200.0, "defaultTipLength": 57.9, diff --git a/shared-data/pipette/definitions/2/liquid/single_channel/p50/lowVolumeDefault/3_5.json b/shared-data/pipette/definitions/2/liquid/single_channel/p50/lowVolumeDefault/3_5.json index 644d93354e8..e1b92133bd6 100644 --- a/shared-data/pipette/definitions/2/liquid/single_channel/p50/lowVolumeDefault/3_5.json +++ b/shared-data/pipette/definitions/2/liquid/single_channel/p50/lowVolumeDefault/3_5.json @@ -2,17 +2,18 @@ "$otSharedSchema": "#/pipette/schemas/2/pipetteLiquidPropertiesSchema.json", "supportedTips": { "t50": { + "uiMaxFlowRate": 26.7, "defaultAspirateFlowRate": { - "default": 35, - "valuesByApiLevel": { "2.14": 35 } + "default": 26.7, + "valuesByApiLevel": { "2.14": 26.7 } }, "defaultDispenseFlowRate": { - "default": 57, - "valuesByApiLevel": { "2.14": 57 } + "default": 26.7, + "valuesByApiLevel": { "2.14": 26.7 } }, "defaultBlowOutFlowRate": { - "default": 57, - "valuesByApiLevel": { "2.14": 57 } + "default": 26.7, + "valuesByApiLevel": { "2.14": 26.7 } }, "defaultFlowAcceleration": 1200.0, "defaultTipLength": 57.9, diff --git a/shared-data/pipette/schemas/2/pipetteLiquidPropertiesSchema.json b/shared-data/pipette/schemas/2/pipetteLiquidPropertiesSchema.json index f7a76e0cde0..a4ba8e659f1 100644 --- a/shared-data/pipette/schemas/2/pipetteLiquidPropertiesSchema.json +++ b/shared-data/pipette/schemas/2/pipetteLiquidPropertiesSchema.json @@ -68,13 +68,16 @@ ], "properties": { "defaultAspirateFlowRate": { - "$ref": "#/definitions/flowRate" + "$ref": "#/definitions/flowRate", + "$comment": "for lowVolumeDefault only, the flowRate matches the uiMaxFlowRate. This does not change physical behavior and was made for UI purposes" }, "defaultDispenseFlowRate": { - "$ref": "#/definitions/flowRate" + "$ref": "#/definitions/flowRate", + "$comment": "for lowVolumeDefault only, the flowRate matches the uiMaxFlowRate. This does not change physical behavior and was made for UI purposes" }, "defaultBlowOutFlowRate": { - "$ref": "#/definitions/flowRate" + "$ref": "#/definitions/flowRate", + "$comment": "for lowVolumeDefault only, the flowRate matches the uiMaxFlowRate. This does not change physical behavior and was made for UI purposes" }, "defaultFlowAcceleration": { "$ref": "#/definitions/positiveNumber" @@ -92,6 +95,11 @@ "dispense": { "type": "array", "items": { "$ref": "#/definitions/liquidHandlingSpecs" } + }, + "uiMaxFlowRate": { + "$ref": "#/definitions/positiveNumber", + "$comment": "To be used in frontend applications only since it is the limit-to-lowest-max", + "description": "An optional number to represent each pipette's supported tip max flow rate for aspirate and dispense. The limit is the lowest max flow rate given all the tip's volumes minus 2% for safety." } } } diff --git a/shared-data/protocol/fixtures/8/simpleFlexV8.json b/shared-data/protocol/fixtures/8/simpleFlexV8.json index 277d7e636fe..a188ab7c710 100644 --- a/shared-data/protocol/fixtures/8/simpleFlexV8.json +++ b/shared-data/protocol/fixtures/8/simpleFlexV8.json @@ -1220,8 +1220,8 @@ { "commandType": "loadModule", "params": { - "moduleId": "magneticModuleId", - "model": "magneticModuleV2", + "moduleId": "magneticBlockId", + "model": "magneticBlockV1", "location": { "slotName": "3" } } }, @@ -1254,7 +1254,7 @@ "namespace": "opentrons", "version": 1, "location": { - "moduleId": "magneticModuleId" + "moduleId": "magneticBlockId" }, "displayName": "Sample Collection Plate" } diff --git a/shared-data/protocol/types/schemaV8/index.ts b/shared-data/protocol/types/schemaV8/index.ts index 0a6972fe271..d501abbe38e 100644 --- a/shared-data/protocol/types/schemaV8/index.ts +++ b/shared-data/protocol/types/schemaV8/index.ts @@ -4,6 +4,7 @@ import type { LoadedLabware, LoadedModule, Liquid, + RunTimeParameter, } from '../../../js' import type { CommandAnnotation } from '../../../commandAnnotation/types' import type { LabwareDefinition2, RobotType } from '../../../js/types' @@ -136,6 +137,7 @@ export interface ProtocolAnalysisOutput { modules: LoadedModule[] liquids: Liquid[] errors: AnalysisError[] + runTimeParameters: RunTimeParameter[] robotType?: RobotType } diff --git a/shared-data/python/opentrons_shared_data/deck/__init__.py b/shared-data/python/opentrons_shared_data/deck/__init__.py index e922d905ec2..24d56ad730e 100644 --- a/shared-data/python/opentrons_shared_data/deck/__init__.py +++ b/shared-data/python/opentrons_shared_data/deck/__init__.py @@ -15,9 +15,11 @@ DeckSchemaVersion3, DeckDefinitionV4, DeckSchemaVersion4, + DeckDefinitionV5, + DeckSchemaVersion5, ) -DEFAULT_DECK_DEFINITION_VERSION: Final = 4 +DEFAULT_DECK_DEFINITION_VERSION: Final = 5 class Offset(NamedTuple): @@ -38,6 +40,11 @@ class Offset(NamedTuple): } +@overload +def load(name: str, version: "DeckSchemaVersion5") -> "DeckDefinitionV5": + ... + + @overload def load(name: str, version: "DeckSchemaVersion4") -> "DeckDefinitionV4": ... diff --git a/shared-data/python/opentrons_shared_data/deck/dev_types.py b/shared-data/python/opentrons_shared_data/deck/dev_types.py index 06f372d73bd..4563ff10953 100644 --- a/shared-data/python/opentrons_shared_data/deck/dev_types.py +++ b/shared-data/python/opentrons_shared_data/deck/dev_types.py @@ -10,6 +10,7 @@ from ..module.dev_types import ModuleType +DeckSchemaVersion5 = Literal[5] DeckSchemaVersion4 = Literal[4] DeckSchemaVersion3 = Literal[3] DeckSchemaVersion2 = Literal[2] @@ -111,9 +112,11 @@ class Cutout(TypedDict): class CutoutFixture(TypedDict): id: str + expectOpentronsModuleSerialNumber: bool mayMountTo: List[str] displayName: str providesAddressableAreas: Dict[str, List[str]] + fixtureGroup: Dict[str, List[Dict[str, str]]] height: float @@ -176,4 +179,19 @@ class DeckDefinitionV4(_RequiredDeckDefinitionV4, total=False): gripperOffsets: Dict[str, GripperOffsets] -DeckDefinition = Union[DeckDefinitionV3, DeckDefinitionV4] +class _RequiredDeckDefinitionV5(TypedDict): + otId: str + schemaVersion: Literal[5] + cornerOffsetFromOrigin: List[float] + dimensions: List[float] + metadata: Metadata + robot: Robot + locations: LocationsV4 + cutoutFixtures: List[CutoutFixture] + + +class DeckDefinitionV5(_RequiredDeckDefinitionV5, total=False): + gripperOffsets: Dict[str, GripperOffsets] + + +DeckDefinition = Union[DeckDefinitionV3, DeckDefinitionV4, DeckDefinitionV5] diff --git a/shared-data/python/opentrons_shared_data/labware/labware_definition.py b/shared-data/python/opentrons_shared_data/labware/labware_definition.py index 203dba1455d..1b2e68040de 100644 --- a/shared-data/python/opentrons_shared_data/labware/labware_definition.py +++ b/shared-data/python/opentrons_shared_data/labware/labware_definition.py @@ -160,7 +160,7 @@ class Parameters(BaseModel): loadName: str = Field( ..., description="Name used to reference a labware definition", - pattern=SAFE_STRING_REGEX, + regex=SAFE_STRING_REGEX, ) isMagneticModuleCompatible: bool = Field( ..., @@ -262,7 +262,7 @@ class LabwareDefinition(BaseModel): "(eg myPlate v1/v2/v3). An incrementing integer", ge=1.0, ) - namespace: str = Field(..., pattern=SAFE_STRING_REGEX) + namespace: str = Field(..., regex=SAFE_STRING_REGEX) metadata: Metadata = Field( ..., description="Properties used for search and display" ) diff --git a/shared-data/python/opentrons_shared_data/performance/dev_types.py b/shared-data/python/opentrons_shared_data/performance/dev_types.py new file mode 100644 index 00000000000..842399f2c3b --- /dev/null +++ b/shared-data/python/opentrons_shared_data/performance/dev_types.py @@ -0,0 +1,56 @@ +"""Type definitions for performance tracking.""" + +from typing import Protocol, TypeVar, Callable, Any +from pathlib import Path +from enum import Enum + +F = TypeVar("F", bound=Callable[..., Any]) + + +class SupportsTracking(Protocol): + """Protocol for classes that support tracking of robot context.""" + + def __init__(self, storage_location: Path, should_track: bool) -> None: + """Initialize the tracker.""" + ... + + def track(self, state: "RobotContextState") -> Callable[[F], F]: + """Decorator to track the given state for the decorated function.""" + ... + + def store(self) -> None: + """Store the tracked data.""" + ... + + +class RobotContextState(Enum): + """Enum representing different states of a robot's operation context.""" + + STARTING_UP = 0, "STARTING_UP" + CALIBRATING = 1, "CALIBRATING" + ANALYZING_PROTOCOL = 2, "ANALYZING_PROTOCOL" + RUNNING_PROTOCOL = 3, "RUNNING_PROTOCOL" + SHUTTING_DOWN = 4, "SHUTTING_DOWN" + + def __init__(self, state_id: int, state_name: str) -> None: + """Initialize the enum member.""" + self.state_id = state_id + self.state_name = state_name + + @classmethod + def from_id(cls, state_id: int) -> "RobotContextState": + """Returns the enum member matching the given state ID. + + Args: + state_id: The ID of the state to retrieve. + + Returns: + RobotContextStates: The enum member corresponding to the given ID. + + Raises: + ValueError: If no matching state is found. + """ + for state in RobotContextState: + if state.state_id == state_id: + return state + raise ValueError(f"Invalid state id: {state_id}") diff --git a/shared-data/python/opentrons_shared_data/pipette/pipette_definition.py b/shared-data/python/opentrons_shared_data/pipette/pipette_definition.py index d7f3435ec73..a7b43663884 100644 --- a/shared-data/python/opentrons_shared_data/pipette/pipette_definition.py +++ b/shared-data/python/opentrons_shared_data/pipette/pipette_definition.py @@ -72,17 +72,17 @@ class SupportedTipsDefinition(BaseModel): default_aspirate_flowrate: FlowRateDefinition = Field( ..., - description="The flowrate used in aspirations by default.", + description="The flowrate used in aspirations by default. For lowVolumeDefault only, the flowrate matches uiMaxFlowRate for ui purposes, it does not change physical behavior.", alias="defaultAspirateFlowRate", ) default_dispense_flowrate: FlowRateDefinition = Field( ..., - description="The flowrate used in dispenses by default.", + description="The flowrate used in dispenses by default. For lowVolumeDefault only, the flowrate matches uiMaxFlowRate for ui purposes, it does not change physical behavior.", alias="defaultDispenseFlowRate", ) default_blowout_flowrate: FlowRateDefinition = Field( ..., - description="The flowrate used in blowouts by default.", + description="The flowrate used in blowouts by default. For lowVolumeDefault only, the flowrate matches uiMaxFlowRate for ui purposes, it does not change physical behavior.", alias="defaultBlowOutFlowRate", ) default_flow_acceleration: float = Field( @@ -111,6 +111,13 @@ class SupportedTipsDefinition(BaseModel): description="The default volume for a push-out during dispense.", alias="defaultPushOutVolume", ) + ui_max_flow_rate: float = Field( + float( + "inf" + ), # some pipettes (GEN1, unreleased prototype models) don't have a max flow rate + description="The lowest volume max flow rate for a pipette's given supported tip, minus 2 percent for safety.", + alias="uiMaxFlowRate", + ) class MotorConfigurations(BaseModel): diff --git a/api/src/opentrons/hardware_control/instruments/instrument_helpers.py b/shared-data/python/opentrons_shared_data/pipette/ul_per_mm.py similarity index 100% rename from api/src/opentrons/hardware_control/instruments/instrument_helpers.py rename to shared-data/python/opentrons_shared_data/pipette/ul_per_mm.py diff --git a/shared-data/python/setup.py b/shared-data/python/setup.py index 8aebebcb408..4e1720cb610 100644 --- a/shared-data/python/setup.py +++ b/shared-data/python/setup.py @@ -130,8 +130,6 @@ def get_version(): "Intended Audience :: Science/Research", "Programming Language :: Python", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Topic :: Scientific/Engineering", ] @@ -151,7 +149,7 @@ def get_version(): if __name__ == "__main__": setup( - python_requires=">=3.8", + python_requires=">=3.10", name=DISTNAME, description=DESCRIPTION, license=LICENSE, diff --git a/shared-data/python/tests/deck/test_typechecks.py b/shared-data/python/tests/deck/test_typechecks.py index f021004b050..4e2406df0fa 100644 --- a/shared-data/python/tests/deck/test_typechecks.py +++ b/shared-data/python/tests/deck/test_typechecks.py @@ -5,7 +5,10 @@ list_names as list_deck_definition_names, load as load_deck_definition, ) -from opentrons_shared_data.deck.dev_types import DeckDefinitionV3, DeckDefinitionV4 +from opentrons_shared_data.deck.dev_types import ( + DeckDefinitionV3, + DeckDefinitionV5, +) @pytest.mark.parametrize("defname", list_deck_definition_names(version=3)) @@ -14,7 +17,7 @@ def test_v3_defs(defname): typeguard.check_type(defn, DeckDefinitionV3) -@pytest.mark.parametrize("defname", list_deck_definition_names(version=4)) -def test_v4_defs(defname): - defn = load_deck_definition(name=defname, version=4) - typeguard.check_type(defn, DeckDefinitionV4) +@pytest.mark.parametrize("defname", list_deck_definition_names(version=5)) +def test_v5_defs(defname): + defn = load_deck_definition(name=defname, version=5) + typeguard.check_type(defn, DeckDefinitionV5) diff --git a/shared-data/python/tests/labware/test_validations.py b/shared-data/python/tests/labware/test_validations.py new file mode 100644 index 00000000000..39052e5d150 --- /dev/null +++ b/shared-data/python/tests/labware/test_validations.py @@ -0,0 +1,21 @@ +import pytest + +from pydantic import ValidationError +from opentrons_shared_data.labware import load_definition +from opentrons_shared_data.labware.labware_definition import LabwareDefinition + +from . import get_ot_defs + + +def test_loadname_regex_applied() -> None: + defdict = load_definition(*get_ot_defs()[0]) + defdict["parameters"]["loadName"] = "ALSJHDAKJLA" + with pytest.raises(ValidationError): + LabwareDefinition.parse_obj(defdict) + + +def test_namespace_regex_applied() -> None: + defdict = load_definition(*get_ot_defs()[0]) + defdict["namespace"] = "ALSJHDAKJLA" + with pytest.raises(ValidationError): + LabwareDefinition.parse_obj(defdict) diff --git a/shared-data/python/tests/pipette/test_max_flow_rates_per_volume.py b/shared-data/python/tests/pipette/test_max_flow_rates_per_volume.py new file mode 100644 index 00000000000..ff731ec0e3c --- /dev/null +++ b/shared-data/python/tests/pipette/test_max_flow_rates_per_volume.py @@ -0,0 +1,88 @@ +import os +import pytest +from typing import Iterator +from opentrons_shared_data import get_shared_data_root +from opentrons_shared_data.pipette.pipette_load_name_conversions import ( + convert_pipette_model, +) +from opentrons_shared_data.pipette.load_data import load_definition +from opentrons_shared_data.pipette.ul_per_mm import piecewise_volume_conversion + +from opentrons_shared_data.pipette.dev_types import PipetteModel +from opentrons_shared_data.pipette.pipette_definition import ( + ulPerMMDefinition, +) + + +DEFAULT_MAX_SPEED_HIGH_THROUGHPUT_OT3_AXIS_KIND_P = 15 +DEFAULT_MAX_SPEED_LOW_THROUGHPUT_OT3_AXIS_KIND_P = 70 +B_MAX_SPEED = 40 + + +def _get_plunger_max_speed(pipette_model: PipetteModel) -> float: + if "v2" in pipette_model: + return B_MAX_SPEED + else: + if "96" in pipette_model: + return DEFAULT_MAX_SPEED_HIGH_THROUGHPUT_OT3_AXIS_KIND_P + else: + return DEFAULT_MAX_SPEED_LOW_THROUGHPUT_OT3_AXIS_KIND_P + + +def _get_max_flow_rate_at_volume( + ul_per_mm_definition: ulPerMMDefinition, + pipette_model: PipetteModel, + volume: float, +) -> float: + max_speed = _get_plunger_max_speed(pipette_model) + map = list(ul_per_mm_definition.default.values())[-1] + ul_per_mm = piecewise_volume_conversion(volume, map) + return round(ul_per_mm * max_speed, 1) + + +def get_all_pipette_models() -> Iterator[PipetteModel]: + paths_to_validate = ( + get_shared_data_root() / "pipette" / "definitions" / "2" / "liquid" + ) + + _channel_model_str = { + "single_channel": "single", + "ninety_six_channel": "96", + "eight_channel": "multi", + } + assert os.listdir(paths_to_validate), "You have a path wrong" + for channel_dir in os.listdir(paths_to_validate): + for model_dir in os.listdir(paths_to_validate / channel_dir): + for liquid_file in os.listdir(paths_to_validate / channel_dir / model_dir): + for version_file in os.listdir( + paths_to_validate / channel_dir / model_dir / liquid_file + ): + version_list = version_file.split(".json")[0].split("_") + built_model: PipetteModel = PipetteModel( + f"{model_dir}_{_channel_model_str[channel_dir]}_v{version_list[0]}.{version_list[1]}" + ) + if version_list[0] != "1" and version_list[1] != "0": + yield built_model + + +@pytest.mark.parametrize("pipette", list(get_all_pipette_models())) +@pytest.mark.parametrize("action", ["aspirate", "dispense"]) +def test_max_flow_rates_per_volume(pipette: PipetteModel, action: str) -> None: + """Verify the max flow rate values for each pipette's supported tip is in range""" + pipette_model_version = convert_pipette_model(pipette) + definition = load_definition( + pipette_model_version.pipette_type, + pipette_model_version.pipette_channels, + pipette_model_version.pipette_version, + ) + for liquid_name, liquid_properties in definition.liquid_properties.items(): + for ( + tip_type, + supported_tip, + ) in liquid_properties.supported_tips.items(): + assert supported_tip.ui_max_flow_rate < _get_max_flow_rate_at_volume( + supported_tip.aspirate, pipette, liquid_properties.min_volume + ) + assert supported_tip.ui_max_flow_rate < _get_max_flow_rate_at_volume( + supported_tip.dispense, pipette, liquid_properties.min_volume + ) diff --git a/shared-data/webpack.config.js b/shared-data/webpack.config.js deleted file mode 100644 index 18aa6478319..00000000000 --- a/shared-data/webpack.config.js +++ /dev/null @@ -1,20 +0,0 @@ -'use strict' - -const path = require('path') -const webpackMerge = require('webpack-merge') -const { baseConfig } = require('@opentrons/webpack-config') - -const ENTRY_INDEX = path.join(__dirname, 'js/index.ts') -const OUTPUT_PATH = path.join(__dirname, 'lib') - -module.exports = async () => - webpackMerge(baseConfig, { - entry: { index: ENTRY_INDEX }, - output: { - path: OUTPUT_PATH, - filename: 'opentrons-shared-data.js', - library: '@opentrons/shared-data', - libraryTarget: 'umd', - globalObject: 'this', - }, - }) diff --git a/step-generation/src/__tests__/aspirate.test.ts b/step-generation/src/__tests__/aspirate.test.ts index 7731f5e389e..d937fcda7a4 100644 --- a/step-generation/src/__tests__/aspirate.test.ts +++ b/step-generation/src/__tests__/aspirate.test.ts @@ -67,6 +67,8 @@ describe('aspirate', () => { well: 'A1', } as AspDispAirgapParams), tipRack: 'tiprack1Id', + xOffset: 0, + yOffset: 0, } const result = aspirate(params, invariantContext, robotStateWithTip) expect(getSuccessResult(result).commands).toEqual([ @@ -82,6 +84,8 @@ describe('aspirate', () => { wellLocation: { origin: 'bottom', offset: { + x: 0, + y: 0, z: 5, }, }, @@ -106,6 +110,8 @@ describe('aspirate', () => { well: 'A1', } as AspDispAirgapParams), tipRack: 'tiprack1Id', + xOffset: 0, + yOffset: 0, }, invariantContext, robotStateWithTip @@ -133,6 +139,8 @@ describe('aspirate', () => { well: 'A1', } as AspDispAirgapParams), tipRack: 'tipRack', + xOffset: 0, + yOffset: 0, }, invariantContext, robotStateWithTip @@ -153,6 +161,8 @@ describe('aspirate', () => { well: 'A1', } as AspDispAirgapParams), tipRack: 'tipRack', + xOffset: 0, + yOffset: 0, }, invariantContext, robotStateWithTip @@ -170,6 +180,8 @@ describe('aspirate', () => { well: 'A1', } as AspDispAirgapParams), tipRack: 'tipRack', + xOffset: 0, + yOffset: 0, }, invariantContext, initialRobotState @@ -190,6 +202,8 @@ describe('aspirate', () => { well: 'A1', } as AspDispAirgapParams), tipRack: 'tipRack', + xOffset: 0, + yOffset: 0, }, invariantContext, robotStateWithTip @@ -214,6 +228,8 @@ describe('aspirate', () => { well: 'A1', } as AspDispAirgapParams), tipRack: 'tipRack', + xOffset: 0, + yOffset: 0, }, invariantContext, initialRobotState @@ -246,6 +262,8 @@ describe('aspirate', () => { well: 'A1', } as AspDispAirgapParams), tipRack: 'tipRack', + xOffset: 0, + yOffset: 0, }, invariantContext, robotStateWithTip @@ -278,6 +296,8 @@ describe('aspirate', () => { well: 'A1', } as AspDispAirgapParams), tipRack: 'tipRack', + xOffset: 0, + yOffset: 0, }, invariantContext, robotStateWithTip @@ -316,6 +336,8 @@ describe('aspirate', () => { well: 'A1', } as AspDispAirgapParams), tipRack: 'tipRack', + xOffset: 0, + yOffset: 0, }, invariantContext, robotStateWithTip @@ -348,6 +370,8 @@ describe('aspirate', () => { well: 'A1', } as AspDispAirgapParams), tipRack: 'tipRack', + xOffset: 0, + yOffset: 0, }, invariantContext, robotStateWithTip @@ -386,6 +410,8 @@ describe('aspirate', () => { well: 'A1', } as AspDispAirgapParams), tipRack: 'tipRack', + xOffset: 0, + yOffset: 0, }, invariantContext, robotStateWithTip @@ -414,6 +440,8 @@ describe('aspirate', () => { well: 'A1', } as AspDispAirgapParams), tipRack: 'tipRack', + xOffset: 0, + yOffset: 0, }, invariantContext, robotStateWithTip @@ -441,6 +469,8 @@ describe('aspirate', () => { well: 'A1', } as AspDispAirgapParams), tipRack: 'tipRack', + xOffset: 0, + yOffset: 0, }, invariantContext, robotStateWithTip @@ -468,6 +498,8 @@ describe('aspirate', () => { well: 'A1', } as AspDispAirgapParams), tipRack: 'tipRack', + xOffset: 0, + yOffset: 0, }, invariantContext, robotStateWithTip @@ -497,6 +529,8 @@ describe('aspirate', () => { well: 'A1', } as AspDispAirgapParams), tipRack: 'tipRack', + xOffset: 0, + yOffset: 0, }, invariantContext, robotStateWithTip diff --git a/step-generation/src/__tests__/blowout.test.ts b/step-generation/src/__tests__/blowout.test.ts index c52cac83042..8e16cafb331 100644 --- a/step-generation/src/__tests__/blowout.test.ts +++ b/step-generation/src/__tests__/blowout.test.ts @@ -11,7 +11,7 @@ import { DEFAULT_PIPETTE, SOURCE_LABWARE, } from '../fixtures' -import { BlowoutParams } from '@opentrons/shared-data/protocol/types/schemaV3' +import type { BlowoutParams } from '@opentrons/shared-data' import type { RobotState, InvariantContext } from '../types' describe('blowout', () => { @@ -24,11 +24,15 @@ describe('blowout', () => { initialRobotState = getInitialRobotStateStandard(invariantContext) robotStateWithTip = getRobotStateWithTipStandard(invariantContext) params = { - pipette: DEFAULT_PIPETTE, - labware: SOURCE_LABWARE, - well: 'A1', + pipetteId: DEFAULT_PIPETTE, + labwareId: SOURCE_LABWARE, + wellName: 'A1', flowRate: 21.1, - offsetFromBottomMm: 1.3, + wellLocation: { + offset: { + z: -1.3, + }, + }, } }) it('blowout with tip', () => { @@ -44,9 +48,9 @@ describe('blowout', () => { wellName: 'A1', flowRate: 21.1, wellLocation: { - origin: 'bottom', + origin: 'top', offset: { - z: 1.3, + z: -1.3, }, }, }, @@ -55,7 +59,7 @@ describe('blowout', () => { }) it('blowout with invalid pipette ID should throw error', () => { const result = blowout( - { ...params, pipette: 'badPipette' }, + { ...params, pipetteId: 'badPipette' }, invariantContext, robotStateWithTip ) @@ -63,7 +67,7 @@ describe('blowout', () => { }) it('blowout with invalid labware ID should throw error', () => { const result = blowout( - { ...params, labware: 'badLabware' }, + { ...params, labwareId: 'badLabware' }, invariantContext, robotStateWithTip ) @@ -88,11 +92,15 @@ describe('blowout', () => { const result = blowout( { flowRate: 10, - offsetFromBottomMm: 5, - pipette: DEFAULT_PIPETTE, + wellLocation: { + offset: { + z: -3, + }, + }, + pipetteId: DEFAULT_PIPETTE, volume: 50, - labware: SOURCE_LABWARE, - well: 'A1', + labwareId: SOURCE_LABWARE, + wellName: 'A1', } as BlowoutParams, invariantContext, initialRobotState diff --git a/step-generation/src/__tests__/blowoutUtil.test.ts b/step-generation/src/__tests__/blowoutUtil.test.ts index 33ff3770567..ac2a1c1cd87 100644 --- a/step-generation/src/__tests__/blowoutUtil.test.ts +++ b/step-generation/src/__tests__/blowoutUtil.test.ts @@ -63,11 +63,15 @@ describe('blowoutUtil', () => { blowoutLocation: SOURCE_WELL_BLOWOUT_DESTINATION, }) expect(curryCommandCreator).toHaveBeenCalledWith(blowout, { - pipette: blowoutArgs.pipette, - labware: blowoutArgs.sourceLabwareId, - well: blowoutArgs.sourceWell, + pipetteId: blowoutArgs.pipette, + labwareId: blowoutArgs.sourceLabwareId, + wellName: blowoutArgs.sourceWell, flowRate: blowoutArgs.flowRate, - offsetFromBottomMm: expect.any(Number), + wellLocation: { + offset: { + z: expect.any(Number), + }, + }, }) }) it('blowoutUtil curries waste chute commands when there is no well', () => { @@ -104,11 +108,15 @@ describe('blowoutUtil', () => { blowoutLocation: DEST_WELL_BLOWOUT_DESTINATION, }) expect(curryCommandCreator).toHaveBeenCalledWith(blowout, { - pipette: blowoutArgs.pipette, - labware: blowoutArgs.destLabwareId, - well: blowoutArgs.destWell, + pipetteId: blowoutArgs.pipette, + labwareId: blowoutArgs.destLabwareId, + wellName: blowoutArgs.destWell, flowRate: blowoutArgs.flowRate, - offsetFromBottomMm: expect.any(Number), + wellLocation: { + offset: { + z: expect.any(Number), + }, + }, }) }) it('blowoutUtil curries blowout with an arbitrary labware Id', () => { @@ -117,11 +125,15 @@ describe('blowoutUtil', () => { blowoutLocation: TROUGH_LABWARE, }) expect(curryCommandCreator).toHaveBeenCalledWith(blowout, { - pipette: blowoutArgs.pipette, - labware: TROUGH_LABWARE, - well: 'A1', + pipetteId: blowoutArgs.pipette, + labwareId: TROUGH_LABWARE, + wellName: 'A1', flowRate: blowoutArgs.flowRate, - offsetFromBottomMm: expect.any(Number), + wellLocation: { + offset: { + z: expect.any(Number), + }, + }, }) }) it('blowoutUtil returns an empty array if not given a blowoutLocation', () => { diff --git a/step-generation/src/__tests__/consolidate.test.ts b/step-generation/src/__tests__/consolidate.test.ts index 219c7b51c54..11b20e65267 100644 --- a/step-generation/src/__tests__/consolidate.test.ts +++ b/step-generation/src/__tests__/consolidate.test.ts @@ -33,6 +33,8 @@ const airGapHelper = makeAirGapHelper({ origin: 'bottom', offset: { z: 11.54, + x: 0, + y: 0, }, }, }) @@ -98,6 +100,10 @@ beforeEach(() => { blowoutLocation: null, dropTipLocation: FIXED_TRASH_ID, tipRack: 'tiprack1Id', + aspirateXOffset: 0, + dispenseXOffset: 0, + aspirateYOffset: 0, + dispenseYOffset: 0, } }) @@ -259,6 +265,8 @@ describe('consolidate single-channel', () => { wellLocation: { origin: 'bottom', offset: { + x: 0, + y: 0, z: ASPIRATE_OFFSET_FROM_BOTTOM_MM, }, }, @@ -274,6 +282,8 @@ describe('consolidate single-channel', () => { wellLocation: { origin: 'bottom', offset: { + x: 0, + y: 0, z: ASPIRATE_OFFSET_FROM_BOTTOM_MM, }, }, @@ -307,6 +317,8 @@ describe('consolidate single-channel', () => { wellLocation: { origin: 'bottom', offset: { + x: 0, + y: 0, z: ASPIRATE_OFFSET_FROM_BOTTOM_MM, }, }, @@ -330,6 +342,8 @@ describe('consolidate single-channel', () => { wellLocation: { origin: 'bottom', offset: { + x: 0, + y: 0, z: ASPIRATE_OFFSET_FROM_BOTTOM_MM, }, }, @@ -363,6 +377,8 @@ describe('consolidate single-channel', () => { wellLocation: { origin: 'bottom', offset: { + x: 0, + y: 0, z: ASPIRATE_OFFSET_FROM_BOTTOM_MM, }, }, @@ -373,6 +389,8 @@ describe('consolidate single-channel', () => { wellLocation: { origin: 'bottom', offset: { + x: 0, + y: 0, z: ASPIRATE_OFFSET_FROM_BOTTOM_MM, }, }, @@ -383,6 +401,8 @@ describe('consolidate single-channel', () => { wellLocation: { origin: 'bottom', offset: { + x: 0, + y: 0, z: ASPIRATE_OFFSET_FROM_BOTTOM_MM, }, }, @@ -399,6 +419,8 @@ describe('consolidate single-channel', () => { wellLocation: { origin: 'bottom', offset: { + x: 0, + y: 0, z: ASPIRATE_OFFSET_FROM_BOTTOM_MM, }, }, @@ -409,6 +431,8 @@ describe('consolidate single-channel', () => { wellLocation: { origin: 'bottom', offset: { + x: 0, + y: 0, z: ASPIRATE_OFFSET_FROM_BOTTOM_MM, }, }, @@ -419,6 +443,8 @@ describe('consolidate single-channel', () => { wellLocation: { origin: 'bottom', offset: { + x: 0, + y: 0, z: ASPIRATE_OFFSET_FROM_BOTTOM_MM, }, }, @@ -454,6 +480,8 @@ describe('consolidate single-channel', () => { wellLocation: { origin: 'bottom', offset: { + x: 0, + y: 0, z: DISPENSE_OFFSET_FROM_BOTTOM_MM, }, }, @@ -467,6 +495,8 @@ describe('consolidate single-channel', () => { wellLocation: { origin: 'bottom', offset: { + x: 0, + y: 0, z: DISPENSE_OFFSET_FROM_BOTTOM_MM, }, }, @@ -501,6 +531,8 @@ describe('consolidate single-channel', () => { wellLocation: { origin: 'bottom', offset: { + x: 0, + y: 0, z: DISPENSE_OFFSET_FROM_BOTTOM_MM, }, }, @@ -520,6 +552,8 @@ describe('consolidate single-channel', () => { wellLocation: { origin: 'bottom', offset: { + x: 0, + y: 0, z: DISPENSE_OFFSET_FROM_BOTTOM_MM, }, }, @@ -553,6 +587,8 @@ describe('consolidate single-channel', () => { wellLocation: { origin: 'bottom', offset: { + x: 0, + y: 0, z: DISPENSE_OFFSET_FROM_BOTTOM_MM, }, }, @@ -566,6 +602,8 @@ describe('consolidate single-channel', () => { wellLocation: { origin: 'bottom', offset: { + x: 0, + y: 0, z: DISPENSE_OFFSET_FROM_BOTTOM_MM, }, }, @@ -599,6 +637,8 @@ describe('consolidate single-channel', () => { wellLocation: { origin: 'bottom', offset: { + x: 0, + y: 0, z: ASPIRATE_OFFSET_FROM_BOTTOM_MM, }, }, @@ -616,6 +656,8 @@ describe('consolidate single-channel', () => { wellLocation: { origin: 'bottom', offset: { + x: 0, + y: 0, z: ASPIRATE_OFFSET_FROM_BOTTOM_MM, }, }, @@ -655,6 +697,8 @@ describe('consolidate single-channel', () => { wellLocation: { origin: 'bottom', offset: { + x: 0, + y: 0, z: ASPIRATE_OFFSET_FROM_BOTTOM_MM, }, }, @@ -675,6 +719,8 @@ describe('consolidate single-channel', () => { wellLocation: { origin: 'bottom', offset: { + x: 0, + y: 0, z: ASPIRATE_OFFSET_FROM_BOTTOM_MM, }, }, @@ -715,6 +761,8 @@ describe('consolidate single-channel', () => { wellLocation: { origin: 'bottom', offset: { + x: 0, + y: 0, z: ASPIRATE_OFFSET_FROM_BOTTOM_MM, }, }, @@ -734,6 +782,8 @@ describe('consolidate single-channel', () => { wellLocation: { origin: 'bottom', offset: { + x: 0, + y: 0, z: ASPIRATE_OFFSET_FROM_BOTTOM_MM, }, }, @@ -1056,6 +1106,8 @@ describe('consolidate single-channel', () => { wellLocation: { origin: 'bottom', offset: { + x: 0, + y: 0, z: 3.1, }, }, @@ -1080,6 +1132,8 @@ describe('consolidate single-channel', () => { wellLocation: { origin: 'bottom', offset: { + x: 0, + y: 0, z: 3.1, }, }, @@ -1105,6 +1159,8 @@ describe('consolidate single-channel', () => { wellLocation: { origin: 'bottom', offset: { + x: 0, + y: 0, z: 3.1, }, }, @@ -1163,6 +1219,8 @@ describe('consolidate single-channel', () => { wellLocation: { origin: 'bottom', offset: { + x: 0, + y: 0, z: 11.54, }, }, @@ -1188,6 +1246,8 @@ describe('consolidate single-channel', () => { wellLocation: { origin: 'bottom', offset: { + x: 0, + y: 0, z: 3.1, }, }, @@ -1246,6 +1306,8 @@ describe('consolidate single-channel', () => { wellLocation: { origin: 'bottom', offset: { + x: 0, + y: 0, z: 11.54, }, }, @@ -1271,6 +1333,8 @@ describe('consolidate single-channel', () => { wellLocation: { origin: 'bottom', offset: { + x: 0, + y: 0, z: 3.2, }, }, @@ -1313,6 +1377,8 @@ describe('consolidate single-channel', () => { wellLocation: { origin: 'bottom', offset: { + x: 0, + y: 0, z: 3.2, }, }, @@ -1337,6 +1403,8 @@ describe('consolidate single-channel', () => { wellLocation: { origin: 'bottom', offset: { + x: 0, + y: 0, z: 3.2, }, }, @@ -1350,6 +1418,8 @@ describe('consolidate single-channel', () => { seconds: 12, }, }, + // Blowout to trash + ...blowoutInPlaceHelper(), // Touch tip (disp) { commandType: 'touchTip', @@ -1370,8 +1440,6 @@ describe('consolidate single-channel', () => { // No Dispense > Air Gap here because we're re-using the tip // for the next chunk - // Blowout to trash - ...blowoutInPlaceHelper(), // Second chunk: source well A3 // pre-wet { @@ -1385,6 +1453,8 @@ describe('consolidate single-channel', () => { wellLocation: { origin: 'bottom', offset: { + x: 0, + y: 0, z: 3.1, }, }, @@ -1409,6 +1479,8 @@ describe('consolidate single-channel', () => { wellLocation: { origin: 'bottom', offset: { + x: 0, + y: 0, z: 3.1, }, }, @@ -1434,6 +1506,8 @@ describe('consolidate single-channel', () => { wellLocation: { origin: 'bottom', offset: { + x: 0, + y: 0, z: 3.1, }, }, @@ -1492,6 +1566,8 @@ describe('consolidate single-channel', () => { wellLocation: { origin: 'bottom', offset: { + x: 0, + y: 0, z: 11.54, }, }, @@ -1517,6 +1593,8 @@ describe('consolidate single-channel', () => { wellLocation: { origin: 'bottom', offset: { + x: 0, + y: 0, z: 3.2, }, }, @@ -1559,6 +1637,8 @@ describe('consolidate single-channel', () => { wellLocation: { origin: 'bottom', offset: { + x: 0, + y: 0, z: 3.2, }, }, @@ -1583,6 +1663,8 @@ describe('consolidate single-channel', () => { wellLocation: { origin: 'bottom', offset: { + x: 0, + y: 0, z: 3.2, }, }, @@ -1596,6 +1678,8 @@ describe('consolidate single-channel', () => { seconds: 12, }, }, + // Blowout to trash + ...blowoutInPlaceHelper(), // Touch tip (disp) { commandType: 'touchTip', @@ -1612,9 +1696,6 @@ describe('consolidate single-channel', () => { }, }, }, - - // Blowout to trash - ...blowoutInPlaceHelper(), // Dispense > air gap in dest well { commandType: 'aspirate', @@ -1628,6 +1709,8 @@ describe('consolidate single-channel', () => { wellLocation: { origin: 'bottom', offset: { + x: 0, + y: 0, z: 11.54, }, }, @@ -1698,6 +1781,8 @@ describe('consolidate single-channel', () => { wellLocation: { origin: 'bottom', offset: { + x: 0, + y: 0, z: 3.1, }, }, @@ -1722,6 +1807,8 @@ describe('consolidate single-channel', () => { wellLocation: { origin: 'bottom', offset: { + x: 0, + y: 0, z: 3.1, }, }, @@ -1747,6 +1834,8 @@ describe('consolidate single-channel', () => { wellLocation: { origin: 'bottom', offset: { + x: 0, + y: 0, z: 3.1, }, }, @@ -1805,6 +1894,8 @@ describe('consolidate single-channel', () => { wellLocation: { origin: 'bottom', offset: { + x: 0, + y: 0, z: 11.54, }, }, @@ -1831,6 +1922,8 @@ describe('consolidate single-channel', () => { origin: 'bottom', offset: { z: 3.1, + x: 0, + y: 0, }, }, flowRate: 2.1, @@ -1888,6 +1981,8 @@ describe('consolidate single-channel', () => { wellLocation: { origin: 'bottom', offset: { + x: 0, + y: 0, z: 11.54, }, }, @@ -1913,6 +2008,8 @@ describe('consolidate single-channel', () => { wellLocation: { origin: 'bottom', offset: { + x: 0, + y: 0, z: 3.2, }, }, @@ -1955,6 +2052,8 @@ describe('consolidate single-channel', () => { wellLocation: { origin: 'bottom', offset: { + x: 0, + y: 0, z: 3.2, }, }, @@ -1979,6 +2078,8 @@ describe('consolidate single-channel', () => { wellLocation: { origin: 'bottom', offset: { + x: 0, + y: 0, z: 3.2, }, }, @@ -1992,44 +2093,43 @@ describe('consolidate single-channel', () => { seconds: 12, }, }, - // Touch tip (disp) + // Blowout to dest well { - commandType: 'touchTip', + commandType: 'blowout', key: expect.any(String), params: { pipetteId: 'p300SingleId', labwareId: 'destPlateId', wellName: 'B1', + flowRate: 2.3, wellLocation: { - origin: 'bottom', + origin: 'top', offset: { - z: 3.4, + z: 3.3, }, }, }, }, - - // No Dispense > Air Gap here because we're re-using the tip - // for the next chunk - - // Blowout to dest well + // Touch tip (disp) { - commandType: 'blowout', + commandType: 'touchTip', key: expect.any(String), params: { pipetteId: 'p300SingleId', labwareId: 'destPlateId', wellName: 'B1', - flowRate: 2.3, wellLocation: { origin: 'bottom', offset: { - z: 13.84, + z: 3.4, }, }, }, }, + // No Dispense > Air Gap here because we're re-using the tip + // for the next chunk + // Second chunk: source well A3 // pre-wet { @@ -2043,6 +2143,8 @@ describe('consolidate single-channel', () => { wellLocation: { origin: 'bottom', offset: { + x: 0, + y: 0, z: 3.1, }, }, @@ -2067,6 +2169,8 @@ describe('consolidate single-channel', () => { wellLocation: { origin: 'bottom', offset: { + x: 0, + y: 0, z: 3.1, }, }, @@ -2092,6 +2196,8 @@ describe('consolidate single-channel', () => { wellLocation: { origin: 'bottom', offset: { + x: 0, + y: 0, z: 3.1, }, }, @@ -2150,6 +2256,8 @@ describe('consolidate single-channel', () => { wellLocation: { origin: 'bottom', offset: { + x: 0, + y: 0, z: 11.54, }, }, @@ -2175,6 +2283,8 @@ describe('consolidate single-channel', () => { wellLocation: { origin: 'bottom', offset: { + x: 0, + y: 0, z: 3.2, }, }, @@ -2217,6 +2327,8 @@ describe('consolidate single-channel', () => { wellLocation: { origin: 'bottom', offset: { + x: 0, + y: 0, z: 3.2, }, }, @@ -2241,6 +2353,8 @@ describe('consolidate single-channel', () => { wellLocation: { origin: 'bottom', offset: { + x: 0, + y: 0, z: 3.2, }, }, @@ -2254,35 +2368,35 @@ describe('consolidate single-channel', () => { seconds: 12, }, }, - // Touch tip (disp) + // Blowout to dest { - commandType: 'touchTip', + commandType: 'blowout', key: expect.any(String), params: { pipetteId: 'p300SingleId', labwareId: 'destPlateId', wellName: 'B1', + flowRate: 2.3, wellLocation: { - origin: 'bottom', + origin: 'top', offset: { - z: 3.4, + z: 3.3, }, }, }, }, - // Blowout to dest + // Touch tip (disp) { - commandType: 'blowout', + commandType: 'touchTip', key: expect.any(String), params: { pipetteId: 'p300SingleId', labwareId: 'destPlateId', wellName: 'B1', - flowRate: 2.3, wellLocation: { origin: 'bottom', offset: { - z: 13.84, + z: 3.4, }, }, }, @@ -2300,6 +2414,8 @@ describe('consolidate single-channel', () => { wellLocation: { origin: 'bottom', offset: { + x: 0, + y: 0, z: 11.54, }, }, @@ -2367,6 +2483,8 @@ describe('consolidate single-channel', () => { wellLocation: { origin: 'bottom', offset: { + x: 0, + y: 0, z: 3.1, }, }, @@ -2391,6 +2509,8 @@ describe('consolidate single-channel', () => { wellLocation: { origin: 'bottom', offset: { + x: 0, + y: 0, z: 3.1, }, }, @@ -2416,6 +2536,8 @@ describe('consolidate single-channel', () => { wellLocation: { origin: 'bottom', offset: { + x: 0, + y: 0, z: 3.1, }, }, @@ -2474,6 +2596,8 @@ describe('consolidate single-channel', () => { wellLocation: { origin: 'bottom', offset: { + x: 0, + y: 0, z: 11.54, }, }, @@ -2499,6 +2623,8 @@ describe('consolidate single-channel', () => { wellLocation: { origin: 'bottom', offset: { + x: 0, + y: 0, z: 3.1, }, }, @@ -2557,6 +2683,8 @@ describe('consolidate single-channel', () => { wellLocation: { origin: 'bottom', offset: { + x: 0, + y: 0, z: 11.54, }, }, @@ -2582,6 +2710,8 @@ describe('consolidate single-channel', () => { wellLocation: { origin: 'bottom', offset: { + x: 0, + y: 0, z: 3.2, }, }, @@ -2624,6 +2754,8 @@ describe('consolidate single-channel', () => { wellLocation: { origin: 'bottom', offset: { + x: 0, + y: 0, z: 3.2, }, }, @@ -2648,6 +2780,8 @@ describe('consolidate single-channel', () => { wellLocation: { origin: 'bottom', offset: { + x: 0, + y: 0, z: 3.2, }, }, @@ -2661,36 +2795,35 @@ describe('consolidate single-channel', () => { seconds: 12, }, }, - // Touch tip (disp) + // Blowout to dest well { - commandType: 'touchTip', + commandType: 'blowout', key: expect.any(String), params: { pipetteId: 'p300SingleId', labwareId: 'destPlateId', wellName: 'B1', + flowRate: 2.3, wellLocation: { - origin: 'bottom', + origin: 'top', offset: { - z: 3.4, + z: 3.3, }, }, }, }, - - // Blowout to dest well + // Touch tip (disp) { - commandType: 'blowout', + commandType: 'touchTip', key: expect.any(String), params: { pipetteId: 'p300SingleId', labwareId: 'destPlateId', wellName: 'B1', - flowRate: 2.3, wellLocation: { origin: 'bottom', offset: { - z: 13.84, + z: 3.4, }, }, }, @@ -2708,6 +2841,8 @@ describe('consolidate single-channel', () => { wellLocation: { origin: 'bottom', offset: { + x: 0, + y: 0, z: 11.54, }, }, @@ -2747,6 +2882,8 @@ describe('consolidate single-channel', () => { wellLocation: { origin: 'bottom', offset: { + x: 0, + y: 0, z: 3.1, }, }, @@ -2772,6 +2909,8 @@ describe('consolidate single-channel', () => { origin: 'bottom', offset: { z: 3.1, + x: 0, + y: 0, }, }, flowRate: 2.2, @@ -2796,6 +2935,8 @@ describe('consolidate single-channel', () => { wellLocation: { origin: 'bottom', offset: { + x: 0, + y: 0, z: 3.1, }, }, @@ -2854,6 +2995,8 @@ describe('consolidate single-channel', () => { wellLocation: { origin: 'bottom', offset: { + x: 0, + y: 0, z: 11.54, }, }, @@ -2879,6 +3022,8 @@ describe('consolidate single-channel', () => { wellLocation: { origin: 'bottom', offset: { + x: 0, + y: 0, z: 3.2, }, }, @@ -2921,6 +3066,8 @@ describe('consolidate single-channel', () => { wellLocation: { origin: 'bottom', offset: { + x: 0, + y: 0, z: 3.2, }, }, @@ -2945,6 +3092,8 @@ describe('consolidate single-channel', () => { wellLocation: { origin: 'bottom', offset: { + x: 0, + y: 0, z: 3.2, }, }, @@ -2958,35 +3107,35 @@ describe('consolidate single-channel', () => { seconds: 12, }, }, - // Touch tip (disp) + // Blowout to dest { - commandType: 'touchTip', + commandType: 'blowout', key: expect.any(String), params: { pipetteId: 'p300SingleId', labwareId: 'destPlateId', wellName: 'B1', + flowRate: 2.3, wellLocation: { - origin: 'bottom', + origin: 'top', offset: { - z: 3.4, + z: 3.3, }, }, }, }, - // Blowout to dest + // Touch tip (disp) { - commandType: 'blowout', + commandType: 'touchTip', key: expect.any(String), params: { pipetteId: 'p300SingleId', labwareId: 'destPlateId', wellName: 'B1', - flowRate: 2.3, wellLocation: { origin: 'bottom', offset: { - z: 13.84, + z: 3.4, }, }, }, @@ -3003,6 +3152,8 @@ describe('consolidate single-channel', () => { wellLocation: { origin: 'bottom', offset: { + x: 0, + y: 0, z: 11.54, }, }, @@ -3061,6 +3212,10 @@ describe('consolidate multi-channel', () => { volume: 140, tipRack: 'tiprack1Id', changeTip: 'once', + aspirateXOffset: 0, + dispenseXOffset: 0, + aspirateYOffset: 0, + dispenseYOffset: 0, } as ConsolidateArgs const result = consolidate(data, invariantContext, initialRobotState) const res = getSuccessResult(result) diff --git a/step-generation/src/__tests__/dispense.test.ts b/step-generation/src/__tests__/dispense.test.ts index 18e51c9b7a7..1ef07707d80 100644 --- a/step-generation/src/__tests__/dispense.test.ts +++ b/step-generation/src/__tests__/dispense.test.ts @@ -20,12 +20,11 @@ import { DEFAULT_PIPETTE, SOURCE_LABWARE, } from '../fixtures' -import { dispense } from '../commandCreators/atomic/dispense' -import { InvariantContext, RobotState } from '../types' -import type { - AspDispAirgapParams as V3AspDispAirgapParams, - DispenseParams, -} from '@opentrons/shared-data/protocol/types/schemaV3' +import { + ExtendedDispenseParams, + dispense, +} from '../commandCreators/atomic/dispense' +import type { InvariantContext, RobotState } from '../types' vi.mock('../utils/thermocyclerPipetteCollision') vi.mock('../utils/heaterShakerCollision') @@ -46,7 +45,7 @@ describe('dispense', () => { vi.resetAllMocks() }) describe('tip tracking & commands:', () => { - let params: V3AspDispAirgapParams + let params: ExtendedDispenseParams beforeEach(() => { params = { pipette: DEFAULT_PIPETTE, @@ -55,6 +54,8 @@ describe('dispense', () => { well: 'A1', offsetFromBottomMm: 5, flowRate: 6, + xOffset: 0, + yOffset: 0, } }) it('dispense normally (with tip)', () => { @@ -71,6 +72,8 @@ describe('dispense', () => { wellLocation: { origin: 'bottom', offset: { + x: 0, + y: 0, z: 5, }, }, @@ -99,7 +102,9 @@ describe('dispense', () => { volume: 50, labware: SOURCE_LABWARE, well: 'A1', - } as DispenseParams, + xOffset: 0, + yOffset: 0, + }, invariantContext, initialRobotState ) diff --git a/step-generation/src/__tests__/distribute.test.ts b/step-generation/src/__tests__/distribute.test.ts index 2db91df01d2..6793b9df81e 100644 --- a/step-generation/src/__tests__/distribute.test.ts +++ b/step-generation/src/__tests__/distribute.test.ts @@ -36,6 +36,8 @@ const airGapHelper = makeAirGapHelper({ wellLocation: { origin: 'bottom', offset: { + x: 0, + y: 0, z: 11.54, }, }, @@ -44,6 +46,8 @@ const dispenseAirGapHelper = makeDispenseAirGapHelper({ wellLocation: { origin: 'bottom', offset: { + x: 0, + y: 0, z: 11.54, }, }, @@ -84,11 +88,15 @@ beforeEach(() => { aspirateAirGapVolume: null, touchTipAfterDispense: false, dropTipLocation: FIXED_TRASH_ID, + aspirateXOffset: 0, + dispenseXOffset: 0, + aspirateYOffset: 0, + dispenseYOffset: 0, } blowoutSingleToTrash = blowoutInPlaceHelper() blowoutSingleToSourceA1 = blowoutHelper(SOURCE_LABWARE, { wellLocation: { - origin: 'bottom', + origin: 'top', offset: { z: BLOWOUT_OFFSET_ANY, }, @@ -96,7 +104,7 @@ beforeEach(() => { }) blowoutSingleToDestA4 = blowoutHelper(DEST_LABWARE, { wellLocation: { - origin: 'bottom', + origin: 'top', offset: { z: BLOWOUT_OFFSET_ANY, }, @@ -105,7 +113,7 @@ beforeEach(() => { }) blowoutSingleToDestA3 = blowoutHelper(DEST_LABWARE, { wellLocation: { - origin: 'bottom', + origin: 'top', offset: { z: BLOWOUT_OFFSET_ANY, }, @@ -274,6 +282,8 @@ describe('advanced settings: volume, mix, pre-wet tip, tip touch, tip position', wellLocation: { origin: 'bottom', offset: { + x: 0, + y: 0, z: ASPIRATE_OFFSET_FROM_BOTTOM_MM, }, }, @@ -309,6 +319,8 @@ describe('advanced settings: volume, mix, pre-wet tip, tip touch, tip position', wellLocation: { origin: 'bottom', offset: { + x: 0, + y: 0, z: ASPIRATE_OFFSET_FROM_BOTTOM_MM, }, }, @@ -320,6 +332,8 @@ describe('advanced settings: volume, mix, pre-wet tip, tip touch, tip position', wellLocation: { origin: 'bottom', offset: { + x: 0, + y: 0, z: ASPIRATE_OFFSET_FROM_BOTTOM_MM, }, }, @@ -553,6 +567,8 @@ describe('advanced settings: volume, mix, pre-wet tip, tip touch, tip position', wellLocation: { origin: 'bottom', offset: { + x: 0, + y: 0, z: ASPIRATE_OFFSET_FROM_BOTTOM_MM, }, }, @@ -565,6 +581,8 @@ describe('advanced settings: volume, mix, pre-wet tip, tip touch, tip position', wellLocation: { origin: 'bottom', offset: { + x: 0, + y: 0, z: ASPIRATE_OFFSET_FROM_BOTTOM_MM, }, }, @@ -690,6 +708,8 @@ describe('advanced settings: volume, mix, pre-wet tip, tip touch, tip position', wellLocation: { origin: 'bottom', offset: { + x: 0, + y: 0, z: ASPIRATE_OFFSET_FROM_BOTTOM_MM, }, }, @@ -701,6 +721,8 @@ describe('advanced settings: volume, mix, pre-wet tip, tip touch, tip position', wellLocation: { origin: 'bottom', offset: { + x: 0, + y: 0, z: ASPIRATE_OFFSET_FROM_BOTTOM_MM, }, }, @@ -781,6 +803,8 @@ describe('advanced settings: volume, mix, pre-wet tip, tip touch, tip position', wellLocation: { origin: 'bottom', offset: { + x: 0, + y: 0, z: ASPIRATE_OFFSET_FROM_BOTTOM_MM, }, }, @@ -793,6 +817,8 @@ describe('advanced settings: volume, mix, pre-wet tip, tip touch, tip position', wellLocation: { origin: 'bottom', offset: { + x: 0, + y: 0, z: ASPIRATE_OFFSET_FROM_BOTTOM_MM, }, }, @@ -879,6 +905,8 @@ describe('advanced settings: volume, mix, pre-wet tip, tip touch, tip position', wellLocation: { origin: 'bottom', offset: { + x: 0, + y: 0, z: ASPIRATE_OFFSET_FROM_BOTTOM_MM, }, }, diff --git a/step-generation/src/__tests__/mix.test.ts b/step-generation/src/__tests__/mix.test.ts index c2392a94c98..9fd099a5388 100644 --- a/step-generation/src/__tests__/mix.test.ts +++ b/step-generation/src/__tests__/mix.test.ts @@ -51,6 +51,10 @@ beforeEach(() => { aspirateDelaySeconds: null, dispenseDelaySeconds: null, dropTipLocation: FIXED_TRASH_ID, + aspirateXOffset: 0, + dispenseXOffset: 0, + aspirateYOffset: 0, + dispenseYOffset: 0, } invariantContext = makeContext() @@ -191,7 +195,7 @@ describe('mix: advanced options', () => { dispenseHelper(well, volume), blowoutHelper(blowoutLabwareId, { wellLocation: { - origin: 'bottom', + origin: 'top', offset: { z: BLOWOUT_OFFSET_ANY, }, @@ -225,7 +229,7 @@ describe('mix: advanced options', () => { dispenseHelper(well, volume), blowoutHelper(blowoutLabwareId, { wellLocation: { - origin: 'bottom', + origin: 'top', offset: { z: BLOWOUT_OFFSET_ANY, }, @@ -315,7 +319,7 @@ describe('mix: advanced options', () => { delayCommand(12), blowoutHelper(blowoutLabwareId, { wellLocation: { - origin: 'bottom', + origin: 'top', offset: { z: BLOWOUT_OFFSET_ANY, }, diff --git a/step-generation/src/__tests__/transfer.test.ts b/step-generation/src/__tests__/transfer.test.ts index 43b33ce0ca3..b3da39db41d 100644 --- a/step-generation/src/__tests__/transfer.test.ts +++ b/step-generation/src/__tests__/transfer.test.ts @@ -37,6 +37,8 @@ const airGapHelper = makeAirGapHelper({ wellLocation: { origin: 'bottom', offset: { + x: 0, + y: 0, z: 11.54, }, }, @@ -45,6 +47,8 @@ const dispenseAirGapHelper = makeDispenseAirGapHelper({ wellLocation: { origin: 'bottom', offset: { + x: 0, + y: 0, z: 11.54, }, }, @@ -78,6 +82,10 @@ beforeEach(() => { mixInDestination: null, blowoutLocation: null, dropTipLocation: FIXED_TRASH_ID, + aspirateXOffset: 0, + dispenseXOffset: 0, + aspirateYOffset: 0, + dispenseYOffset: 0, } invariantContext = makeContext() @@ -561,6 +569,8 @@ describe('advanced options', () => { wellLocation: { origin: 'bottom', offset: { + x: 0, + y: 0, z: ASPIRATE_OFFSET_FROM_BOTTOM_MM, }, }, @@ -594,6 +604,8 @@ describe('advanced options', () => { wellLocation: { origin: 'bottom', offset: { + x: 0, + y: 0, z: ASPIRATE_OFFSET_FROM_BOTTOM_MM, }, }, @@ -628,6 +640,8 @@ describe('advanced options', () => { wellLocation: { origin: 'bottom', offset: { + x: 0, + y: 0, z: ASPIRATE_OFFSET_FROM_BOTTOM_MM, }, }, @@ -704,6 +718,8 @@ describe('advanced options', () => { wellLocation: { origin: 'bottom', offset: { + x: 0, + y: 0, z: ASPIRATE_OFFSET_FROM_BOTTOM_MM, }, }, @@ -715,6 +731,8 @@ describe('advanced options', () => { wellLocation: { origin: 'bottom', offset: { + x: 0, + y: 0, z: ASPIRATE_OFFSET_FROM_BOTTOM_MM, }, }, @@ -754,6 +772,8 @@ describe('advanced options', () => { wellLocation: { origin: 'bottom', offset: { + x: 0, + y: 0, z: ASPIRATE_OFFSET_FROM_BOTTOM_MM, }, }, @@ -766,6 +786,8 @@ describe('advanced options', () => { wellLocation: { origin: 'bottom', offset: { + x: 0, + y: 0, z: ASPIRATE_OFFSET_FROM_BOTTOM_MM, }, }, @@ -928,6 +950,8 @@ describe('advanced options', () => { wellLocation: { origin: 'bottom', offset: { + x: 0, + y: 0, z: DISPENSE_OFFSET_FROM_BOTTOM_MM, }, }, @@ -939,6 +963,8 @@ describe('advanced options', () => { wellLocation: { origin: 'bottom', offset: { + x: 0, + y: 0, z: DISPENSE_OFFSET_FROM_BOTTOM_MM, }, }, @@ -977,6 +1003,8 @@ describe('advanced options', () => { wellLocation: { origin: 'bottom', offset: { + x: 0, + y: 0, z: DISPENSE_OFFSET_FROM_BOTTOM_MM, }, }, @@ -986,6 +1014,8 @@ describe('advanced options', () => { wellLocation: { origin: 'bottom', offset: { + x: 0, + y: 0, z: DISPENSE_OFFSET_FROM_BOTTOM_MM, }, }, @@ -997,6 +1027,8 @@ describe('advanced options', () => { wellLocation: { origin: 'bottom', offset: { + x: 0, + y: 0, z: DISPENSE_OFFSET_FROM_BOTTOM_MM, }, }, @@ -1097,6 +1129,8 @@ describe('advanced options', () => { wellLocation: { origin: 'bottom', offset: { + x: 0, + y: 0, z: 3.1, }, }, @@ -1122,6 +1156,8 @@ describe('advanced options', () => { origin: 'bottom', offset: { z: 3.1, + y: 0, + x: 0, }, }, flowRate: 2.2, @@ -1146,6 +1182,8 @@ describe('advanced options', () => { wellLocation: { origin: 'bottom', offset: { + x: 0, + y: 0, z: 3.1, }, }, @@ -1171,6 +1209,8 @@ describe('advanced options', () => { origin: 'bottom', offset: { z: 3.1, + y: 0, + x: 0, }, }, flowRate: 2.2, @@ -1195,6 +1235,8 @@ describe('advanced options', () => { wellLocation: { origin: 'bottom', offset: { + x: 0, + y: 0, z: 3.1, }, }, @@ -1254,6 +1296,8 @@ describe('advanced options', () => { wellLocation: { origin: 'bottom', offset: { + x: 0, + y: 0, z: 11.54, }, }, @@ -1281,6 +1325,8 @@ describe('advanced options', () => { origin: 'bottom', offset: { z: 11.54, + y: 0, + x: 0, }, }, flowRate: 2.2, @@ -1303,8 +1349,11 @@ describe('advanced options', () => { wellName: 'B1', wellLocation: { origin: 'bottom', + offset: { z: DISPENSE_OFFSET_FROM_BOTTOM_MM, + y: 0, + x: 0, }, }, flowRate: 2.2, @@ -1346,6 +1395,8 @@ describe('advanced options', () => { wellLocation: { origin: 'bottom', offset: { + x: 0, + y: 0, z: 3.2, }, }, @@ -1371,6 +1422,8 @@ describe('advanced options', () => { origin: 'bottom', offset: { z: DISPENSE_OFFSET_FROM_BOTTOM_MM, + y: 0, + x: 0, }, }, flowRate: 2.2, @@ -1383,22 +1436,6 @@ describe('advanced options', () => { seconds: 12, }, }, - // touch tip (disp) - { - commandType: 'touchTip', - key: expect.any(String), - params: { - pipetteId: 'p300SingleId', - labwareId: 'destPlateId', - wellName: 'B1', - wellLocation: { - origin: 'bottom', - offset: { - z: 3.4, - }, - }, - }, - }, // no dispense > air gap, because tip will be reused // blowout { @@ -1418,6 +1455,23 @@ describe('advanced options', () => { flowRate: 2.3, }, }, + // touch tip (disp) + { + commandType: 'touchTip', + key: expect.any(String), + params: { + pipetteId: 'p300SingleId', + + labwareId: 'destPlateId', + wellName: 'B1', + wellLocation: { + origin: 'bottom', + offset: { + z: 3.4, + }, + }, + }, + }, // next chunk from A1: remaining volume // do not pre-wet // mix (asp) @@ -1432,6 +1486,8 @@ describe('advanced options', () => { wellLocation: { origin: 'bottom', offset: { + x: 0, + y: 0, z: 3.1, }, }, @@ -1457,6 +1513,8 @@ describe('advanced options', () => { origin: 'bottom', offset: { z: 3.1, + y: 0, + x: 0, }, }, flowRate: 2.2, @@ -1481,6 +1539,8 @@ describe('advanced options', () => { wellLocation: { origin: 'bottom', offset: { + x: 0, + y: 0, z: 3.1, }, }, @@ -1540,6 +1600,8 @@ describe('advanced options', () => { wellLocation: { origin: 'bottom', offset: { + x: 0, + y: 0, z: 11.54, }, }, @@ -1567,6 +1629,8 @@ describe('advanced options', () => { origin: 'bottom', offset: { z: 11.54, + y: 0, + x: 0, }, }, flowRate: 2.2, @@ -1591,6 +1655,8 @@ describe('advanced options', () => { origin: 'bottom', offset: { z: 3.2, + y: 0, + x: 0, }, }, flowRate: 2.2, @@ -1632,6 +1698,8 @@ describe('advanced options', () => { wellLocation: { origin: 'bottom', offset: { + y: 0, + x: 0, z: 3.2, }, }, @@ -1657,6 +1725,8 @@ describe('advanced options', () => { origin: 'bottom', offset: { z: 3.2, + y: 0, + x: 0, }, }, flowRate: 2.2, @@ -1669,37 +1739,37 @@ describe('advanced options', () => { seconds: 12, }, }, - // touch tip (disp) { - commandType: 'touchTip', + commandType: 'moveToAddressableArea', key: expect.any(String), params: { pipetteId: 'p300SingleId', - labwareId: 'destPlateId', - wellName: 'B1', - wellLocation: { - origin: 'bottom', - offset: { - z: 3.4, - }, - }, + addressableAreaName: 'movableTrashA3', + offset: { x: 0, y: 0, z: 0 }, }, }, { - commandType: 'moveToAddressableArea', + commandType: 'blowOutInPlace', key: expect.any(String), params: { pipetteId: 'p300SingleId', - addressableAreaName: 'movableTrashA3', - offset: { x: 0, y: 0, z: 0 }, + flowRate: 2.3, }, }, + // touch tip (disp) { - commandType: 'blowOutInPlace', + commandType: 'touchTip', key: expect.any(String), params: { pipetteId: 'p300SingleId', - flowRate: 2.3, + labwareId: 'destPlateId', + wellName: 'B1', + wellLocation: { + origin: 'bottom', + offset: { + z: 3.4, + }, + }, }, }, // use the dispense > air gap here before moving to trash @@ -1716,6 +1786,8 @@ describe('advanced options', () => { origin: 'bottom', offset: { z: 11.54, + y: 0, + x: 0, }, }, flowRate: 2.1, @@ -1756,6 +1828,8 @@ describe('advanced options', () => { origin: 'bottom', offset: { z: 3.1, + y: 0, + x: 0, }, }, flowRate: 2.1, @@ -1780,6 +1854,8 @@ describe('advanced options', () => { origin: 'bottom', offset: { z: 3.1, + y: 0, + x: 0, }, }, flowRate: 2.2, @@ -1805,6 +1881,8 @@ describe('advanced options', () => { origin: 'bottom', offset: { z: 3.1, + y: 0, + x: 0, }, }, flowRate: 2.1, @@ -1829,6 +1907,8 @@ describe('advanced options', () => { origin: 'bottom', offset: { z: 3.1, + y: 0, + x: 0, }, }, flowRate: 2.2, @@ -1854,6 +1934,8 @@ describe('advanced options', () => { origin: 'bottom', offset: { z: 3.1, + y: 0, + x: 0, }, }, flowRate: 2.1, @@ -1913,6 +1995,8 @@ describe('advanced options', () => { origin: 'bottom', offset: { z: 11.54, + y: 0, + x: 0, }, }, flowRate: 2.1, @@ -1939,6 +2023,8 @@ describe('advanced options', () => { origin: 'bottom', offset: { z: 11.54, + y: 0, + x: 0, }, }, flowRate: 2.2, @@ -1963,6 +2049,8 @@ describe('advanced options', () => { origin: 'bottom', offset: { z: DISPENSE_OFFSET_FROM_BOTTOM_MM, + y: 0, + x: 0, }, }, flowRate: 2.2, @@ -2005,6 +2093,8 @@ describe('advanced options', () => { origin: 'bottom', offset: { z: DISPENSE_OFFSET_FROM_BOTTOM_MM, + y: 0, + x: 0, }, }, flowRate: 2.1, @@ -2029,6 +2119,8 @@ describe('advanced options', () => { origin: 'bottom', offset: { z: DISPENSE_OFFSET_FROM_BOTTOM_MM, + y: 0, + x: 0, }, }, flowRate: 2.2, @@ -2041,35 +2133,35 @@ describe('advanced options', () => { seconds: 12, }, }, - // touch tip (disp) + // blowout { - commandType: 'touchTip', + commandType: 'blowout', key: expect.any(String), params: { pipetteId: 'p300SingleId', labwareId: 'destPlateId', wellName: 'B1', + flowRate: 2.3, wellLocation: { - origin: 'bottom', + origin: 'top', offset: { - z: 3.4, + z: 3.3, }, }, }, }, - // blowout + // touch tip (disp) { - commandType: 'blowout', + commandType: 'touchTip', key: expect.any(String), params: { pipetteId: 'p300SingleId', labwareId: 'destPlateId', wellName: 'B1', - flowRate: 2.3, wellLocation: { origin: 'bottom', offset: { - z: 13.84, + z: 3.4, }, }, }, @@ -2091,6 +2183,8 @@ describe('advanced options', () => { origin: 'bottom', offset: { z: 3.1, + y: 0, + x: 0, }, }, flowRate: 2.1, @@ -2115,6 +2209,8 @@ describe('advanced options', () => { origin: 'bottom', offset: { z: 3.1, + y: 0, + x: 0, }, }, flowRate: 2.2, @@ -2140,6 +2236,8 @@ describe('advanced options', () => { origin: 'bottom', offset: { z: 3.1, + y: 0, + x: 0, }, }, flowRate: 2.1, @@ -2197,6 +2295,8 @@ describe('advanced options', () => { origin: 'bottom', offset: { z: 11.54, + y: 0, + x: 0, }, }, pipetteId: 'p300SingleId', @@ -2222,6 +2322,8 @@ describe('advanced options', () => { origin: 'bottom', offset: { z: 11.54, + y: 0, + x: 0, }, }, pipetteId: 'p300SingleId', @@ -2248,6 +2350,8 @@ describe('advanced options', () => { origin: 'bottom', offset: { z: DISPENSE_OFFSET_FROM_BOTTOM_MM, + y: 0, + x: 0, }, }, flowRate: 2.2, @@ -2290,6 +2394,8 @@ describe('advanced options', () => { origin: 'bottom', offset: { z: DISPENSE_OFFSET_FROM_BOTTOM_MM, + y: 0, + x: 0, }, }, flowRate: 2.1, @@ -2314,6 +2420,8 @@ describe('advanced options', () => { origin: 'bottom', offset: { z: DISPENSE_OFFSET_FROM_BOTTOM_MM, + y: 0, + x: 0, }, }, flowRate: 2.2, @@ -2326,35 +2434,35 @@ describe('advanced options', () => { seconds: 12, }, }, - // touch tip (disp) + // blowout to dest well { - commandType: 'touchTip', + commandType: 'blowout', key: expect.any(String), params: { pipetteId: 'p300SingleId', labwareId: 'destPlateId', wellName: 'B1', + flowRate: 2.3, wellLocation: { - origin: 'bottom', + origin: 'top', offset: { - z: 3.4, + z: 3.3, }, }, }, }, - // blowout to dest well + // touch tip (disp) { - commandType: 'blowout', + commandType: 'touchTip', key: expect.any(String), params: { pipetteId: 'p300SingleId', labwareId: 'destPlateId', wellName: 'B1', - flowRate: 2.3, wellLocation: { origin: 'bottom', offset: { - z: 13.84, + z: 3.4, }, }, }, @@ -2374,6 +2482,8 @@ describe('advanced options', () => { origin: 'bottom', offset: { z: 11.54, + y: 0, + x: 0, }, }, }, @@ -2442,6 +2552,8 @@ describe('advanced options', () => { origin: 'bottom', offset: { z: 3.1, + y: 0, + x: 0, }, }, flowRate: 2.1, @@ -2466,6 +2578,8 @@ describe('advanced options', () => { origin: 'bottom', offset: { z: 3.1, + y: 0, + x: 0, }, }, flowRate: 2.2, @@ -2491,6 +2605,8 @@ describe('advanced options', () => { origin: 'bottom', offset: { z: 3.1, + y: 0, + x: 0, }, }, flowRate: 2.1, @@ -2515,6 +2631,8 @@ describe('advanced options', () => { origin: 'bottom', offset: { z: 3.1, + y: 0, + x: 0, }, }, flowRate: 2.2, @@ -2540,6 +2658,8 @@ describe('advanced options', () => { origin: 'bottom', offset: { z: 3.1, + y: 0, + x: 0, }, }, flowRate: 2.1, @@ -2599,6 +2719,8 @@ describe('advanced options', () => { origin: 'bottom', offset: { z: 11.54, + y: 0, + x: 0, }, }, flowRate: 2.1, @@ -2625,6 +2747,8 @@ describe('advanced options', () => { origin: 'bottom', offset: { z: 11.54, + y: 0, + x: 0, }, }, flowRate: 2.2, @@ -2649,6 +2773,8 @@ describe('advanced options', () => { origin: 'bottom', offset: { z: DISPENSE_OFFSET_FROM_BOTTOM_MM, + y: 0, + x: 0, }, }, flowRate: 2.2, @@ -2691,6 +2817,8 @@ describe('advanced options', () => { origin: 'bottom', offset: { z: DISPENSE_OFFSET_FROM_BOTTOM_MM, + y: 0, + x: 0, }, }, flowRate: 2.1, @@ -2715,6 +2843,8 @@ describe('advanced options', () => { origin: 'bottom', offset: { z: DISPENSE_OFFSET_FROM_BOTTOM_MM, + y: 0, + x: 0, }, }, flowRate: 2.2, @@ -2727,35 +2857,35 @@ describe('advanced options', () => { seconds: 12, }, }, - // touch tip (disp) + // blowout { - commandType: 'touchTip', + commandType: 'blowout', key: expect.any(String), params: { pipetteId: 'p300SingleId', labwareId: 'destPlateId', wellName: 'B1', + flowRate: 2.3, wellLocation: { - origin: 'bottom', + origin: 'top', offset: { - z: 3.4, + z: 3.3, }, }, }, }, - // blowout + // touch tip (disp) { - commandType: 'blowout', + commandType: 'touchTip', key: expect.any(String), params: { pipetteId: 'p300SingleId', labwareId: 'destPlateId', wellName: 'B1', - flowRate: 2.3, wellLocation: { origin: 'bottom', offset: { - z: 13.84, + z: 3.4, }, }, }, @@ -2777,6 +2907,8 @@ describe('advanced options', () => { origin: 'bottom', offset: { z: 3.1, + y: 0, + x: 0, }, }, flowRate: 2.1, @@ -2801,6 +2933,8 @@ describe('advanced options', () => { origin: 'bottom', offset: { z: 3.1, + y: 0, + x: 0, }, }, flowRate: 2.2, @@ -2826,6 +2960,8 @@ describe('advanced options', () => { origin: 'bottom', offset: { z: 3.1, + y: 0, + x: 0, }, }, flowRate: 2.1, @@ -2885,6 +3021,8 @@ describe('advanced options', () => { origin: 'bottom', offset: { z: 11.54, + y: 0, + x: 0, }, }, flowRate: 2.1, @@ -2911,6 +3049,8 @@ describe('advanced options', () => { origin: 'bottom', offset: { z: 11.54, + y: 0, + x: 0, }, }, flowRate: 2.2, @@ -2935,6 +3075,8 @@ describe('advanced options', () => { origin: 'bottom', offset: { z: DISPENSE_OFFSET_FROM_BOTTOM_MM, + y: 0, + x: 0, }, }, flowRate: 2.2, @@ -2977,6 +3119,8 @@ describe('advanced options', () => { origin: 'bottom', offset: { z: DISPENSE_OFFSET_FROM_BOTTOM_MM, + y: 0, + x: 0, }, }, flowRate: 2.1, @@ -3001,6 +3145,8 @@ describe('advanced options', () => { origin: 'bottom', offset: { z: DISPENSE_OFFSET_FROM_BOTTOM_MM, + y: 0, + x: 0, }, }, flowRate: 2.2, @@ -3013,35 +3159,35 @@ describe('advanced options', () => { seconds: 12, }, }, - // touch tip (disp) + // blowout { - commandType: 'touchTip', + commandType: 'blowout', key: expect.any(String), params: { pipetteId: 'p300SingleId', labwareId: 'destPlateId', wellName: 'B1', + flowRate: 2.3, wellLocation: { - origin: 'bottom', + origin: 'top', offset: { - z: 3.4, + z: 3.3, }, }, }, }, - // blowout + // touch tip (disp) { - commandType: 'blowout', + commandType: 'touchTip', key: expect.any(String), params: { pipetteId: 'p300SingleId', labwareId: 'destPlateId', wellName: 'B1', - flowRate: 2.3, wellLocation: { origin: 'bottom', offset: { - z: 13.84, + z: 3.4, }, }, }, @@ -3061,6 +3207,8 @@ describe('advanced options', () => { origin: 'bottom', offset: { z: 11.54, + y: 0, + x: 0, }, }, }, @@ -3127,6 +3275,8 @@ describe('advanced options', () => { origin: 'bottom', offset: { z: 3.1, + y: 0, + x: 0, }, }, flowRate: 2.1, @@ -3151,6 +3301,8 @@ describe('advanced options', () => { origin: 'bottom', offset: { z: 3.1, + y: 0, + x: 0, }, }, flowRate: 2.2, @@ -3176,6 +3328,8 @@ describe('advanced options', () => { origin: 'bottom', offset: { z: 3.1, + y: 0, + x: 0, }, }, flowRate: 2.1, @@ -3200,6 +3354,8 @@ describe('advanced options', () => { origin: 'bottom', offset: { z: 3.1, + y: 0, + x: 0, }, }, flowRate: 2.2, @@ -3225,6 +3381,8 @@ describe('advanced options', () => { origin: 'bottom', offset: { z: 3.1, + y: 0, + x: 0, }, }, flowRate: 2.1, @@ -3284,6 +3442,8 @@ describe('advanced options', () => { origin: 'bottom', offset: { z: 11.54, + y: 0, + x: 0, }, }, flowRate: 2.1, @@ -3310,6 +3470,8 @@ describe('advanced options', () => { origin: 'bottom', offset: { z: 11.54, + y: 0, + x: 0, }, }, flowRate: 2.2, @@ -3334,6 +3496,8 @@ describe('advanced options', () => { origin: 'bottom', offset: { z: DISPENSE_OFFSET_FROM_BOTTOM_MM, + y: 0, + x: 0, }, }, flowRate: 2.2, @@ -3376,6 +3540,8 @@ describe('advanced options', () => { origin: 'bottom', offset: { z: DISPENSE_OFFSET_FROM_BOTTOM_MM, + y: 0, + x: 0, }, }, flowRate: 2.1, @@ -3399,6 +3565,8 @@ describe('advanced options', () => { wellLocation: { origin: 'bottom', offset: { + x: 0, + y: 0, z: DISPENSE_OFFSET_FROM_BOTTOM_MM, }, }, @@ -3412,35 +3580,35 @@ describe('advanced options', () => { seconds: 12, }, }, - // touch tip (disp) + // blowout { - commandType: 'touchTip', + commandType: 'blowout', key: expect.any(String), params: { pipetteId: 'p300SingleId', - labwareId: 'destPlateId', - wellName: 'B1', + labwareId: 'sourcePlateId', + wellName: 'A1', + flowRate: 2.3, wellLocation: { - origin: 'bottom', + origin: 'top', offset: { - z: 3.4, + z: 3.3, }, }, }, }, - // blowout + // touch tip (disp) { - commandType: 'blowout', + commandType: 'touchTip', key: expect.any(String), params: { pipetteId: 'p300SingleId', - labwareId: 'sourcePlateId', - wellName: 'A1', - flowRate: 2.3, + labwareId: 'destPlateId', + wellName: 'B1', wellLocation: { origin: 'bottom', offset: { - z: 13.84, + z: 3.4, }, }, }, @@ -3459,6 +3627,8 @@ describe('advanced options', () => { origin: 'bottom', offset: { z: 11.54, + y: 0, + x: 0, }, }, volume: 3, @@ -3511,6 +3681,8 @@ describe('advanced options', () => { origin: 'bottom', offset: { z: 3.1, + y: 0, + x: 0, }, }, flowRate: 2.1, @@ -3535,6 +3707,8 @@ describe('advanced options', () => { origin: 'bottom', offset: { z: 3.1, + y: 0, + x: 0, }, }, flowRate: 2.2, @@ -3560,6 +3734,8 @@ describe('advanced options', () => { origin: 'bottom', offset: { z: 3.1, + y: 0, + x: 0, }, }, flowRate: 2.1, @@ -3619,6 +3795,8 @@ describe('advanced options', () => { origin: 'bottom', offset: { z: 11.54, + y: 0, + x: 0, }, }, flowRate: 2.1, @@ -3644,6 +3822,8 @@ describe('advanced options', () => { wellLocation: { origin: 'bottom', offset: { + x: 0, + y: 0, z: 11.54, }, }, @@ -3669,6 +3849,8 @@ describe('advanced options', () => { origin: 'bottom', offset: { z: DISPENSE_OFFSET_FROM_BOTTOM_MM, + y: 0, + x: 0, }, }, flowRate: 2.2, @@ -3711,6 +3893,8 @@ describe('advanced options', () => { origin: 'bottom', offset: { z: DISPENSE_OFFSET_FROM_BOTTOM_MM, + y: 0, + x: 0, }, }, flowRate: 2.1, @@ -3735,6 +3919,8 @@ describe('advanced options', () => { origin: 'bottom', offset: { z: DISPENSE_OFFSET_FROM_BOTTOM_MM, + y: 0, + x: 0, }, }, flowRate: 2.2, @@ -3747,35 +3933,35 @@ describe('advanced options', () => { seconds: 12, }, }, - // touch tip (disp) + // blowout { - commandType: 'touchTip', + commandType: 'blowout', key: expect.any(String), params: { pipetteId: 'p300SingleId', - labwareId: 'destPlateId', - wellName: 'B1', + labwareId: 'sourcePlateId', + wellName: 'A1', + flowRate: 2.3, wellLocation: { - origin: 'bottom', + origin: 'top', offset: { - z: 3.4, + z: 3.3, }, }, }, }, - // blowout + // touch tip (disp) { - commandType: 'blowout', + commandType: 'touchTip', key: expect.any(String), params: { pipetteId: 'p300SingleId', - labwareId: 'sourcePlateId', - wellName: 'A1', - flowRate: 2.3, + labwareId: 'destPlateId', + wellName: 'B1', wellLocation: { origin: 'bottom', offset: { - z: 13.84, + z: 3.4, }, }, }, @@ -3795,6 +3981,8 @@ describe('advanced options', () => { origin: 'bottom', offset: { z: 11.54, + y: 0, + x: 0, }, }, }, diff --git a/step-generation/src/commandCreators/atomic/aspirate.ts b/step-generation/src/commandCreators/atomic/aspirate.ts index fb360c4cebf..d7226da3387 100644 --- a/step-generation/src/commandCreators/atomic/aspirate.ts +++ b/step-generation/src/commandCreators/atomic/aspirate.ts @@ -18,6 +18,8 @@ import type { AspirateParams } from '@opentrons/shared-data/protocol/types/schem import type { CommandCreator, CommandCreatorError } from '../../types' export interface ExtendedAspirateParams extends AspirateParams { + xOffset: number + yOffset: number tipRack: string } /** Aspirate with given args. Requires tip. */ @@ -35,6 +37,8 @@ export const aspirate: CommandCreator = ( flowRate, isAirGap, tipRack, + xOffset, + yOffset, } = args const actionName = 'aspirate' const errors: CommandCreatorError[] = [] @@ -208,6 +212,8 @@ export const aspirate: CommandCreator = ( origin: 'bottom', offset: { z: offsetFromBottomMm, + x: xOffset, + y: yOffset, }, }, flowRate, diff --git a/step-generation/src/commandCreators/atomic/blowout.ts b/step-generation/src/commandCreators/atomic/blowout.ts index 497257a98d6..ff3be46d786 100644 --- a/step-generation/src/commandCreators/atomic/blowout.ts +++ b/step-generation/src/commandCreators/atomic/blowout.ts @@ -1,8 +1,7 @@ import { uuid, getLabwareSlot } from '../../utils' import { COLUMN_4_SLOTS } from '../../constants' import * as errorCreators from '../../errorCreators' -import type { CreateCommand } from '@opentrons/shared-data' -import type { BlowoutParams } from '@opentrons/shared-data/protocol/types/schemaV3' +import type { CreateCommand, BlowoutParams } from '@opentrons/shared-data' import type { CommandCreatorError, CommandCreator } from '../../types' export const blowout: CommandCreator = ( @@ -11,12 +10,13 @@ export const blowout: CommandCreator = ( prevRobotState ) => { /** Blowout with given args. Requires tip. */ - const { pipette, labware, well, offsetFromBottomMm, flowRate } = args + const { pipetteId, labwareId, wellName, wellLocation, flowRate } = args + const actionName = 'blowout' const errors: CommandCreatorError[] = [] - const pipetteData = prevRobotState.pipettes[pipette] + const pipetteData = prevRobotState.pipettes[pipetteId] const slotName = getLabwareSlot( - labware, + labwareId, prevRobotState.labware, prevRobotState.modules ) @@ -27,30 +27,30 @@ export const blowout: CommandCreator = ( errors.push( errorCreators.pipetteDoesNotExist({ actionName, - pipette, + pipette: pipetteId, }) ) } - if (!prevRobotState.tipState.pipettes[pipette]) { + if (!prevRobotState.tipState.pipettes[pipetteId]) { errors.push( errorCreators.noTipOnPipette({ actionName, - pipette, - labware, - well, + pipette: pipetteId, + labware: labwareId, + well: wellName, }) ) } - if (!labware || !prevRobotState.labware[labware]) { + if (!labwareId || !prevRobotState.labware[labwareId]) { errors.push( errorCreators.labwareDoesNotExist({ actionName, - labware, + labware: labwareId, }) ) - } else if (prevRobotState.labware[labware]?.slot === 'offDeck') { + } else if (prevRobotState.labware[labwareId]?.slot === 'offDeck') { errors.push(errorCreators.labwareOffDeck()) } @@ -69,14 +69,14 @@ export const blowout: CommandCreator = ( commandType: 'blowout', key: uuid(), params: { - pipetteId: pipette, - labwareId: labware, - wellName: well, + pipetteId, + labwareId, + wellName, flowRate, wellLocation: { - origin: 'bottom', + origin: 'top', offset: { - z: offsetFromBottomMm, + z: wellLocation?.offset?.z, }, }, }, diff --git a/step-generation/src/commandCreators/atomic/dispense.ts b/step-generation/src/commandCreators/atomic/dispense.ts index 58c7019fe75..2bec571bd6e 100644 --- a/step-generation/src/commandCreators/atomic/dispense.ts +++ b/step-generation/src/commandCreators/atomic/dispense.ts @@ -16,8 +16,12 @@ import type { CreateCommand } from '@opentrons/shared-data' import type { DispenseParams } from '@opentrons/shared-data/protocol/types/schemaV3' import type { CommandCreator, CommandCreatorError } from '../../types' +export interface ExtendedDispenseParams extends DispenseParams { + xOffset: number + yOffset: number +} /** Dispense with given args. Requires tip. */ -export const dispense: CommandCreator = ( +export const dispense: CommandCreator = ( args, invariantContext, prevRobotState @@ -30,6 +34,8 @@ export const dispense: CommandCreator = ( offsetFromBottomMm, flowRate, isAirGap, + xOffset, + yOffset, } = args const actionName = 'dispense' const errors: CommandCreatorError[] = [] @@ -172,6 +178,8 @@ export const dispense: CommandCreator = ( origin: 'bottom', offset: { z: offsetFromBottomMm, + x: xOffset, + y: yOffset, }, }, flowRate, diff --git a/step-generation/src/commandCreators/compound/consolidate.ts b/step-generation/src/commandCreators/compound/consolidate.ts index 09c1b02a9ae..b37f2ede1b0 100644 --- a/step-generation/src/commandCreators/compound/consolidate.ts +++ b/step-generation/src/commandCreators/compound/consolidate.ts @@ -152,6 +152,10 @@ export const consolidate: CommandCreator = ( mixFirstAspirate, mixInDestination, dropTipLocation, + aspirateXOffset, + aspirateYOffset, + dispenseXOffset, + dispenseYOffset, } = args const aspirateAirGapVolume = args.aspirateAirGapVolume || 0 const maxWellsPerChunk = Math.floor( @@ -220,6 +224,8 @@ export const consolidate: CommandCreator = ( offsetFromBottomMm: airGapOffsetSourceWell, isAirGap: true, tipRack: args.tipRack, + xOffset: 0, + yOffset: 0, }), ...(aspirateDelay != null ? [ @@ -277,6 +283,8 @@ export const consolidate: CommandCreator = ( flowRate: aspirateFlowRateUlSec, offsetFromBottomMm: aspirateOffsetFromBottomMm, tipRack: args.tipRack, + xOffset: aspirateXOffset, + yOffset: aspirateYOffset, }), ...delayAfterAspirateCommands, ...touchTipAfterAspirateCommand, @@ -326,6 +334,10 @@ export const consolidate: CommandCreator = ( aspirateDelaySeconds: aspirateDelay?.seconds, dispenseDelaySeconds: dispenseDelay?.seconds, tipRack: args.tipRack, + aspirateXOffset, + aspirateYOffset, + dispenseXOffset, + dispenseYOffset, }) : [] const preWetTipCommands = args.preWetTip // Pre-wet tip is equivalent to a single mix, with volume equal to the consolidate volume. @@ -342,6 +354,10 @@ export const consolidate: CommandCreator = ( aspirateDelaySeconds: aspirateDelay?.seconds, dispenseDelaySeconds: dispenseDelay?.seconds, tipRack: args.tipRack, + aspirateXOffset, + aspirateYOffset, + dispenseXOffset, + dispenseYOffset, }) : [] // can not mix in a waste chute @@ -360,6 +376,10 @@ export const consolidate: CommandCreator = ( aspirateDelaySeconds: aspirateDelay?.seconds, dispenseDelaySeconds: dispenseDelay?.seconds, tipRack: args.tipRack, + aspirateXOffset, + aspirateYOffset, + dispenseXOffset, + dispenseYOffset, }) : [] @@ -385,6 +405,8 @@ export const consolidate: CommandCreator = ( well: destinationWell ?? undefined, flowRate: dispenseFlowRateUlSec, offsetFromBottomMm: dispenseOffsetFromBottomMm, + xOffset: dispenseXOffset, + yOffset: dispenseYOffset, }), ] @@ -496,8 +518,8 @@ export const consolidate: CommandCreator = ( ...dispenseCommands, ...delayAfterDispenseCommands, ...mixAfterCommands, - ...touchTipAfterDispenseCommands, ...blowoutCommand, + ...touchTipAfterDispenseCommands, ...airGapAfterDispenseCommands, ...dropTipAfterDispenseAirGap, ] diff --git a/step-generation/src/commandCreators/compound/distribute.ts b/step-generation/src/commandCreators/compound/distribute.ts index 9662a07d959..520ce06aeb4 100644 --- a/step-generation/src/commandCreators/compound/distribute.ts +++ b/step-generation/src/commandCreators/compound/distribute.ts @@ -147,6 +147,10 @@ export const distribute: CommandCreator = ( dispenseFlowRateUlSec, dispenseOffsetFromBottomMm, blowoutLocation, + aspirateXOffset, + aspirateYOffset, + dispenseXOffset, + dispenseYOffset, } = args const aspirateAirGapVolume = args.aspirateAirGapVolume || 0 const dispenseAirGapVolume = args.dispenseAirGapVolume || 0 @@ -211,6 +215,8 @@ export const distribute: CommandCreator = ( flowRate: aspirateFlowRateUlSec, offsetFromBottomMm: airGapOffsetSourceWell, isAirGap: true, + xOffset: 0, + yOffset: 0, tipRack: args.tipRack, }), ...(aspirateDelay != null @@ -232,6 +238,8 @@ export const distribute: CommandCreator = ( flowRate: dispenseFlowRateUlSec, offsetFromBottomMm: airGapOffsetDestWell, isAirGap: true, + xOffset: 0, + yOffset: 0, }), ...(dispenseDelay != null ? [ @@ -290,6 +298,8 @@ export const distribute: CommandCreator = ( well: destWell, flowRate: dispenseFlowRateUlSec, offsetFromBottomMm: dispenseOffsetFromBottomMm, + xOffset: dispenseXOffset, + yOffset: dispenseYOffset, }), ...delayAfterDispenseCommands, ...touchTipAfterDispenseCommand, @@ -337,6 +347,8 @@ export const distribute: CommandCreator = ( offsetFromBottomMm: airGapOffsetDestWell, isAirGap: true, tipRack: args.tipRack, + xOffset: 0, + yOffset: 0, }), ...(aspirateDelay != null ? [ @@ -439,6 +451,10 @@ export const distribute: CommandCreator = ( aspirateDelaySeconds: aspirateDelay?.seconds, dispenseDelaySeconds: dispenseDelay?.seconds, tipRack: args.tipRack, + aspirateXOffset, + aspirateYOffset, + dispenseXOffset, + dispenseYOffset, }) : [] @@ -478,6 +494,8 @@ export const distribute: CommandCreator = ( flowRate: aspirateFlowRateUlSec, offsetFromBottomMm: aspirateOffsetFromBottomMm, tipRack: args.tipRack, + xOffset: aspirateXOffset, + yOffset: aspirateYOffset, }), ...delayAfterAspirateCommands, ...touchTipAfterAspirateCommand, diff --git a/step-generation/src/commandCreators/compound/mix.ts b/step-generation/src/commandCreators/compound/mix.ts index 4a918da5a0d..284529c7c1f 100644 --- a/step-generation/src/commandCreators/compound/mix.ts +++ b/step-generation/src/commandCreators/compound/mix.ts @@ -35,6 +35,10 @@ export function mixUtil(args: { aspirateFlowRateUlSec: number dispenseFlowRateUlSec: number tipRack: string + aspirateXOffset: number + dispenseXOffset: number + aspirateYOffset: number + dispenseYOffset: number aspirateDelaySeconds?: number | null | undefined dispenseDelaySeconds?: number | null | undefined }): CurriedCommandCreator[] { @@ -51,6 +55,10 @@ export function mixUtil(args: { aspirateDelaySeconds, dispenseDelaySeconds, tipRack, + aspirateXOffset, + aspirateYOffset, + dispenseXOffset, + dispenseYOffset, } = args const getDelayCommand = (seconds?: number | null): CurriedCommandCreator[] => @@ -76,6 +84,8 @@ export function mixUtil(args: { offsetFromBottomMm: aspirateOffsetFromBottomMm, flowRate: aspirateFlowRateUlSec, tipRack, + xOffset: aspirateXOffset, + yOffset: aspirateYOffset, }), ...getDelayCommand(aspirateDelaySeconds), curryCommandCreator(dispense, { @@ -85,6 +95,8 @@ export function mixUtil(args: { well, offsetFromBottomMm: dispenseOffsetFromBottomMm, flowRate: dispenseFlowRateUlSec, + xOffset: dispenseXOffset, + yOffset: dispenseYOffset, }), ...getDelayCommand(dispenseDelaySeconds), ], @@ -123,6 +135,10 @@ export const mix: CommandCreator = ( blowoutOffsetFromTopMm, dropTipLocation, tipRack, + aspirateXOffset, + aspirateYOffset, + dispenseXOffset, + dispenseYOffset, } = data const is96Channel = @@ -257,6 +273,10 @@ export const mix: CommandCreator = ( aspirateDelaySeconds, dispenseDelaySeconds, tipRack, + aspirateXOffset, + aspirateYOffset, + dispenseXOffset, + dispenseYOffset, }) return [ ...configureNozzleLayoutCommand, diff --git a/step-generation/src/commandCreators/compound/transfer.ts b/step-generation/src/commandCreators/compound/transfer.ts index 6d57f7ee457..2d16c8064bf 100644 --- a/step-generation/src/commandCreators/compound/transfer.ts +++ b/step-generation/src/commandCreators/compound/transfer.ts @@ -205,6 +205,10 @@ export const transfer: CommandCreator = ( dispenseFlowRateUlSec, dispenseOffsetFromBottomMm, tipRack, + aspirateXOffset, + aspirateYOffset, + dispenseXOffset, + dispenseYOffset, } = args const aspirateAirGapVolume = args.aspirateAirGapVolume || 0 const dispenseAirGapVolume = args.dispenseAirGapVolume || 0 @@ -329,6 +333,10 @@ export const transfer: CommandCreator = ( aspirateDelaySeconds: aspirateDelay?.seconds, dispenseDelaySeconds: dispenseDelay?.seconds, tipRack, + aspirateXOffset, + aspirateYOffset, + dispenseXOffset, + dispenseYOffset, }) : [] const mixBeforeAspirateCommands = @@ -346,6 +354,10 @@ export const transfer: CommandCreator = ( aspirateDelaySeconds: aspirateDelay?.seconds, dispenseDelaySeconds: dispenseDelay?.seconds, tipRack, + aspirateXOffset, + aspirateYOffset, + dispenseXOffset, + dispenseYOffset, }) : [] const delayAfterAspirateCommands = @@ -410,6 +422,10 @@ export const transfer: CommandCreator = ( aspirateDelaySeconds: aspirateDelay?.seconds, dispenseDelaySeconds: dispenseDelay?.seconds, tipRack, + aspirateXOffset, + aspirateYOffset, + dispenseXOffset, + dispenseYOffset, }) : [] @@ -425,6 +441,8 @@ export const transfer: CommandCreator = ( offsetFromBottomMm: airGapOffsetSourceWell, isAirGap: true, tipRack, + xOffset: 0, + yOffset: 0, }), ...(aspirateDelay != null ? [ @@ -445,6 +463,8 @@ export const transfer: CommandCreator = ( flowRate: dispenseFlowRateUlSec, offsetFromBottomMm: airGapOffsetDestWell, isAirGap: true, + xOffset: 0, + yOffset: 0, }), ...(dispenseDelay != null ? [ @@ -486,6 +506,8 @@ export const transfer: CommandCreator = ( flowRate: aspirateFlowRateUlSec, offsetFromBottomMm: aspirateOffsetFromBottomMm, tipRack, + xOffset: aspirateXOffset, + yOffset: aspirateYOffset, }), ] const dispenseCommand = [ @@ -496,6 +518,8 @@ export const transfer: CommandCreator = ( well: destinationWell ?? undefined, flowRate: dispenseFlowRateUlSec, offsetFromBottomMm: dispenseOffsetFromBottomMm, + xOffset: dispenseXOffset, + yOffset: dispenseYOffset, }), ] @@ -602,8 +626,8 @@ export const transfer: CommandCreator = ( ...dispenseCommand, ...delayAfterDispenseCommands, ...mixInDestinationCommands, - ...touchTipAfterDispenseCommands, ...blowoutCommand, + ...touchTipAfterDispenseCommands, ...airGapAfterDispenseCommands, ...dropTipAfterDispenseAirGap, ] diff --git a/step-generation/src/fixtures/commandFixtures.ts b/step-generation/src/fixtures/commandFixtures.ts index 2c38a361ee7..3d1ee394574 100644 --- a/step-generation/src/fixtures/commandFixtures.ts +++ b/step-generation/src/fixtures/commandFixtures.ts @@ -129,6 +129,8 @@ export const makeAspirateHelper: MakeAspDispHelper = bakedP wellLocation: { origin: 'bottom', offset: { + y: 0, + x: 0, z: ASPIRATE_OFFSET_FROM_BOTTOM_MM, }, }, @@ -199,6 +201,8 @@ const _defaultDispenseParams = { wellLocation: { origin: 'bottom' as const, offset: { + y: 0, + x: 0, z: DISPENSE_OFFSET_FROM_BOTTOM_MM, }, }, diff --git a/step-generation/src/types.ts b/step-generation/src/types.ts index 98e1e8ec90c..6cef80c43ed 100644 --- a/step-generation/src/types.ts +++ b/step-generation/src/types.ts @@ -192,6 +192,10 @@ export type SharedTransferLikeArgs = CommonArgs & { aspirateFlowRateUlSec: number /** offset from bottom of well in mm */ aspirateOffsetFromBottomMm: number + /** x offset mm */ + aspirateXOffset: number + /** y offset mm */ + aspirateYOffset: number // ===== DISPENSE SETTINGS ===== /** Air gap after dispense */ @@ -206,6 +210,10 @@ export type SharedTransferLikeArgs = CommonArgs & { dispenseFlowRateUlSec: number /** offset from bottom of well in mm */ dispenseOffsetFromBottomMm: number + /** x offset mm */ + dispenseXOffset: number + /** y offset mm */ + dispenseYOffset: number } export type ConsolidateArgs = SharedTransferLikeArgs & { @@ -286,6 +294,12 @@ export type MixArgs = CommonArgs & { /** offset from bottom of well in mm */ aspirateOffsetFromBottomMm: number dispenseOffsetFromBottomMm: number + /** x offset */ + aspirateXOffset: number + dispenseXOffset: number + /** y offset */ + aspirateYOffset: number + dispenseYOffset: number /** flow rates in uL/sec */ aspirateFlowRateUlSec: number dispenseFlowRateUlSec: number diff --git a/step-generation/src/utils/misc.ts b/step-generation/src/utils/misc.ts index c9f36587213..77d91213d63 100644 --- a/step-generation/src/utils/misc.ts +++ b/step-generation/src/utils/misc.ts @@ -5,7 +5,6 @@ import reduce from 'lodash/reduce' import { getIsTiprack, getLabwareDefURI, - getWellsDepth, getWellNamePerMultiTip, WASTE_CHUTE_CUTOUT, PipetteChannels, @@ -26,8 +25,8 @@ import { movableTrashCommandsUtil } from './movableTrashCommandsUtil' import type { AddressableAreaName, LabwareDefinition2, + BlowoutParams, } from '@opentrons/shared-data' -import type { BlowoutParams } from '@opentrons/shared-data/protocol/types/schemaV4' import type { AdditionalEquipmentEntities, AdditionalEquipmentEntity, @@ -244,15 +243,15 @@ export function getWellsForTips( // the SOURCE_WELL_BLOWOUT_DESTINATION / DEST_WELL_BLOWOUT_DESTINATION // special strings, or to a labware ID. export const blowoutUtil = (args: { - pipette: BlowoutParams['pipette'] + pipette: BlowoutParams['pipetteId'] sourceLabwareId: string - sourceWell: BlowoutParams['well'] + sourceWell: BlowoutParams['wellName'] destLabwareId: string blowoutLocation: string | null | undefined flowRate: number offsetFromTopMm: number invariantContext: InvariantContext - destWell: BlowoutParams['well'] | null + destWell: BlowoutParams['wellName'] | null prevRobotState: RobotState }): CurriedCommandCreator[] => { const { @@ -293,18 +292,18 @@ export const blowoutUtil = (args: { well = trashOrLabware === 'labware' ? 'A1' : null } - const wellDepth = - labware != null && well != null ? getWellsDepth(labware.def, [well]) : 0 - - const offsetFromBottomMm = wellDepth + offsetFromTopMm if (well != null && trashOrLabware === 'labware' && labware != null) { return [ curryCommandCreator(blowout, { - pipette: pipette, - labware: labware.id, - well, + pipetteId: pipette, + labwareId: labware.id, + wellName: well, flowRate, - offsetFromBottomMm, + wellLocation: { + offset: { + z: offsetFromTopMm, + }, + }, }), ] } else if (trashOrLabware === 'wasteChute') { @@ -479,6 +478,8 @@ interface DispenseLocationHelperArgs { pipetteId: string volume: number flowRate: number + xOffset: number + yOffset: number offsetFromBottomMm?: number well?: string } @@ -494,6 +495,8 @@ export const dispenseLocationHelper: CommandCreator flowRate, offsetFromBottomMm, well, + xOffset, + yOffset, } = args const trashOrLabware = getTrashOrLabware( @@ -516,6 +519,8 @@ export const dispenseLocationHelper: CommandCreator well, flowRate, offsetFromBottomMm, + xOffset, + yOffset, }), ] } else if (trashOrLabware === 'wasteChute') { @@ -660,6 +665,8 @@ export const airGapHelper: CommandCreator = ( offsetFromBottomMm, isAirGap: true, tipRack, + xOffset: 0, + yOffset: 0, }), ] // when aspirating out of multi wells for consolidate @@ -674,6 +681,9 @@ export const airGapHelper: CommandCreator = ( offsetFromBottomMm, isAirGap: true, tipRack, + // NOTE: airgap aspirates happen at default x/y offset + xOffset: 0, + yOffset: 0, }), ] } diff --git a/system-server/Pipfile b/system-server/Pipfile index 78c13a0ff55..d1ce7f43f6a 100644 --- a/system-server/Pipfile +++ b/system-server/Pipfile @@ -14,6 +14,7 @@ pydantic = "==1.10.12" importlib-metadata = ">=4.13.0,<5" sqlalchemy = "==1.4.51" pyjwt = "==2.6.0" +filetype = "==1.2.0" systemd-python = { version = "==234", markers="sys_platform == 'linux'" } server-utils = {editable = true, path = "./../server-utils"} system_server = {path = ".", editable = true} diff --git a/system-server/Pipfile.lock b/system-server/Pipfile.lock index bbaa48e640c..d7d315362f2 100644 --- a/system-server/Pipfile.lock +++ b/system-server/Pipfile.lock @@ -50,6 +50,14 @@ "markers": "python_version >= '3.7'", "version": "==0.99.1" }, + "filetype": { + "hashes": [ + "sha256:66b56cd6474bf41d8c54660347d37afcc3f7d1970648de365c102ef77548aadb", + "sha256:7ce71b6880181241cf7ac8697a2f1eb6a8bd9b429f7ad6d27b8db9ba5f1c2d25" + ], + "index": "pypi", + "version": "==1.2.0" + }, "h11": { "hashes": [ "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d", @@ -231,12 +239,12 @@ }, "typing-extensions": { "hashes": [ - "sha256:23478f88c37f27d76ac8aee6c905017a143b0b1b886c3c9f66bc2fd94f9f5783", - "sha256:af72aea155e91adfc61c3ae9e0e342dbc0cba726d6cba4b6c72c1f34e47291cd" + "sha256:83f085bd5ca59c80295fc2a82ab5dac679cbe02b9f33f7d83af68e241bea51b0", + "sha256:c1f94d72897edaf4ce775bb7558d5b79d8126906a14ea5ed1635921406c0387a" ], "index": "pypi", "markers": "python_version >= '3.8'", - "version": "==4.9.0" + "version": "==4.11.0" }, "uvicorn": { "hashes": [ diff --git a/system-server/settings_schema.json b/system-server/settings_schema.json index c16b2c49621..7916f39dcf9 100644 --- a/system-server/settings_schema.json +++ b/system-server/settings_schema.json @@ -9,6 +9,19 @@ "default": "/var/lib/opentrons-system-server/", "env_names": ["ot_system_server_persistence_directory"], "type": "string" + }, + "oem_mode_enabled": { + "title": "OEM Mode Enabled", + "description": "A flag used to change the default splash screen on system startup. If this flag is disabled (default), the Opentrons loading video will be shown. If this flag is enabled but `oem_mode_splash_custom` is not set, then the default OEM Mode splash screen will be shown. If this flag is enabled and `oem_mode_splash_custom` is set to a PNG filepath, the custom splash screen will be shown.", + "default": false, + "env_names": ["ot_system_server_oem_mode_enabled"], + "type": "bool" + }, + "oem_mode_splash_custom": { + "description": "The filepath of the PNG image used as the custom splash screen. Read the description of the `oem_mode_enabled` flag to know how the splash screen changes when the flag is enabled/disabled.", + "default": null, + "env_names": ["ot_system_server_oem_mode_splash_custom"], + "type": "string" } }, "additionalProperties": false diff --git a/system-server/system_server/settings/__init__.py b/system-server/system_server/settings/__init__.py index b2db58a6389..feae773340f 100644 --- a/system-server/system_server/settings/__init__.py +++ b/system-server/system_server/settings/__init__.py @@ -1,6 +1,10 @@ """system_server.settings: Provides an interface to get server settings.""" -from .settings import get_settings, SystemServerSettings +from .settings import ( + save_settings, + get_settings, + SystemServerSettings, +) -__all__ = ["get_settings", "SystemServerSettings"] +__all__ = ["save_settings", "get_settings", "SystemServerSettings"] diff --git a/system-server/system_server/settings/settings.py b/system-server/system_server/settings/settings.py index a042b76b91d..32e34079ebd 100644 --- a/system-server/system_server/settings/settings.py +++ b/system-server/system_server/settings/settings.py @@ -1,27 +1,20 @@ """System server configuration options.""" import typing -import logging from functools import lru_cache from pydantic import BaseSettings, Field -from dotenv import load_dotenv - -log = logging.getLogger(__name__) +from dotenv import load_dotenv, set_key @lru_cache(maxsize=1) def get_settings() -> "SystemServerSettings": """Get the settings.""" - update_from_dotenv() - return SystemServerSettings() - - -def update_from_dotenv() -> None: - """Get the location of the settings file.""" env = Environment().dot_env_path if env: load_dotenv(env) + return SystemServerSettings() + class Environment(BaseSettings): """Environment related settings.""" @@ -56,7 +49,44 @@ class SystemServerSettings(BaseSettings): ), ) + oem_mode_enabled: typing.Optional[bool] = Field( + default=False, + description=( + "A flag used to change the default splash screen on system startup." + " If this flag is disabled (default), the Opentrons loading video will be shown." + " If this flag is enabled but `oem_mode_splash_custom` is not set," + " then the default OEM Mode splash screen will be shown." + " If this flag is enabled and `oem_mode_splash_custom` is set to a" + " PNG filepath, the custom splash screen will be shown." + ), + ) + + oem_mode_splash_custom: typing.Optional[str] = Field( + default=None, + description=( + "The filepath of the PNG image used as the custom splash screen." + " Read the description of the `oem_mode_enabled` flag to know how" + " the splash screen changes when the flag is enabled/disabled." + ), + ) + class Config: """Prefix configuration for environment variables.""" + env_file = Environment().dot_env_path env_prefix = "OT_SYSTEM_SERVER_" + + +def save_settings(settings: SystemServerSettings) -> bool: + """Save the settings to the dotenv file.""" + env_path = Environment().dot_env_path + env_path = env_path or f"{settings.persistence_directory}/system.env" + prefix = settings.Config.env_prefix + try: + for key, val in settings.dict().items(): + name = f"{prefix}{key}" + value = str(val) if val is not None else "" + set_key(env_path, name, value) + return True + except (IOError, ValueError): + return False diff --git a/system-server/system_server/system/oem_mode/__init__.py b/system-server/system_server/system/oem_mode/__init__.py new file mode 100644 index 00000000000..ddbd3555ebf --- /dev/null +++ b/system-server/system_server/system/oem_mode/__init__.py @@ -0,0 +1,5 @@ +"""OEM Mode endpoint.""" + +from .router import oem_mode_router + +__all__ = ["oem_mode_router"] diff --git a/system-server/system_server/system/oem_mode/dependencies.py b/system-server/system_server/system/oem_mode/dependencies.py new file mode 100644 index 00000000000..b59bb825024 --- /dev/null +++ b/system-server/system_server/system/oem_mode/dependencies.py @@ -0,0 +1,21 @@ +"""Dependencies for /system/register endpoints.""" +from system_server.jwt import Registrant +from fastapi import Query + + +def create_registrant( + subject: str = Query( + ..., description="Identifies the human intending to register with the robot" + ), + agent: str = Query(..., description="Identifies the app type making the request"), + agentId: str = Query( + ..., description="A unique identifier for the instance of the agent" + ), +) -> Registrant: + """Define a unique Registrant to create a registration token for. + + A registrant is defined by a set of unique identifiers that remain + persistent indefinitely for the same person using the same method of + access to the system. + """ + return Registrant(subject=subject, agent=agent, agent_id=agentId) diff --git a/system-server/system_server/system/oem_mode/models.py b/system-server/system_server/system/oem_mode/models.py new file mode 100644 index 00000000000..192e1ce4fa6 --- /dev/null +++ b/system-server/system_server/system/oem_mode/models.py @@ -0,0 +1,9 @@ +"""Models for /system/oem_mode.""" + +from pydantic import BaseModel, Field + + +class EnableOEMMode(BaseModel): + """Enable OEM Mode model.""" + + enable: bool = Field(..., description="Enable or Disable OEM Mode.") diff --git a/system-server/system_server/system/oem_mode/router.py b/system-server/system_server/system/oem_mode/router.py new file mode 100644 index 00000000000..0f3b9aa52f4 --- /dev/null +++ b/system-server/system_server/system/oem_mode/router.py @@ -0,0 +1,132 @@ +"""Router for /system/register endpoint.""" + +import os +import filetype # type: ignore[import-untyped] +from fastapi import ( + APIRouter, + Depends, + status, + Response, + UploadFile, + File, + HTTPException, +) + +from .models import EnableOEMMode +from ...settings import SystemServerSettings, get_settings, save_settings + + +oem_mode_router = APIRouter() + + +@oem_mode_router.put( + "/system/oem_mode/enable", + summary="Enable or Disable OEM Mode for this robot.", + responses={ + status.HTTP_200_OK: {"message": "OEM Mode changed successfully."}, + status.HTTP_400_BAD_REQUEST: {"message": "OEM Mode did not changed."}, + status.HTTP_500_INTERNAL_SERVER_ERROR: { + "message": "OEM Mode unhandled exception." + }, + }, +) +async def enable_oem_mode_endpoint( + response: Response, + enableRequest: EnableOEMMode, + settings: SystemServerSettings = Depends(get_settings), +) -> Response: + """Router for /system/oem_mode/enable endpoint.""" + enable = enableRequest.enable + try: + settings.oem_mode_enabled = enable + success = save_settings(settings) + response.status_code = ( + status.HTTP_200_OK if success else status.HTTP_400_BAD_REQUEST + ) + except Exception: + response.status_code = status.HTTP_500_INTERNAL_SERVER_ERROR + return response + + +@oem_mode_router.post( + "/system/oem_mode/upload_splash", + summary="Upload an image to be used as the boot up splash screen.", + responses={ + status.HTTP_201_CREATED: {"message": "OEM Mode splash screen uploaded"}, + status.HTTP_400_BAD_REQUEST: {"message": "OEM Mode splash screen not set"}, + status.HTTP_413_REQUEST_ENTITY_TOO_LARGE: { + "message": "File is larger than 5mb" + }, + status.HTTP_415_UNSUPPORTED_MEDIA_TYPE: {"message": "Invalid file type"}, + status.HTTP_500_INTERNAL_SERVER_ERROR: { + "message": "OEM Mode splash unhandled exception." + }, + }, +) +async def upload_splash_image( + response: Response, + file: UploadFile = File(...), + settings: SystemServerSettings = Depends(get_settings), +) -> Response: + """Router for /system/oem_mode/upload_splash endpoint.""" + # Make sure oem mode is enabled before this request + if not settings.oem_mode_enabled: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="OEM Mode needs to be enabled to upload splash image.", + ) + + # Get the file info + file_info = filetype.guess(file.file) + if file_info is None: + raise HTTPException( + status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, + detail="Unable to determine file type", + ) + + # Only accept PNG files + accepted_file_types = ["image/png", "png"] + content_type = file_info.extension.lower() + if ( + file.content_type not in accepted_file_types + or content_type not in accepted_file_types + ): + raise HTTPException( + status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, + detail="Unsupported file type", + ) + + file_size = 0 + for chunk in file.file: + file_size += len(chunk) + if file_size > 5 * 1024 * 1024: # 5MB + raise HTTPException( + status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, + detail="File is larger than 5mb.", + ) + + # TODO: Validate image dimensions + + # return the pointer back to the starting point so that the next read starts from the starting point + await file.seek(0) + + try: + # Remove the old image if exists + if settings.oem_mode_splash_custom: + os.unlink(settings.oem_mode_splash_custom) + + # file is valid, save to final location + filepath = f"{settings.persistence_directory}/{file.filename}" + with open(filepath, "wb+") as f: + f.write(file.file.read()) + + # store the file location to settings and save the dotenv + settings.oem_mode_splash_custom = filepath + success = save_settings(settings) + response.status_code = ( + status.HTTP_201_CREATED if success else status.HTTP_400_BAD_REQUEST + ) + except Exception: + response.status_code = status.HTTP_500_INTERNAL_SERVER_ERROR + + return response diff --git a/system-server/system_server/system/router.py b/system-server/system_server/system/router.py index 0ae868c5f51..b138a1d0ed6 100644 --- a/system-server/system_server/system/router.py +++ b/system-server/system_server/system/router.py @@ -3,6 +3,7 @@ from .register.router import register_router from .authorize.router import authorize_router from .connected.router import connected_router +from .oem_mode.router import oem_mode_router system_router = APIRouter() @@ -11,3 +12,5 @@ system_router.include_router(router=authorize_router) system_router.include_router(router=connected_router) + +system_router.include_router(router=oem_mode_router) diff --git a/system-server/tests/integration/resources/oem_mode_custom.png b/system-server/tests/integration/resources/oem_mode_custom.png new file mode 100644 index 00000000000..14cf4ac12bd Binary files /dev/null and b/system-server/tests/integration/resources/oem_mode_custom.png differ diff --git a/system-server/tests/integration/resources/oem_mode_wrong_dimensions.png b/system-server/tests/integration/resources/oem_mode_wrong_dimensions.png new file mode 100644 index 00000000000..2cc51a01cb0 Binary files /dev/null and b/system-server/tests/integration/resources/oem_mode_wrong_dimensions.png differ diff --git a/system-server/tests/integration/resources/oem_mode_wrong_image_type.jpeg b/system-server/tests/integration/resources/oem_mode_wrong_image_type.jpeg new file mode 100644 index 00000000000..aa95d031d93 Binary files /dev/null and b/system-server/tests/integration/resources/oem_mode_wrong_image_type.jpeg differ diff --git a/system-server/tests/integration/test_oem_mode.tavern.yaml b/system-server/tests/integration/test_oem_mode.tavern.yaml new file mode 100644 index 00000000000..9778495c6c3 --- /dev/null +++ b/system-server/tests/integration/test_oem_mode.tavern.yaml @@ -0,0 +1,124 @@ +--- +test_name: Test enable/disable OEM Mode +marks: + - usefixtures: + - run_server +stages: + - name: PUT first request + request: &enable_oem_mode_first + url: "{host:s}:{port:d}/system/oem_mode/enable" + json: + enable: true + method: PUT + headers: + content-type: application/json + response: + status_code: 200 + - name: PUT second request + request: &enable_oem_mode_second + url: "{host:s}:{port:d}/system/oem_mode/enable" + json: + enable: false + method: PUT + headers: + content-type: application/json + response: + status_code: 200 + - name: PUT third request + request: &enable_oem_mode_third + url: "{host:s}:{port:d}/system/oem_mode/enable" + json: + wrong_key: false + method: PUT + headers: + content-type: application/json + response: + status_code: 422 +--- +test_name: Upload, and validate a good image for OEM Mode + +marks: + - usefixtures: + - run_server +stages: + - name: Enable OEM Mode + request: + url: "{host:s}:{port:d}/system/oem_mode/enable" + method: PUT + json: + "enable": true + - name: Upload PNG Image + request: &upload_splash_first + url: "{host:s}:{port:d}/system/oem_mode/upload_splash" + method: POST + files: + file: 'tests/integration/resources/oem_mode_custom.png' + response: + status_code: 201 + +--- +test_name: Dont process upload_splash request if oem mode is disabled + +marks: + - usefixtures: + - run_server + +stages: + - name: Disable OEM Mode + request: + url: "{host:s}:{port:d}/system/oem_mode/enable" + method: PUT + json: + "enable": false + - name: Upload PNG Image + request: + url: "{host:s}:{port:d}/system/oem_mode/upload_splash" + method: POST + files: + file: 'tests/integration/resources/oem_mode_custom.png' + response: + status_code: 403 + - name: Enable OEM Mode + request: + url: "{host:s}:{port:d}/system/oem_mode/enable" + method: PUT + json: + "enable": true + - name: Upload PNG Image + request: + url: "{host:s}:{port:d}/system/oem_mode/upload_splash" + method: POST + files: + file: 'tests/integration/resources/oem_mode_custom.png' + response: + status_code: 201 +--- +test_name: Validate the image before processing + +marks: + - usefixtures: + - run_server + +stages: + - name: Enable OEM Mode + request: + url: "{host:s}:{port:d}/system/oem_mode/enable" + method: PUT + json: + "enable": true + - name: Upload non-PNG Image + request: + url: "{host:s}:{port:d}/system/oem_mode/upload_splash" + method: POST + files: + file: 'tests/integration/resources/oem_mode_wrong_image_type.jpeg' + response: + status_code: 415 + - name: Upload a PNG Image + request: + url: "{host:s}:{port:d}/system/oem_mode/upload_splash" + method: POST + files: + file: 'tests/integration/resources/oem_mode_custom.png' + response: + status_code: 201 diff --git a/test-data-generation/.flake8 b/test-data-generation/.flake8 new file mode 100644 index 00000000000..4aa1c02d7aa --- /dev/null +++ b/test-data-generation/.flake8 @@ -0,0 +1,25 @@ +[flake8] + +# max cyclomatic complexity +max-complexity = 9 + +extend-ignore = + # defer formatting concerns to black + # E203: space around `:` operator + # E501: maximum line length + E203, + E501, + # do not require type annotations for self nor cls + ANN101, + ANN102 + # do not require docstring for __init__, put them on the class + D107, + +# configure flake8-docstrings +# https://pypi.org/project/flake8-docstrings/ +docstring-convention = google + +noqa-require-code = true + +per-file-ignores = + setup.py:ANN,D \ No newline at end of file diff --git a/test-data-generation/Makefile b/test-data-generation/Makefile new file mode 100644 index 00000000000..03c881dbf89 --- /dev/null +++ b/test-data-generation/Makefile @@ -0,0 +1,32 @@ +include ../scripts/python.mk + +.PHONY: lint +lint: + $(python) -m black --check . + $(python) -m flake8 . + $(python) -m mypy . + +.PHONY: format +format: + $(python) -m black . + +.PHONY: setup +setup: + $(pipenv) sync --dev + +.PHONY: teardown +teardown: + $(pipenv) --rm + +.PHONY: clean +clean: + rm -rf build dist *.egg-info .mypy_cache .pytest_cache src/test_data_generation.egg-info + +.PHONY: wheel +wheel: + $(python) setup.py $(wheel_opts) bdist_wheel + rm -rf build + +.PHONY: test +test: + $(pytest) tests -vvv \ No newline at end of file diff --git a/test-data-generation/Pipfile b/test-data-generation/Pipfile new file mode 100644 index 00000000000..758bcddacb7 --- /dev/null +++ b/test-data-generation/Pipfile @@ -0,0 +1,20 @@ +[[source]] +name = "pypi" +url = "https://pypi.org/simple" +verify_ssl = true + +[packages] +pytest = "==7.4.3" +black = "==23.11.0" +mypy = "==1.7.1" +flake8 = "==7.0.0" +flake8-annotations = "~=3.0.1" +flake8-docstrings = "~=1.7.0" +flake8-noqa = "~=1.4.0" +hypothesis = "==6.96.1" +opentrons-shared-data = {file = "../shared-data/python", editable = true} +test-data-generation = {file = ".", editable = true} + + +[requires] +python_version = "3.10" diff --git a/test-data-generation/Pipfile.lock b/test-data-generation/Pipfile.lock new file mode 100644 index 00000000000..1b223033d61 --- /dev/null +++ b/test-data-generation/Pipfile.lock @@ -0,0 +1,365 @@ +{ + "_meta": { + "hash": { + "sha256": "1df89f797a19f2c0febc582e7452a52858511cece041f9f612a59d35628226c2" + }, + "pipfile-spec": 6, + "requires": { + "python_version": "3.10" + }, + "sources": [ + { + "name": "pypi", + "url": "https://pypi.org/simple", + "verify_ssl": true + } + ] + }, + "default": { + "attrs": { + "hashes": [ + "sha256:935dc3b529c262f6cf76e50877d35a4bd3c1de194fd41f47a2b7ae8f19971f30", + "sha256:99b87a485a5820b23b879f04c2305b44b951b502fd64be915879d77a7e8fc6f1" + ], + "markers": "python_version >= '3.7'", + "version": "==23.2.0" + }, + "black": { + "hashes": [ + "sha256:250d7e60f323fcfc8ea6c800d5eba12f7967400eb6c2d21ae85ad31c204fb1f4", + "sha256:2a9acad1451632021ee0d146c8765782a0c3846e0e0ea46659d7c4f89d9b212b", + "sha256:412f56bab20ac85927f3a959230331de5614aecda1ede14b373083f62ec24e6f", + "sha256:421f3e44aa67138ab1b9bfbc22ee3780b22fa5b291e4db8ab7eee95200726b07", + "sha256:45aa1d4675964946e53ab81aeec7a37613c1cb71647b5394779e6efb79d6d187", + "sha256:4c44b7211a3a0570cc097e81135faa5f261264f4dfaa22bd5ee2875a4e773bd6", + "sha256:4c68855825ff432d197229846f971bc4d6666ce90492e5b02013bcaca4d9ab05", + "sha256:5133f5507007ba08d8b7b263c7aa0f931af5ba88a29beacc4b2dc23fcefe9c06", + "sha256:54caaa703227c6e0c87b76326d0862184729a69b73d3b7305b6288e1d830067e", + "sha256:58e5f4d08a205b11800332920e285bd25e1a75c54953e05502052738fe16b3b5", + "sha256:698c1e0d5c43354ec5d6f4d914d0d553a9ada56c85415700b81dc90125aac244", + "sha256:6c1cac07e64433f646a9a838cdc00c9768b3c362805afc3fce341af0e6a9ae9f", + "sha256:760415ccc20f9e8747084169110ef75d545f3b0932ee21368f63ac0fee86b221", + "sha256:7f622b6822f02bfaf2a5cd31fdb7cd86fcf33dab6ced5185c35f5db98260b055", + "sha256:cf57719e581cfd48c4efe28543fea3d139c6b6f1238b3f0102a9c73992cbb479", + "sha256:d136ef5b418c81660ad847efe0e55c58c8208b77a57a28a503a5f345ccf01394", + "sha256:dbea0bb8575c6b6303cc65017b46351dc5953eea5c0a59d7b7e3a2d2f433a911", + "sha256:fc7f6a44d52747e65a02558e1d807c82df1d66ffa80a601862040a43ec2e3142" + ], + "index": "pypi", + "markers": "python_version >= '3.8'", + "version": "==23.11.0" + }, + "click": { + "hashes": [ + "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28", + "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de" + ], + "markers": "python_version >= '3.7'", + "version": "==8.1.7" + }, + "exceptiongroup": { + "hashes": [ + "sha256:5258b9ed329c5bbdd31a309f53cbfb0b155341807f6ff7606a1e801a891b29ad", + "sha256:a4785e48b045528f5bfe627b6ad554ff32def154f42372786903b7abcfe1aa16" + ], + "markers": "python_version < '3.11'", + "version": "==1.2.1" + }, + "flake8": { + "hashes": [ + "sha256:33f96621059e65eec474169085dc92bf26e7b2d47366b70be2f67ab80dc25132", + "sha256:a6dfbb75e03252917f2473ea9653f7cd799c3064e54d4c8140044c5c065f53c3" + ], + "index": "pypi", + "markers": "python_full_version >= '3.8.1'", + "version": "==7.0.0" + }, + "flake8-annotations": { + "hashes": [ + "sha256:af78e3216ad800d7e144745ece6df706c81b3255290cbf870e54879d495e8ade", + "sha256:ff37375e71e3b83f2a5a04d443c41e2c407de557a884f3300a7fa32f3c41cb0a" + ], + "index": "pypi", + "markers": "python_full_version >= '3.8.1'", + "version": "==3.0.1" + }, + "flake8-docstrings": { + "hashes": [ + "sha256:4c8cc748dc16e6869728699e5d0d685da9a10b0ea718e090b1ba088e67a941af", + "sha256:51f2344026da083fc084166a9353f5082b01f72901df422f74b4d953ae88ac75" + ], + "index": "pypi", + "markers": "python_version >= '3.7'", + "version": "==1.7.0" + }, + "flake8-noqa": { + "hashes": [ + "sha256:4465e16a19be433980f6f563d05540e2e54797eb11facb9feb50fed60624dc45", + "sha256:771765ab27d1efd157528379acd15131147f9ae578a72d17fb432ca197881243" + ], + "index": "pypi", + "markers": "python_version >= '3.7'", + "version": "==1.4.0" + }, + "hypothesis": { + "hashes": [ + "sha256:848ea0952f0bdfd02eac59e41b03f1cbba8fa2cffeffa8db328bbd6cfe159974", + "sha256:955a57e56be4607c81c17ca53e594af54aadeed91e07b88bb7f84e8208ea7739" + ], + "index": "pypi", + "markers": "python_version >= '3.8'", + "version": "==6.96.1" + }, + "iniconfig": { + "hashes": [ + "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", + "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374" + ], + "markers": "python_version >= '3.7'", + "version": "==2.0.0" + }, + "jsonschema": { + "hashes": [ + "sha256:0f864437ab8b6076ba6707453ef8f98a6a0d512a80e93f8abdb676f737ecb60d", + "sha256:a870ad254da1a8ca84b6a2905cac29d265f805acc57af304784962a2aa6508f6" + ], + "markers": "python_version >= '3.7'", + "version": "==4.17.3" + }, + "mccabe": { + "hashes": [ + "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325", + "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e" + ], + "markers": "python_version >= '3.6'", + "version": "==0.7.0" + }, + "mypy": { + "hashes": [ + "sha256:12cce78e329838d70a204293e7b29af9faa3ab14899aec397798a4b41be7f340", + "sha256:1484b8fa2c10adf4474f016e09d7a159602f3239075c7bf9f1627f5acf40ad49", + "sha256:204e0d6de5fd2317394a4eff62065614c4892d5a4d1a7ee55b765d7a3d9e3f82", + "sha256:2643d145af5292ee956aa0a83c2ce1038a3bdb26e033dadeb2f7066fb0c9abce", + "sha256:2c6e4464ed5f01dc44dc9821caf67b60a4e5c3b04278286a85c067010653a0eb", + "sha256:2f7f6985d05a4e3ce8255396df363046c28bea790e40617654e91ed580ca7c51", + "sha256:31902408f4bf54108bbfb2e35369877c01c95adc6192958684473658c322c8a5", + "sha256:40716d1f821b89838589e5b3106ebbc23636ffdef5abc31f7cd0266db936067e", + "sha256:4b901927f16224d0d143b925ce9a4e6b3a758010673eeded9b748f250cf4e8f7", + "sha256:4fc3d14ee80cd22367caaaf6e014494415bf440980a3045bf5045b525680ac33", + "sha256:5cf3f0c5ac72139797953bd50bc6c95ac13075e62dbfcc923571180bebb662e9", + "sha256:6dbdec441c60699288adf051f51a5d512b0d818526d1dcfff5a41f8cd8b4aaf1", + "sha256:72cf32ce7dd3562373f78bd751f73c96cfb441de147cc2448a92c1a308bd0ca6", + "sha256:75aa828610b67462ffe3057d4d8a4112105ed211596b750b53cbfe182f44777a", + "sha256:75c4d2a6effd015786c87774e04331b6da863fc3fc4e8adfc3b40aa55ab516fe", + "sha256:78e25b2fd6cbb55ddfb8058417df193f0129cad5f4ee75d1502248e588d9e0d7", + "sha256:84860e06ba363d9c0eeabd45ac0fde4b903ad7aa4f93cd8b648385a888e23200", + "sha256:8c5091ebd294f7628eb25ea554852a52058ac81472c921150e3a61cdd68f75a7", + "sha256:944bdc21ebd620eafefc090cdf83158393ec2b1391578359776c00de00e8907a", + "sha256:9c7ac372232c928fff0645d85f273a726970c014749b924ce5710d7d89763a28", + "sha256:d9b338c19fa2412f76e17525c1b4f2c687a55b156320acb588df79f2e6fa9fea", + "sha256:ee5d62d28b854eb61889cde4e1dbc10fbaa5560cb39780c3995f6737f7e82120", + "sha256:f2c2521a8e4d6d769e3234350ba7b65ff5d527137cdcde13ff4d99114b0c8e7d", + "sha256:f6efc9bd72258f89a3816e3a98c09d36f079c223aa345c659622f056b760ab42", + "sha256:f7c5d642db47376a0cc130f0de6d055056e010debdaf0707cd2b0fc7e7ef30ea", + "sha256:fcb6d9afb1b6208b4c712af0dafdc650f518836065df0d4fb1d800f5d6773db2", + "sha256:fcd2572dd4519e8a6642b733cd3a8cfc1ef94bafd0c1ceed9c94fe736cb65b6a" + ], + "index": "pypi", + "markers": "python_version >= '3.8'", + "version": "==1.7.1" + }, + "mypy-extensions": { + "hashes": [ + "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d", + "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782" + ], + "markers": "python_version >= '3.5'", + "version": "==1.0.0" + }, + "opentrons-shared-data": { + "editable": true, + "file": "../shared-data/python", + "markers": "python_version >= '3.8'" + }, + "packaging": { + "hashes": [ + "sha256:2ddfb553fdf02fb784c234c7ba6ccc288296ceabec964ad2eae3777778130bc5", + "sha256:eb82c5e3e56209074766e6885bb04b8c38a0c015d0a30036ebe7ece34c9989e9" + ], + "markers": "python_version >= '3.7'", + "version": "==24.0" + }, + "pathspec": { + "hashes": [ + "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", + "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712" + ], + "markers": "python_version >= '3.8'", + "version": "==0.12.1" + }, + "platformdirs": { + "hashes": [ + "sha256:0614df2a2f37e1a662acbd8e2b25b92ccf8632929bc6d43467e17fe89c75e068", + "sha256:ef0cc731df711022c174543cb70a9b5bd22e5a9337c8624ef2c2ceb8ddad8768" + ], + "markers": "python_version >= '3.8'", + "version": "==4.2.0" + }, + "pluggy": { + "hashes": [ + "sha256:7db9f7b503d67d1c5b95f59773ebb58a8c1c288129a88665838012cfb07b8981", + "sha256:8c85c2876142a764e5b7548e7d9a0e0ddb46f5185161049a79b7e974454223be" + ], + "markers": "python_version >= '3.8'", + "version": "==1.4.0" + }, + "pycodestyle": { + "hashes": [ + "sha256:41ba0e7afc9752dfb53ced5489e89f8186be00e599e712660695b7a75ff2663f", + "sha256:44fe31000b2d866f2e41841b18528a505fbd7fef9017b04eff4e2648a0fadc67" + ], + "markers": "python_version >= '3.8'", + "version": "==2.11.1" + }, + "pydantic": { + "hashes": [ + "sha256:005655cabc29081de8243126e036f2065bd7ea5b9dff95fde6d2c642d39755de", + "sha256:0d142fa1b8f2f0ae11ddd5e3e317dcac060b951d605fda26ca9b234b92214986", + "sha256:22ed12ee588b1df028a2aa5d66f07bf8f8b4c8579c2e96d5a9c1f96b77f3bb55", + "sha256:2746189100c646682eff0bce95efa7d2e203420d8e1c613dc0c6b4c1d9c1fde4", + "sha256:28e552a060ba2740d0d2aabe35162652c1459a0b9069fe0db7f4ee0e18e74d58", + "sha256:3287e1614393119c67bd4404f46e33ae3be3ed4cd10360b48d0a4459f420c6a3", + "sha256:3350f527bb04138f8aff932dc828f154847fbdc7a1a44c240fbfff1b57f49a12", + "sha256:3453685ccd7140715e05f2193d64030101eaad26076fad4e246c1cc97e1bb30d", + "sha256:394f08750bd8eaad714718812e7fab615f873b3cdd0b9d84e76e51ef3b50b6b7", + "sha256:4e316e54b5775d1eb59187f9290aeb38acf620e10f7fd2f776d97bb788199e53", + "sha256:50f1666a9940d3d68683c9d96e39640f709d7a72ff8702987dab1761036206bb", + "sha256:51d405b42f1b86703555797270e4970a9f9bd7953f3990142e69d1037f9d9e51", + "sha256:584f2d4c98ffec420e02305cf675857bae03c9d617fcfdc34946b1160213a948", + "sha256:5e09c19df304b8123938dc3c53d3d3be6ec74b9d7d0d80f4f4b5432ae16c2022", + "sha256:676ed48f2c5bbad835f1a8ed8a6d44c1cd5a21121116d2ac40bd1cd3619746ed", + "sha256:67f1a1fb467d3f49e1708a3f632b11c69fccb4e748a325d5a491ddc7b5d22383", + "sha256:6a51a1dd4aa7b3f1317f65493a182d3cff708385327c1c82c81e4a9d6d65b2e4", + "sha256:6bd7030c9abc80134087d8b6e7aa957e43d35714daa116aced57269a445b8f7b", + "sha256:75279d3cac98186b6ebc2597b06bcbc7244744f6b0b44a23e4ef01e5683cc0d2", + "sha256:7ac9237cd62947db00a0d16acf2f3e00d1ae9d3bd602b9c415f93e7a9fc10528", + "sha256:7ea210336b891f5ea334f8fc9f8f862b87acd5d4a0cbc9e3e208e7aa1775dabf", + "sha256:82790d4753ee5d00739d6cb5cf56bceb186d9d6ce134aca3ba7befb1eedbc2c8", + "sha256:92229f73400b80c13afcd050687f4d7e88de9234d74b27e6728aa689abcf58cc", + "sha256:9bea1f03b8d4e8e86702c918ccfd5d947ac268f0f0cc6ed71782e4b09353b26f", + "sha256:a980a77c52723b0dc56640ced396b73a024d4b74f02bcb2d21dbbac1debbe9d0", + "sha256:af9850d98fc21e5bc24ea9e35dd80a29faf6462c608728a110c0a30b595e58b7", + "sha256:bbc6989fad0c030bd70a0b6f626f98a862224bc2b1e36bfc531ea2facc0a340c", + "sha256:be51dd2c8596b25fe43c0a4a59c2bee4f18d88efb8031188f9e7ddc6b469cf44", + "sha256:c365ad9c394f9eeffcb30a82f4246c0006417f03a7c0f8315d6211f25f7cb654", + "sha256:c3d5731a120752248844676bf92f25a12f6e45425e63ce22e0849297a093b5b0", + "sha256:ca832e124eda231a60a041da4f013e3ff24949d94a01154b137fc2f2a43c3ffb", + "sha256:d207d5b87f6cbefbdb1198154292faee8017d7495a54ae58db06762004500d00", + "sha256:d31ee5b14a82c9afe2bd26aaa405293d4237d0591527d9129ce36e58f19f95c1", + "sha256:d3b5c4cbd0c9cb61bbbb19ce335e1f8ab87a811f6d589ed52b0254cf585d709c", + "sha256:d573082c6ef99336f2cb5b667b781d2f776d4af311574fb53d908517ba523c22", + "sha256:e49db944fad339b2ccb80128ffd3f8af076f9f287197a480bf1e4ca053a866f0" + ], + "markers": "python_version >= '3.7'", + "version": "==1.10.15" + }, + "pydocstyle": { + "hashes": [ + "sha256:118762d452a49d6b05e194ef344a55822987a462831ade91ec5c06fd2169d019", + "sha256:7ce43f0c0ac87b07494eb9c0b462c0b73e6ff276807f204d6b53edc72b7e44e1" + ], + "markers": "python_version >= '3.6'", + "version": "==6.3.0" + }, + "pyflakes": { + "hashes": [ + "sha256:1c61603ff154621fb2a9172037d84dca3500def8c8b630657d1701f026f8af3f", + "sha256:84b5be138a2dfbb40689ca07e2152deb896a65c3a3e24c251c5c62489568074a" + ], + "markers": "python_version >= '3.8'", + "version": "==3.2.0" + }, + "pyrsistent": { + "hashes": [ + "sha256:0724c506cd8b63c69c7f883cc233aac948c1ea946ea95996ad8b1380c25e1d3f", + "sha256:09848306523a3aba463c4b49493a760e7a6ca52e4826aa100ee99d8d39b7ad1e", + "sha256:0f3b1bcaa1f0629c978b355a7c37acd58907390149b7311b5db1b37648eb6958", + "sha256:21cc459636983764e692b9eba7144cdd54fdec23ccdb1e8ba392a63666c60c34", + "sha256:2e14c95c16211d166f59c6611533d0dacce2e25de0f76e4c140fde250997b3ca", + "sha256:2e2c116cc804d9b09ce9814d17df5edf1df0c624aba3b43bc1ad90411487036d", + "sha256:4021a7f963d88ccd15b523787d18ed5e5269ce57aa4037146a2377ff607ae87d", + "sha256:4c48f78f62ab596c679086084d0dd13254ae4f3d6c72a83ffdf5ebdef8f265a4", + "sha256:4f5c2d012671b7391803263419e31b5c7c21e7c95c8760d7fc35602353dee714", + "sha256:58b8f6366e152092194ae68fefe18b9f0b4f89227dfd86a07770c3d86097aebf", + "sha256:59a89bccd615551391f3237e00006a26bcf98a4d18623a19909a2c48b8e986ee", + "sha256:5cdd7ef1ea7a491ae70d826b6cc64868de09a1d5ff9ef8d574250d0940e275b8", + "sha256:6288b3fa6622ad8a91e6eb759cfc48ff3089e7c17fb1d4c59a919769314af224", + "sha256:6d270ec9dd33cdb13f4d62c95c1a5a50e6b7cdd86302b494217137f760495b9d", + "sha256:79ed12ba79935adaac1664fd7e0e585a22caa539dfc9b7c7c6d5ebf91fb89054", + "sha256:7d29c23bdf6e5438c755b941cef867ec2a4a172ceb9f50553b6ed70d50dfd656", + "sha256:8441cf9616d642c475684d6cf2520dd24812e996ba9af15e606df5f6fd9d04a7", + "sha256:881bbea27bbd32d37eb24dd320a5e745a2a5b092a17f6debc1349252fac85423", + "sha256:8c3aba3e01235221e5b229a6c05f585f344734bd1ad42a8ac51493d74722bbce", + "sha256:a14798c3005ec892bbada26485c2eea3b54109cb2533713e355c806891f63c5e", + "sha256:b14decb628fac50db5e02ee5a35a9c0772d20277824cfe845c8a8b717c15daa3", + "sha256:b318ca24db0f0518630e8b6f3831e9cba78f099ed5c1d65ffe3e023003043ba0", + "sha256:c1beb78af5423b879edaf23c5591ff292cf7c33979734c99aa66d5914ead880f", + "sha256:c55acc4733aad6560a7f5f818466631f07efc001fd023f34a6c203f8b6df0f0b", + "sha256:ca52d1ceae015859d16aded12584c59eb3825f7b50c6cfd621d4231a6cc624ce", + "sha256:cae40a9e3ce178415040a0383f00e8d68b569e97f31928a3a8ad37e3fde6df6a", + "sha256:e78d0c7c1e99a4a45c99143900ea0546025e41bb59ebc10182e947cf1ece9174", + "sha256:ef3992833fbd686ee783590639f4b8343a57f1f75de8633749d984dc0eb16c86", + "sha256:f058a615031eea4ef94ead6456f5ec2026c19fb5bd6bfe86e9665c4158cf802f", + "sha256:f5ac696f02b3fc01a710427585c855f65cd9c640e14f52abe52020722bb4906b", + "sha256:f920385a11207dc372a028b3f1e1038bb244b3ec38d448e6d8e43c6b3ba20e98", + "sha256:fed2c3216a605dc9a6ea50c7e84c82906e3684c4e80d2908208f662a6cbf9022" + ], + "markers": "python_version >= '3.8'", + "version": "==0.20.0" + }, + "pytest": { + "hashes": [ + "sha256:0d009c083ea859a71b76adf7c1d502e4bc170b80a8ef002da5806527b9591fac", + "sha256:d989d136982de4e3b29dabcc838ad581c64e8ed52c11fbe86ddebd9da0818cd5" + ], + "index": "pypi", + "markers": "python_version >= '3.7'", + "version": "==7.4.3" + }, + "snowballstemmer": { + "hashes": [ + "sha256:09b16deb8547d3412ad7b590689584cd0fe25ec8db3be37788be3810cbf19cb1", + "sha256:c8e1716e83cc398ae16824e5572ae04e0d9fc2c6b985fb0f900f5f0c96ecba1a" + ], + "version": "==2.2.0" + }, + "sortedcontainers": { + "hashes": [ + "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", + "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0" + ], + "version": "==2.4.0" + }, + "test-data-generation": { + "editable": true, + "file": "." + }, + "tomli": { + "hashes": [ + "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc", + "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f" + ], + "markers": "python_version < '3.11'", + "version": "==2.0.1" + }, + "typing-extensions": { + "hashes": [ + "sha256:83f085bd5ca59c80295fc2a82ab5dac679cbe02b9f33f7d83af68e241bea51b0", + "sha256:c1f94d72897edaf4ce775bb7558d5b79d8126906a14ea5ed1635921406c0387a" + ], + "markers": "python_version < '3.11'", + "version": "==4.11.0" + } + }, + "develop": {} +} diff --git a/test-data-generation/mypy.ini b/test-data-generation/mypy.ini new file mode 100644 index 00000000000..b94476cbcaa --- /dev/null +++ b/test-data-generation/mypy.ini @@ -0,0 +1,5 @@ +[mypy] +show_error_codes = True +warn_unused_configs = True +strict = True +exclude = setup.py \ No newline at end of file diff --git a/test-data-generation/pytest.ini b/test-data-generation/pytest.ini new file mode 100644 index 00000000000..49f04412746 --- /dev/null +++ b/test-data-generation/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +addopts = --color=yes --strict-markers +asyncio_mode = auto diff --git a/test-data-generation/setup.py b/test-data-generation/setup.py new file mode 100755 index 00000000000..4246340dd68 --- /dev/null +++ b/test-data-generation/setup.py @@ -0,0 +1,91 @@ +# Inspired by: +# https://hynek.me/articles/sharing-your-labor-of-love-pypi-quick-and-dirty/ +import sys +import codecs +import os +import os.path +from setuptools import setup, find_packages + +# make stdout blocking since Travis sets it to nonblocking +if os.name == "posix": + import fcntl + + flags = fcntl.fcntl(sys.stdout, fcntl.F_GETFL) + fcntl.fcntl(sys.stdout, fcntl.F_SETFL, flags & ~os.O_NONBLOCK) + +HERE = os.path.abspath(os.path.dirname(__file__)) +sys.path.append(os.path.join(HERE, "..", "scripts")) + +from python_build_utils import normalize_version # noqa: E402 + + +def get_version(): + buildno = os.getenv("BUILD_NUMBER") + project = os.getenv("OPENTRONS_PROJECT", "robot-stack") + git_dir = os.getenv("OPENTRONS_GIT_DIR", None) + if buildno: + normalize_opts = {"extra_tag": buildno} + else: + normalize_opts = {} + return normalize_version( + "test-data-generation", project, git_dir=git_dir, **normalize_opts + ) + + +VERSION = get_version() + +DISTNAME = "test_data_generation" +LICENSE = "Apache 2.0" +AUTHOR = "Opentrons" +EMAIL = "engineering@opentrons.com" +URL = "https://github.com/Opentrons/opentrons" +DOWNLOAD_URL = "" +CLASSIFIERS = [ + "Development Status :: 5 - Production/Stable", + "Environment :: Console", + "Operating System :: OS Independent", + "Intended Audience :: Science/Research", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Topic :: Scientific/Engineering", +] +KEYWORDS = ["robots", "protocols", "synbio", "pcr", "automation", "lab"] +DESCRIPTION = "Library for working with test data on the Opentrons robots" +PACKAGES = find_packages(where="src", exclude=["tests.*", "tests"]) +INSTALL_REQUIRES = [ + f"opentrons-shared-data=={VERSION}", +] + + +def read(*parts): + """ + Build an absolute path from *parts* and and return the contents of the + resulting file. Assume UTF-8 encoding. + """ + with codecs.open(os.path.join(HERE, *parts), "rb", "utf-8") as f: + return f.read() + + +if __name__ == "__main__": + setup( + python_requires="~=3.10", + name=DISTNAME, + description=DESCRIPTION, + license=LICENSE, + url=URL, + version=VERSION, + author=AUTHOR, + author_email=EMAIL, + maintainer=AUTHOR, + maintainer_email=EMAIL, + keywords=KEYWORDS, + long_description=__doc__, + packages=PACKAGES, + zip_safe=False, + classifiers=CLASSIFIERS, + install_requires=INSTALL_REQUIRES, + include_package_data=True, + package_dir={"": "src"}, + package_data={"test-data-generation": ["py.typed"]}, + ) diff --git a/tsconfig-eslint.json b/tsconfig-eslint.json index 4468d4f6fd4..541feb786c0 100644 --- a/tsconfig-eslint.json +++ b/tsconfig-eslint.json @@ -19,6 +19,7 @@ "labware-designer/typings", "labware-library/src", "labware-library/typings", + "opentrons-ai-client/src", "shared-data/deck", "shared-data/js", "shared-data/protocol", diff --git a/usb-bridge/node-client/webpack.config.js b/usb-bridge/node-client/webpack.config.js deleted file mode 100644 index c01e57beb07..00000000000 --- a/usb-bridge/node-client/webpack.config.js +++ /dev/null @@ -1,26 +0,0 @@ -'use strict' - -const path = require('path') -const webpackMerge = require('webpack-merge') -const { DefinePlugin } = require('webpack') -const { nodeBaseConfig } = require('@opentrons/webpack-config') -const { versionForProject } = require('../../scripts/git-version') - -const ENTRY_INDEX = path.join(__dirname, 'src/index.ts') -const ENTRY_CLI = path.join(__dirname, 'src/cli.ts') -const OUTPUT_PATH = path.join(__dirname, 'lib') -const project = process.env.OPENTRONS_PROJECT ?? 'robot-stack' - -module.exports = async () => - webpackMerge(nodeBaseConfig, { - entry: { - index: ENTRY_INDEX, - cli: ENTRY_CLI, - }, - output: { path: OUTPUT_PATH }, - plugins: [ - new DefinePlugin({ - _PKG_VERSION_: JSON.stringify(await versionForProject(project)), - }), - ], - }) diff --git a/yarn.lock b/yarn.lock index fe7459d12e4..9b1d8cc5d27 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2187,13 +2187,6 @@ lodash "^4.17.15" tmp-promise "^3.0.2" -"@mapbox/hast-util-table-cell-style@^0.1.3": - version "0.1.3" - resolved "https://registry.yarnpkg.com/@mapbox/hast-util-table-cell-style/-/hast-util-table-cell-style-0.1.3.tgz#5b7166ae01297d72216932b245e4b2f0b642dca6" - integrity sha512-QsEsh5YaDvHoMQ2YHdvZy2iDnU3GgKVBTcHf6cILyoWDZtPSdlG444pL/ioPYO/GpXSfODBb9sefEetfC4v9oA== - dependencies: - unist-util-visit "^1.3.0" - "@mdx-js/react@^2.1.5": version "2.3.0" resolved "https://registry.yarnpkg.com/@mdx-js/react/-/react-2.3.0.tgz#4208bd6d70f0d0831def28ef28c26149b03180b3" @@ -2413,16 +2406,15 @@ react-hook-form "7.50.1" react-i18next "13.5.0" react-intersection-observer "^8.33.1" + react-markdown "9.0.1" react-redux "8.1.2" react-router-dom "5.3.4" react-select "5.4.0" - react-simple-keyboard "^3.4.187" + react-simple-keyboard "^3.7.0" react-viewport-list "6.3.0" redux "4.0.5" redux-observable "1.1.0" redux-thunk "2.3.0" - remark "9.0.0" - remark-react "4.0.3" reselect "4.0.0" rxjs "^6.5.1" semver "5.5.0" @@ -3873,7 +3865,7 @@ resolved "https://registry.yarnpkg.com/@types/dateformat/-/dateformat-3.0.1.tgz#98d747a2e5e9a56070c6bf14e27bff56204e34cc" integrity sha512-KlPPdikagvL6ELjWsljbyDIPzNCeliYkqRpI+zea99vBBbCIA5JNshZAwQKTON139c87y9qvTFVgkFd14rtS4g== -"@types/debug@^4.1.6": +"@types/debug@^4.0.0", "@types/debug@^4.1.6": version "4.1.12" resolved "https://registry.yarnpkg.com/@types/debug/-/debug-4.1.12.tgz#a155f21690871953410df4b6b6f53187f0500917" integrity sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ== @@ -3910,6 +3902,13 @@ resolved "https://registry.yarnpkg.com/@types/escodegen/-/escodegen-0.0.6.tgz#5230a9ce796e042cda6f086dbf19f22ea330659c" integrity sha512-AjwI4MvWx3HAOaZqYsjKWyEObT9lcVV0Y0V8nXo6cXzN8ZiMxVhf6F3d/UNvXVGKrEzL/Dluc5p+y9GkzlTWig== +"@types/estree-jsx@^1.0.0": + version "1.0.5" + resolved "https://registry.yarnpkg.com/@types/estree-jsx/-/estree-jsx-1.0.5.tgz#858a88ea20f34fe65111f005a689fa1ebf70dc18" + integrity sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg== + dependencies: + "@types/estree" "*" + "@types/estree@*", "@types/estree@1.0.5", "@types/estree@^1.0.0": version "1.0.5" resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.5.tgz#a6ce3e556e00fd9895dd872dd172ad0d4bd687f4" @@ -3985,6 +3984,13 @@ dependencies: "@types/node" "*" +"@types/hast@^3.0.0": + version "3.0.4" + resolved "https://registry.yarnpkg.com/@types/hast/-/hast-3.0.4.tgz#1d6b39993b82cea6ad783945b0508c25903e15aa" + integrity sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ== + dependencies: + "@types/unist" "*" + "@types/history@^4.7.11": version "4.7.11" resolved "https://registry.yarnpkg.com/@types/history/-/history-4.7.11.tgz#56588b17ae8f50c53983a524fc3cc47437969d64" @@ -4056,6 +4062,13 @@ resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.202.tgz#f09dbd2fb082d507178b2f2a5c7e74bd72ff98f8" integrity sha512-OvlIYQK9tNneDlS0VN54LLd5uiPCBOp7gS5Z0f1mjoJYBrtStzgmJBxONW3U6OZqdtNzZPmn9BS/7WI7BFFcFQ== +"@types/mdast@^4.0.0": + version "4.0.3" + resolved "https://registry.yarnpkg.com/@types/mdast/-/mdast-4.0.3.tgz#1e011ff013566e919a4232d1701ad30d70cab333" + integrity sha512-LsjtqsyF+d2/yFOYaN22dHZI1Cpwkrj+g06G8+qtUKlhovPW89YhqSnfKtMbkgmEtYpH2gydRNULd6y8mciAFg== + dependencies: + "@types/unist" "*" + "@types/mdx@^2.0.0": version "2.0.11" resolved "https://registry.yarnpkg.com/@types/mdx/-/mdx-2.0.11.tgz#21f4c166ed0e0a3a733869ba04cd8daea9834b8e" @@ -4595,7 +4608,7 @@ "@typescript-eslint/types" "6.21.0" eslint-visitor-keys "^3.4.1" -"@ungap/structured-clone@^1.2.0": +"@ungap/structured-clone@^1.0.0", "@ungap/structured-clone@^1.2.0": version "1.2.0" resolved "https://registry.yarnpkg.com/@ungap/structured-clone/-/structured-clone-1.2.0.tgz#756641adb587851b5ccb3e095daf27ae581c8406" integrity sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ== @@ -5728,6 +5741,11 @@ bail@^1.0.0: resolved "https://registry.yarnpkg.com/bail/-/bail-1.0.5.tgz#b6fa133404a392cbc1f8c4bf63f5953351e7a776" integrity sha512-xFbRxM1tahm08yHBP16MMjVUAvDaBMD38zsM9EMAUN61omwLmKlOpB/Zku5QkjZ8TZ4vn53pj+t518cH0S03RQ== +bail@^2.0.0: + version "2.0.2" + resolved "https://registry.yarnpkg.com/bail/-/bail-2.0.2.tgz#d26f5cd8fe5d6f832a31517b9f7c356040ba6d5d" + integrity sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw== + balanced-match@^1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" @@ -6481,6 +6499,11 @@ ccount@^1.0.0, ccount@^1.0.3: resolved "https://registry.yarnpkg.com/ccount/-/ccount-1.1.0.tgz#246687debb6014735131be8abab2d93898f8d043" integrity sha512-vlNK021QdI7PNeiUh/lKkC/mNHHfV0m/Ad5JoI0TYtlBnJAslM/JIkm/tGC88bkLIwO6OQ5uV6ztS6kVAtCDlg== +ccount@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/ccount/-/ccount-2.0.1.tgz#17a3bf82302e0870d6da43a01311a8bc02a3ecf5" + integrity sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg== + chai@^4.3.10: version "4.4.1" resolved "https://registry.yarnpkg.com/chai/-/chai-4.4.1.tgz#3603fa6eba35425b0f2ac91a009fe924106e50d1" @@ -6535,21 +6558,41 @@ character-entities-html4@^1.0.0: resolved "https://registry.yarnpkg.com/character-entities-html4/-/character-entities-html4-1.1.4.tgz#0e64b0a3753ddbf1fdc044c5fd01d0199a02e125" integrity sha512-HRcDxZuZqMx3/a+qrzxdBKBPUpxWEq9xw2OPZ3a/174ihfrQKVsFhqtthBInFy1zZ9GgZyFXOatNujm8M+El3g== +character-entities-html4@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/character-entities-html4/-/character-entities-html4-2.1.0.tgz#1f1adb940c971a4b22ba39ddca6b618dc6e56b2b" + integrity sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA== + character-entities-legacy@^1.0.0: version "1.1.4" resolved "https://registry.yarnpkg.com/character-entities-legacy/-/character-entities-legacy-1.1.4.tgz#94bc1845dce70a5bb9d2ecc748725661293d8fc1" integrity sha512-3Xnr+7ZFS1uxeiUDvV02wQ+QDbc55o97tIV5zHScSPJpcLm/r0DFPcoY3tYRp+VZukxuMeKgXYmsXQHO05zQeA== +character-entities-legacy@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz#76bc83a90738901d7bc223a9e93759fdd560125b" + integrity sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ== + character-entities@^1.0.0: version "1.2.4" resolved "https://registry.yarnpkg.com/character-entities/-/character-entities-1.2.4.tgz#e12c3939b7eaf4e5b15e7ad4c5e28e1d48c5b16b" integrity sha512-iBMyeEHxfVnIakwOuDXpVkc54HijNgCyQB2w0VfGQThle6NXn50zU6V/u+LDhxHcDUPojn6Kpga3PTAD8W1bQw== +character-entities@^2.0.0: + version "2.0.2" + resolved "https://registry.yarnpkg.com/character-entities/-/character-entities-2.0.2.tgz#2d09c2e72cd9523076ccb21157dff66ad43fcc22" + integrity sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ== + character-reference-invalid@^1.0.0: version "1.1.4" resolved "https://registry.yarnpkg.com/character-reference-invalid/-/character-reference-invalid-1.1.4.tgz#083329cda0eae272ab3dbbf37e9a382c13af1560" integrity sha512-mKKUkUbhPpQlCOfIuZkvSEgktjPFIsZKRRbC6KWVEMvlzblj3i3asQv5ODsrwt0N3pHAEvjP8KTQPHkp0+6jOg== +character-reference-invalid@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz#85c66b041e43b47210faf401278abf808ac45cb9" + integrity sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw== + check-error@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/check-error/-/check-error-1.0.3.tgz#a6502e4312a7ee969f646e83bb3ddd56281bd694" @@ -6833,7 +6876,7 @@ code-point-at@^1.0.0: resolved "https://registry.yarnpkg.com/code-point-at/-/code-point-at-1.1.0.tgz#0d070b4d043a5bea33a2f1a40e2edb3d9a4ccf77" integrity sha512-RpAVKQA5T63xEj6/giIbUEtZwJ4UFIc3ZtvEkiaUERylqe8xb5IvqcgOurZLahv93CLKfxcw5YI+DZcUBRyLXA== -collapse-white-space@^1.0.0, collapse-white-space@^1.0.2: +collapse-white-space@^1.0.2: version "1.0.6" resolved "https://registry.yarnpkg.com/collapse-white-space/-/collapse-white-space-1.0.6.tgz#e63629c0016665792060dbbeb79c42239d2c5287" integrity sha512-jEovNnrhMuqyCcjfEJA56v0Xq8SkIoPKDyaHahwo3POf4qcSXqMYuwNcOTzp74vTsR9Tn08z4MxWqAhcekogkQ== @@ -6921,6 +6964,11 @@ comma-separated-tokens@^1.0.0, comma-separated-tokens@^1.0.1: resolved "https://registry.yarnpkg.com/comma-separated-tokens/-/comma-separated-tokens-1.0.8.tgz#632b80b6117867a158f1080ad498b2fbe7e3f5ea" integrity sha512-GHuDRO12Sypu2cV70d1dkA2EUmXHgntrzbpvOB+Qy+49ypNfGgFQIC2fhhXbnyrJRynDCAARsT7Ou0M6hirpfw== +comma-separated-tokens@^2.0.0: + version "2.0.3" + resolved "https://registry.yarnpkg.com/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz#4e89c9458acb61bc8fef19f4529973b2392839ee" + integrity sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg== + commander@2.17.x: version "2.17.1" resolved "https://registry.yarnpkg.com/commander/-/commander-2.17.1.tgz#bd77ab7de6de94205ceacc72f1716d29f20a77bf" @@ -7922,6 +7970,13 @@ decimal.js@^10.2.1: resolved "https://registry.yarnpkg.com/decimal.js/-/decimal.js-10.4.3.tgz#1044092884d245d1b7f65725fa4ad4c6f781cc23" integrity sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA== +decode-named-character-reference@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/decode-named-character-reference/-/decode-named-character-reference-1.0.2.tgz#daabac9690874c394c81e4162a0304b35d824f0e" + integrity sha512-O8x12RzrUF8xyVcY0KJowWsmaJxQbmy0/EtnNtHRpsOcT7dFk5W598coHqBVpmWo1oQQfsCqfCmkZN5DJrZVdg== + dependencies: + character-entities "^2.0.0" + decode-uri-component@^0.2.0: version "0.2.2" resolved "https://registry.yarnpkg.com/decode-uri-component/-/decode-uri-component-0.2.2.tgz#e69dbe25d37941171dd540e024c444cd5188e1e9" @@ -8210,7 +8265,7 @@ deprecation@^2.0.0: resolved "https://registry.yarnpkg.com/deprecation/-/deprecation-2.3.1.tgz#6368cbdb40abf3373b525ac87e4a260c3a700919" integrity sha512-xmHIy4F3scKVwMsQ4WnVaS8bHOx0DmVwRywosKhaILI0ywMDWPtBSku2HNxRvF7jtwDRsoEwYQSfbxj8b7RlJQ== -dequal@^2.0.2, dequal@^2.0.3: +dequal@^2.0.0, dequal@^2.0.2, dequal@^2.0.3: version "2.0.3" resolved "https://registry.yarnpkg.com/dequal/-/dequal-2.0.3.tgz#2644214f1997d39ed0ee0ece72335490a7ac67be" integrity sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA== @@ -8233,13 +8288,6 @@ destroy@~1.0.4: resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.0.4.tgz#978857442c44749e4206613e37946205826abd80" integrity sha512-3NdhDuEXnfun/z7x9GOElY49LoqVHoGScmOKwmxhsS8N5Y+Z8KyPPDnaSzqWgYt/ji4mqwfTS34Htrk0zPIXVg== -detab@^2.0.0: - version "2.0.4" - resolved "https://registry.yarnpkg.com/detab/-/detab-2.0.4.tgz#b927892069aff405fbb9a186fe97a44a92a94b43" - integrity sha512-8zdsQA5bIkoRECvCrNKPla84lyoR7DSAyf7p0YgXzBO9PDJx8KntPUay7NS6yp+KdxdVtiE5SpHKtbp2ZQyA9g== - dependencies: - repeat-string "^1.5.4" - detect-file@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/detect-file/-/detect-file-1.0.0.tgz#f0d66d03672a825cb1b73bdb3fe62310c8e552b7" @@ -8355,6 +8403,13 @@ detective-typescript@^5.8.0: node-source-walk "^4.2.0" typescript "^3.8.3" +devlop@^1.0.0, devlop@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/devlop/-/devlop-1.1.0.tgz#4db7c2ca4dc6e0e834c30be70c94bbc976dc7018" + integrity sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA== + dependencies: + dequal "^2.0.0" + diagnostics@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/diagnostics/-/diagnostics-1.1.1.tgz#cab6ac33df70c9d9a727490ae43ac995a769b22a" @@ -9531,6 +9586,11 @@ estraverse@^5.1.0, estraverse@^5.2.0, estraverse@^5.3.0: resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-5.3.0.tgz#2eea5290702f26ab8fe5370370ff86c965d21123" integrity sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA== +estree-util-is-identifier-name@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz#0b5ef4c4ff13508b34dcd01ecfa945f61fce5dbd" + integrity sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg== + estree-walker@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-1.0.1.tgz#31bc5d612c96b704106b477e6dd5d8aa138cb700" @@ -11208,19 +11268,6 @@ hasown@^2.0.0, hasown@^2.0.1: dependencies: function-bind "^1.1.2" -hast-to-hyperscript@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/hast-to-hyperscript/-/hast-to-hyperscript-4.0.0.tgz#3eb25483ec72a8e9a71e4b1ad7eb8f7c86f755db" - integrity sha512-4kOn4ihjDJTQg7B53ZcZ6NyExtTeG3hLNZv6rSKhq4haQvD52zCllE+49iLiC1VWuc4DbHmt96FHPGlHbslZqQ== - dependencies: - comma-separated-tokens "^1.0.0" - is-nan "^1.2.1" - kebab-case "^1.0.0" - property-information "^3.0.0" - space-separated-tokens "^1.0.0" - trim "0.0.1" - unist-util-is "^2.0.0" - hast-util-from-parse5@^5.0.0: version "5.0.3" resolved "https://registry.yarnpkg.com/hast-util-from-parse5/-/hast-util-from-parse5-5.0.3.tgz#3089dc0ee2ccf6ec8bc416919b51a54a589e097c" @@ -11247,13 +11294,6 @@ hast-util-parse-selector@^2.0.0: resolved "https://registry.yarnpkg.com/hast-util-parse-selector/-/hast-util-parse-selector-2.2.5.tgz#d57c23f4da16ae3c63b3b6ca4616683313499c3a" integrity sha512-7j6mrk/qqkSehsM92wQjdIgWM2/BW61u/53G6xmC8i1OmEdKLHbk419QKQUjz6LglWsfqoiHmyMRkP1BGjecNQ== -hast-util-sanitize@^1.0.0: - version "1.3.1" - resolved "https://registry.yarnpkg.com/hast-util-sanitize/-/hast-util-sanitize-1.3.1.tgz#4e60d66336bd67e52354d581967467029a933f2e" - integrity sha512-AIeKHuHx0Wk45nSkGVa2/ujQYTksnDl8gmmKo/mwQi7ag7IBZ8cM3nJ2G86SajbjGP/HRpud6kMkPtcM2i0Tlw== - dependencies: - xtend "^4.0.1" - hast-util-to-html@^6.0.0: version "6.1.0" resolved "https://registry.yarnpkg.com/hast-util-to-html/-/hast-util-to-html-6.1.0.tgz#86bcd19c3bd46af456984f8f34db16298c2b10b0" @@ -11270,11 +11310,39 @@ hast-util-to-html@^6.0.0: unist-util-is "^3.0.0" xtend "^4.0.1" +hast-util-to-jsx-runtime@^2.0.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.0.tgz#3ed27caf8dc175080117706bf7269404a0aa4f7c" + integrity sha512-H/y0+IWPdsLLS738P8tDnrQ8Z+dj12zQQ6WC11TIM21C8WFVoIxcqWXf2H3hiTVZjF1AWqoimGwrTWecWrnmRQ== + dependencies: + "@types/estree" "^1.0.0" + "@types/hast" "^3.0.0" + "@types/unist" "^3.0.0" + comma-separated-tokens "^2.0.0" + devlop "^1.0.0" + estree-util-is-identifier-name "^3.0.0" + hast-util-whitespace "^3.0.0" + mdast-util-mdx-expression "^2.0.0" + mdast-util-mdx-jsx "^3.0.0" + mdast-util-mdxjs-esm "^2.0.0" + property-information "^6.0.0" + space-separated-tokens "^2.0.0" + style-to-object "^1.0.0" + unist-util-position "^5.0.0" + vfile-message "^4.0.0" + hast-util-whitespace@^1.0.0: version "1.0.4" resolved "https://registry.yarnpkg.com/hast-util-whitespace/-/hast-util-whitespace-1.0.4.tgz#e4fe77c4a9ae1cb2e6c25e02df0043d0164f6e41" integrity sha512-I5GTdSfhYfAPNztx2xJRQpG8cuDSNt599/7YUn7Gx/WxNMsG+a835k97TDkFgk123cwjfwINaZknkKkphx/f2A== +hast-util-whitespace@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz#7778ed9d3c92dd9e8c5c8f648a49c21fc51cb621" + integrity sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw== + dependencies: + "@types/hast" "^3.0.0" + hastscript@^5.0.0: version "5.1.2" resolved "https://registry.yarnpkg.com/hastscript/-/hastscript-5.1.2.tgz#bde2c2e56d04c62dd24e8c5df288d050a355fb8a" @@ -11435,6 +11503,11 @@ html-tags@^3.0.0, html-tags@^3.1.0: resolved "https://registry.yarnpkg.com/html-tags/-/html-tags-3.3.1.tgz#a04026a18c882e4bba8a01a3d39cfe465d40b5ce" integrity sha512-ztqyC3kLto0e9WbNp0aeP+M3kTt+nbaIveGmUxAtZa+8iFgKLUOD4YKM5j+f3QD89bra7UeumolZHKuOXnTmeQ== +html-url-attributes@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/html-url-attributes/-/html-url-attributes-3.0.0.tgz#fc4abf0c3fb437e2329c678b80abb3c62cff6f08" + integrity sha512-/sXbVCWayk6GDVg3ctOX6nxaVj7So40FcFAnWlWGNAB1LpYKcV5Cd10APjPjW80O7zYW2MsjBV4zZ7IZO5fVow== + html-void-elements@^1.0.0: version "1.0.5" resolved "https://registry.yarnpkg.com/html-void-elements/-/html-void-elements-1.0.5.tgz#ce9159494e86d95e45795b166c2021c2cfca4483" @@ -11807,6 +11880,11 @@ ini@^1.3.2, ini@^1.3.4, ini@^1.3.5, ini@~1.3.0: resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.8.tgz#a29da425b48806f34767a4efce397269af28432c" integrity sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew== +inline-style-parser@0.2.3: + version "0.2.3" + resolved "https://registry.yarnpkg.com/inline-style-parser/-/inline-style-parser-0.2.3.tgz#e35c5fb45f3a83ed7849fe487336eb7efa25971c" + integrity sha512-qlD8YNDqyTKTyuITrDOffsl6Tdhv+UC4hcdAVuQsK4IMQ99nSgd1MIA/Q+jQYoh9r3hVUXhYh7urSRmXPkW04g== + interactjs@^1.10.17: version "1.10.26" resolved "https://registry.yarnpkg.com/interactjs/-/interactjs-1.10.26.tgz#ad009a46ee3610cb75de6aec22ea6cc0b0e277e2" @@ -11906,6 +11984,11 @@ is-alphabetical@^1.0.0: resolved "https://registry.yarnpkg.com/is-alphabetical/-/is-alphabetical-1.0.4.tgz#9e7d6b94916be22153745d184c298cbf986a686d" integrity sha512-DwzsA04LQ10FHTZuL0/grVDk4rFoVH1pjAToYwBrHSxcrBIGQuXrQMtD5U1b0U2XVgKZCTLLP8u2Qxqhy3l2Vg== +is-alphabetical@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/is-alphabetical/-/is-alphabetical-2.0.1.tgz#01072053ea7c1036df3c7d19a6daaec7f19e789b" + integrity sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ== + is-alphanumeric@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/is-alphanumeric/-/is-alphanumeric-1.0.0.tgz#4a9cef71daf4c001c1d81d63d140cf53fd6889f4" @@ -11919,6 +12002,14 @@ is-alphanumerical@^1.0.0: is-alphabetical "^1.0.0" is-decimal "^1.0.0" +is-alphanumerical@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz#7c03fbe96e3e931113e57f964b0a368cc2dfd875" + integrity sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw== + dependencies: + is-alphabetical "^2.0.0" + is-decimal "^2.0.0" + is-arguments@^1.0.4, is-arguments@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/is-arguments/-/is-arguments-1.1.1.tgz#15b3f88fda01f2a97fec84ca761a560f123efa9b" @@ -11981,7 +12072,7 @@ is-boolean-object@^1.1.0: call-bind "^1.0.2" has-tostringtag "^1.0.0" -is-buffer@^1.1.4, is-buffer@^1.1.5: +is-buffer@^1.1.5: version "1.1.6" resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-1.1.6.tgz#efaa2ea9daa0d7ab2ea13a97b2b8ad51fefbe8be" integrity sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w== @@ -12062,6 +12153,11 @@ is-decimal@^1.0.0, is-decimal@^1.0.2: resolved "https://registry.yarnpkg.com/is-decimal/-/is-decimal-1.0.4.tgz#65a3a5958a1c5b63a706e1b333d7cd9f630d3fa5" integrity sha512-RGdriMmQQvZ2aqaQq3awNA6dCGtKpiDFcOzrTWrDAT2MiWrKQVPmxLGHl7Y2nNu6led0kEyoX0enY0qXYsv9zw== +is-decimal@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/is-decimal/-/is-decimal-2.0.1.tgz#9469d2dc190d0214fd87d78b78caecc0cc14eef7" + integrity sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A== + is-deflate@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/is-deflate/-/is-deflate-1.0.0.tgz#c862901c3c161fb09dac7cdc7e784f80e98f2f14" @@ -12165,6 +12261,11 @@ is-hexadecimal@^1.0.0: resolved "https://registry.yarnpkg.com/is-hexadecimal/-/is-hexadecimal-1.0.4.tgz#cc35c97588da4bd49a8eedd6bc4082d44dcb23a7" integrity sha512-gyPJuv83bHMpocVYoqof5VDiZveEoGoFL8m3BXNb2VW8Xs+rz9kqO8LOQ5DH6EsuvilT1ApazU0pyl+ytbPtlw== +is-hexadecimal@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz#86b5bf668fca307498d319dfc03289d781a90027" + integrity sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg== + is-installed-globally@^0.3.2: version "0.3.2" resolved "https://registry.yarnpkg.com/is-installed-globally/-/is-installed-globally-0.3.2.tgz#fd3efa79ee670d1187233182d5b0a1dd00313141" @@ -12200,7 +12301,7 @@ is-module@^1.0.0: resolved "https://registry.yarnpkg.com/is-module/-/is-module-1.0.0.tgz#3258fb69f78c14d5b815d664336b4cffb6441591" integrity sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g== -is-nan@^1.2.1, is-nan@^1.3.2: +is-nan@^1.3.2: version "1.3.2" resolved "https://registry.yarnpkg.com/is-nan/-/is-nan-1.3.2.tgz#043a54adea31748b55b6cd4e09aadafa69bd9e1d" integrity sha512-E+zBKpQ2t6MEo1VsonYmluk9NxGrbzpeeLC2xIViuO2EjU2xsXsBPwTr3Ykv9l08UYEVEdWeRZNouaZqF6RN0w== @@ -12293,6 +12394,11 @@ is-plain-obj@^2.0.0: resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-2.1.0.tgz#45e42e37fccf1f40da8e5f76ee21515840c09287" integrity sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA== +is-plain-obj@^4.0.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-4.1.0.tgz#d65025edec3657ce032fd7db63c97883eaed71f0" + integrity sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg== + is-plain-object@5.0.0, is-plain-object@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-5.0.0.tgz#4427f50ab3429e9025ea7d52e9043a9ef4159344" @@ -12922,11 +13028,6 @@ jszip@^3.1.0: readable-stream "~2.3.6" setimmediate "^1.0.5" -kebab-case@^1.0.0: - version "1.0.2" - resolved "https://registry.yarnpkg.com/kebab-case/-/kebab-case-1.0.2.tgz#5eac97d5d220acf606d40e3c0ecfea21f1f9e1eb" - integrity sha512-7n6wXq4gNgBELfDCpzKc+mRrZFs7D+wgfF5WRFLNAr4DA/qtr9Js8uOAVAfHhuLMfAcQ0pRKqbpjx+TcJVdE1Q== - keyboardevent-from-electron-accelerator@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/keyboardevent-from-electron-accelerator/-/keyboardevent-from-electron-accelerator-2.0.0.tgz#ace21b1aa4e47148815d160057f9edb66567c50c" @@ -13310,6 +13411,11 @@ longest-streak@^2.0.1: resolved "https://registry.yarnpkg.com/longest-streak/-/longest-streak-2.0.4.tgz#b8599957da5b5dab64dee3fe316fa774597d90e4" integrity sha512-vM6rUVCVUJJt33bnmHiZEvr7wPT78ztX7rojL+LW51bHtLh6HTjx84LA5W4+oa6aKEJA7jJu5LR6vQRBpA5DVg== +longest-streak@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/longest-streak/-/longest-streak-3.1.0.tgz#62fa67cd958742a1574af9f39866364102d90cd4" + integrity sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g== + longest@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/longest/-/longest-1.0.1.tgz#30a0b2da38f73770e8294a0d22e6625ed77d0097" @@ -13607,13 +13713,6 @@ mdast-util-compact@^1.0.0: dependencies: unist-util-visit "^1.1.0" -mdast-util-definitions@^1.2.0: - version "1.2.5" - resolved "https://registry.yarnpkg.com/mdast-util-definitions/-/mdast-util-definitions-1.2.5.tgz#3fe622a4171c774ebd06f11e9f8af7ec53ea5c74" - integrity sha512-CJXEdoLfiISCDc2JB6QLb79pYfI6+GcIH+W2ox9nMc7od0Pz+bovcHsiq29xAQY6ayqe/9CsK2VzkSJdg1pFYA== - dependencies: - unist-util-visit "^1.0.0" - mdast-util-definitions@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/mdast-util-definitions/-/mdast-util-definitions-4.0.0.tgz#c5c1a84db799173b4dcf7643cda999e440c24db2" @@ -13621,28 +13720,116 @@ mdast-util-definitions@^4.0.0: dependencies: unist-util-visit "^2.0.0" -mdast-util-to-hast@^3.0.0: - version "3.0.4" - resolved "https://registry.yarnpkg.com/mdast-util-to-hast/-/mdast-util-to-hast-3.0.4.tgz#132001b266031192348d3366a6b011f28e54dc40" - integrity sha512-/eIbly2YmyVgpJNo+bFLLMCI1XgolO/Ffowhf+pHDq3X4/V6FntC9sGQCDLM147eTS+uSXv5dRzJyFn+o0tazA== +mdast-util-from-markdown@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.0.tgz#52f14815ec291ed061f2922fd14d6689c810cb88" + integrity sha512-n7MTOr/z+8NAX/wmhhDji8O3bRvPTV/U0oTCaZJkjhPSKTPhS3xufVhKGF8s1pJ7Ox4QgoIU7KHseh09S+9rTA== dependencies: - collapse-white-space "^1.0.0" - detab "^2.0.0" - mdast-util-definitions "^1.2.0" - mdurl "^1.0.1" - trim "0.0.1" - trim-lines "^1.0.0" - unist-builder "^1.0.1" - unist-util-generated "^1.1.0" - unist-util-position "^3.0.0" - unist-util-visit "^1.1.0" - xtend "^4.0.1" + "@types/mdast" "^4.0.0" + "@types/unist" "^3.0.0" + decode-named-character-reference "^1.0.0" + devlop "^1.0.0" + mdast-util-to-string "^4.0.0" + micromark "^4.0.0" + micromark-util-decode-numeric-character-reference "^2.0.0" + micromark-util-decode-string "^2.0.0" + micromark-util-normalize-identifier "^2.0.0" + micromark-util-symbol "^2.0.0" + micromark-util-types "^2.0.0" + unist-util-stringify-position "^4.0.0" + +mdast-util-mdx-expression@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.0.tgz#4968b73724d320a379110d853e943a501bfd9d87" + integrity sha512-fGCu8eWdKUKNu5mohVGkhBXCXGnOTLuFqOvGMvdikr+J1w7lDJgxThOKpwRWzzbyXAU2hhSwsmssOY4yTokluw== + dependencies: + "@types/estree-jsx" "^1.0.0" + "@types/hast" "^3.0.0" + "@types/mdast" "^4.0.0" + devlop "^1.0.0" + mdast-util-from-markdown "^2.0.0" + mdast-util-to-markdown "^2.0.0" + +mdast-util-mdx-jsx@^3.0.0: + version "3.1.2" + resolved "https://registry.yarnpkg.com/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.1.2.tgz#daae777c72f9c4a106592e3025aa50fb26068e1b" + integrity sha512-eKMQDeywY2wlHc97k5eD8VC+9ASMjN8ItEZQNGwJ6E0XWKiW/Z0V5/H8pvoXUf+y+Mj0VIgeRRbujBmFn4FTyA== + dependencies: + "@types/estree-jsx" "^1.0.0" + "@types/hast" "^3.0.0" + "@types/mdast" "^4.0.0" + "@types/unist" "^3.0.0" + ccount "^2.0.0" + devlop "^1.1.0" + mdast-util-from-markdown "^2.0.0" + mdast-util-to-markdown "^2.0.0" + parse-entities "^4.0.0" + stringify-entities "^4.0.0" + unist-util-remove-position "^5.0.0" + unist-util-stringify-position "^4.0.0" + vfile-message "^4.0.0" + +mdast-util-mdxjs-esm@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz#019cfbe757ad62dd557db35a695e7314bcc9fa97" + integrity sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg== + dependencies: + "@types/estree-jsx" "^1.0.0" + "@types/hast" "^3.0.0" + "@types/mdast" "^4.0.0" + devlop "^1.0.0" + mdast-util-from-markdown "^2.0.0" + mdast-util-to-markdown "^2.0.0" + +mdast-util-phrasing@^4.0.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz#7cc0a8dec30eaf04b7b1a9661a92adb3382aa6e3" + integrity sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w== + dependencies: + "@types/mdast" "^4.0.0" + unist-util-is "^6.0.0" + +mdast-util-to-hast@^13.0.0: + version "13.1.0" + resolved "https://registry.yarnpkg.com/mdast-util-to-hast/-/mdast-util-to-hast-13.1.0.tgz#1ae54d903150a10fe04d59f03b2b95fd210b2124" + integrity sha512-/e2l/6+OdGp/FB+ctrJ9Avz71AN/GRH3oi/3KAx/kMnoUsD6q0woXlDT8lLEeViVKE7oZxE7RXzvO3T8kF2/sA== + dependencies: + "@types/hast" "^3.0.0" + "@types/mdast" "^4.0.0" + "@ungap/structured-clone" "^1.0.0" + devlop "^1.0.0" + micromark-util-sanitize-uri "^2.0.0" + trim-lines "^3.0.0" + unist-util-position "^5.0.0" + unist-util-visit "^5.0.0" + vfile "^6.0.0" + +mdast-util-to-markdown@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.0.tgz#9813f1d6e0cdaac7c244ec8c6dabfdb2102ea2b4" + integrity sha512-SR2VnIEdVNCJbP6y7kVTJgPLifdr8WEU440fQec7qHoHOUz/oJ2jmNRqdDQ3rbiStOXb2mCDGTuwsK5OPUgYlQ== + dependencies: + "@types/mdast" "^4.0.0" + "@types/unist" "^3.0.0" + longest-streak "^3.0.0" + mdast-util-phrasing "^4.0.0" + mdast-util-to-string "^4.0.0" + micromark-util-decode-string "^2.0.0" + unist-util-visit "^5.0.0" + zwitch "^2.0.0" mdast-util-to-string@^1.0.0: version "1.1.0" resolved "https://registry.yarnpkg.com/mdast-util-to-string/-/mdast-util-to-string-1.1.0.tgz#27055500103f51637bd07d01da01eb1967a43527" integrity sha512-jVU0Nr2B9X3MU4tSK7JP1CMkSvOj7X5l/GboG1tKRw52lLF1x2Ju92Ms9tNetCcbfX3hzlM73zYo2NKkWSfF/A== +mdast-util-to-string@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz#7a5121475556a04e7eddeb67b264aae79d312814" + integrity sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg== + dependencies: + "@types/mdast" "^4.0.0" + mdn-data@2.0.14: version "2.0.14" resolved "https://registry.yarnpkg.com/mdn-data/-/mdn-data-2.0.14.tgz#7113fc4281917d63ce29b43446f701e68c25ba50" @@ -13667,11 +13854,6 @@ mdns-js@1.0.1: dns-js "~0.2.1" semver "^5.4.1" -mdurl@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/mdurl/-/mdurl-1.0.1.tgz#fe85b2ec75a59037f2adfec100fd6c601761152e" - integrity sha512-/sKlQJCBYVY9Ers9hqzKou4H6V5UWc/M59TH2dvkt+84itfnq7uFOMLpOiOS4ujvHP4etln18fmIxA5R5fll0g== - media-typer@0.3.0: version "0.3.0" resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748" @@ -13764,6 +13946,200 @@ methods@~1.1.2: resolved "https://registry.yarnpkg.com/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee" integrity sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w== +micromark-core-commonmark@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/micromark-core-commonmark/-/micromark-core-commonmark-2.0.0.tgz#50740201f0ee78c12a675bf3e68ffebc0bf931a3" + integrity sha512-jThOz/pVmAYUtkroV3D5c1osFXAMv9e0ypGDOIZuCeAe91/sD6BoE2Sjzt30yuXtwOYUmySOhMas/PVyh02itA== + dependencies: + decode-named-character-reference "^1.0.0" + devlop "^1.0.0" + micromark-factory-destination "^2.0.0" + micromark-factory-label "^2.0.0" + micromark-factory-space "^2.0.0" + micromark-factory-title "^2.0.0" + micromark-factory-whitespace "^2.0.0" + micromark-util-character "^2.0.0" + micromark-util-chunked "^2.0.0" + micromark-util-classify-character "^2.0.0" + micromark-util-html-tag-name "^2.0.0" + micromark-util-normalize-identifier "^2.0.0" + micromark-util-resolve-all "^2.0.0" + micromark-util-subtokenize "^2.0.0" + micromark-util-symbol "^2.0.0" + micromark-util-types "^2.0.0" + +micromark-factory-destination@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/micromark-factory-destination/-/micromark-factory-destination-2.0.0.tgz#857c94debd2c873cba34e0445ab26b74f6a6ec07" + integrity sha512-j9DGrQLm/Uhl2tCzcbLhy5kXsgkHUrjJHg4fFAeoMRwJmJerT9aw4FEhIbZStWN8A3qMwOp1uzHr4UL8AInxtA== + dependencies: + micromark-util-character "^2.0.0" + micromark-util-symbol "^2.0.0" + micromark-util-types "^2.0.0" + +micromark-factory-label@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/micromark-factory-label/-/micromark-factory-label-2.0.0.tgz#17c5c2e66ce39ad6f4fc4cbf40d972f9096f726a" + integrity sha512-RR3i96ohZGde//4WSe/dJsxOX6vxIg9TimLAS3i4EhBAFx8Sm5SmqVfR8E87DPSR31nEAjZfbt91OMZWcNgdZw== + dependencies: + devlop "^1.0.0" + micromark-util-character "^2.0.0" + micromark-util-symbol "^2.0.0" + micromark-util-types "^2.0.0" + +micromark-factory-space@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/micromark-factory-space/-/micromark-factory-space-2.0.0.tgz#5e7afd5929c23b96566d0e1ae018ae4fcf81d030" + integrity sha512-TKr+LIDX2pkBJXFLzpyPyljzYK3MtmllMUMODTQJIUfDGncESaqB90db9IAUcz4AZAJFdd8U9zOp9ty1458rxg== + dependencies: + micromark-util-character "^2.0.0" + micromark-util-types "^2.0.0" + +micromark-factory-title@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/micromark-factory-title/-/micromark-factory-title-2.0.0.tgz#726140fc77892af524705d689e1cf06c8a83ea95" + integrity sha512-jY8CSxmpWLOxS+t8W+FG3Xigc0RDQA9bKMY/EwILvsesiRniiVMejYTE4wumNc2f4UbAa4WsHqe3J1QS1sli+A== + dependencies: + micromark-factory-space "^2.0.0" + micromark-util-character "^2.0.0" + micromark-util-symbol "^2.0.0" + micromark-util-types "^2.0.0" + +micromark-factory-whitespace@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.0.tgz#9e92eb0f5468083381f923d9653632b3cfb5f763" + integrity sha512-28kbwaBjc5yAI1XadbdPYHX/eDnqaUFVikLwrO7FDnKG7lpgxnvk/XGRhX/PN0mOZ+dBSZ+LgunHS+6tYQAzhA== + dependencies: + micromark-factory-space "^2.0.0" + micromark-util-character "^2.0.0" + micromark-util-symbol "^2.0.0" + micromark-util-types "^2.0.0" + +micromark-util-character@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/micromark-util-character/-/micromark-util-character-2.1.0.tgz#31320ace16b4644316f6bf057531689c71e2aee1" + integrity sha512-KvOVV+X1yLBfs9dCBSopq/+G1PcgT3lAK07mC4BzXi5E7ahzMAF8oIupDDJ6mievI6F+lAATkbQQlQixJfT3aQ== + dependencies: + micromark-util-symbol "^2.0.0" + micromark-util-types "^2.0.0" + +micromark-util-chunked@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/micromark-util-chunked/-/micromark-util-chunked-2.0.0.tgz#e51f4db85fb203a79dbfef23fd41b2f03dc2ef89" + integrity sha512-anK8SWmNphkXdaKgz5hJvGa7l00qmcaUQoMYsBwDlSKFKjc6gjGXPDw3FNL3Nbwq5L8gE+RCbGqTw49FK5Qyvg== + dependencies: + micromark-util-symbol "^2.0.0" + +micromark-util-classify-character@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/micromark-util-classify-character/-/micromark-util-classify-character-2.0.0.tgz#8c7537c20d0750b12df31f86e976d1d951165f34" + integrity sha512-S0ze2R9GH+fu41FA7pbSqNWObo/kzwf8rN/+IGlW/4tC6oACOs8B++bh+i9bVyNnwCcuksbFwsBme5OCKXCwIw== + dependencies: + micromark-util-character "^2.0.0" + micromark-util-symbol "^2.0.0" + micromark-util-types "^2.0.0" + +micromark-util-combine-extensions@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.0.tgz#75d6ab65c58b7403616db8d6b31315013bfb7ee5" + integrity sha512-vZZio48k7ON0fVS3CUgFatWHoKbbLTK/rT7pzpJ4Bjp5JjkZeasRfrS9wsBdDJK2cJLHMckXZdzPSSr1B8a4oQ== + dependencies: + micromark-util-chunked "^2.0.0" + micromark-util-types "^2.0.0" + +micromark-util-decode-numeric-character-reference@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.1.tgz#2698bbb38f2a9ba6310e359f99fcb2b35a0d2bd5" + integrity sha512-bmkNc7z8Wn6kgjZmVHOX3SowGmVdhYS7yBpMnuMnPzDq/6xwVA604DuOXMZTO1lvq01g+Adfa0pE2UKGlxL1XQ== + dependencies: + micromark-util-symbol "^2.0.0" + +micromark-util-decode-string@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/micromark-util-decode-string/-/micromark-util-decode-string-2.0.0.tgz#7dfa3a63c45aecaa17824e656bcdb01f9737154a" + integrity sha512-r4Sc6leeUTn3P6gk20aFMj2ntPwn6qpDZqWvYmAG6NgvFTIlj4WtrAudLi65qYoaGdXYViXYw2pkmn7QnIFasA== + dependencies: + decode-named-character-reference "^1.0.0" + micromark-util-character "^2.0.0" + micromark-util-decode-numeric-character-reference "^2.0.0" + micromark-util-symbol "^2.0.0" + +micromark-util-encode@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/micromark-util-encode/-/micromark-util-encode-2.0.0.tgz#0921ac7953dc3f1fd281e3d1932decfdb9382ab1" + integrity sha512-pS+ROfCXAGLWCOc8egcBvT0kf27GoWMqtdarNfDcjb6YLuV5cM3ioG45Ys2qOVqeqSbjaKg72vU+Wby3eddPsA== + +micromark-util-html-tag-name@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.0.tgz#ae34b01cbe063363847670284c6255bb12138ec4" + integrity sha512-xNn4Pqkj2puRhKdKTm8t1YHC/BAjx6CEwRFXntTaRf/x16aqka6ouVoutm+QdkISTlT7e2zU7U4ZdlDLJd2Mcw== + +micromark-util-normalize-identifier@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.0.tgz#91f9a4e65fe66cc80c53b35b0254ad67aa431d8b" + integrity sha512-2xhYT0sfo85FMrUPtHcPo2rrp1lwbDEEzpx7jiH2xXJLqBuy4H0GgXk5ToU8IEwoROtXuL8ND0ttVa4rNqYK3w== + dependencies: + micromark-util-symbol "^2.0.0" + +micromark-util-resolve-all@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.0.tgz#189656e7e1a53d0c86a38a652b284a252389f364" + integrity sha512-6KU6qO7DZ7GJkaCgwBNtplXCvGkJToU86ybBAUdavvgsCiG8lSSvYxr9MhwmQ+udpzywHsl4RpGJsYWG1pDOcA== + dependencies: + micromark-util-types "^2.0.0" + +micromark-util-sanitize-uri@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.0.tgz#ec8fbf0258e9e6d8f13d9e4770f9be64342673de" + integrity sha512-WhYv5UEcZrbAtlsnPuChHUAsu/iBPOVaEVsntLBIdpibO0ddy8OzavZz3iL2xVvBZOpolujSliP65Kq0/7KIYw== + dependencies: + micromark-util-character "^2.0.0" + micromark-util-encode "^2.0.0" + micromark-util-symbol "^2.0.0" + +micromark-util-subtokenize@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/micromark-util-subtokenize/-/micromark-util-subtokenize-2.0.1.tgz#76129c49ac65da6e479c09d0ec4b5f29ec6eace5" + integrity sha512-jZNtiFl/1aY73yS3UGQkutD0UbhTt68qnRpw2Pifmz5wV9h8gOVsN70v+Lq/f1rKaU/W8pxRe8y8Q9FX1AOe1Q== + dependencies: + devlop "^1.0.0" + micromark-util-chunked "^2.0.0" + micromark-util-symbol "^2.0.0" + micromark-util-types "^2.0.0" + +micromark-util-symbol@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/micromark-util-symbol/-/micromark-util-symbol-2.0.0.tgz#12225c8f95edf8b17254e47080ce0862d5db8044" + integrity sha512-8JZt9ElZ5kyTnO94muPxIGS8oyElRJaiJO8EzV6ZSyGQ1Is8xwl4Q45qU5UOg+bGH4AikWziz0iN4sFLWs8PGw== + +micromark-util-types@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/micromark-util-types/-/micromark-util-types-2.0.0.tgz#63b4b7ffeb35d3ecf50d1ca20e68fc7caa36d95e" + integrity sha512-oNh6S2WMHWRZrmutsRmDDfkzKtxF+bc2VxLC9dvtrDIRFln627VsFP6fLMgTryGDljgLPjkrzQSDcPrjPyDJ5w== + +micromark@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/micromark/-/micromark-4.0.0.tgz#84746a249ebd904d9658cfabc1e8e5f32cbc6249" + integrity sha512-o/sd0nMof8kYff+TqcDx3VSrgBTcZpSvYcAHIfHhv5VAuNmisCxjhx6YmxS8PFEpb9z5WKWKPdzf0jM23ro3RQ== + dependencies: + "@types/debug" "^4.0.0" + debug "^4.0.0" + decode-named-character-reference "^1.0.0" + devlop "^1.0.0" + micromark-core-commonmark "^2.0.0" + micromark-factory-space "^2.0.0" + micromark-util-character "^2.0.0" + micromark-util-chunked "^2.0.0" + micromark-util-combine-extensions "^2.0.0" + micromark-util-decode-numeric-character-reference "^2.0.0" + micromark-util-encode "^2.0.0" + micromark-util-normalize-identifier "^2.0.0" + micromark-util-resolve-all "^2.0.0" + micromark-util-sanitize-uri "^2.0.0" + micromark-util-subtokenize "^2.0.0" + micromark-util-symbol "^2.0.0" + micromark-util-types "^2.0.0" + micromatch@^3.0.4, micromatch@^3.1.10, micromatch@^3.1.4: version "3.1.10" resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-3.1.10.tgz#70859bc95c9840952f359a068a3fc49f9ecfac23" @@ -15040,6 +15416,20 @@ parse-entities@^1.0.2, parse-entities@^1.1.0: is-decimal "^1.0.0" is-hexadecimal "^1.0.0" +parse-entities@^4.0.0: + version "4.0.1" + resolved "https://registry.yarnpkg.com/parse-entities/-/parse-entities-4.0.1.tgz#4e2a01111fb1c986549b944af39eeda258fc9e4e" + integrity sha512-SWzvYcSJh4d/SGLIOQfZ/CoNv6BTlI6YEQ7Nj82oDVnRpwe/Z/F1EMx42x3JAOwGBlCjeCH0BRJQbQ/opHL17w== + dependencies: + "@types/unist" "^2.0.0" + character-entities "^2.0.0" + character-entities-legacy "^3.0.0" + character-reference-invalid "^2.0.0" + decode-named-character-reference "^1.0.0" + is-alphanumerical "^2.0.0" + is-decimal "^2.0.0" + is-hexadecimal "^2.0.0" + parse-json@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-4.0.0.tgz#be35f5425be1f7f6c747184f98a788cb99477ee0" @@ -16222,11 +16612,6 @@ property-expr@^2.0.4, property-expr@^2.0.5: resolved "https://registry.yarnpkg.com/property-expr/-/property-expr-2.0.6.tgz#f77bc00d5928a6c748414ad12882e83f24aec1e8" integrity sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA== -property-information@^3.0.0: - version "3.2.0" - resolved "https://registry.yarnpkg.com/property-information/-/property-information-3.2.0.tgz#fd1483c8fbac61808f5fe359e7693a1f48a58331" - integrity sha512-BKU45RMZAA+3npkQ/VxEH7EeZImQcfV6rfKH0O4HkkDz3uqqz+689dbkjiWia00vK390MY6EARPS6TzNS4tXPg== - property-information@^5.0.0, property-information@^5.2.0: version "5.6.0" resolved "https://registry.yarnpkg.com/property-information/-/property-information-5.6.0.tgz#61675545fb23002f245c6540ec46077d4da3ed69" @@ -16234,6 +16619,11 @@ property-information@^5.0.0, property-information@^5.2.0: dependencies: xtend "^4.0.0" +property-information@^6.0.0: + version "6.5.0" + resolved "https://registry.yarnpkg.com/property-information/-/property-information-6.5.0.tgz#6212fbb52ba757e92ef4fb9d657563b933b7ffec" + integrity sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig== + proxy-addr@~2.0.4, proxy-addr@~2.0.7: version "2.0.7" resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.7.tgz#f19fe69ceab311eeb94b42e70e8c2070f9ba1025" @@ -16654,6 +17044,22 @@ react-is@^18.0.0: resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.2.0.tgz#199431eeaaa2e09f86427efbb4f1473edb47609b" integrity sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w== +react-markdown@9.0.1: + version "9.0.1" + resolved "https://registry.yarnpkg.com/react-markdown/-/react-markdown-9.0.1.tgz#c05ddbff67fd3b3f839f8c648e6fb35d022397d1" + integrity sha512-186Gw/vF1uRkydbsOIkcGXw7aHq0sZOCRFFjGrr7b9+nVZg4UfA4enXCaxm4fUzecU38sWfrNDitGhshuU7rdg== + dependencies: + "@types/hast" "^3.0.0" + devlop "^1.0.0" + hast-util-to-jsx-runtime "^2.0.0" + html-url-attributes "^3.0.0" + mdast-util-to-hast "^13.0.0" + remark-parse "^11.0.0" + remark-rehype "^11.0.0" + unified "^11.0.0" + unist-util-visit "^5.0.0" + vfile "^6.0.0" + react-popper@1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/react-popper/-/react-popper-1.0.0.tgz#b99452144e8fe4acc77fa3d959a8c79e07a65084" @@ -16763,10 +17169,10 @@ react-select@5.4.0: prop-types "^15.6.0" react-transition-group "^4.3.0" -react-simple-keyboard@^3.4.187: - version "3.7.93" - resolved "https://registry.yarnpkg.com/react-simple-keyboard/-/react-simple-keyboard-3.7.93.tgz#2343be2f96d59ab1f00ce8dcd0ed576eb9f59945" - integrity sha512-MJSwiBOiU0xMjyHfrHVJ6YJkH/TKga4S4DINfqL+MbNYglJ0qMhCyLxorjjlqs744X71/+InV5Dnc8dYK7YMYg== +react-simple-keyboard@^3.7.0: + version "3.7.107" + resolved "https://registry.yarnpkg.com/react-simple-keyboard/-/react-simple-keyboard-3.7.107.tgz#6e71f48950a1923486f2ca8edc5194cdbae0f332" + integrity sha512-r2emrLGoD6A37fl+GCEODFLxtUET1uXZsmFokb7cB6/3OlE7EV08wSzB+yTju+qwIibsc6EXLC6KoRf0FsVC1A== react-snap@^1.23.0: version "1.23.0" @@ -17148,26 +17554,15 @@ remark-external-links@^8.0.0: space-separated-tokens "^1.0.0" unist-util-visit "^2.0.0" -remark-parse@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/remark-parse/-/remark-parse-5.0.0.tgz#4c077f9e499044d1d5c13f80d7a98cf7b9285d95" - integrity sha512-b3iXszZLH1TLoyUzrATcTQUZrwNl1rE70rVdSruJFlDaJ9z5aMkhrG43Pp68OgfHndL/ADz6V69Zow8cTQu+JA== +remark-parse@^11.0.0: + version "11.0.0" + resolved "https://registry.yarnpkg.com/remark-parse/-/remark-parse-11.0.0.tgz#aa60743fcb37ebf6b069204eb4da304e40db45a1" + integrity sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA== dependencies: - collapse-white-space "^1.0.2" - is-alphabetical "^1.0.0" - is-decimal "^1.0.0" - is-whitespace-character "^1.0.0" - is-word-character "^1.0.0" - markdown-escapes "^1.0.0" - parse-entities "^1.1.0" - repeat-string "^1.5.4" - state-toggle "^1.0.0" - trim "0.0.1" - trim-trailing-lines "^1.0.0" - unherit "^1.0.4" - unist-util-remove-position "^1.0.0" - vfile-location "^2.0.0" - xtend "^4.0.1" + "@types/mdast" "^4.0.0" + mdast-util-from-markdown "^2.0.0" + micromark-util-types "^2.0.0" + unified "^11.0.0" remark-parse@^6.0.0: version "6.0.3" @@ -17190,15 +17585,16 @@ remark-parse@^6.0.0: vfile-location "^2.0.0" xtend "^4.0.1" -remark-react@4.0.3: - version "4.0.3" - resolved "https://registry.yarnpkg.com/remark-react/-/remark-react-4.0.3.tgz#980938f3bcc93bef220215b26b0b0a80f3158c7d" - integrity sha512-M2DxXfX8/GK0hV84PUcsvkvb+8yGLdV+krb8mW28eoa9ZgTrhC5rk01EPRMXRNGCAEl3JMDFs+VKdT/FbsN9vg== +remark-rehype@^11.0.0: + version "11.1.0" + resolved "https://registry.yarnpkg.com/remark-rehype/-/remark-rehype-11.1.0.tgz#d5f264f42bcbd4d300f030975609d01a1697ccdc" + integrity sha512-z3tJrAs2kIs1AqIIy6pzHmAHlF1hWQ+OdY4/hv+Wxe35EhyLKcajL33iUEn3ScxtFox9nUvRufR/Zre8Q08H/g== dependencies: - "@mapbox/hast-util-table-cell-style" "^0.1.3" - hast-to-hyperscript "^4.0.0" - hast-util-sanitize "^1.0.0" - mdast-util-to-hast "^3.0.0" + "@types/hast" "^3.0.0" + "@types/mdast" "^4.0.0" + mdast-util-to-hast "^13.0.0" + unified "^11.0.0" + vfile "^6.0.0" remark-slug@^6.0.0: version "6.1.0" @@ -17209,26 +17605,6 @@ remark-slug@^6.0.0: mdast-util-to-string "^1.0.0" unist-util-visit "^2.0.0" -remark-stringify@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/remark-stringify/-/remark-stringify-5.0.0.tgz#336d3a4d4a6a3390d933eeba62e8de4bd280afba" - integrity sha512-Ws5MdA69ftqQ/yhRF9XhVV29mhxbfGhbz0Rx5bQH+oJcNhhSM6nCu1EpLod+DjrFGrU0BMPs+czVmJZU7xiS7w== - dependencies: - ccount "^1.0.0" - is-alphanumeric "^1.0.0" - is-decimal "^1.0.0" - is-whitespace-character "^1.0.0" - longest-streak "^2.0.1" - markdown-escapes "^1.0.0" - markdown-table "^1.1.0" - mdast-util-compact "^1.0.0" - parse-entities "^1.0.2" - repeat-string "^1.5.4" - state-toggle "^1.0.0" - stringify-entities "^1.0.1" - unherit "^1.0.4" - xtend "^4.0.1" - remark-stringify@^6.0.0: version "6.0.4" resolved "https://registry.yarnpkg.com/remark-stringify/-/remark-stringify-6.0.4.tgz#16ac229d4d1593249018663c7bddf28aafc4e088" @@ -17249,15 +17625,6 @@ remark-stringify@^6.0.0: unherit "^1.0.4" xtend "^4.0.1" -remark@9.0.0: - version "9.0.0" - resolved "https://registry.yarnpkg.com/remark/-/remark-9.0.0.tgz#c5cfa8ec535c73a67c4b0f12bfdbd3a67d8b2f60" - integrity sha512-amw8rGdD5lHbMEakiEsllmkdBP+/KpjW/PRK6NSGPZKCQowh0BT4IWXDAkRMyG3SB9dKPXWMviFjNusXzXNn3A== - dependencies: - remark-parse "^5.0.0" - remark-stringify "^5.0.0" - unified "^6.0.0" - remark@^10.0.1: version "10.0.1" resolved "https://registry.yarnpkg.com/remark/-/remark-10.0.1.tgz#3058076dc41781bf505d8978c291485fe47667df" @@ -18327,6 +18694,11 @@ space-separated-tokens@^1.0.0: resolved "https://registry.yarnpkg.com/space-separated-tokens/-/space-separated-tokens-1.1.5.tgz#85f32c3d10d9682007e917414ddc5c26d1aa6899" integrity sha512-q/JSVd1Lptzhf5bkYm4ob4iWPjx0KiRe3sRFBNrVqbJkFaBm5vbbowy1mymoPNLRa52+oadOhJ+K49wsSeSjTA== +space-separated-tokens@^2.0.0: + version "2.0.2" + resolved "https://registry.yarnpkg.com/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz#1ecd9d2350a3844572c3f4a312bceb018348859f" + integrity sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q== + spawn-command@0.0.2: version "0.0.2" resolved "https://registry.yarnpkg.com/spawn-command/-/spawn-command-0.0.2.tgz#9544e1a43ca045f8531aac1a48cb29bdae62338e" @@ -18536,10 +18908,10 @@ store2@^2.14.2: resolved "https://registry.yarnpkg.com/store2/-/store2-2.14.3.tgz#24077d7ba110711864e4f691d2af941ec533deb5" integrity sha512-4QcZ+yx7nzEFiV4BMLnr/pRa5HYzNITX2ri0Zh6sT9EyQHbBHacC6YigllUPU9X3D0f/22QCgfokpKs52YRrUg== -storybook-addon-pseudo-states@^1.15.5: - version "1.15.5" - resolved "https://registry.yarnpkg.com/storybook-addon-pseudo-states/-/storybook-addon-pseudo-states-1.15.5.tgz#47d40391440dff235c05938c5b033aa655dda38e" - integrity sha512-DVngZ4121lJ6s42vKNfmLCBKhBMhh01D7sCV/LohP0rZoVW6Zws552g906Wan5R14gnArAlPCxQ+zbgm7QqxDA== +storybook-addon-pseudo-states@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/storybook-addon-pseudo-states/-/storybook-addon-pseudo-states-2.0.0.tgz#4fa251aaea04ebc6d17b7e57e5f09ea240f14583" + integrity sha512-tLuuwB1k+xFsX8C1fn4G/vJm5wX33jvSLeqTsJgWwI3/AKJUf6Thbg/kg14I2AwN8nqffjun2PzE05Iea23n0w== storybook@^7.6.16: version "7.6.17" @@ -18721,6 +19093,14 @@ stringify-entities@^2.0.0: is-decimal "^1.0.2" is-hexadecimal "^1.0.0" +stringify-entities@^4.0.0: + version "4.0.4" + resolved "https://registry.yarnpkg.com/stringify-entities/-/stringify-entities-4.0.4.tgz#b3b79ef5f277cc4ac73caeb0236c5ba939b3a4f3" + integrity sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg== + dependencies: + character-entities-html4 "^2.0.0" + character-entities-legacy "^3.0.0" + stringify-object@^3.2.1: version "3.3.0" resolved "https://registry.yarnpkg.com/stringify-object/-/stringify-object-3.3.0.tgz#703065aefca19300d3ce88af4f5b3956d7556629" @@ -18848,6 +19228,13 @@ style-search@^0.1.0: resolved "https://registry.yarnpkg.com/style-search/-/style-search-0.1.0.tgz#7958c793e47e32e07d2b5cafe5c0bf8e12e77902" integrity sha512-Dj1Okke1C3uKKwQcetra4jSuk0DqbzbYtXipzFlFMZtowbF1x7BKJwB9AayVMyFARvU8EDrZdcax4At/452cAg== +style-to-object@^1.0.0: + version "1.0.6" + resolved "https://registry.yarnpkg.com/style-to-object/-/style-to-object-1.0.6.tgz#0c28aed8be1813d166c60d962719b2907c26547b" + integrity sha512-khxq+Qm3xEyZfKd/y9L3oIWQimxuc4STrQKtQn8aSDRHb8mFgpukgX1hdzfrMEW6JCjyJ8p89x+IUMVnCBI1PA== + dependencies: + inline-style-parser "0.2.3" + styled-components@5.3.6: version "5.3.6" resolved "https://registry.yarnpkg.com/styled-components/-/styled-components-5.3.6.tgz#27753c8c27c650bee9358e343fc927966bfd00d1" @@ -19441,10 +19828,10 @@ tree-kill@^1.2.2: resolved "https://registry.yarnpkg.com/tree-kill/-/tree-kill-1.2.2.tgz#4ca09a9092c88b73a7cdc5e8a01b507b0790a0cc" integrity sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A== -trim-lines@^1.0.0: - version "1.1.3" - resolved "https://registry.yarnpkg.com/trim-lines/-/trim-lines-1.1.3.tgz#839514be82428fd9e7ec89e35081afe8f6f93115" - integrity sha512-E0ZosSWYK2mkSu+KEtQ9/KqarVjA9HztOSX+9FDdNacRAq29RRV6ZQNgob3iuW8Htar9vAfEa6yyt5qBAHZDBA== +trim-lines@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/trim-lines/-/trim-lines-3.0.1.tgz#d802e332a07df861c48802c04321017b1bd87338" + integrity sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg== trim-newlines@^2.0.0: version "2.0.0" @@ -19483,6 +19870,11 @@ trough@^1.0.0: resolved "https://registry.yarnpkg.com/trough/-/trough-1.0.5.tgz#b8b639cefad7d0bb2abd37d433ff8293efa5f406" integrity sha512-rvuRbTarPXmMb79SmzEp8aqXNKcK+y0XaB298IXueQ8I2PsrATcPBCSPyK/dDNa2iWOhKlfNnOjdAOTBU/nkFA== +trough@^2.0.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/trough/-/trough-2.2.0.tgz#94a60bd6bd375c152c1df911a4b11d5b0256f50f" + integrity sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw== + truncate-utf8-bytes@^1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz#405923909592d56f78a5818434b0b78489ca5f2b" @@ -19762,17 +20154,18 @@ unicode-property-aliases-ecmascript@^2.0.0: resolved "https://registry.yarnpkg.com/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.1.0.tgz#43d41e3be698bd493ef911077c9b131f827e8ccd" integrity sha512-6t3foTQI9qne+OZoVQB/8x8rk2k1eVy1gRXhV3oFQ5T6R1dqQ1xtin3XqSlx3+ATBkliTaR/hHyJBm+LVPNM8w== -unified@^6.0.0: - version "6.2.0" - resolved "https://registry.yarnpkg.com/unified/-/unified-6.2.0.tgz#7fbd630f719126d67d40c644b7e3f617035f6dba" - integrity sha512-1k+KPhlVtqmG99RaTbAv/usu85fcSRu3wY8X+vnsEhIxNP5VbVIDiXnLqyKIG+UMdyTg0ZX9EI6k2AfjJkHPtA== +unified@^11.0.0: + version "11.0.4" + resolved "https://registry.yarnpkg.com/unified/-/unified-11.0.4.tgz#f4be0ac0fe4c88cb873687c07c64c49ed5969015" + integrity sha512-apMPnyLjAX+ty4OrNap7yumyVAMlKx5IWU2wlzzUdYJO9A8f1p9m/gywF/GM2ZDFcjQPrx59Mc90KwmxsoklxQ== dependencies: - bail "^1.0.0" + "@types/unist" "^3.0.0" + bail "^2.0.0" + devlop "^1.0.0" extend "^3.0.0" - is-plain-obj "^1.1.0" - trough "^1.0.0" - vfile "^2.0.0" - x-is-string "^0.1.0" + is-plain-obj "^4.0.0" + trough "^2.0.0" + vfile "^6.0.0" unified@^7.0.0: version "7.1.0" @@ -19854,13 +20247,6 @@ unique-string@^2.0.0: dependencies: crypto-random-string "^2.0.0" -unist-builder@^1.0.1: - version "1.0.4" - resolved "https://registry.yarnpkg.com/unist-builder/-/unist-builder-1.0.4.tgz#e1808aed30bd72adc3607f25afecebef4dd59e17" - integrity sha512-v6xbUPP7ILrT15fHGrNyHc1Xda8H3xVhP7/HAIotHOhVPjH5dCXA097C3Rry1Q2O+HbOLCao4hfPB+EYEjHgVg== - dependencies: - object-assign "^4.1.0" - unist-util-find-all-after@^1.0.2: version "1.0.5" resolved "https://registry.yarnpkg.com/unist-util-find-all-after/-/unist-util-find-all-after-1.0.5.tgz#5751a8608834f41d117ad9c577770c5f2f1b2899" @@ -19868,16 +20254,6 @@ unist-util-find-all-after@^1.0.2: dependencies: unist-util-is "^3.0.0" -unist-util-generated@^1.1.0: - version "1.1.6" - resolved "https://registry.yarnpkg.com/unist-util-generated/-/unist-util-generated-1.1.6.tgz#5ab51f689e2992a472beb1b35f2ce7ff2f324d4b" - integrity sha512-cln2Mm1/CZzN5ttGK7vkoGw+RZ8VcUH6BtGbq98DDtRGquAAOXig1mrBQYelOwMXYS8rK+vZDyyojSjp7JX+Lg== - -unist-util-is@^2.0.0: - version "2.1.3" - resolved "https://registry.yarnpkg.com/unist-util-is/-/unist-util-is-2.1.3.tgz#459182db31f4742fceaea88d429693cbf0043d20" - integrity sha512-4WbQX2iwfr/+PfM4U3zd2VNXY+dWtZsN1fLnWEi2QQXA4qyDYAZcDMfXUX0Cu6XZUHHAO9q4nyxxLT4Awk1qUA== - unist-util-is@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/unist-util-is/-/unist-util-is-3.0.0.tgz#d9e84381c2468e82629e4a5be9d7d05a2dd324cd" @@ -19888,10 +20264,19 @@ unist-util-is@^4.0.0: resolved "https://registry.yarnpkg.com/unist-util-is/-/unist-util-is-4.1.0.tgz#976e5f462a7a5de73d94b706bac1b90671b57797" integrity sha512-ZOQSsnce92GrxSqlnEEseX0gi7GH9zTJZ0p9dtu87WRb/37mMPO2Ilx1s/t9vBHrFhbgweUwb+t7cIn5dxPhZg== -unist-util-position@^3.0.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/unist-util-position/-/unist-util-position-3.1.0.tgz#1c42ee6301f8d52f47d14f62bbdb796571fa2d47" - integrity sha512-w+PkwCbYSFw8vpgWD0v7zRCl1FpY3fjDSQ3/N/wNd9Ffa4gPi8+4keqt99N3XW6F99t/mUzp2xAhNmfKWp95QA== +unist-util-is@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/unist-util-is/-/unist-util-is-6.0.0.tgz#b775956486aff107a9ded971d996c173374be424" + integrity sha512-2qCTHimwdxLfz+YzdGfkqNlH0tLi9xjTnHddPmJwtIG9MGsdbutfTc4P+haPD7l7Cjxf/WZj+we5qfVPvvxfYw== + dependencies: + "@types/unist" "^3.0.0" + +unist-util-position@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/unist-util-position/-/unist-util-position-5.0.0.tgz#678f20ab5ca1207a97d7ea8a388373c9cf896be4" + integrity sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA== + dependencies: + "@types/unist" "^3.0.0" unist-util-remove-position@^1.0.0: version "1.1.4" @@ -19900,6 +20285,14 @@ unist-util-remove-position@^1.0.0: dependencies: unist-util-visit "^1.1.0" +unist-util-remove-position@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/unist-util-remove-position/-/unist-util-remove-position-5.0.0.tgz#fea68a25658409c9460408bc6b4991b965b52163" + integrity sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q== + dependencies: + "@types/unist" "^3.0.0" + unist-util-visit "^5.0.0" + unist-util-stringify-position@^1.0.0, unist-util-stringify-position@^1.1.1: version "1.1.2" resolved "https://registry.yarnpkg.com/unist-util-stringify-position/-/unist-util-stringify-position-1.1.2.tgz#3f37fcf351279dcbca7480ab5889bb8a832ee1c6" @@ -19934,7 +20327,15 @@ unist-util-visit-parents@^3.0.0: "@types/unist" "^2.0.0" unist-util-is "^4.0.0" -unist-util-visit@^1.0.0, unist-util-visit@^1.1.0, unist-util-visit@^1.3.0, unist-util-visit@^1.4.0: +unist-util-visit-parents@^6.0.0: + version "6.0.1" + resolved "https://registry.yarnpkg.com/unist-util-visit-parents/-/unist-util-visit-parents-6.0.1.tgz#4d5f85755c3b8f0dc69e21eca5d6d82d22162815" + integrity sha512-L/PqWzfTP9lzzEa6CKs0k2nARxTdZduw3zyh8d2NVBnsyvHjSX4TWse388YrrQKbvI8w20fGjGlhgT96WwKykw== + dependencies: + "@types/unist" "^3.0.0" + unist-util-is "^6.0.0" + +unist-util-visit@^1.1.0, unist-util-visit@^1.4.0: version "1.4.1" resolved "https://registry.yarnpkg.com/unist-util-visit/-/unist-util-visit-1.4.1.tgz#4724aaa8486e6ee6e26d7ff3c8685960d560b1e3" integrity sha512-AvGNk7Bb//EmJZyhtRUnNMEpId/AZ5Ph/KUpTI09WHQuDZHKovQ1oEv3mfmKpWKtoMzyMC4GLBm1Zy5k12fjIw== @@ -19950,6 +20351,15 @@ unist-util-visit@^2.0.0: unist-util-is "^4.0.0" unist-util-visit-parents "^3.0.0" +unist-util-visit@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/unist-util-visit/-/unist-util-visit-5.0.0.tgz#a7de1f31f72ffd3519ea71814cccf5fd6a9217d6" + integrity sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg== + dependencies: + "@types/unist" "^3.0.0" + unist-util-is "^6.0.0" + unist-util-visit-parents "^6.0.0" + universal-user-agent@^6.0.0: version "6.0.1" resolved "https://registry.yarnpkg.com/universal-user-agent/-/universal-user-agent-6.0.1.tgz#15f20f55da3c930c57bddbf1734c6654d5fd35aa" @@ -20302,7 +20712,7 @@ vfile-location@^2.0.0: resolved "https://registry.yarnpkg.com/vfile-location/-/vfile-location-2.0.6.tgz#8a274f39411b8719ea5728802e10d9e0dff1519e" integrity sha512-sSFdyCP3G6Ka0CEmN83A2YCMKIieHx0EDaj5IDP4g1pa5ZJ4FJDvpO0WODLxo4LUX4oe52gmSCK7Jw4SBghqxA== -vfile-message@*: +vfile-message@*, vfile-message@^4.0.0: version "4.0.2" resolved "https://registry.yarnpkg.com/vfile-message/-/vfile-message-4.0.2.tgz#c883c9f677c72c166362fd635f21fc165a7d1181" integrity sha512-jRDZ1IMLttGj41KcZvlrYAaI3CfqpLpfpf+Mfig13viT6NKvRzWZ+lXz0Y5D60w6uJIBAOGq9mSHf0gktF0duw== @@ -20325,16 +20735,6 @@ vfile-message@^2.0.0: "@types/unist" "^2.0.0" unist-util-stringify-position "^2.0.0" -vfile@^2.0.0: - version "2.3.0" - resolved "https://registry.yarnpkg.com/vfile/-/vfile-2.3.0.tgz#e62d8e72b20e83c324bc6c67278ee272488bf84a" - integrity sha512-ASt4mBUHcTpMKD/l5Q+WJXNtshlWxOogYyGYYrg4lt/vuRjC1EFQtlAofL5VmtVNIZJzWYFJjzGWZ0Gw8pzW1w== - dependencies: - is-buffer "^1.1.4" - replace-ext "1.0.0" - unist-util-stringify-position "^1.0.0" - vfile-message "^1.0.0" - vfile@^3.0.0: version "3.0.1" resolved "https://registry.yarnpkg.com/vfile/-/vfile-3.0.1.tgz#47331d2abe3282424f4a4bb6acd20a44c4121803" @@ -20355,6 +20755,15 @@ vfile@^4.0.0: unist-util-stringify-position "^2.0.0" vfile-message "^2.0.0" +vfile@^6.0.0: + version "6.0.1" + resolved "https://registry.yarnpkg.com/vfile/-/vfile-6.0.1.tgz#1e8327f41eac91947d4fe9d237a2dd9209762536" + integrity sha512-1bYqc7pt6NIADBJ98UiG0Bn/CHIVOoZ/IyEkqIruLg0mE1BKzkOXY2D6CSqQIcKqgadppE5lrxgWXJmXd7zZJw== + dependencies: + "@types/unist" "^3.0.0" + unist-util-stringify-position "^4.0.0" + vfile-message "^4.0.0" + vite-node@1.2.2: version "1.2.2" resolved "https://registry.yarnpkg.com/vite-node/-/vite-node-1.2.2.tgz#f6d329b06f9032130ae6eac1dc773f3663903c25" @@ -21217,3 +21626,8 @@ yup@1.3.3: tiny-case "^1.0.3" toposort "^2.0.2" type-fest "^2.19.0" + +zwitch@^2.0.0: + version "2.0.4" + resolved "https://registry.yarnpkg.com/zwitch/-/zwitch-2.0.4.tgz#c827d4b0acb76fc3e685a4c6ec2902d51070e9d7" + integrity sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==