diff --git a/.github/workflows/analyses-snapshot-test.yaml b/.github/workflows/analyses-snapshot-test.yaml index 09539d873e9..fffdd6b667d 100644 --- a/.github/workflows/analyses-snapshot-test.yaml +++ b/.github/workflows/analyses-snapshot-test.yaml @@ -45,12 +45,13 @@ jobs: timeout-minutes: 15 runs-on: ubuntu-latest env: + BASE_IMAGE_NAME: opentrons-python-base:3.10 ANALYSIS_REF: ${{ github.event.inputs.ANALYSIS_REF || github.head_ref || 'edge' }} SNAPSHOT_REF: ${{ github.event.inputs.SNAPSHOT_REF || github.head_ref || 'edge' }} # If we're running because of workflow_dispatch, use the user input to decide # whether to open a PR on failure. Otherwise, there is no user input, # so we only open a PR if the PR has the label 'gen-analyses-snapshot-pr' - OPEN_PR_ON_FAILURE: ${{ (github.event_name == 'workflow_dispatch' && github.events.inputs.OPEN_PR_ON_FAILURE) || ((github.event_name != 'workflow_dispatch') && (contains(github.event.pull_request.labels.*.name, 'gen-analyses-snapshot-pr'))) }} + OPEN_PR_ON_FAILURE: ${{ (github.event_name == 'workflow_dispatch' && github.event.inputs.OPEN_PR_ON_FAILURE) || ((github.event_name != 'workflow_dispatch') && (contains(github.event.pull_request.labels.*.name, 'gen-analyses-snapshot-pr'))) }} PR_TARGET_BRANCH: ${{ github.event.pull_request.base.ref || 'not a pr'}} steps: - name: Checkout Repository @@ -71,9 +72,24 @@ jobs: echo "Analyses snapshots match ${{ env.PR_TARGET_BRANCH }} snapshots." fi - - name: Docker Build + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build base image + id: build_base_image + uses: docker/build-push-action@v6 + with: + context: analyses-snapshot-testing/citools + file: analyses-snapshot-testing/citools/Dockerfile.base + push: false + load: true + tags: ${{ env.BASE_IMAGE_NAME }} + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Build analysis image working-directory: analyses-snapshot-testing - run: make build-opentrons-analysis + run: make build-opentrons-analysis BASE_IMAGE_NAME=${{ env.BASE_IMAGE_NAME }} ANALYSIS_REF=${{ env.ANALYSIS_REF }} CACHEBUST=${{ github.run_number }} - name: Set up Python 3.13 uses: actions/setup-python@v5 @@ -112,8 +128,8 @@ jobs: commit-message: 'fix(analyses-snapshot-testing): heal analyses snapshots' title: 'fix(analyses-snapshot-testing): heal ${{ env.ANALYSIS_REF }} snapshots' body: 'This PR was requested on the PR https://github.com/${{ github.repository }}/pull/${{ github.event.pull_request.number }}' - branch: 'analyses-snapshot-testing/${{ env.ANALYSIS_REF }}-from-${{ env.SNAPSHOT_REF}}' - base: ${{ env.SNAPSHOT_REF}} + branch: 'analyses-snapshot-testing/${{ env.ANALYSIS_REF }}-from-${{ env.SNAPSHOT_REF }}' + base: ${{ env.SNAPSHOT_REF }} - name: Comment on feature PR if: always() && steps.create_pull_request.outcome == 'success' && github.event_name == 'pull_request' @@ -135,5 +151,5 @@ jobs: commit-message: 'fix(analyses-snapshot-testing): heal ${{ env.ANALYSIS_REF }} snapshots' title: 'fix(analyses-snapshot-testing): heal ${{ env.ANALYSIS_REF }} snapshots' body: 'The ${{ env.ANALYSIS_REF }} overnight analyses snapshot test is failing. This PR was opened to alert us to the failure.' - branch: 'analyses-snapshot-testing/${{ env.ANALYSIS_REF }}-from-${{ env.SNAPSHOT_REF}}' - base: ${{ env.SNAPSHOT_REF}} + branch: 'analyses-snapshot-testing/${{ env.ANALYSIS_REF }}-from-${{ env.SNAPSHOT_REF }}' + base: ${{ env.SNAPSHOT_REF }} diff --git a/.github/workflows/pd-test-build-deploy.yaml b/.github/workflows/pd-test-build-deploy.yaml index 006da36d6a4..306a475aacc 100644 --- a/.github/workflows/pd-test-build-deploy.yaml +++ b/.github/workflows/pd-test-build-deploy.yaml @@ -79,49 +79,49 @@ jobs: files: ./coverage/lcov.info flags: protocol-designer - # e2e-test: - # name: 'pd e2e tests' - # needs: ['js-unit-test'] - # timeout-minutes: 30 - # strategy: - # matrix: - # os: ['ubuntu-22.04'] - # runs-on: '${{ matrix.os }}' - # steps: - # - uses: 'actions/checkout@v3' - # with: - # fetch-depth: 0 - # # https://github.com/actions/checkout/issues/290 - # - name: 'Fix actions/checkout odd handling of tags' - # if: startsWith(github.ref, 'refs/tags') - # run: | - # git fetch -f origin ${{ github.ref }}:${{ github.ref }} - # git checkout ${{ github.ref }} - # - uses: 'actions/setup-node@v3' - # with: - # node-version: '18.19.0' - # - name: 'install udev for usb-detection' - # if: startsWith(matrix.os, 'ubuntu') - # run: | - # # WORKAROUND: Remove microsoft debian repo due to https://github.com/microsoft/linux-package-repositories/issues/130. Remove line below after it is resolved - # sudo rm -f /etc/apt/sources.list.d/microsoft-prod.list - # sudo apt-get update && sudo apt-get install libudev-dev - # - name: 'cache yarn cache' - # uses: actions/cache@v3 - # with: - # path: | - # ${{ github.workspace }}/.yarn-cache - # ${{ github.workspace }}/.npm-cache - # key: js-${{ secrets.GH_CACHE_VERSION }}-${{ runner.os }}-yarn-${{ hashFiles('yarn.lock') }} - # restore-keys: | - # js-${{ secrets.GH_CACHE_VERSION }}-${{ runner.os }}-yarn- - # - name: 'setup-js' - # run: | - # npm config set cache ./.npm-cache - # yarn config set cache-folder ./.yarn-cache - # make setup-js - # - name: 'test-e2e' - # run: make -C protocol-designer test-e2e + e2e-test: + name: 'pd e2e tests' + needs: ['js-unit-test'] + timeout-minutes: 30 + strategy: + matrix: + os: ['ubuntu-22.04'] + runs-on: '${{ matrix.os }}' + steps: + - uses: 'actions/checkout@v3' + with: + fetch-depth: 0 + # https://github.com/actions/checkout/issues/290 + - name: 'Fix actions/checkout odd handling of tags' + if: startsWith(github.ref, 'refs/tags') + run: | + git fetch -f origin ${{ github.ref }}:${{ github.ref }} + git checkout ${{ github.ref }} + - uses: 'actions/setup-node@v3' + with: + node-version: '18.19.0' + - name: 'install udev for usb-detection' + if: startsWith(matrix.os, 'ubuntu') + run: | + # WORKAROUND: Remove microsoft debian repo due to https://github.com/microsoft/linux-package-repositories/issues/130. Remove line below after it is resolved + sudo rm -f /etc/apt/sources.list.d/microsoft-prod.list + sudo apt-get update && sudo apt-get install libudev-dev + - name: 'cache yarn cache' + uses: actions/cache@v3 + with: + path: | + ${{ github.workspace }}/.yarn-cache + ${{ github.workspace }}/.npm-cache + key: js-${{ secrets.GH_CACHE_VERSION }}-${{ runner.os }}-yarn-${{ hashFiles('yarn.lock') }} + restore-keys: | + js-${{ secrets.GH_CACHE_VERSION }}-${{ runner.os }}-yarn- + - name: 'setup-js' + run: | + npm config set cache ./.npm-cache + yarn config set cache-folder ./.yarn-cache + make setup-js + - name: 'test-e2e' + run: make -C protocol-designer test-e2e build-pd: name: 'build protocol designer artifact' needs: ['js-unit-test'] diff --git a/abr-testing/abr_testing/automation/google_drive_tool.py b/abr-testing/abr_testing/automation/google_drive_tool.py index 45464d35c3f..d9ac35f76d1 100644 --- a/abr-testing/abr_testing/automation/google_drive_tool.py +++ b/abr-testing/abr_testing/automation/google_drive_tool.py @@ -56,6 +56,8 @@ def list_folder(self, delete: Any = False, folder: bool = False) -> Set[str]: else "" # type: ignore if self.parent_folder else None, + supportsAllDrives=True, + includeItemsFromAllDrives=True, pageSize=1000, fields="nextPageToken, files(id, name, mimeType)", pageToken=page_token, 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 88ed55cab82..6b9d7dd7ebe 100644 --- a/abr-testing/abr_testing/data_collection/abr_google_drive.py +++ b/abr-testing/abr_testing/data_collection/abr_google_drive.py @@ -37,12 +37,13 @@ def create_data_dictionary( plate: str, accuracy: Any, hellma_plate_standards: List[Dict[str, Any]], -) -> Tuple[List[List[Any]], List[str], List[List[Any]], List[str]]: +) -> Tuple[List[List[Any]], List[str], List[List[Any]], List[str], List[List[Any]]]: """Pull data from run files and format into a dictionary.""" runs_and_robots: List[Any] = [] runs_and_lpc: List[Dict[str, Any]] = [] headers: List[str] = [] headers_lpc: List[str] = [] + list_of_heights: List[List[Any]] = [[], [], [], [], [], [], [], []] for filename in os.listdir(storage_directory): file_path = os.path.join(storage_directory, filename) if file_path.endswith(".json"): @@ -120,6 +121,9 @@ def create_data_dictionary( plate_reader_dict = read_robot_logs.plate_reader_commands( file_results, hellma_plate_standards ) + list_of_heights = read_robot_logs.liquid_height_commands( + file_results, list_of_heights + ) notes = {"Note1": "", "Jira Link": issue_url} plate_measure = { "Plate Measured": plate, @@ -155,7 +159,13 @@ def create_data_dictionary( print(f"Number of runs read: {num_of_runs_read}") transposed_runs_and_robots = list(map(list, zip(*runs_and_robots))) transposed_runs_and_lpc = list(map(list, zip(*runs_and_lpc))) - return transposed_runs_and_robots, headers, transposed_runs_and_lpc, headers_lpc + return ( + transposed_runs_and_robots, + headers, + transposed_runs_and_lpc, + headers_lpc, + list_of_heights, + ) def run( @@ -173,7 +183,8 @@ def run( credentials_path, google_sheet_name, 0 ) # Get run ids on google sheet - run_ids_on_gs = set(google_sheet.get_column(2)) + run_ids_on_gs: Set[str] = set(google_sheet.get_column(2)) + # Get robots on google sheet # Uploads files that are not in google drive directory google_drive.upload_missing_files(storage_directory) @@ -191,6 +202,7 @@ def run( headers, transposed_runs_and_lpc, headers_lpc, + list_of_heights, ) = create_data_dictionary( missing_runs_from_gs, storage_directory, @@ -201,7 +213,15 @@ def run( ) start_row = google_sheet.get_index_row() + 1 google_sheet.batch_update_cells(transposed_runs_and_robots, "A", start_row, "0") - + # Record Liquid Heights Found + google_sheet_ldf = google_sheets_tool.google_sheet( + credentials_path, google_sheet_name, 2 + ) + google_sheet_ldf.get_row(1) + start_row_lhd = google_sheet_ldf.get_index_row() + 1 + google_sheet_ldf.batch_update_cells( + list_of_heights, "A", start_row_lhd, "2075262446" + ) # Add LPC to google sheet google_sheet_lpc = google_sheets_tool.google_sheet(credentials_path, "ABR-LPC", 0) start_row_lpc = google_sheet_lpc.get_index_row() + 1 diff --git a/abr-testing/abr_testing/data_collection/abr_robot_error.py b/abr-testing/abr_testing/data_collection/abr_robot_error.py index 1849699bfa1..f7a4237f52a 100644 --- a/abr-testing/abr_testing/data_collection/abr_robot_error.py +++ b/abr-testing/abr_testing/data_collection/abr_robot_error.py @@ -602,6 +602,7 @@ def get_run_error_info_from_robot( headers, runs_and_lpc, headers_lpc, + list_of_heights, ) = abr_google_drive.create_data_dictionary( run_id, error_folder_path, @@ -614,6 +615,15 @@ def get_run_error_info_from_robot( start_row = google_sheet.get_index_row() + 1 google_sheet.batch_update_cells(runs_and_robots, "A", start_row, "0") print("Wrote run to ABR-run-data") + # Record Liquid Heights Found + google_sheet_ldf = google_sheets_tool.google_sheet( + credentials_path, google_sheet_name, 4 + ) + start_row_lhd = google_sheet_ldf.get_index_row() + 1 + google_sheet_ldf.batch_update_cells( + list_of_heights, "A", start_row_lhd, "1795535088" + ) + print("wrote liquid heights found.") # Add LPC to google sheet google_sheet_lpc = google_sheets_tool.google_sheet( credentials_path, "ABR-LPC", 0 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 9fd9f0e7d71..40712118fe5 100644 --- a/abr-testing/abr_testing/data_collection/read_robot_logs.py +++ b/abr-testing/abr_testing/data_collection/read_robot_logs.py @@ -213,6 +213,42 @@ def instrument_commands( return pipette_dict +def liquid_height_commands( + file_results: Dict[str, Any], all_heights_list: List[List[Any]] +) -> List[List[Any]]: + """Record found liquid heights during a protocol.""" + commandData = file_results.get("commands", "") + robot = file_results.get("robot_name", "") + run_id = file_results.get("run_id", "") + for command in commandData: + commandType = command["commandType"] + if commandType == "comment": + result = command["params"].get("message", "") + try: + result_str = "'" + result.split("result: {")[1] + "'" + entries = result_str.split(", (") + comment_time = command["completedAt"] + for entry in entries: + height = float(entry.split(": ")[1].split("'")[0].split("}")[0]) + labware_type = str( + entry.split(",")[0].replace("'", "").replace("(", "") + ) + well_location = str(entry.split(", ")[1].split(" ")[0]) + slot_location = str(entry.split("slot ")[1].split(")")[0]) + labware_name = str(entry.split("of ")[1].split(" on")[0]) + all_heights_list[0].append(robot) + all_heights_list[1].append(run_id) + all_heights_list[2].append(comment_time) + all_heights_list[3].append(labware_type) + all_heights_list[4].append(labware_name) + all_heights_list[5].append(slot_location) + all_heights_list[6].append(well_location) + all_heights_list[7].append(height) + except (IndexError, ValueError): + continue + return all_heights_list + + def plate_reader_commands( file_results: Dict[str, Any], hellma_plate_standards: List[Dict[str, Any]] ) -> Dict[str, object]: diff --git a/abr-testing/abr_testing/data_collection/single_run_log_reader.py b/abr-testing/abr_testing/data_collection/single_run_log_reader.py index 39060529c89..a61670e6d12 100644 --- a/abr-testing/abr_testing/data_collection/single_run_log_reader.py +++ b/abr-testing/abr_testing/data_collection/single_run_log_reader.py @@ -34,6 +34,7 @@ header, runs_and_lpc, lpc_headers, + list_of_heights, ) = abr_google_drive.create_data_dictionary( run_ids_in_storage, run_log_file_path, @@ -42,6 +43,7 @@ "", hellma_plate_standards=file_values, ) + print("list_of_heights not recorded.") transposed_list = list(zip(*runs_and_robots)) # Adds Run to local csv sheet_location = os.path.join(run_log_file_path, "saved_data.csv") diff --git a/abr-testing/abr_testing/protocol_simulation/abr_sim_check.py b/abr-testing/abr_testing/protocol_simulation/abr_sim_check.py index 86df9f73824..f55c9ebb51f 100644 --- a/abr-testing/abr_testing/protocol_simulation/abr_sim_check.py +++ b/abr-testing/abr_testing/protocol_simulation/abr_sim_check.py @@ -1,34 +1,108 @@ """Check ABR Protocols Simulate Successfully.""" from abr_testing.protocol_simulation import simulation_metrics import os -import traceback from pathlib import Path +from typing import Dict, List, Tuple, Union +import traceback -def run(file_to_simulate: Path) -> None: +def run( + file_dict: Dict[str, Dict[str, Union[str, Path]]], labware_defs: List[Path] +) -> None: """Simulate protocol and raise errors.""" - protocol_name = file_to_simulate.stem - try: - simulation_metrics.main(file_to_simulate, False) - except Exception: - print(f"Error in protocol: {protocol_name}") - traceback.print_exc() + for file in file_dict: + path = file_dict[file]["path"] + csv_params = "" + try: + csv_params = str(file_dict[file]["csv"]) + except KeyError: + pass + try: + print(f"Simulating {file}") + simulation_metrics.main( + protocol_file_path=Path(path), + save=False, + parameters=csv_params, + extra_files=labware_defs, + ) + except Exception as e: + traceback.print_exc() + print(str(e)) + print("\n") + + +def search(seq: str, dictionary: dict) -> str: + """Search for specific sequence in file.""" + for key in dictionary.keys(): + parts = key.split("_") + if parts[0] == seq: + return key + return "" + + +def get_files() -> Tuple[Dict[str, Dict[str, Union[str, Path]]], List[Path]]: + """Map protocols with corresponding csv files.""" + file_dict: Dict[str, Dict[str, Union[str, Path]]] = {} + labware_defs = [] + for root, directories, _ in os.walk(root_dir): + for directory in directories: + if directory == "active_protocols": + active_dir = os.path.join(root, directory) + for file in os.listdir( + active_dir + ): # Iterate over files in `active_protocols` + if file.endswith(".py") and file not in exclude: + file_dict[file] = {} + file_dict[file]["path"] = Path( + os.path.abspath( + os.path.join(root_dir, os.path.join(directory, file)) + ) + ) + if directory == "csv_parameters": + active_dir = os.path.join(root, directory) + for file in os.listdir( + active_dir + ): # Iterate over files in `active_protocols` + if file.endswith(".csv") and file not in exclude: + search_str = file.split("_")[0] + protocol = search(search_str, file_dict) + if protocol: + file_dict[protocol]["csv"] = str( + os.path.abspath( + os.path.join( + root_dir, os.path.join(directory, file) + ) + ) + ) + if directory == "custom_labware": + active_dir = os.path.join(root, directory) + for file in os.listdir( + active_dir + ): # Iterate over files in `active_protocols` + if file.endswith(".json") and file not in exclude: + labware_defs.append( + Path( + os.path.abspath( + os.path.join( + root_dir, os.path.join(directory, file) + ) + ) + ) + ) + return (file_dict, labware_defs) if __name__ == "__main__": # Directory to search + global root_dir root_dir = "abr_testing/protocols" - + global exclude exclude = [ "__init__.py", "helpers.py", + "shared_vars_and_funcs.py", ] - # Walk through the root directory and its subdirectories - for root, dirs, files in os.walk(root_dir): - for file in files: - if file.endswith(".py"): # If it's a Python file - if file in exclude: - continue - file_path = Path(os.path.join(root, file)) - print(f"Simulating protocol: {file_path.stem}") - run(file_path) + print("Simulating Protocols") + file_dict, labware_defs = get_files() + # print(file_dict) + run(file_dict, labware_defs) diff --git a/abr-testing/abr_testing/protocol_simulation/simulation_metrics.py b/abr-testing/abr_testing/protocol_simulation/simulation_metrics.py index 9d21109f37e..57695f03557 100644 --- a/abr-testing/abr_testing/protocol_simulation/simulation_metrics.py +++ b/abr-testing/abr_testing/protocol_simulation/simulation_metrics.py @@ -6,6 +6,7 @@ from opentrons.cli import analyze import json import argparse +import traceback from datetime import datetime from abr_testing.automation import google_sheets_tool from abr_testing.data_collection import read_robot_logs @@ -13,13 +14,39 @@ from abr_testing.tools import plate_reader +def build_parser() -> Any: + """Builds argument parser.""" + 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( + "sheet_name", + metavar="SHEET_NAME", + type=str, + nargs=1, + help="Name of sheet to upload results to", + ) + parser.add_argument( + "protocol_file_path", + metavar="PROTOCOL_FILE_PATH", + type=str, + nargs="*", + help="Path to protocol file(s)", + ) + return parser + + def set_api_level(protocol_file_path: str) -> None: """Set API level for analysis.""" with open(protocol_file_path, "r") as file: file_contents = file.readlines() # Look for current'apiLevel:' for i, line in enumerate(file_contents): - print(line) if "apiLevel" in line: print(f"The current API level of this protocol is: {line}") change = ( @@ -27,12 +54,10 @@ def set_api_level(protocol_file_path: str) -> None: .strip() .upper() ) - if change == "Y": api_level = input("Protocol API Level to Simulate with: ") # Update new API level file_contents[i] = f"apiLevel: {api_level}\n" - print(f"Updated line: {file_contents[i]}") break with open(protocol_file_path, "w") as file: file.writelines(file_contents) @@ -326,18 +351,20 @@ def main( save: bool, storage_directory: str = os.curdir, google_sheet_name: str = "", - parameters: List[str] = [], + parameters: str = "", + extra_files: List[Path] = [], ) -> None: """Main module control.""" sys.exit = mock_exit # Replace sys.exit with the mock function - # Read file path from arguments - # protocol_file_path = Path(protocol_file_path_name) - protocol_name = protocol_file_path.stem - print("Simulating", protocol_name) + # Simulation run date file_date = datetime.now() file_date_formatted = file_date.strftime("%Y-%m-%d_%H-%M-%S") error_output = f"{storage_directory}\\test_debug" - # Run protocol simulation + protocol_name = protocol_file_path.stem + protocol_files = [protocol_file_path] + if extra_files != []: + protocol_files += extra_files + print("Simulating....") try: with Context(analyze) as ctx: if save: @@ -348,13 +375,12 @@ def main( json_file_output = open(json_file_path, "wb+") # log_output_file = f"{protocol_name}_log" if parameters: - print(f"Parameter: {parameters[0]}\n") csv_params = {} - csv_params["parameters_csv"] = parameters[0] + csv_params["parameters_csv"] = parameters rtp_json = json.dumps(csv_params) ctx.invoke( analyze, - files=[protocol_file_path], + files=protocol_files, rtp_files=rtp_json, json_output=json_file_output, human_json_output=None, @@ -366,7 +392,7 @@ def main( else: ctx.invoke( analyze, - files=[protocol_file_path], + files=protocol_files, json_output=json_file_output, human_json_output=None, log_output=error_output, @@ -377,11 +403,11 @@ def main( else: if parameters: csv_params = {} - csv_params["parameters_csv"] = parameters[0] + csv_params["parameters_csv"] = parameters rtp_json = json.dumps(csv_params) ctx.invoke( analyze, - files=[protocol_file_path], + files=protocol_files, rtp_files=rtp_json, json_output=None, human_json_output=None, @@ -392,16 +418,18 @@ def main( else: ctx.invoke( analyze, - files=[protocol_file_path], + files=protocol_files, json_output=None, human_json_output=None, log_output=error_output, log_level="ERROR", check=True, ) - + print("done!") except SystemExit as e: print(f"SystemExit caught with code: {e}") + if e != 0: + traceback.print_exc finally: # Reset sys.exit to the original behavior sys.exit = original_exit @@ -411,11 +439,12 @@ def main( if not errors: pass else: - print(errors) - sys.exit(1) + print(f"Error:\n{errors}") + raise except FileNotFoundError: print("error simulating ...") - sys.exit() + raise + open_file.close if save: try: credentials_path = os.path.join(storage_directory, "credentials.json") @@ -443,31 +472,46 @@ def main( google_sheet.write_to_row(row) +def check_params(protocol_path: str) -> str: + """Check if protocol requires supporting files.""" + print("checking for parameters") + with open(protocol_path, "r") as f: + lines = f.readlines() + file_as_str = "".join(lines) + if ( + "parameters.add_csv_file" in file_as_str + or "helpers.create_csv_parameter" in file_as_str + ): + params = "" + while not params: + name = Path(protocol_file_path).stem + params = input( + f"Protocol {name} needs a CSV parameter file. Please enter the path: " + ) + if os.path.exists(params): + return params + else: + params = "" + print("Invalid file path") + return "" + + +def get_extra_files(protocol_file_path: str) -> tuple[str, List[Path]]: + """Get supporting files for protocol simulation if needed.""" + params = check_params(protocol_file_path) + needs_files = input("Does your protocol utilize custom labware? (y/n): ") + labware_files = [] + if needs_files == "y": + num_labware = input("How many custom labware?: ") + for labware_num in range(int(num_labware)): + path = input("Enter custom labware definition: ") + labware_files.append(Path(path)) + return (params, labware_files) + + if __name__ == "__main__": CLEAN_PROTOCOL = True - 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( - "sheet_name", - metavar="SHEET_NAME", - type=str, - nargs=1, - help="Name of sheet to upload results to", - ) - parser.add_argument( - "protocol_file_path", - metavar="PROTOCOL_FILE_PATH", - type=str, - nargs="*", - help="Path to protocol file", - ) - args = parser.parse_args() + args = build_parser().parse_args() storage_directory = args.storage_directory[0] sheet_name = args.sheet_name[0] protocol_file_path: str = args.protocol_file_path[0] @@ -500,20 +544,18 @@ def main( # Change api level if CLEAN_PROTOCOL: set_api_level(protocol_file_path) - if parameters: - main( - Path(protocol_file_path), - True, - storage_directory, - sheet_name, - parameters=parameters, - ) - else: + params, extra_files = get_extra_files(protocol_file_path) + try: main( protocol_file_path=Path(protocol_file_path), save=True, storage_directory=storage_directory, google_sheet_name=sheet_name, + parameters=params, + extra_files=extra_files, ) + except Exception as e: + traceback.print_exc() + sys.exit(str(e)) else: sys.exit(0) diff --git a/abr-testing/abr_testing/protocols/active_protocols/10_ZymoBIOMICS_Magbead_DNA_Cells_Flex.py b/abr-testing/abr_testing/protocols/active_protocols/10_ZymoBIOMICS_Magbead_DNA_Cells_Flex.py index 62a077cbc5f..9631b442694 100644 --- a/abr-testing/abr_testing/protocols/active_protocols/10_ZymoBIOMICS_Magbead_DNA_Cells_Flex.py +++ b/abr-testing/abr_testing/protocols/active_protocols/10_ZymoBIOMICS_Magbead_DNA_Cells_Flex.py @@ -17,7 +17,7 @@ "protocolName": "Flex ZymoBIOMICS Magbead DNA Extraction: Cells", } -requirements = {"robotType": "OT-3", "apiLevel": "2.20"} +requirements = {"robotType": "OT-3", "apiLevel": "2.21"} """ Slot A1: Tips 1000 Slot A2: Tips 1000 @@ -94,7 +94,6 @@ def run(ctx: protocol_api.ProtocolContext) -> None: bind_time_1 = bind_time_2 = wash_time = 0.25 drybeads = 0.5 lysis_rep_1 = lysis_rep_2 = bead_reps_2 = 1 - PK_vol = 20.0 bead_vol = 25.0 starting_vol = lysis_vol + sample_vol binding_buffer_vol = bind_vol + bead_vol @@ -137,7 +136,7 @@ def run(ctx: protocol_api.ProtocolContext) -> None: """ lysis_ = res1.wells()[0] binding_buffer = res1.wells()[1:4] - bind2_res = res1.wells()[4:6] + bind2_res = res1.wells()[4:8] wash1 = res1.wells()[6:8] elution_solution = res1.wells()[-1] wash2 = res2.wells()[:6] @@ -149,16 +148,12 @@ def run(ctx: protocol_api.ProtocolContext) -> None: samps = sample_plate.wells()[: (8 * num_cols)] liquid_vols_and_wells: Dict[str, List[Dict[str, Well | List[Well] | float]]] = { - "Lysis": [{"well": lysis_, "volume": lysis_vol}], - "PK": [{"well": lysis_, "volume": PK_vol}], - "Beads": [{"well": binding_buffer, "volume": bead_vol}], - "Binding": [{"well": binding_buffer, "volume": bind_vol}], - "Binding 2": [{"well": bind2_res, "volume": bind2_vol}], - "Wash 1": [{"well": wash1_vol, "volume": wash1}], - "Wash 2": [{"well": wash2_vol, "volume": wash2}], - "Wash 3": [{"well": wash3_vol, "volume": wash3}], - "Final Elution": [{"well": elution_solution, "volume": elution_vol}], - "Samples": [{"well": samps, "volume": 0}], + "Lysis and PK": [{"well": lysis_, "volume": 12320.0}], + "Beads and Binding": [{"well": binding_buffer, "volume": 11875.0}], + "Binding 2": [{"well": bind2_res, "volume": 13500.0}], + "Final Elution": [{"well": elution_solution, "volume": 52000}], + "Samples": [{"well": samps, "volume": 0.0}], + "Reagents": [{"well": res2.wells(), "volume": 9000.0}], } flattened_list_of_wells = helpers.find_liquid_height_of_loaded_liquids( ctx, liquid_vols_and_wells, m1000 diff --git a/abr-testing/abr_testing/protocols/active_protocols/11_Dynabeads_IP_Flex_96well_RIT.py b/abr-testing/abr_testing/protocols/active_protocols/11_Dynabeads_IP_Flex_96well_RIT.py index a50e0b94904..e885ec45a5e 100644 --- a/abr-testing/abr_testing/protocols/active_protocols/11_Dynabeads_IP_Flex_96well_RIT.py +++ b/abr-testing/abr_testing/protocols/active_protocols/11_Dynabeads_IP_Flex_96well_RIT.py @@ -16,7 +16,7 @@ requirements = { "robotType": "OT-3", - "apiLevel": "2.20", + "apiLevel": "2.21", } @@ -125,7 +125,7 @@ def run(ctx: ProtocolContext) -> None: "Wash": [{"well": wash, "volume": 750.0}], "Samples": [{"well": samples, "volume": 250.0}], } - flattened_wells = helpers.find_liquid_height_of_loaded_liquids( + helpers.find_liquid_height_of_loaded_liquids( ctx, liquid_vols_and_wells, p1000_single ) @@ -268,5 +268,6 @@ def discard(vol3: float, start: List[Well]) -> None: ctx.delay(minutes=MAG_DELAY_MIN) transfer_plate_to_plate(ELUTION_VOL * 1.1, working_cols, final_cols, 6) temp.deactivate() - flattened_wells.append(waste) - helpers.find_liquid_height_of_all_wells(ctx, p1000_single, flattened_wells) + end_wells_to_probe = [reagent_res["A1"], reagent_res["B1"], reagent_res["C1"]] + end_wells_to_probe.extend(wash_res.wells()) + helpers.find_liquid_height_of_all_wells(ctx, p1000_single, end_wells_to_probe) diff --git a/abr-testing/abr_testing/protocols/active_protocols/12_KAPA HyperPlus Library Prep.py b/abr-testing/abr_testing/protocols/active_protocols/12_KAPA HyperPlus Library Prep.py index 1629b6c6626..79414e13765 100644 --- a/abr-testing/abr_testing/protocols/active_protocols/12_KAPA HyperPlus Library Prep.py +++ b/abr-testing/abr_testing/protocols/active_protocols/12_KAPA HyperPlus Library Prep.py @@ -21,7 +21,7 @@ "author": "Your Name ", } -requirements = {"robotType": "Flex", "apiLevel": "2.20"} +requirements = {"robotType": "Flex", "apiLevel": "2.21"} tt_50 = 0 tt_200 = 0 @@ -50,6 +50,7 @@ def add_parameters(parameters: ParameterContext) -> None: default=False, ) helpers.create_disposable_lid_parameter(parameters) + helpers.create_tc_lid_deck_riser_parameter(parameters) helpers.create_two_pipette_mount_parameters(parameters) parameters.add_int( variable_name="num_samples", @@ -85,6 +86,7 @@ def run(ctx: ProtocolContext) -> None: dry_run = ctx.params.dry_run # type: ignore[attr-defined] pipette_1000_mount = ctx.params.pipette_mount_1 # type: ignore[attr-defined] pipette_50_mount = ctx.params.pipette_mount_2 # type: ignore[attr-defined] + deck_riser = ctx.params.deck_riser # type: ignore[attr-defined] REUSE_ETOH_TIPS = True REUSE_RSB_TIPS = ( True # Reuse tips for RSB buffer (adding RSB, mixing, and transferring) @@ -158,7 +160,7 @@ def run(ctx: ProtocolContext) -> None: unused_lids: List[Labware] = [] # Load TC Lids if disposable_lid: - unused_lids = helpers.load_disposable_lids(ctx, 5, ["C3"]) + unused_lids = helpers.load_disposable_lids(ctx, 5, ["C3"], deck_riser) # Import Global Variables global tip50 @@ -451,6 +453,10 @@ def tiptrack(rack: int, reuse_col: Optional[int], reuse: bool = False) -> None: ) p200.pick_up_tip() + tiptrack(tip50, None, reuse=False) + p50.return_tip() + helpers.find_liquid_height_of_loaded_liquids(ctx, liquid_vols_and_wells, p50) + def TipSwap(tipvol: int) -> None: """Tip swap.""" if tipvol == 50: @@ -1203,18 +1209,11 @@ def lib_cleanup_2() -> None: # Set Block Temp for Final Plate tc_mod.set_block_temperature(4) - tiptrack(tip50, None, reuse=False) - p50.return_tip() - probed_wells = helpers.find_liquid_height_of_loaded_liquids( - ctx, liquid_vols_and_wells, p50 - ) - unused_lids, used_lids = Fragmentation(unused_lids, used_lids) unused_lids, used_lids = end_repair(unused_lids, used_lids) unused_lids, used_lids = index_ligation(unused_lids, used_lids) lib_cleanup() unused_lids, used_lids = lib_amplification(unused_lids, used_lids) lib_cleanup_2() - probed_wells.append(waste1_res) - probed_wells.append(waste2_res) - helpers.find_liquid_height_of_all_wells(ctx, p50, probed_wells) + end_probed_wells = [waste1_res, waste2_res] + helpers.find_liquid_height_of_all_wells(ctx, p50, end_probed_wells) diff --git a/abr-testing/abr_testing/protocols/active_protocols/1_Simple Normalize Long Right.py b/abr-testing/abr_testing/protocols/active_protocols/1_Simple Normalize Long Right.py index 5c63511dac7..525a82c3095 100644 --- a/abr-testing/abr_testing/protocols/active_protocols/1_Simple Normalize Long Right.py +++ b/abr-testing/abr_testing/protocols/active_protocols/1_Simple Normalize Long Right.py @@ -17,7 +17,7 @@ requirements = { "robotType": "Flex", - "apiLevel": "2.20", + "apiLevel": "2.21", } @@ -121,8 +121,6 @@ def run(protocol: ProtocolContext) -> None: style=SINGLE, start="H1", tip_racks=[tiprack_x_1, tiprack_x_2, tiprack_x_3] ) helpers.find_liquid_height_of_all_wells(protocol, p1000, wells) - tiprack_x_1.reset() - sample_quant_csv = """ sample_plate_1, Sample_well,DYE,DILUENT sample_plate_1,A1,0,100 diff --git a/abr-testing/abr_testing/protocols/active_protocols/2_BMS_PCR_Protocol.py b/abr-testing/abr_testing/protocols/active_protocols/2_BMS_PCR_Protocol.py index d044b5e8ed3..24e7358f6e1 100644 --- a/abr-testing/abr_testing/protocols/active_protocols/2_BMS_PCR_Protocol.py +++ b/abr-testing/abr_testing/protocols/active_protocols/2_BMS_PCR_Protocol.py @@ -14,7 +14,7 @@ "protocolName": "PCR Protocol with TC Auto Sealing Lid", "author": "Rami Farawi None: @@ -22,6 +22,7 @@ def add_parameters(parameters: ParameterContext) -> None: helpers.create_single_pipette_mount_parameter(parameters) helpers.create_disposable_lid_parameter(parameters) helpers.create_csv_parameter(parameters) + helpers.create_tc_lid_deck_riser_parameter(parameters) def run(ctx: ProtocolContext) -> None: @@ -29,6 +30,8 @@ def run(ctx: ProtocolContext) -> None: pipette_mount = ctx.params.pipette_mount # type: ignore[attr-defined] disposable_lid = ctx.params.disposable_lid # type: ignore[attr-defined] parsed_csv = ctx.params.parameters_csv.parse_as_csv() # type: ignore[attr-defined] + deck_riser = ctx.params.deck_riser # type: ignore[attr-defined] + rxn_vol = 50 real_mode = True # DECK SETUP AND LABWARE @@ -60,7 +63,7 @@ def run(ctx: ProtocolContext) -> None: # Opentrons tough pcr auto sealing lids if disposable_lid: - unused_lids = helpers.load_disposable_lids(ctx, 3, ["C3"]) + unused_lids = helpers.load_disposable_lids(ctx, 3, ["C3"], deck_riser) used_lids: List[Labware] = [] # LOAD PIPETTES @@ -205,4 +208,6 @@ def run(ctx: ProtocolContext) -> None: ctx.move_labware(lid_on_plate, "C2", use_gripper=True) else: ctx.move_labware(lid_on_plate, used_lids[-2], use_gripper=True) + p50.drop_tip() + p50.configure_nozzle_layout(style=SINGLE, start="A1", tip_racks=tiprack_50) helpers.find_liquid_height_of_all_wells(ctx, p50, wells_to_probe_flattened) diff --git a/abr-testing/abr_testing/protocols/active_protocols/3_OT3 ABR Normalize with Tubes.py b/abr-testing/abr_testing/protocols/active_protocols/3_OT3 ABR Normalize with Tubes.py index e25e4a6d7c8..50fb82e94d5 100644 --- a/abr-testing/abr_testing/protocols/active_protocols/3_OT3 ABR Normalize with Tubes.py +++ b/abr-testing/abr_testing/protocols/active_protocols/3_OT3 ABR Normalize with Tubes.py @@ -9,7 +9,7 @@ "source": "Protocol Library", } -requirements = {"robotType": "Flex", "apiLevel": "2.20"} +requirements = {"robotType": "Flex", "apiLevel": "2.21"} # SCRIPT SETTINGS ABR_TEST = True @@ -340,5 +340,4 @@ def run(ctx: ProtocolContext) -> None: ) current += 1 - print(wells_with_liquids) helpers.find_liquid_height_of_all_wells(ctx, p50, wells_with_liquids) diff --git a/abr-testing/abr_testing/protocols/active_protocols/5_96ch complex protocol with single tip Pick Up.py b/abr-testing/abr_testing/protocols/active_protocols/5_96ch complex protocol with single tip Pick Up.py index 86801cfcc6e..dc40db7f177 100644 --- a/abr-testing/abr_testing/protocols/active_protocols/5_96ch complex protocol with single tip Pick Up.py +++ b/abr-testing/abr_testing/protocols/active_protocols/5_96ch complex protocol with single tip Pick Up.py @@ -22,7 +22,7 @@ requirements = { "robotType": "OT-3", - "apiLevel": "2.20", + "apiLevel": "2.21", } @@ -44,6 +44,7 @@ def add_parameters(parameters: ParameterContext) -> None: helpers.create_tip_size_parameter(parameters) helpers.create_dot_bottom_parameter(parameters) helpers.create_disposable_lid_parameter(parameters) + helpers.create_tc_lid_deck_riser_parameter(parameters) def run(ctx: ProtocolContext) -> None: @@ -51,6 +52,7 @@ def run(ctx: ProtocolContext) -> None: b = ctx.params.dot_bottom # type: ignore[attr-defined] TIPRACK_96_NAME = ctx.params.tip_size # type: ignore[attr-defined] disposable_lid = ctx.params.disposable_lid # type: ignore[attr-defined] + deck_riser = ctx.params.deck_riser # type: ignore[attr-defined] waste_chute = ctx.load_waste_chute() @@ -61,7 +63,7 @@ def run(ctx: ProtocolContext) -> None: helpers.temp_str, "C1" ) # type: ignore[assignment] if disposable_lid: - unused_lids = helpers.load_disposable_lids(ctx, 3, ["A4"]) + unused_lids = helpers.load_disposable_lids(ctx, 3, ["A4"], deck_riser) used_lids: List[Labware] = [] thermocycler.open_lid() h_s.open_labware_latch() diff --git a/abr-testing/abr_testing/protocols/active_protocols/7_HDQ_DNA_Bacteria_Flex.py b/abr-testing/abr_testing/protocols/active_protocols/7_HDQ_DNA_Bacteria_Flex.py index f247af69c5f..aa33079f553 100644 --- a/abr-testing/abr_testing/protocols/active_protocols/7_HDQ_DNA_Bacteria_Flex.py +++ b/abr-testing/abr_testing/protocols/active_protocols/7_HDQ_DNA_Bacteria_Flex.py @@ -23,7 +23,7 @@ requirements = { "robotType": "OT-3", - "apiLevel": "2.20", + "apiLevel": "2.21", } """ Slot A1: Tips 1000 diff --git a/abr-testing/abr_testing/protocols/active_protocols/8_Illumina and Plate Reader.py b/abr-testing/abr_testing/protocols/active_protocols/8_Illumina and Plate Reader.py index 634cac538a0..2e835ac04dd 100644 --- a/abr-testing/abr_testing/protocols/active_protocols/8_Illumina and Plate Reader.py +++ b/abr-testing/abr_testing/protocols/active_protocols/8_Illumina and Plate Reader.py @@ -77,6 +77,7 @@ def plate_reader_actions( """Plate reader single and multi wavelength readings.""" wavelengths = [450, 650] # Single Wavelength Readings + plate_reader.close_lid() for wavelength in wavelengths: plate_reader.initialize("single", [wavelength], reference_wavelength=wavelength) plate_reader.open_lid() @@ -156,14 +157,13 @@ def run(protocol: ProtocolContext) -> None: p50 = protocol.load_instrument( "flex_8channel_50", "right", tip_racks=[tiprack_50_1, tiprack_50_2] ) - - plate_reader_actions(protocol, plate_reader, hellma_plate) # reagent AMPure = reservoir["A1"] SMB = reservoir["A2"] EtOH = reservoir["A4"] RSB = reservoir["A5"] + Liquid_trash_well_1 = reservoir["A9"] Liquid_trash_well_2 = reservoir["A10"] Liquid_trash_well_3 = reservoir["A11"] @@ -181,6 +181,8 @@ def run(protocol: ProtocolContext) -> None: ET2 = reagent_plate.wells_by_name()["A5"] PPC = reagent_plate.wells_by_name()["A6"] EPM = reagent_plate.wells_by_name()["A7"] + # Load Liquids + plate_reader_actions(protocol, plate_reader, hellma_plate) # tip and sample tracking if COLUMNS == 1: diff --git a/abr-testing/abr_testing/protocols/active_protocols/9_Magmax_RNA_Cells_Flex.py b/abr-testing/abr_testing/protocols/active_protocols/9_Magmax_RNA_Cells_Flex.py index 7b48972bb42..09201e58314 100644 --- a/abr-testing/abr_testing/protocols/active_protocols/9_Magmax_RNA_Cells_Flex.py +++ b/abr-testing/abr_testing/protocols/active_protocols/9_Magmax_RNA_Cells_Flex.py @@ -25,7 +25,7 @@ requirements = { "robotType": "OT-3", - "apiLevel": "2.20", + "apiLevel": "2.21", } """ Slot A1: Tips 200 @@ -172,9 +172,7 @@ def run(ctx: ProtocolContext) -> None: "Stop": [{"well": stopreaction, "volume": stop_vol}], } - flattened_list_of_wells = helpers.find_liquid_height_of_loaded_liquids( - ctx, liquid_vols_and_wells, m1000 - ) + helpers.find_liquid_height_of_loaded_liquids(ctx, liquid_vols_and_wells, m1000) m1000.flow_rate.aspirate = 50 m1000.flow_rate.dispense = 150 @@ -556,5 +554,6 @@ def elute(vol: float) -> None: ) elute(elution_vol) - flattened_list_of_wells.append(waste_reservoir["A1"]) - helpers.find_liquid_height_of_all_wells(ctx, m1000, flattened_list_of_wells) + end_list_of_wells_to_probe = [waste_reservoir["A1"], res1["A1"]] + end_list_of_wells_to_probe.extend(elution_samples_m) + helpers.find_liquid_height_of_all_wells(ctx, m1000, end_list_of_wells_to_probe) diff --git a/abr-testing/abr_testing/protocols/csv_parameters/9_parameters.csv b/abr-testing/abr_testing/protocols/csv_parameters/9_parameters.csv deleted file mode 100644 index a5f947d97d5..00000000000 --- a/abr-testing/abr_testing/protocols/csv_parameters/9_parameters.csv +++ /dev/null @@ -1,2 +0,0 @@ -heater_shaker_speed, dot_bottom -2000, 0.1 \ No newline at end of file diff --git a/abr-testing/abr_testing/protocols/helpers.py b/abr-testing/abr_testing/protocols/helpers.py index c3b1b7f217d..12abbfa9b3f 100644 --- a/abr-testing/abr_testing/protocols/helpers.py +++ b/abr-testing/abr_testing/protocols/helpers.py @@ -14,7 +14,6 @@ ThermocyclerContext, TemperatureModuleContext, ) - from typing import List, Union, Dict from opentrons.hardware_control.modules.types import ThermocyclerStep from opentrons_shared_data.errors.exceptions import PipetteLiquidNotFoundError @@ -39,12 +38,24 @@ def load_common_liquid_setup_labware_and_instruments( def load_disposable_lids( - protocol: ProtocolContext, num_of_lids: int, deck_slot: List[str] + protocol: ProtocolContext, + num_of_lids: int, + deck_slot: List[str], + deck_riser: bool = False, ) -> List[Labware]: """Load Stack of Disposable lids.""" - unused_lids = [ - protocol.load_labware("opentrons_tough_pcr_auto_sealing_lid", deck_slot[0]) - ] + if deck_riser: + deck_riser_adapter = protocol.load_adapter( + "opentrons_flex_deck_riser", deck_slot[0] + ) + unused_lids = [ + deck_riser_adapter.load_labware("opentrons_tough_pcr_auto_sealing_lid") + ] + else: + unused_lids = [ + protocol.load_labware("opentrons_tough_pcr_auto_sealing_lid", deck_slot[0]) + ] + if len(deck_slot) == 1: for i in range(num_of_lids - 1): unused_lids.append( @@ -152,6 +163,16 @@ def create_disposable_lid_parameter(parameters: ParameterContext) -> None: ) +def create_tc_lid_deck_riser_parameter(parameters: ParameterContext) -> None: + """Create parameter for tc lid deck riser.""" + parameters.add_bool( + variable_name="deck_riser", + display_name="Deck Riser", + description="True means use deck riser.", + default=False, + ) + + def create_tip_size_parameter(parameters: ParameterContext) -> None: """Create parameter for tip size.""" parameters.add_str( @@ -203,6 +224,31 @@ def create_hs_speed_parameter(parameters: ParameterContext) -> None: ) +def create_tc_compatible_labware_parameter(parameters: ParameterContext) -> None: + """Create parameter for labware type compatible with thermocycler.""" + parameters.add_str( + variable_name="labware_tc_compatible", + display_name="Labware Type for Thermocycler", + description="labware compatible with thermocycler.", + default="biorad_96_wellplate_200ul_pcr", + choices=[ + { + "display_name": "Armadillo_200ul", + "value": "armadillo_96_wellplate_200ul_pcr_full_skirt", + }, + {"display_name": "Bio-Rad_200ul", "value": "biorad_96_wellplate_200ul_pcr"}, + { + "display_name": "NEST_100ul", + "value": "nest_96_wellplate_100ul_pcr_full_skirt", + }, + { + "display_name": "Opentrons_200ul", + "value": "opentrons_96_wellplate_200ul_pcr_full_skirt", + }, + ], + ) + + # FUNCTIONS FOR COMMON MODULE SEQUENCES @@ -334,10 +380,10 @@ def find_liquid_height_of_all_wells( """Find the liquid height of all wells in protocol.""" dict_of_labware_heights = {} pipette.pick_up_tip() + pip_channels = pipette.active_channels for well in wells: labware_name = well.parent.load_name total_number_of_wells_in_plate = len(well.parent.wells()) - pip_channels = pipette.active_channels # if pip_channels is > 1 and total_wells > 12 - only probe 1st row. if ( pip_channels > 1 @@ -349,11 +395,11 @@ def find_liquid_height_of_all_wells( elif total_number_of_wells_in_plate <= 12: liquid_height_of_well = find_liquid_height(pipette, well) dict_of_labware_heights[labware_name, well] = liquid_height_of_well - if pip_channels == pipette.channels: + if pip_channels != pipette.channels: + pipette.drop_tip() + else: pipette.return_tip() pipette.reset_tipracks() - else: - pipette.drop_tip() msg = f"result: {dict_of_labware_heights}" protocol.comment(msg=msg) return dict_of_labware_heights diff --git a/abr-testing/abr_testing/protocols/liquid_setups/10_ZymoBIOMICS Magbead Liquid Setup.py b/abr-testing/abr_testing/protocols/liquid_setups/10_ZymoBIOMICS Magbead Liquid Setup.py index e5c70194afa..422102e4321 100644 --- a/abr-testing/abr_testing/protocols/liquid_setups/10_ZymoBIOMICS Magbead Liquid Setup.py +++ b/abr-testing/abr_testing/protocols/liquid_setups/10_ZymoBIOMICS Magbead Liquid Setup.py @@ -29,23 +29,35 @@ def run(protocol: protocol_api.ProtocolContext) -> None: res1 = protocol.load_labware("nest_12_reservoir_15ml", "C3", "R1") res2 = protocol.load_labware("nest_12_reservoir_15ml", "B3", "R2") - lysis_and_pk = (3200 + 320) / 8 - beads_and_binding = (275 + 6600) / 8 - binding2 = 5500 / 8 - wash1 = 5500 / 8 - final_elution = 2100 / 8 + lysis_and_pk = 12320 / 8 + beads_and_binding = 11875 / 8 + binding2 = 13500 / 8 wash2 = 9000 / 8 - wash3 = 9000 / 8 + wash2_list = [wash2] * 12 # Fill up Plates # Res1 p1000.transfer( - volume=[lysis_and_pk, beads_and_binding, binding2, wash1, final_elution], + volume=[ + lysis_and_pk, + beads_and_binding, + beads_and_binding, + beads_and_binding, + binding2, + binding2, + binding2, + binding2, + binding2, + ], source=source_reservoir["A1"].bottom(z=0.2), dest=[ res1["A1"].top(), res1["A2"].top(), + res1["A3"].top(), + res1["A4"].top(), res1["A5"].top(), + res1["A6"].top(), res1["A7"].top(), + res1["A8"].top(), res1["A12"].top(), ], blow_out=True, @@ -54,9 +66,9 @@ def run(protocol: protocol_api.ProtocolContext) -> None: ) # Res2 p1000.transfer( - volume=[wash2, wash3], + volume=wash2_list, source=source_reservoir["A1"], - dest=[res2["A1"].top(), res2["A7"].top()], + dest=res2.wells(), blow_out=True, blowout_location="source well", trash=False, diff --git a/abr-testing/abr_testing/protocols/liquid_setups/7_HDQ DNA Bacteria Extraction Liquid Setup.py b/abr-testing/abr_testing/protocols/liquid_setups/7_HDQ DNA Bacteria Extraction Liquid Setup.py index 410e46fd9bb..4addbd5c7e8 100644 --- a/abr-testing/abr_testing/protocols/liquid_setups/7_HDQ DNA Bacteria Extraction Liquid Setup.py +++ b/abr-testing/abr_testing/protocols/liquid_setups/7_HDQ DNA Bacteria Extraction Liquid Setup.py @@ -35,15 +35,23 @@ def run(protocol: protocol_api.ProtocolContext) -> None: res1 = protocol.load_labware("nest_12_reservoir_15ml", "D2", "reagent reservoir 1") # Label Reservoirs well1 = res1["A1"].top() + well2 = res1["A2"].top() well3 = res1["A3"].top() well4 = res1["A4"].top() + well5 = res1["A5"].top() + well6 = res1["A6"].top() well7 = res1["A7"].top() + well8 = res1["A8"].top() + well9 = res1["A9"].top() well10 = res1["A10"].top() - + well11 = res1["A11"].top() + well12 = res1["A12"].top() # Volumes wash = 600 - al_and_pk = 468 - beads_and_binding = 552 + binding = 320 + beads = 230 + pk = 230 + lysis = 230 # Sample Plate p1000.transfer( @@ -65,9 +73,41 @@ def run(protocol: protocol_api.ProtocolContext) -> None: ) # Res 1 p1000.transfer( - volume=[beads_and_binding, al_and_pk, wash, wash, wash], + volume=[ + binding, + beads, + binding, + beads, + lysis, + pk, + wash, + wash, + wash, + wash, + wash, + wash, + wash, + wash, + wash, + ], source=source_reservoir["A1"].bottom(z=0.5), - dest=[well1, well3, well4, well7, well10], + dest=[ + well1, + well1, + well2, + well2, + well3, + well3, + well4, + well5, + well6, + well7, + well8, + well9, + well10, + well11, + well12, + ], blowout=True, blowout_location="source well", trash=False, diff --git a/abr-testing/abr_testing/protocols/test_protocols/tc_biorad_evap_test.py b/abr-testing/abr_testing/protocols/test_protocols/tc_biorad_evap_test.py new file mode 100644 index 00000000000..4593c06f425 --- /dev/null +++ b/abr-testing/abr_testing/protocols/test_protocols/tc_biorad_evap_test.py @@ -0,0 +1,109 @@ +"""Test TC Disposable Lid with BioRad Plate.""" + +from opentrons.protocol_api import ( + ProtocolContext, + ParameterContext, + Well, + Labware, + InstrumentContext, +) +from typing import List +from abr_testing.protocols import helpers +from opentrons.protocol_api.module_contexts import ThermocyclerContext +from opentrons.hardware_control.modules.types import ThermocyclerStep + +metadata = {"protocolName": "Tough Auto Seal Lid Evaporation Test"} +requirements = {"robotType": "Flex", "apiLevel": "2.21"} + + +def add_parameters(parameters: ParameterContext) -> None: + """Add Parameters.""" + helpers.create_single_pipette_mount_parameter(parameters) + helpers.create_tc_lid_deck_riser_parameter(parameters) + helpers.create_tc_compatible_labware_parameter(parameters) + + +def _pcr_cycle(thermocycler: ThermocyclerContext) -> None: + """30x cycles of: 70° for 30s 72° for 30s 95° for 10s.""" + profile_TAG2: List[ThermocyclerStep] = [ + {"temperature": 70, "hold_time_seconds": 30}, + {"temperature": 72, "hold_time_seconds": 30}, + {"temperature": 95, "hold_time_seconds": 10}, + ] + thermocycler.execute_profile( + steps=profile_TAG2, repetitions=30, block_max_volume=50 + ) + + +def _fill_with_liquid_and_measure( + protocol: ProtocolContext, + pipette: InstrumentContext, + reservoir: Labware, + plate_in_cycler: Labware, +) -> None: + """Fill plate with 10 ul per well.""" + locations: List[Well] = [ + plate_in_cycler["A1"], + plate_in_cycler["A2"], + plate_in_cycler["A3"], + plate_in_cycler["A4"], + plate_in_cycler["A5"], + plate_in_cycler["A6"], + plate_in_cycler["A7"], + plate_in_cycler["A8"], + plate_in_cycler["A9"], + plate_in_cycler["A10"], + plate_in_cycler["A11"], + plate_in_cycler["A12"], + ] + volumes = [10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10] + protocol.pause("Weight Armadillo Plate, place on thermocycler") + # pipette 10uL into Armadillo wells + source_well: Well = reservoir["A1"] + pipette.distribute( + volume=volumes, + source=source_well, + dest=locations, + return_tips=True, + blow_out=False, + ) + protocol.pause("Weight Armadillo Plate, place on thermocycler, put on lid") + + +def run(ctx: ProtocolContext) -> None: + """Evaporation Test.""" + pipette_mount = ctx.params.pipette_mount # type: ignore[attr-defined] + deck_riser = ctx.params.deck_riser # type: ignore[attr-defined] + labware_tc_compatible = ctx.params.labware_tc_compatible # type: ignore[attr-defined] + tiprack_50 = ctx.load_labware("opentrons_flex_96_tiprack_50ul", "B2") + ctx.load_trash_bin("A3") + tc_mod: ThermocyclerContext = ctx.load_module( + helpers.tc_str + ) # type: ignore[assignment] + plate_in_cycler = tc_mod.load_labware(labware_tc_compatible) + p50 = ctx.load_instrument("flex_8channel_50", pipette_mount, tip_racks=[tiprack_50]) + unused_lids = helpers.load_disposable_lids(ctx, 5, ["D2"], deck_riser) + top_lid = unused_lids[0] + reservoir = ctx.load_labware("nest_12_reservoir_15ml", "A2") + tc_mod.open_lid() + tc_mod.set_block_temperature(4) + tc_mod.set_lid_temperature(105) + + # hold at 95° for 3 minutes + profile_TAG: List[ThermocyclerStep] = [{"temperature": 95, "hold_time_minutes": 3}] + # hold at 72° for 5min + profile_TAG3: List[ThermocyclerStep] = [{"temperature": 72, "hold_time_minutes": 5}] + tc_mod.open_lid() + _fill_with_liquid_and_measure(ctx, p50, reservoir, plate_in_cycler) + ctx.move_labware(top_lid, plate_in_cycler, use_gripper=True) + tc_mod.close_lid() + tc_mod.execute_profile(steps=profile_TAG, repetitions=1, block_max_volume=50) + _pcr_cycle(tc_mod) + tc_mod.execute_profile(steps=profile_TAG3, repetitions=1, block_max_volume=50) + # # # Cool to 4° + tc_mod.set_block_temperature(4) + tc_mod.set_lid_temperature(105) + # Open lid + tc_mod.open_lid() + ctx.move_labware(top_lid, "C2", use_gripper=True) + ctx.move_labware(top_lid, unused_lids[1], use_gripper=True) diff --git a/abr-testing/abr_testing/tools/abr_scale.py b/abr-testing/abr_testing/tools/abr_scale.py index 0f6c29c3f69..a35fee93fbf 100644 --- a/abr-testing/abr_testing/tools/abr_scale.py +++ b/abr-testing/abr_testing/tools/abr_scale.py @@ -149,6 +149,7 @@ def get_most_recent_run_and_record( headers, runs_and_lpc, headers_lpc, + list_of_heights, ) = abr_google_drive.create_data_dictionary( most_recent_run_id, storage_directory, @@ -163,6 +164,15 @@ def get_most_recent_run_and_record( start_row = google_sheet_abr_data.get_index_row() + 1 google_sheet_abr_data.batch_update_cells(runs_and_robots, "A", start_row, "0") print("Wrote run to ABR-run-data") + # Add liquid height detection to abr sheet + google_sheet_ldf = google_sheets_tool.google_sheet( + credentials_path, "ABR-run-data", 4 + ) + start_row_lhd = google_sheet_ldf.get_index_row() + 1 + google_sheet_ldf.batch_update_cells( + list_of_heights, "A", start_row_lhd, "1795535088" + ) + print("Wrote found liquid heights to ABR-run-data") # Add LPC to google sheet google_sheet_lpc = google_sheets_tool.google_sheet( credentials_path, "ABR-LPC", tab_number=0 diff --git a/analyses-snapshot-testing/Makefile b/analyses-snapshot-testing/Makefile index 13c4e603f3c..de5e0381131 100644 --- a/analyses-snapshot-testing/Makefile +++ b/analyses-snapshot-testing/Makefile @@ -1,38 +1,56 @@ +BASE_IMAGE_NAME ?= opentrons-python-base:3.10 +CACHEBUST ?= $(shell date +%s) +ANALYSIS_REF ?= edge +PROTOCOL_NAMES ?= all +OVERRIDE_PROTOCOL_NAMES ?= all +OPENTRONS_VERSION ?= edge + +export OPENTRONS_VERSION # used for server +export ANALYSIS_REF # used for analysis and snapshot test +export PROTOCOL_NAMES # used for the snapshot test +export OVERRIDE_PROTOCOL_NAMES # used for the snapshot test + +ifeq ($(CI), true) + PYTHON=python +else + PYTHON=pyenv exec python +endif + .PHONY: black black: - python -m pipenv run python -m black . + $(PYTHON) -m pipenv run python -m black . .PHONY: black-check black-check: - python -m pipenv run python -m black . --check + $(PYTHON) -m pipenv run python -m black . --check .PHONY: ruff ruff: - python -m pipenv run python -m ruff check . --fix + $(PYTHON) -m pipenv run python -m ruff check . --fix .PHONY: ruff-check ruff-check: - python -m pipenv run python -m ruff check . + $(PYTHON) -m pipenv run python -m ruff check . .PHONY: mypy mypy: - python -m pipenv run python -m mypy automation tests citools + $(PYTHON) -m pipenv run python -m mypy automation tests citools .PHONY: lint lint: black-check ruff-check mypy .PHONY: format format: - @echo runnning black + @echo "Running black" $(MAKE) black - @echo running ruff + @echo "Running ruff" $(MAKE) ruff - @echo formatting the readme with yarn prettier + @echo "Formatting the readme with yarn prettier" $(MAKE) format-readme .PHONY: test-ci test-ci: - python -m pipenv run python -m pytest -m "emulated_alpha" + $(PYTHON) -m pipenv run python -m pytest -m "emulated_alpha" .PHONY: test-protocol-analysis test-protocol-analysis: @@ -40,66 +58,64 @@ test-protocol-analysis: .PHONY: setup setup: install-pipenv - python -m pipenv install + $(PYTHON) -m pipenv install .PHONY: teardown teardown: - python -m pipenv --rm + $(PYTHON) -m pipenv --rm .PHONY: format-readme format-readme: - yarn prettier --ignore-path .eslintignore --write analyses-snapshot-testing/**/*.md + yarn prettier --ignore-path .eslintignore --write analyses-snapshot-testing/**/*.md .github/workflows/analyses-snapshot-test.yaml .PHONY: install-pipenv install-pipenv: - python -m pip install -U pipenv - -ANALYSIS_REF ?= edge -PROTOCOL_NAMES ?= all -OVERRIDE_PROTOCOL_NAMES ?= all - -export ANALYSIS_REF -export PROTOCOL_NAMES -export OVERRIDE_PROTOCOL_NAMES + $(PYTHON) -m pip install -U pipenv .PHONY: snapshot-test snapshot-test: @echo "ANALYSIS_REF is $(ANALYSIS_REF)" @echo "PROTOCOL_NAMES is $(PROTOCOL_NAMES)" @echo "OVERRIDE_PROTOCOL_NAMES is $(OVERRIDE_PROTOCOL_NAMES)" - python -m pipenv run pytest -k analyses_snapshot_test -vv + $(PYTHON) -m pipenv run pytest -k analyses_snapshot_test -vv .PHONY: snapshot-test-update snapshot-test-update: @echo "ANALYSIS_REF is $(ANALYSIS_REF)" @echo "PROTOCOL_NAMES is $(PROTOCOL_NAMES)" @echo "OVERRIDE_PROTOCOL_NAMES is $(OVERRIDE_PROTOCOL_NAMES)" - python -m pipenv run pytest -k analyses_snapshot_test --snapshot-update + $(PYTHON) -m pipenv run pytest -k analyses_snapshot_test --snapshot-update -CACHEBUST := $(shell date +%s) +.PHONY: build-base-image +build-base-image: + @echo "Building the base image $(BASE_IMAGE_NAME)" + docker build --build-arg BASE_IMAGE_NAME=$(BASE_IMAGE_NAME) -f citools/Dockerfile.base -t $(BASE_IMAGE_NAME) citools/. .PHONY: build-opentrons-analysis build-opentrons-analysis: @echo "Building docker image for $(ANALYSIS_REF)" @echo "The image will be named opentrons-analysis:$(ANALYSIS_REF)" @echo "If you want to build a different version, run 'make build-opentrons-analysis ANALYSIS_REF='" - @echo "Cache is always busted to ensure latest version of the code is used" - docker build --build-arg ANALYSIS_REF=$(ANALYSIS_REF) --build-arg CACHEBUST=$(CACHEBUST) -t opentrons-analysis:$(ANALYSIS_REF) citools/. + docker build --build-arg BASE_IMAGE_NAME=$(BASE_IMAGE_NAME) --build-arg ANALYSIS_REF=$(ANALYSIS_REF) --build-arg CACHEBUST=$(CACHEBUST) -t opentrons-analysis:$(ANALYSIS_REF) -f citools/Dockerfile.analyze citools/. + +.PHONY: local-build +local-build: + @echo "Building docker image for your local opentrons code" + @echo "The image will be named opentrons-analysis:local" + @echo "For a fresh build, run 'make local-build NO_CACHE=1'" + docker build --build-arg BASE_IMAGE_NAME=$(BASE_IMAGE_NAME) $(BUILD_FLAGS) -t opentrons-analysis:local -f citools/Dockerfile.local .. || true + @echo "Build complete" .PHONY: generate-protocols generate-protocols: - python -m pipenv run python -m automation.data.protocol_registry - - -OPENTRONS_VERSION ?= edge -export OPENTRONS_VERSION + $(PYTHON) -m pipenv run python -m automation.data.protocol_registry .PHONY: build-rs build-rs: @echo "Building docker image for opentrons-robot-server:$(OPENTRONS_VERSION)" @echo "Cache is always busted to ensure latest version of the code is used" @echo "If you want to build a different version, run 'make build-rs OPENTRONS_VERSION=chore_release-8.0.0'" - docker build --build-arg OPENTRONS_VERSION=$(OPENTRONS_VERSION) --build-arg CACHEBUST=$(CACHEBUST) -t opentrons-robot-server:$(OPENTRONS_VERSION) -f citools/Dockerfile.server . + docker build --build-arg BASE_IMAGE_NAME=$(BASE_IMAGE_NAME) --build-arg OPENTRONS_VERSION=$(OPENTRONS_VERSION) --build-arg CACHEBUST=$(CACHEBUST) -t opentrons-robot-server:$(OPENTRONS_VERSION) -f citools/Dockerfile.server . .PHONY: run-flex run-flex: diff --git a/analyses-snapshot-testing/README.md b/analyses-snapshot-testing/README.md index 51a8e194ca1..78423b8447f 100644 --- a/analyses-snapshot-testing/README.md +++ b/analyses-snapshot-testing/README.md @@ -18,7 +18,10 @@ > This ALWAYS gets the remote code pushed to Opentrons/opentrons for the specified ANALYSIS_REF -`make build-opentrons-analysis ANALYSIS_REF=chore_release-8.0.0` +- build the base image + - `make build-base-image` +- build the opentrons-analysis image + - `make build-opentrons-analysis ANALYSIS_REF=release` ## Running the tests locally @@ -51,10 +54,28 @@ ```shell cd analyses-snapshot-testing \ -&& make build-rs OPENTRONS_VERSION=chore_release-8.0.0 \ -&& make run-rs OPENTRONS_VERSION=chore_release-8.0.0` +&& make build-base-image \ +&& make build-rs OPENTRONS_VERSION=release \ +&& make run-rs OPENTRONS_VERSION=release` ``` ### Default OPENTRONS_VERSION=edge in the Makefile so you can omit it if you want latest edge -`cd analyses-snapshot-testing && make build-rs && make run-rs` +```shell +cd analyses-snapshot-testing \ +&& make build-base-image \ +&& make build-rs \ +&& make run-rs +``` + +## Running the Analyses Battery against your local code + +> This copies in your local code to the container and runs the analyses battery against it. + +1. `make build-base-image` +1. `make build-local` +1. `make local-snapshot-test` + +You have the option to specify one or many protocols to run the analyses on. This is also described above [Running the tests against specific protocols](#running-the-tests-against-specific-protocols) + +- `make local-snapshot-test PROTOCOL_NAMES=Flex_S_v2_19_Illumina_DNA_PCR_Free OVERRIDE_PROTOCOL_NAMES=none` diff --git a/analyses-snapshot-testing/citools/Dockerfile b/analyses-snapshot-testing/citools/Dockerfile.analyze similarity index 75% rename from analyses-snapshot-testing/citools/Dockerfile rename to analyses-snapshot-testing/citools/Dockerfile.analyze index 123b7636652..1b85981cdaf 100644 --- a/analyses-snapshot-testing/citools/Dockerfile +++ b/analyses-snapshot-testing/citools/Dockerfile.analyze @@ -1,10 +1,6 @@ -# Use 3.10 just like the app does -FROM python:3.10-slim-bullseye +ARG BASE_IMAGE_NAME=opentrons-python-base:3.10 -# Update packages and install git -RUN apt-get update && \ - apt-get upgrade -y && \ - apt-get install -y git libsystemd-dev +FROM ${BASE_IMAGE_NAME} # Define build arguments ARG ANALYSIS_REF=edge diff --git a/analyses-snapshot-testing/citools/Dockerfile.base b/analyses-snapshot-testing/citools/Dockerfile.base new file mode 100644 index 00000000000..086987e671b --- /dev/null +++ b/analyses-snapshot-testing/citools/Dockerfile.base @@ -0,0 +1,7 @@ +# Use Python 3.10 as the base image +FROM python:3.10-slim-bullseye + +# Update packages and install dependencies +RUN apt-get update && \ + apt-get upgrade -y && \ + apt-get install -y git libsystemd-dev build-essential pkg-config network-manager diff --git a/analyses-snapshot-testing/citools/Dockerfile.local b/analyses-snapshot-testing/citools/Dockerfile.local new file mode 100644 index 00000000000..2346b4680c2 --- /dev/null +++ b/analyses-snapshot-testing/citools/Dockerfile.local @@ -0,0 +1,19 @@ +ARG BASE_IMAGE_NAME=opentrons-python-base:3.10 + +FROM ${BASE_IMAGE_NAME} + +# Set the working directory in the container +WORKDIR /opentrons + +# Copy everything from the build context into the /opentrons directory +# root directory .dockerignore file is respected +COPY . /opentrons + +# Install required packages from the copied code +RUN python -m pip install -U ./shared-data/python +RUN python -m pip install -U ./hardware[flex] +RUN python -m pip install -U ./api +RUN python -m pip install -U pandas==1.4.3 + +# The default command to keep the container running +CMD ["tail", "-f", "/dev/null"] diff --git a/analyses-snapshot-testing/citools/Dockerfile.server b/analyses-snapshot-testing/citools/Dockerfile.server index 6d4d9edcda3..0c44c1e04f0 100644 --- a/analyses-snapshot-testing/citools/Dockerfile.server +++ b/analyses-snapshot-testing/citools/Dockerfile.server @@ -1,10 +1,6 @@ -# Use Python 3.10 as the base image -FROM python:3.10-slim-bullseye +ARG BASE_IMAGE_NAME=opentrons-python-base:3.10 -# Update packages and install dependencies -RUN apt-get update && \ - apt-get upgrade -y && \ - apt-get install -y git libsystemd-dev build-essential pkg-config network-manager +FROM ${BASE_IMAGE_NAME} # Define build arguments ARG OPENTRONS_VERSION=edge diff --git a/analyses-snapshot-testing/citools/generate_analyses.py b/analyses-snapshot-testing/citools/generate_analyses.py index 52aba70363b..7d550b47776 100644 --- a/analyses-snapshot-testing/citools/generate_analyses.py +++ b/analyses-snapshot-testing/citools/generate_analyses.py @@ -24,7 +24,7 @@ HOST_RESULTS: Path = Path(Path(__file__).parent.parent, "analysis_results") ANALYSIS_SUFFIX: str = "analysis.json" ANALYSIS_TIMEOUT_SECONDS: int = 30 -ANALYSIS_CONTAINER_INSTANCES: int = 5 +MAX_ANALYSIS_CONTAINER_INSTANCES: int = 5 console = Console() @@ -241,6 +241,12 @@ def analyze_against_image(tag: str, protocols: List[TargetProtocol], num_contain return protocols +def get_container_instances(protocol_len: int) -> int: + # Scaling linearly with the number of protocols + instances = max(1, min(MAX_ANALYSIS_CONTAINER_INSTANCES, protocol_len // 10)) + return instances + + def generate_analyses_from_test(tag: str, protocols: List[Protocol]) -> None: """Generate analyses from the tests.""" start_time = time.time() @@ -260,6 +266,7 @@ def generate_analyses_from_test(tag: str, protocols: List[Protocol]) -> None: protocol_custom_labware_paths_in_container(test_protocol), ) ) - analyze_against_image(tag, protocols_to_process, ANALYSIS_CONTAINER_INSTANCES) + instance_count = get_container_instances(len(protocols_to_process)) + analyze_against_image(tag, protocols_to_process, instance_count) end_time = time.time() console.print(f"Clock time to generate analyses: {end_time - start_time:.2f} seconds.") diff --git a/api-client/src/runs/types.ts b/api-client/src/runs/types.ts index 621443dce03..a6279d12145 100644 --- a/api-client/src/runs/types.ts +++ b/api-client/src/runs/types.ts @@ -214,7 +214,7 @@ export interface NozzleLayoutValues { } export interface PlaceLabwareState { - labwareId: string + labwareURI: string location: OnDeckLabwareLocation shouldPlaceDown: boolean } diff --git a/api/release-notes-internal.md b/api/release-notes-internal.md index 7a3d81e5cbf..1253f7e92fd 100644 --- a/api/release-notes-internal.md +++ b/api/release-notes-internal.md @@ -2,6 +2,10 @@ For more details about this release, please see the full [technical change log][ [technical change log]: https://github.com/Opentrons/opentrons/releases +## Internal Release 2.2.0-alpha.1 + +This internal release, pulled from the `edge` branch, contains features being developed for 8.2.0. It's for internal testing only. + ## Internal Release 2.2.0-alpha.0 This internal release, pulled from the `edge` branch, contains features being developed for 8.2.0. It's for internal testing only. diff --git a/api/src/opentrons/drivers/absorbance_reader/async_byonoy.py b/api/src/opentrons/drivers/absorbance_reader/async_byonoy.py index dc88c1b2dec..0460a016229 100644 --- a/api/src/opentrons/drivers/absorbance_reader/async_byonoy.py +++ b/api/src/opentrons/drivers/absorbance_reader/async_byonoy.py @@ -24,7 +24,7 @@ SN_PARSER = re.compile(r'ATTRS{serial}=="(?P.+?)"') VERSION_PARSER = re.compile(r"Absorbance (?PV\d+\.\d+\.\d+)") -SERIAL_PARSER = re.compile(r"(?PBYO[A-Z]{3}[0-9]{5})") +SERIAL_PARSER = re.compile(r"(?P(OPT|BYO)[A-Z]{3}[0-9]+)") class AsyncByonoy: @@ -156,9 +156,9 @@ async def get_device_information(self) -> Dict[str, str]: func=partial(self._interface.get_device_information, handle), ) self._raise_if_error(err.name, f"Error getting device information: {err}") - serial_match = SERIAL_PARSER.match(device_info.sn) + serial_match = SERIAL_PARSER.fullmatch(device_info.sn) version_match = VERSION_PARSER.match(device_info.version) - serial = serial_match["serial"] if serial_match else "BYOMAA00000" + serial = serial_match["serial"].strip() if serial_match else "OPTMAA00000" version = version_match["version"].lower() if version_match else "v0.0.0" info = { "serial": serial, diff --git a/api/src/opentrons/hardware_control/backends/ot3controller.py b/api/src/opentrons/hardware_control/backends/ot3controller.py index 1e2a484a4e4..66ffc1efab1 100644 --- a/api/src/opentrons/hardware_control/backends/ot3controller.py +++ b/api/src/opentrons/hardware_control/backends/ot3controller.py @@ -1012,6 +1012,7 @@ def _lookup_serial_key(pipette_name: FirmwarePipetteName) -> str: FirmwarePipetteName.p50_multi: "P50M", FirmwarePipetteName.p1000_96: "P1KH", FirmwarePipetteName.p50_96: "P50H", + FirmwarePipetteName.p200_96: "P2HH", } return lookup_name[pipette_name] diff --git a/api/src/opentrons/legacy_commands/helpers.py b/api/src/opentrons/legacy_commands/helpers.py index b3de03de4bc..5b08bb1e436 100644 --- a/api/src/opentrons/legacy_commands/helpers.py +++ b/api/src/opentrons/legacy_commands/helpers.py @@ -49,7 +49,9 @@ def stringify_disposal_location(location: Union[TrashBin, WasteChute]) -> str: def _stringify_labware_movement_location( - location: Union[DeckLocation, OffDeckType, Labware, ModuleContext, WasteChute] + location: Union[ + DeckLocation, OffDeckType, Labware, ModuleContext, WasteChute, TrashBin + ] ) -> str: if isinstance(location, (int, str)): return f"slot {location}" @@ -61,11 +63,15 @@ def _stringify_labware_movement_location( return str(location) elif isinstance(location, WasteChute): return "Waste Chute" + elif isinstance(location, TrashBin): + return "Trash Bin " + location.location.name def stringify_labware_movement_command( source_labware: Labware, - destination: Union[DeckLocation, OffDeckType, Labware, ModuleContext, WasteChute], + destination: Union[ + DeckLocation, OffDeckType, Labware, ModuleContext, WasteChute, TrashBin + ], use_gripper: bool, ) -> str: source_labware_text = _stringify_labware_movement_location(source_labware) diff --git a/api/src/opentrons/protocol_api/_liquid_properties.py b/api/src/opentrons/protocol_api/_liquid_properties.py index 8bd7aa6cfd8..cda533b1587 100644 --- a/api/src/opentrons/protocol_api/_liquid_properties.py +++ b/api/src/opentrons/protocol_api/_liquid_properties.py @@ -1,5 +1,6 @@ from dataclasses import dataclass -from typing import Optional, Dict, Sequence +from numpy import interp +from typing import Optional, Dict, Sequence, Union, Tuple from opentrons_shared_data.liquid_classes.liquid_class_definition import ( AspirateProperties as SharedDataAspirateProperties, @@ -18,9 +19,62 @@ Coordinate, ) -# TODO replace this with a class that can extrapolate given volumes to the correct float, -# also figure out how we want people to be able to set this -LiquidHandlingPropertyByVolume = Dict[str, float] +from . import validation + + +class LiquidHandlingPropertyByVolume: + def __init__(self, properties_by_volume: Dict[str, float]) -> None: + self._default = properties_by_volume["default"] + self._properties_by_volume: Dict[float, float] = { + float(volume): value + for volume, value in properties_by_volume.items() + if volume != "default" + } + # Volumes need to be sorted for proper interpolation of non-defined volumes, and the + # corresponding values need to be in the same order for them to be interpolated correctly + self._sorted_volumes: Tuple[float, ...] = () + self._sorted_values: Tuple[float, ...] = () + self._sort_volume_and_values() + + @property + def default(self) -> float: + """Get the default value not associated with any volume for this property.""" + return self._default + + def as_dict(self) -> Dict[Union[float, str], float]: + """Get a dictionary representation of all set volumes and values along with the default.""" + return self._properties_by_volume | {"default": self._default} + + def get_for_volume(self, volume: float) -> float: + """Get a value by volume for this property. Volumes not defined will be interpolated between set volumes.""" + validated_volume = validation.ensure_positive_float(volume) + try: + return self._properties_by_volume[validated_volume] + except KeyError: + # If volume is not defined in dictionary, do a piecewise interpolation with existing sorted values + return float( + interp(validated_volume, self._sorted_volumes, self._sorted_values) + ) + + def set_for_volume(self, volume: float, value: float) -> None: + """Add a new volume and value for the property for the interpolation curve.""" + validated_volume = validation.ensure_positive_float(volume) + self._properties_by_volume[validated_volume] = value + self._sort_volume_and_values() + + def delete_for_volume(self, volume: float) -> None: + """Remove an existing volume and value from the property.""" + try: + del self._properties_by_volume[volume] + self._sort_volume_and_values() + except KeyError: + raise KeyError(f"No value set for volume {volume} uL") + + def _sort_volume_and_values(self) -> None: + """Sort volume in increasing order along with corresponding values in matching order.""" + self._sorted_volumes, self._sorted_values = zip( + *sorted(self._properties_by_volume.items()) + ) @dataclass @@ -35,10 +89,10 @@ def enabled(self) -> bool: @enabled.setter def enabled(self, enable: bool) -> None: - # TODO insert bool validation here - if enable and self._duration is None: + validated_enable = validation.ensure_boolean(enable) + if validated_enable and self._duration is None: raise ValueError("duration must be set before enabling delay.") - self._enabled = enable + self._enabled = validated_enable @property def duration(self) -> Optional[float]: @@ -46,8 +100,8 @@ def duration(self) -> Optional[float]: @duration.setter def duration(self, new_duration: float) -> None: - # TODO insert positive float validation here - self._duration = new_duration + validated_duration = validation.ensure_positive_float(new_duration) + self._duration = validated_duration @dataclass @@ -64,14 +118,14 @@ def enabled(self) -> bool: @enabled.setter def enabled(self, enable: bool) -> None: - # TODO insert bool validation here - if enable and ( + validated_enable = validation.ensure_boolean(enable) + if validated_enable and ( self._z_offset is None or self._mm_to_edge is None or self._speed is None ): raise ValueError( "z_offset, mm_to_edge and speed must be set before enabling touch tip." ) - self._enabled = enable + self._enabled = validated_enable @property def z_offset(self) -> Optional[float]: @@ -79,8 +133,8 @@ def z_offset(self) -> Optional[float]: @z_offset.setter def z_offset(self, new_offset: float) -> None: - # TODO validation for float - self._z_offset = new_offset + validated_offset = validation.ensure_float(new_offset) + self._z_offset = validated_offset @property def mm_to_edge(self) -> Optional[float]: @@ -88,8 +142,8 @@ def mm_to_edge(self) -> Optional[float]: @mm_to_edge.setter def mm_to_edge(self, new_mm: float) -> None: - # TODO validation for float - self._z_offset = new_mm + validated_mm = validation.ensure_float(new_mm) + self._z_offset = validated_mm @property def speed(self) -> Optional[float]: @@ -97,8 +151,8 @@ def speed(self) -> Optional[float]: @speed.setter def speed(self, new_speed: float) -> None: - # TODO insert positive float validation here - self._speed = new_speed + validated_speed = validation.ensure_positive_float(new_speed) + self._speed = validated_speed @dataclass @@ -114,10 +168,10 @@ def enabled(self) -> bool: @enabled.setter def enabled(self, enable: bool) -> None: - # TODO insert bool validation here - if enable and (self._repetitions is None or self._volume is None): + validated_enable = validation.ensure_boolean(enable) + if validated_enable and (self._repetitions is None or self._volume is None): raise ValueError("repetitions and volume must be set before enabling mix.") - self._enabled = enable + self._enabled = validated_enable @property def repetitions(self) -> Optional[int]: @@ -125,8 +179,8 @@ def repetitions(self) -> Optional[int]: @repetitions.setter def repetitions(self, new_repetitions: int) -> None: - # TODO validations for positive int - self._repetitions = new_repetitions + validated_repetitions = validation.ensure_positive_int(new_repetitions) + self._repetitions = validated_repetitions @property def volume(self) -> Optional[float]: @@ -134,8 +188,8 @@ def volume(self) -> Optional[float]: @volume.setter def volume(self, new_volume: float) -> None: - # TODO validations for volume float - self._volume = new_volume + validated_volume = validation.ensure_positive_float(new_volume) + self._volume = validated_volume @dataclass @@ -151,12 +205,12 @@ def enabled(self) -> bool: @enabled.setter def enabled(self, enable: bool) -> None: - # TODO insert bool validation here - if enable and (self._location is None or self._flow_rate is None): + validated_enable = validation.ensure_boolean(enable) + if validated_enable and (self._location is None or self._flow_rate is None): raise ValueError( "location and flow_rate must be set before enabling blowout." ) - self._enabled = enable + self._enabled = validated_enable @property def location(self) -> Optional[BlowoutLocation]: @@ -164,7 +218,6 @@ def location(self) -> Optional[BlowoutLocation]: @location.setter def location(self, new_location: str) -> None: - # TODO blowout location validation self._location = BlowoutLocation(new_location) @property @@ -173,8 +226,8 @@ def flow_rate(self) -> Optional[float]: @flow_rate.setter def flow_rate(self, new_flow_rate: float) -> None: - # TODO validations for positive float - self._flow_rate = new_flow_rate + validated_flow_rate = validation.ensure_positive_float(new_flow_rate) + self._flow_rate = validated_flow_rate @dataclass @@ -191,7 +244,6 @@ def position_reference(self) -> PositionReference: @position_reference.setter def position_reference(self, new_position: str) -> None: - # TODO validation for position reference self._position_reference = PositionReference(new_position) @property @@ -200,8 +252,8 @@ def offset(self) -> Coordinate: @offset.setter def offset(self, new_offset: Sequence[float]) -> None: - # TODO validate valid coordinates - self._offset = Coordinate(x=new_offset[0], y=new_offset[1], z=new_offset[2]) + x, y, z = validation.validate_coordinates(new_offset) + self._offset = Coordinate(x=x, y=y, z=z) @property def speed(self) -> float: @@ -209,8 +261,8 @@ def speed(self) -> float: @speed.setter def speed(self, new_speed: float) -> None: - # TODO insert positive float validation here - self._speed = new_speed + validated_speed = validation.ensure_positive_float(new_speed) + self._speed = validated_speed @property def delay(self) -> DelayProperties: @@ -276,7 +328,6 @@ def position_reference(self) -> PositionReference: @position_reference.setter def position_reference(self, new_position: str) -> None: - # TODO validation for position reference self._position_reference = PositionReference(new_position) @property @@ -285,8 +336,8 @@ def offset(self) -> Coordinate: @offset.setter def offset(self, new_offset: Sequence[float]) -> None: - # TODO validate valid coordinates - self._offset = Coordinate(x=new_offset[0], y=new_offset[1], z=new_offset[2]) + x, y, z = validation.validate_coordinates(new_offset) + self._offset = Coordinate(x=x, y=y, z=z) @property def flow_rate_by_volume(self) -> LiquidHandlingPropertyByVolume: @@ -310,8 +361,8 @@ def pre_wet(self) -> bool: @pre_wet.setter def pre_wet(self, new_setting: bool) -> None: - # TODO boolean validation - self._pre_wet = new_setting + validated_setting = validation.ensure_boolean(new_setting) + self._pre_wet = validated_setting @property def retract(self) -> RetractAspirate: @@ -362,8 +413,6 @@ def disposal_by_volume(self) -> LiquidHandlingPropertyByVolume: return self._disposal_by_volume -# TODO (spp, 2024-10-17): create PAPI-equivalent types for all the properties -# and have validation on value updates with user-facing error messages @dataclass class TransferProperties: _aspirate: AspirateProperties @@ -461,7 +510,9 @@ def _build_retract_aspirate( _position_reference=retract_aspirate.positionReference, _offset=retract_aspirate.offset, _speed=retract_aspirate.speed, - _air_gap_by_volume=retract_aspirate.airGapByVolume, + _air_gap_by_volume=LiquidHandlingPropertyByVolume( + retract_aspirate.airGapByVolume + ), _touch_tip=_build_touch_tip_properties(retract_aspirate.touchTip), _delay=_build_delay_properties(retract_aspirate.delay), ) @@ -474,7 +525,9 @@ def _build_retract_dispense( _position_reference=retract_dispense.positionReference, _offset=retract_dispense.offset, _speed=retract_dispense.speed, - _air_gap_by_volume=retract_dispense.airGapByVolume, + _air_gap_by_volume=LiquidHandlingPropertyByVolume( + retract_dispense.airGapByVolume + ), _blowout=_build_blowout_properties(retract_dispense.blowout), _touch_tip=_build_touch_tip_properties(retract_dispense.touchTip), _delay=_build_delay_properties(retract_dispense.delay), @@ -489,7 +542,9 @@ def build_aspirate_properties( _retract=_build_retract_aspirate(aspirate_properties.retract), _position_reference=aspirate_properties.positionReference, _offset=aspirate_properties.offset, - _flow_rate_by_volume=aspirate_properties.flowRateByVolume, + _flow_rate_by_volume=LiquidHandlingPropertyByVolume( + aspirate_properties.flowRateByVolume + ), _pre_wet=aspirate_properties.preWet, _mix=_build_mix_properties(aspirate_properties.mix), _delay=_build_delay_properties(aspirate_properties.delay), @@ -504,9 +559,13 @@ def build_single_dispense_properties( _retract=_build_retract_dispense(single_dispense_properties.retract), _position_reference=single_dispense_properties.positionReference, _offset=single_dispense_properties.offset, - _flow_rate_by_volume=single_dispense_properties.flowRateByVolume, + _flow_rate_by_volume=LiquidHandlingPropertyByVolume( + single_dispense_properties.flowRateByVolume + ), _mix=_build_mix_properties(single_dispense_properties.mix), - _push_out_by_volume=single_dispense_properties.pushOutByVolume, + _push_out_by_volume=LiquidHandlingPropertyByVolume( + single_dispense_properties.pushOutByVolume + ), _delay=_build_delay_properties(single_dispense_properties.delay), ) @@ -521,9 +580,15 @@ def build_multi_dispense_properties( _retract=_build_retract_dispense(multi_dispense_properties.retract), _position_reference=multi_dispense_properties.positionReference, _offset=multi_dispense_properties.offset, - _flow_rate_by_volume=multi_dispense_properties.flowRateByVolume, - _conditioning_by_volume=multi_dispense_properties.conditioningByVolume, - _disposal_by_volume=multi_dispense_properties.disposalByVolume, + _flow_rate_by_volume=LiquidHandlingPropertyByVolume( + multi_dispense_properties.flowRateByVolume + ), + _conditioning_by_volume=LiquidHandlingPropertyByVolume( + multi_dispense_properties.conditioningByVolume + ), + _disposal_by_volume=LiquidHandlingPropertyByVolume( + multi_dispense_properties.disposalByVolume + ), _delay=_build_delay_properties(multi_dispense_properties.delay), ) diff --git a/api/src/opentrons/protocol_api/core/engine/labware.py b/api/src/opentrons/protocol_api/core/engine/labware.py index cf92a5ad3b8..70c6186a2d7 100644 --- a/api/src/opentrons/protocol_api/core/engine/labware.py +++ b/api/src/opentrons/protocol_api/core/engine/labware.py @@ -19,7 +19,7 @@ LabwareOffsetCreate, LabwareOffsetVector, ) -from opentrons.types import DeckSlotName, Point, NozzleMapInterface +from opentrons.types import DeckSlotName, NozzleMapInterface, Point, StagingSlotName from ..labware import AbstractLabware, LabwareLoadParams @@ -138,6 +138,10 @@ def is_adapter(self) -> bool: """Whether the labware is an adapter.""" return LabwareRole.adapter in self._definition.allowedRoles + def is_lid(self) -> bool: + """Whether the labware is a lid.""" + return LabwareRole.lid in self._definition.allowedRoles + def is_fixed_trash(self) -> bool: """Whether the labware is a fixed trash.""" return self._engine_client.state.labware.is_fixed_trash( @@ -185,9 +189,13 @@ def get_well_core(self, well_name: str) -> WellCore: def get_deck_slot(self) -> Optional[DeckSlotName]: """Get the deck slot the labware is in, if on deck.""" try: - return self._engine_client.state.geometry.get_ancestor_slot_name( + ancestor = self._engine_client.state.geometry.get_ancestor_slot_name( self.labware_id ) + if isinstance(ancestor, StagingSlotName): + # The only use case for get_deck_slot is with a legacy OT-2 function which resolves to a numerical deck slot, so we can ignore staging area slots for now + return None + return ancestor except ( LabwareNotOnDeckError, ModuleNotOnDeckError, diff --git a/api/src/opentrons/protocol_api/core/engine/module_core.py b/api/src/opentrons/protocol_api/core/engine/module_core.py index 1e6d4e26b2f..d3cf8dca725 100644 --- a/api/src/opentrons/protocol_api/core/engine/module_core.py +++ b/api/src/opentrons/protocol_api/core/engine/module_core.py @@ -41,6 +41,11 @@ from .exceptions import InvalidMagnetEngageHeightError +# Valid wavelength range for absorbance reader +ABS_WAVELENGTH_MIN = 350 +ABS_WAVELENGTH_MAX = 1000 + + class ModuleCore(AbstractModuleCore): """Module core logic implementation for Python protocols. Args: @@ -581,7 +586,39 @@ def initialize( "Cannot perform Initialize action on Absorbance Reader without calling `.close_lid()` first." ) - # TODO: check that the wavelengths are within the supported wavelengths + wavelength_len = len(wavelengths) + if mode == "single" and wavelength_len != 1: + raise ValueError( + f"Single mode can only be initialized with 1 wavelength" + f" {wavelength_len} wavelengths provided instead." + ) + + if mode == "multi" and (wavelength_len < 1 or wavelength_len > 6): + raise ValueError( + f"Multi mode can only be initialized with 1 - 6 wavelengths." + f" {wavelength_len} wavelengths provided instead." + ) + + if reference_wavelength is not None and ( + reference_wavelength < ABS_WAVELENGTH_MIN + or reference_wavelength > ABS_WAVELENGTH_MAX + ): + raise ValueError( + f"Unsupported reference wavelength: ({reference_wavelength}) needs" + f" to between {ABS_WAVELENGTH_MIN} and {ABS_WAVELENGTH_MAX} nm." + ) + + for wavelength in wavelengths: + if ( + not isinstance(wavelength, int) + or wavelength < ABS_WAVELENGTH_MIN + or wavelength > ABS_WAVELENGTH_MAX + ): + raise ValueError( + f"Unsupported sample wavelength: ({wavelength}) needs" + f" to between {ABS_WAVELENGTH_MIN} and {ABS_WAVELENGTH_MAX} nm." + ) + self._engine_client.execute_command( cmd.absorbance_reader.InitializeParams( moduleId=self.module_id, diff --git a/api/src/opentrons/protocol_api/core/engine/pipette_movement_conflict.py b/api/src/opentrons/protocol_api/core/engine/pipette_movement_conflict.py index 46968c486d7..6433c638190 100644 --- a/api/src/opentrons/protocol_api/core/engine/pipette_movement_conflict.py +++ b/api/src/opentrons/protocol_api/core/engine/pipette_movement_conflict.py @@ -9,6 +9,7 @@ ) from opentrons_shared_data.errors.exceptions import MotionPlanningFailureError +from opentrons.protocol_engine.errors import LocationIsStagingSlotError from opentrons_shared_data.module import FLEX_TC_LID_COLLISION_ZONE from opentrons.hardware_control import CriticalPoint @@ -63,7 +64,7 @@ def __init__(self, message: str) -> None: ) -def check_safe_for_pipette_movement( +def check_safe_for_pipette_movement( # noqa: C901 engine_state: StateView, pipette_id: str, labware_id: str, @@ -121,8 +122,12 @@ def check_safe_for_pipette_movement( f"Requested motion with the {primary_nozzle} nozzle partial configuration" f" is outside of robot bounds for the pipette." ) - - labware_slot = engine_state.geometry.get_ancestor_slot_name(labware_id) + ancestor = engine_state.geometry.get_ancestor_slot_name(labware_id) + if isinstance(ancestor, StagingSlotName): + raise LocationIsStagingSlotError( + "Cannot perform pipette actions on labware in Staging Area Slot." + ) + labware_slot = ancestor surrounding_slots = adjacent_slots_getters.get_surrounding_slots( slot=labware_slot.as_int(), robot_type=engine_state.config.robot_type @@ -282,8 +287,10 @@ def check_safe_for_tip_pickup_and_return( is_96_ch_tiprack_adapter = engine_state.labware.get_has_quirk( labware_id=tiprack_parent.labwareId, quirk="tiprackAdapterFor96Channel" ) - tiprack_height = engine_state.labware.get_dimensions(labware_id).z - adapter_height = engine_state.labware.get_dimensions(tiprack_parent.labwareId).z + tiprack_height = engine_state.labware.get_dimensions(labware_id=labware_id).z + adapter_height = engine_state.labware.get_dimensions( + labware_id=tiprack_parent.labwareId + ).z if is_partial_config and tiprack_height < adapter_height: raise PartialTipMovementNotAllowedError( f"{tiprack_name} cannot be on an adapter taller than the tip rack" diff --git a/api/src/opentrons/protocol_api/core/engine/protocol.py b/api/src/opentrons/protocol_api/core/engine/protocol.py index e19ae188216..f6ab2c89214 100644 --- a/api/src/opentrons/protocol_api/core/engine/protocol.py +++ b/api/src/opentrons/protocol_api/core/engine/protocol.py @@ -332,6 +332,7 @@ def move_labware( NonConnectedModuleCore, OffDeckType, WasteChute, + TrashBin, ], use_gripper: bool, pause_for_manual_move: bool, @@ -450,40 +451,10 @@ def load_module( existing_module_ids=list(self._module_cores_by_id.keys()), ) - # When the protocol engine is created, we add Module Lids as part of the deck fixed labware - # If a valid module exists in the deck config. For analysis, we add the labware here since - # deck fixed labware is not created under the same conditions. We also need to inject the Module - # lids when the module isnt already on the deck config, like when adding a new - # module during a protocol setup. - self._load_virtual_module_lid(module_core) - self._module_cores_by_id[module_core.module_id] = module_core return module_core - def _load_virtual_module_lid( - self, module_core: Union[ModuleCore, NonConnectedModuleCore] - ) -> None: - if isinstance(module_core, AbsorbanceReaderCore): - substate = self._engine_client.state.modules.get_absorbance_reader_substate( - module_core.module_id - ) - if substate.lid_id is None: - lid = self._engine_client.execute_command_without_recovery( - cmd.LoadLabwareParams( - loadName="opentrons_flex_lid_absorbance_plate_reader_module", - location=ModuleLocation(moduleId=module_core.module_id), - namespace="opentrons", - version=1, - displayName="Absorbance Reader Lid", - ) - ) - - self._engine_client.add_absorbance_reader_lid( - module_id=module_core.module_id, - lid_id=lid.labwareId, - ) - def _create_non_connected_module_core( self, load_module_result: LoadModuleResult ) -> NonConnectedModuleCore: @@ -816,6 +787,7 @@ def _convert_labware_location( NonConnectedModuleCore, OffDeckType, WasteChute, + TrashBin, ], ) -> LabwareLocation: if isinstance(location, LabwareCore): @@ -832,6 +804,7 @@ def _get_non_stacked_location( NonConnectedModuleCore, OffDeckType, WasteChute, + TrashBin, ] ) -> NonStackedLocation: if isinstance(location, (ModuleCore, NonConnectedModuleCore)): @@ -845,3 +818,5 @@ def _get_non_stacked_location( elif isinstance(location, WasteChute): # TODO(mm, 2023-12-06) This will need to determine the appropriate Waste Chute to return, but only move_labware uses this for now return AddressableAreaLocation(addressableAreaName="gripperWasteChute") + elif isinstance(location, TrashBin): + return AddressableAreaLocation(addressableAreaName=location.area_name) diff --git a/api/src/opentrons/protocol_api/core/labware.py b/api/src/opentrons/protocol_api/core/labware.py index c82dc7f1b06..283aa4c4443 100644 --- a/api/src/opentrons/protocol_api/core/labware.py +++ b/api/src/opentrons/protocol_api/core/labware.py @@ -96,6 +96,10 @@ def is_tip_rack(self) -> bool: def is_adapter(self) -> bool: """Whether the labware is an adapter.""" + @abstractmethod + def is_lid(self) -> bool: + """Whether the labware is a lid.""" + @abstractmethod def is_fixed_trash(self) -> bool: """Whether the labware is a fixed trash.""" diff --git a/api/src/opentrons/protocol_api/core/legacy/legacy_labware_core.py b/api/src/opentrons/protocol_api/core/legacy/legacy_labware_core.py index 241d8b932df..1b00dfcfecf 100644 --- a/api/src/opentrons/protocol_api/core/legacy/legacy_labware_core.py +++ b/api/src/opentrons/protocol_api/core/legacy/legacy_labware_core.py @@ -138,6 +138,11 @@ def is_tip_rack(self) -> bool: def is_adapter(self) -> bool: return False # Adapters were introduced in v2.15 and not supported in legacy protocols + def is_lid(self) -> bool: + return ( + False # Lids were introduced in v2.21 and not supported in legacy protocols + ) + def is_fixed_trash(self) -> bool: """Whether the labware is fixed trash.""" return "fixedTrash" in self.get_quirks() diff --git a/api/src/opentrons/protocol_api/core/legacy/legacy_protocol_core.py b/api/src/opentrons/protocol_api/core/legacy/legacy_protocol_core.py index 24fa3d2aab6..e672a6fe839 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 @@ -282,6 +282,7 @@ def move_labware( legacy_module_core.LegacyModuleCore, OffDeckType, WasteChute, + TrashBin, ], use_gripper: bool, pause_for_manual_move: bool, diff --git a/api/src/opentrons/protocol_api/core/protocol.py b/api/src/opentrons/protocol_api/core/protocol.py index 62e2d7cd1d7..ba9f9a7d14a 100644 --- a/api/src/opentrons/protocol_api/core/protocol.py +++ b/api/src/opentrons/protocol_api/core/protocol.py @@ -105,6 +105,7 @@ def move_labware( ModuleCoreType, OffDeckType, WasteChute, + TrashBin, ], use_gripper: bool, pause_for_manual_move: bool, diff --git a/api/src/opentrons/protocol_api/module_contexts.py b/api/src/opentrons/protocol_api/module_contexts.py index 9ae550f8d3f..7beab69c53f 100644 --- a/api/src/opentrons/protocol_api/module_contexts.py +++ b/api/src/opentrons/protocol_api/module_contexts.py @@ -3,6 +3,8 @@ import logging from typing import List, Dict, Optional, Union, cast +from opentrons_shared_data.errors.exceptions import CommandPreconditionViolated + from opentrons.protocol_engine.types import ABSMeasureMode from opentrons_shared_data.labware.types import LabwareDefinition from opentrons_shared_data.module.types import ModuleModel, ModuleType @@ -159,7 +161,18 @@ def load_labware( load_location = loaded_adapter._core else: load_location = self._core + name = validation.ensure_lowercase_name(name) + + # todo(mm, 2024-11-08): This check belongs in opentrons.protocol_api.core.engine.deck_conflict. + # We're currently doing it here, at the ModuleContext level, for consistency with what + # ProtocolContext.load_labware() does. (It should also be moved to the deck_conflict module.) + if isinstance(self._core, AbsorbanceReaderCore): + if self._core.is_lid_on(): + raise CommandPreconditionViolated( + f"Cannot load {name} onto the Absorbance Reader Module when its lid is closed." + ) + labware_core = self._protocol_core.load_labware( load_name=name, label=label, diff --git a/api/src/opentrons/protocol_api/protocol_context.py b/api/src/opentrons/protocol_api/protocol_context.py index 88be8a8ced4..182019674a5 100644 --- a/api/src/opentrons/protocol_api/protocol_context.py +++ b/api/src/opentrons/protocol_api/protocol_context.py @@ -45,6 +45,7 @@ UnsupportedAPIError, ) from opentrons_shared_data.errors.exceptions import CommandPreconditionViolated +from opentrons.protocol_engine.errors import LabwareMovementNotAllowedError from ._types import OffDeckType from .core.common import ModuleCore, LabwareCore, ProtocolCore @@ -679,7 +680,7 @@ def move_labware( self, labware: Labware, new_location: Union[ - DeckLocation, Labware, ModuleTypes, OffDeckType, WasteChute + DeckLocation, Labware, ModuleTypes, OffDeckType, WasteChute, TrashBin ], use_gripper: bool = False, pick_up_offset: Optional[Mapping[str, float]] = None, @@ -724,7 +725,8 @@ def move_labware( f"Expected labware of type 'Labware' but got {type(labware)}." ) - # Ensure that when moving to an absorbance reader than the lid is open + # Ensure that when moving to an absorbance reader that the lid is open + # todo(mm, 2024-11-08): Unify this with opentrons.protocol_api.core.engine.deck_conflict. if isinstance(new_location, AbsorbanceReaderContext): if new_location.is_lid_on(): raise CommandPreconditionViolated( @@ -738,11 +740,19 @@ def move_labware( OffDeckType, DeckSlotName, StagingSlotName, + TrashBin, ] if isinstance(new_location, (Labware, ModuleContext)): location = new_location._core elif isinstance(new_location, (OffDeckType, WasteChute)): location = new_location + elif isinstance(new_location, TrashBin): + if labware._core.is_lid(): + location = new_location + else: + raise LabwareMovementNotAllowedError( + "Can only dispose of tips and Lid-type labware in a Trash Bin. Did you mean to use a Waste Chute?" + ) else: location = validation.ensure_and_convert_deck_slot( new_location, self._api_version, self._core.robot_type @@ -957,7 +967,10 @@ def load_instrument( mount, checked_instrument_name ) - is_96_channel = checked_instrument_name == PipetteNameType.P1000_96 + is_96_channel = checked_instrument_name in [ + PipetteNameType.P1000_96, + PipetteNameType.P200_96, + ] tip_racks = tip_racks or [] diff --git a/api/src/opentrons/protocol_api/validation.py b/api/src/opentrons/protocol_api/validation.py index 1f8fb4bd8c8..44123571081 100644 --- a/api/src/opentrons/protocol_api/validation.py +++ b/api/src/opentrons/protocol_api/validation.py @@ -11,7 +11,7 @@ NamedTuple, TYPE_CHECKING, ) - +from math import isinf, isnan from typing_extensions import TypeGuard from opentrons_shared_data.labware.labware_definition import LabwareRole @@ -72,6 +72,7 @@ "flex_1channel_1000": PipetteNameType.P1000_SINGLE_FLEX, "flex_8channel_1000": PipetteNameType.P1000_MULTI_FLEX, "flex_96channel_1000": PipetteNameType.P1000_96, + "flex_96channel_200": PipetteNameType.P200_96, } @@ -111,7 +112,7 @@ def ensure_mount_for_pipette( mount: Union[str, Mount, None], pipette: PipetteNameType ) -> Mount: """Ensure that an input value represents a valid mount, and is valid for the given pipette.""" - if pipette == PipetteNameType.P1000_96: + if pipette in [PipetteNameType.P1000_96, PipetteNameType.P200_96]: # Always validate the raw mount input, even if the pipette is a 96-channel and we're not going # to use the mount value. if mount is not None: @@ -591,3 +592,45 @@ def validate_location( if well is not None else PointTarget(location=target_location, in_place=in_place) ) + + +def ensure_boolean(value: bool) -> bool: + """Ensure value is a boolean.""" + if not isinstance(value, bool): + raise ValueError("Value must be a boolean.") + return value + + +def ensure_float(value: Union[int, float]) -> float: + """Ensure value is a float (or an integer) and return it as a float.""" + if not isinstance(value, (int, float)): + raise ValueError("Value must be a floating point number.") + return float(value) + + +def ensure_positive_float(value: Union[int, float]) -> float: + """Ensure value is a positive and real float value.""" + float_value = ensure_float(value) + if isnan(float_value) or isinf(float_value): + raise ValueError("Value must be a defined, non-infinite number.") + if float_value < 0: + raise ValueError("Value must be a positive float.") + return float_value + + +def ensure_positive_int(value: int) -> int: + """Ensure value is a positive integer.""" + if not isinstance(value, int): + raise ValueError("Value must be an integer.") + if value < 0: + raise ValueError("Value must be a positive integer.") + return value + + +def validate_coordinates(value: Sequence[float]) -> Tuple[float, float, float]: + """Ensure value is a valid sequence of 3 floats and return a tuple of 3 floats.""" + if len(value) != 3: + raise ValueError("Coordinates must be a sequence of exactly three numbers") + if not all(isinstance(v, (float, int)) for v in value): + raise ValueError("All values in coordinates must be floats.") + return float(value[0]), float(value[1]), float(value[2]) diff --git a/api/src/opentrons/protocol_engine/actions/__init__.py b/api/src/opentrons/protocol_engine/actions/__init__.py index 26dfb0df8e0..6d7125cc83e 100644 --- a/api/src/opentrons/protocol_engine/actions/__init__.py +++ b/api/src/opentrons/protocol_engine/actions/__init__.py @@ -28,7 +28,6 @@ DoorChangeAction, ResetTipsAction, SetPipetteMovementSpeedAction, - AddAbsorbanceReaderLidAction, ) from .get_state_update import get_state_updates @@ -58,7 +57,6 @@ "DoorChangeAction", "ResetTipsAction", "SetPipetteMovementSpeedAction", - "AddAbsorbanceReaderLidAction", # action payload values "PauseSource", "FinishErrorDetails", diff --git a/api/src/opentrons/protocol_engine/actions/actions.py b/api/src/opentrons/protocol_engine/actions/actions.py index 6260a6d4614..15b04048699 100644 --- a/api/src/opentrons/protocol_engine/actions/actions.py +++ b/api/src/opentrons/protocol_engine/actions/actions.py @@ -271,17 +271,6 @@ class SetPipetteMovementSpeedAction: speed: Optional[float] -@dataclasses.dataclass(frozen=True) -class AddAbsorbanceReaderLidAction: - """Add the absorbance reader lid id to the absorbance reader module substate. - - This action is dispatched the absorbance reader module is first loaded. - """ - - module_id: str - lid_id: str - - @dataclasses.dataclass(frozen=True) class SetErrorRecoveryPolicyAction: """See `ProtocolEngine.set_error_recovery_policy()`.""" @@ -309,6 +298,5 @@ class SetErrorRecoveryPolicyAction: AddLiquidAction, ResetTipsAction, SetPipetteMovementSpeedAction, - AddAbsorbanceReaderLidAction, SetErrorRecoveryPolicyAction, ] diff --git a/api/src/opentrons/protocol_engine/clients/sync_client.py b/api/src/opentrons/protocol_engine/clients/sync_client.py index d0c21846d19..3460c13d463 100644 --- a/api/src/opentrons/protocol_engine/clients/sync_client.py +++ b/api/src/opentrons/protocol_engine/clients/sync_client.py @@ -119,12 +119,6 @@ def add_addressable_area(self, addressable_area_name: str) -> None: "add_addressable_area", addressable_area_name=addressable_area_name ) - def add_absorbance_reader_lid(self, module_id: str, lid_id: str) -> None: - """Add an absorbance reader lid to the module state.""" - self._transport.call_method( - "add_absorbance_reader_lid", module_id=module_id, lid_id=lid_id - ) - def add_liquid( self, name: str, color: Optional[str], description: Optional[str] ) -> Liquid: diff --git a/api/src/opentrons/protocol_engine/commands/absorbance_reader/close_lid.py b/api/src/opentrons/protocol_engine/commands/absorbance_reader/close_lid.py index 2f7f96d9523..069c2803b22 100644 --- a/api/src/opentrons/protocol_engine/commands/absorbance_reader/close_lid.py +++ b/api/src/opentrons/protocol_engine/commands/absorbance_reader/close_lid.py @@ -10,7 +10,6 @@ from ...errors import CannotPerformModuleAction from opentrons.protocol_engine.types import AddressableAreaLocation -from opentrons.protocol_engine.resources import labware_validation from ...state.update_types import StateUpdate @@ -53,41 +52,35 @@ def __init__( async def execute(self, params: CloseLidParams) -> SuccessData[CloseLidResult]: """Execute the close lid command.""" + state_update = StateUpdate() mod_substate = self._state_view.modules.get_absorbance_reader_substate( module_id=params.moduleId ) - # lid should currently be on the module - assert mod_substate.lid_id is not None - loaded_lid = self._state_view.labware.get(mod_substate.lid_id) - assert labware_validation.is_absorbance_reader_lid(loaded_lid.loadName) - hardware_lid_status = AbsorbanceReaderLidStatus.OFF - # If the lid is closed, if the lid is open No-op out if not self._state_view.config.use_virtual_modules: abs_reader = self._equipment.get_module_hardware_api(mod_substate.module_id) if abs_reader is not None: - result = await abs_reader.get_current_lid_status() - hardware_lid_status = result + hardware_lid_status = await abs_reader.get_current_lid_status() else: raise CannotPerformModuleAction( "Could not reach the Hardware API for Opentrons Plate Reader Module." ) - # If the lid is already ON, no-op losing lid if hardware_lid_status is AbsorbanceReaderLidStatus.ON: - # The lid is already On, so we can no-op and return the lids current location data - assert isinstance(loaded_lid.location, AddressableAreaLocation) - new_location = loaded_lid.location - new_offset_id = self._equipment.find_applicable_labware_offset_id( - labware_definition_uri=loaded_lid.definitionUri, - labware_location=loaded_lid.location, + # The lid is already physically ON, so we can no-op physically closing it + state_update.set_absorbance_reader_lid( + module_id=mod_substate.module_id, is_lid_on=True ) else: # Allow propagation of ModuleNotAttachedError. _ = self._equipment.get_module_hardware_api(mod_substate.module_id) + lid_definition = ( + self._state_view.labware.get_absorbance_reader_lid_definition() + ) + current_location = self._state_view.modules.absorbance_reader_dock_location( params.moduleId ) @@ -107,35 +100,29 @@ async def execute(self, params: CloseLidParams) -> SuccessData[CloseLidResult]: ) ) - lid_gripper_offsets = self._state_view.labware.get_labware_gripper_offsets( - loaded_lid.id, None + # The lid's labware definition stores gripper offsets for itself in the + # space normally meant for offsets for labware stacked atop it. + lid_gripper_offsets = self._state_view.labware.get_child_gripper_offsets( + labware_definition=lid_definition, + slot_name=None, ) if lid_gripper_offsets is None: raise ValueError( "Gripper Offset values for Absorbance Reader Lid labware must not be None." ) - # Skips gripper moves when using virtual gripper await self._labware_movement.move_labware_with_gripper( - labware_id=loaded_lid.id, + labware_definition=lid_definition, current_location=current_location, new_location=new_location, user_offset_data=lid_gripper_offsets, post_drop_slide_offset=None, ) - - new_offset_id = self._equipment.find_applicable_labware_offset_id( - labware_definition_uri=loaded_lid.definitionUri, - labware_location=new_location, + state_update.set_absorbance_reader_lid( + module_id=mod_substate.module_id, + is_lid_on=True, ) - state_update = StateUpdate() - state_update.set_labware_location( - labware_id=loaded_lid.id, - new_location=new_location, - new_offset_id=new_offset_id, - ) - return SuccessData( public=CloseLidResult(), state_update=state_update, diff --git a/api/src/opentrons/protocol_engine/commands/absorbance_reader/open_lid.py b/api/src/opentrons/protocol_engine/commands/absorbance_reader/open_lid.py index 5f3eed57199..1ad56413f9a 100644 --- a/api/src/opentrons/protocol_engine/commands/absorbance_reader/open_lid.py +++ b/api/src/opentrons/protocol_engine/commands/absorbance_reader/open_lid.py @@ -9,7 +9,6 @@ from ...errors.error_occurrence import ErrorOccurrence from ...errors import CannotPerformModuleAction -from opentrons.protocol_engine.resources import labware_validation from opentrons.protocol_engine.types import AddressableAreaLocation from opentrons.drivers.types import AbsorbanceReaderLidStatus @@ -54,39 +53,35 @@ def __init__( async def execute(self, params: OpenLidParams) -> SuccessData[OpenLidResult]: """Move the absorbance reader lid from the module to the lid dock.""" + state_update = StateUpdate() mod_substate = self._state_view.modules.get_absorbance_reader_substate( module_id=params.moduleId ) - # lid should currently be on the module - assert mod_substate.lid_id is not None - loaded_lid = self._state_view.labware.get(mod_substate.lid_id) - assert labware_validation.is_absorbance_reader_lid(loaded_lid.loadName) hardware_lid_status = AbsorbanceReaderLidStatus.ON - # If the lid is closed, if the lid is open No-op out if not self._state_view.config.use_virtual_modules: abs_reader = self._equipment.get_module_hardware_api(mod_substate.module_id) if abs_reader is not None: - result = await abs_reader.get_current_lid_status() - hardware_lid_status = result + hardware_lid_status = await abs_reader.get_current_lid_status() else: raise CannotPerformModuleAction( "Could not reach the Hardware API for Opentrons Plate Reader Module." ) - # If the lid is already OFF, no-op the lid removal if hardware_lid_status is AbsorbanceReaderLidStatus.OFF: - assert isinstance(loaded_lid.location, AddressableAreaLocation) - new_location = loaded_lid.location - new_offset_id = self._equipment.find_applicable_labware_offset_id( - labware_definition_uri=loaded_lid.definitionUri, - labware_location=loaded_lid.location, + # The lid is already physically OFF, so we can no-op physically closing it + state_update.set_absorbance_reader_lid( + module_id=mod_substate.module_id, is_lid_on=False ) else: # Allow propagation of ModuleNotAttachedError. _ = self._equipment.get_module_hardware_api(mod_substate.module_id) + lid_definition = ( + self._state_view.labware.get_absorbance_reader_lid_definition() + ) + absorbance_model = self._state_view.modules.get_requested_model( params.moduleId ) @@ -106,35 +101,28 @@ async def execute(self, params: OpenLidParams) -> SuccessData[OpenLidResult]: mod_substate.module_id ) - lid_gripper_offsets = self._state_view.labware.get_labware_gripper_offsets( - loaded_lid.id, None + # The lid's labware definition stores gripper offsets for itself in the + # space normally meant for offsets for labware stacked atop it. + lid_gripper_offsets = self._state_view.labware.get_child_gripper_offsets( + labware_definition=lid_definition, + slot_name=None, ) if lid_gripper_offsets is None: raise ValueError( "Gripper Offset values for Absorbance Reader Lid labware must not be None." ) - # Skips gripper moves when using virtual gripper await self._labware_movement.move_labware_with_gripper( - labware_id=loaded_lid.id, + labware_definition=lid_definition, current_location=current_location, new_location=new_location, user_offset_data=lid_gripper_offsets, post_drop_slide_offset=None, ) - new_offset_id = self._equipment.find_applicable_labware_offset_id( - labware_definition_uri=loaded_lid.definitionUri, - labware_location=new_location, + state_update.set_absorbance_reader_lid( + module_id=mod_substate.module_id, is_lid_on=False ) - state_update = StateUpdate() - - state_update.set_labware_location( - labware_id=loaded_lid.id, - new_location=new_location, - new_offset_id=new_offset_id, - ) - return SuccessData( public=OpenLidResult(), state_update=state_update, diff --git a/api/src/opentrons/protocol_engine/commands/command.py b/api/src/opentrons/protocol_engine/commands/command.py index 1fefcbf7315..fe47c9dbbcc 100644 --- a/api/src/opentrons/protocol_engine/commands/command.py +++ b/api/src/opentrons/protocol_engine/commands/command.py @@ -6,8 +6,9 @@ import dataclasses from abc import ABC, abstractmethod from datetime import datetime -from enum import Enum +import enum from typing import ( + cast, TYPE_CHECKING, Generic, Optional, @@ -15,6 +16,11 @@ List, Type, Union, + Callable, + Awaitable, + Literal, + Final, + TypeAlias, ) from pydantic import BaseModel, Field @@ -41,7 +47,7 @@ _ErrorT_co = TypeVar("_ErrorT_co", bound=ErrorOccurrence, covariant=True) -class CommandStatus(str, Enum): +class CommandStatus(str, enum.Enum): """Command execution status.""" QUEUED = "queued" @@ -50,7 +56,7 @@ class CommandStatus(str, Enum): FAILED = "failed" -class CommandIntent(str, Enum): +class CommandIntent(str, enum.Enum): """Run intent for a given command. Props: @@ -242,6 +248,240 @@ class BaseCommand( ] +class IsErrorValue(Exception): + """Panic exception if a Maybe contains an Error.""" + + pass + + +class _NothingEnum(enum.Enum): + _NOTHING = enum.auto() + + +NOTHING: Final = _NothingEnum._NOTHING +NothingT: TypeAlias = Literal[_NothingEnum._NOTHING] + + +class _UnknownEnum(enum.Enum): + _UNKNOWN = enum.auto() + + +UNKNOWN: Final = _UnknownEnum._UNKNOWN +UnknownT: TypeAlias = Literal[_UnknownEnum._UNKNOWN] + +_ResultT_co_general = TypeVar("_ResultT_co_general", covariant=True) +_ErrorT_co_general = TypeVar("_ErrorT_co_general", covariant=True) + + +_SecondResultT_co_general = TypeVar("_SecondResultT_co_general", covariant=True) +_SecondErrorT_co_general = TypeVar("_SecondErrorT_co_general", covariant=True) + + +@dataclasses.dataclass +class Maybe(Generic[_ResultT_co_general, _ErrorT_co_general]): + """Represents an possibly completed, possibly errored result. + + By using this class's chaining methods like and_then or or_else, you can build + functions that preserve previous defined errors and augment them or transform them + and transform the results. + + Build objects of this type using from_result or from_error on fully type-qualified + aliases. For instance, + + MyFunctionReturn = Maybe[SuccessData[SomeSuccessModel], DefinedErrorData[SomeErrorKind]] + + def my_function(args...) -> MyFunctionReturn: + try: + do_thing(args...) + except SomeException as e: + return MyFunctionReturn.from_error(ErrorOccurrence.from_error(e)) + else: + return MyFunctionReturn.from_result(SuccessData(SomeSuccessModel(args...))) + + Then, in the calling function, you can react to the results and unwrap to a union: + + OuterMaybe = Maybe[SuccessData[SomeOtherModel], DefinedErrorData[SomeErrors]] + OuterReturn = Union[SuccessData[SomeOtherModel], DefinedErrorData[SomeErrors]] + + def my_calling_function(args...) -> OuterReturn: + def handle_result(result: SuccessData[SomeSuccessModel]) -> OuterMaybe: + return OuterMaybe.from_result(result=some_result_transformer(result)) + return do_thing.and_then(handle_result).unwrap() + """ + + _contents: tuple[_ResultT_co_general, NothingT] | tuple[ + NothingT, _ErrorT_co_general + ] + + _CtorErrorT = TypeVar("_CtorErrorT") + _CtorResultT = TypeVar("_CtorResultT") + + @classmethod + def from_result( + cls: Type[Maybe[_CtorResultT, _CtorErrorT]], result: _CtorResultT + ) -> Maybe[_CtorResultT, _CtorErrorT]: + """Build a Maybe from a valid result.""" + return cls(_contents=(result, NOTHING)) + + @classmethod + def from_error( + cls: Type[Maybe[_CtorResultT, _CtorErrorT]], error: _CtorErrorT + ) -> Maybe[_CtorResultT, _CtorErrorT]: + """Build a Maybe from a known error.""" + return cls(_contents=(NOTHING, error)) + + def result_or_panic(self) -> _ResultT_co_general: + """Unwrap to a result or throw if the Maybe is an error.""" + contents = self._contents + if contents[1] is NOTHING: + # https://github.com/python/mypy/issues/12364 + return cast(_ResultT_co_general, contents[0]) + else: + raise IsErrorValue() + + def unwrap(self) -> _ResultT_co_general | _ErrorT_co_general: + """Unwrap to a union, which is useful for command returns.""" + # https://github.com/python/mypy/issues/12364 + if self._contents[1] is NOTHING: + return cast(_ResultT_co_general, self._contents[0]) + else: + return self._contents[1] + + # note: casts in these methods are because of https://github.com/python/mypy/issues/11730 + def and_then( + self, + functor: Callable[ + [_ResultT_co_general], + Maybe[_SecondResultT_co_general, _SecondErrorT_co_general], + ], + ) -> Maybe[ + _SecondResultT_co_general, _ErrorT_co_general | _SecondErrorT_co_general + ]: + """Conditionally execute functor if the Maybe contains a result. + + Functor should take the result type and return a new Maybe. Since this function returns + a Maybe, it can be chained. The result type will have only the Result type of the Maybe + returned by the functor, but the error type is the union of the error type in the Maybe + returned by the functor and the error type in this Maybe, since the functor may not have + actually been called. + """ + match self._contents: + case (result, _NothingEnum._NOTHING): + return cast( + Maybe[ + _SecondResultT_co_general, + _ErrorT_co_general | _SecondErrorT_co_general, + ], + functor(cast(_ResultT_co_general, result)), + ) + case _: + return cast( + Maybe[ + _SecondResultT_co_general, + _ErrorT_co_general | _SecondErrorT_co_general, + ], + self, + ) + + def or_else( + self, + functor: Callable[ + [_ErrorT_co_general], + Maybe[_SecondResultT_co_general, _SecondErrorT_co_general], + ], + ) -> Maybe[ + _SecondResultT_co_general | _ResultT_co_general, _SecondErrorT_co_general + ]: + """Conditionally execute functor if the Maybe contains an error. + + The functor should take the error type and return a new Maybe. Since this function returns + a Maybe, it can be chained. The result type will have only the Error type of the Maybe + returned by the functor, but the result type is the union of the Result of the Maybe returned + by the functor and the Result of this Maybe, since the functor may not have been called. + """ + match self._contents: + case (_NothingEnum._NOTHING, error): + return cast( + Maybe[ + _ResultT_co_general | _SecondResultT_co_general, + _SecondErrorT_co_general, + ], + functor(cast(_ErrorT_co_general, error)), + ) + case _: + return cast( + Maybe[ + _ResultT_co_general | _SecondResultT_co_general, + _SecondErrorT_co_general, + ], + self, + ) + + async def and_then_async( + self, + functor: Callable[ + [_ResultT_co_general], + Awaitable[Maybe[_SecondResultT_co_general, _SecondErrorT_co_general]], + ], + ) -> Awaitable[ + Maybe[_SecondResultT_co_general, _ErrorT_co_general | _SecondErrorT_co_general] + ]: + """As and_then, but for an async functor.""" + match self._contents: + case (result, _NothingEnum._NOTHING): + return cast( + Awaitable[ + Maybe[ + _SecondResultT_co_general, + _ErrorT_co_general | _SecondErrorT_co_general, + ] + ], + await functor(cast(_ResultT_co_general, result)), + ) + case _: + return cast( + Awaitable[ + Maybe[ + _SecondResultT_co_general, + _ErrorT_co_general | _SecondErrorT_co_general, + ] + ], + self, + ) + + async def or_else_async( + self, + functor: Callable[ + [_ErrorT_co_general], + Awaitable[Maybe[_SecondResultT_co_general, _SecondErrorT_co_general]], + ], + ) -> Awaitable[ + Maybe[_SecondResultT_co_general | _ResultT_co_general, _SecondErrorT_co_general] + ]: + """As or_else, but for an async functor.""" + match self._contents: + case (_NothingEnum._NOTHING, error): + return cast( + Awaitable[ + Maybe[ + _ResultT_co_general | _SecondResultT_co_general, + _SecondErrorT_co_general, + ] + ], + await functor(cast(_ErrorT_co_general, error)), + ) + case _: + return cast( + Awaitable[ + Maybe[ + _ResultT_co_general | _SecondResultT_co_general, + _SecondErrorT_co_general, + ] + ], + self, + ) + + _ExecuteReturnT_co = TypeVar( "_ExecuteReturnT_co", bound=Union[ diff --git a/api/src/opentrons/protocol_engine/commands/load_labware.py b/api/src/opentrons/protocol_engine/commands/load_labware.py index 05eccb95a7a..fb97f5d2c87 100644 --- a/api/src/opentrons/protocol_engine/commands/load_labware.py +++ b/api/src/opentrons/protocol_engine/commands/load_labware.py @@ -10,6 +10,8 @@ from ..resources import labware_validation, fixture_validation from ..types import ( LabwareLocation, + ModuleLocation, + ModuleModel, OnLabwareLocation, DeckSlotLocation, AddressableAreaLocation, @@ -160,6 +162,13 @@ async def execute( top_labware_definition=loaded_labware.definition, bottom_labware_id=verified_location.labwareId, ) + # Validate labware for the absorbance reader + elif isinstance(params.location, ModuleLocation): + module = self._state_view.modules.get(params.location.moduleId) + if module is not None and module.model == ModuleModel.ABSORBANCE_READER_V1: + self._state_view.labware.raise_if_labware_incompatible_with_plate_reader( + loaded_labware.definition + ) return SuccessData( public=LoadLabwareResult( diff --git a/api/src/opentrons/protocol_engine/commands/load_module.py b/api/src/opentrons/protocol_engine/commands/load_module.py index 9560f4931c3..a44212f9bf5 100644 --- a/api/src/opentrons/protocol_engine/commands/load_module.py +++ b/api/src/opentrons/protocol_engine/commands/load_module.py @@ -5,7 +5,6 @@ from pydantic import BaseModel, Field from .command import AbstractCommandImpl, BaseCommand, BaseCommandCreate, SuccessData -from ..errors import ModuleNotLoadedError from ..errors.error_occurrence import ErrorOccurrence from ..types import ( DeckSlotLocation, @@ -17,7 +16,6 @@ from opentrons.protocol_engine.resources import deck_configuration_provider -from opentrons.drivers.types import AbsorbanceReaderLidStatus if TYPE_CHECKING: from ..state.state import StateView @@ -152,43 +150,6 @@ async def execute(self, params: LoadModuleParams) -> SuccessData[LoadModuleResul module_id=params.moduleId, ) - # Handle lid position update for loaded Plate Reader module on deck - if ( - not self._state_view.config.use_virtual_modules - and params.model == ModuleModel.ABSORBANCE_READER_V1 - and params.moduleId is not None - ): - try: - abs_reader = self._equipment.get_module_hardware_api( - self._state_view.modules.get_absorbance_reader_substate( - params.moduleId - ).module_id - ) - except ModuleNotLoadedError: - abs_reader = None - - if abs_reader is not None: - result = await abs_reader.get_current_lid_status() - if ( - isinstance(result, AbsorbanceReaderLidStatus) - and result is not AbsorbanceReaderLidStatus.ON - ): - reader_area = self._state_view.modules.ensure_and_convert_module_fixture_location( - params.location.slotName, - self._state_view.config.deck_type, - params.model, - ) - lid_labware = self._state_view.labware.get_by_addressable_area( - reader_area - ) - - if lid_labware is not None: - self._state_view.labware._state.labware_by_id[ - lid_labware.id - ].location = self._state_view.modules.absorbance_reader_dock_location( - params.moduleId - ) - return SuccessData( public=LoadModuleResult( moduleId=loaded_module.module_id, diff --git a/api/src/opentrons/protocol_engine/commands/move_labware.py b/api/src/opentrons/protocol_engine/commands/move_labware.py index 0d2967e87d5..09cdc08561c 100644 --- a/api/src/opentrons/protocol_engine/commands/move_labware.py +++ b/api/src/opentrons/protocol_engine/commands/move_labware.py @@ -13,16 +13,22 @@ from opentrons.protocol_engine.resources.model_utils import ModelUtils from opentrons.types import Point from ..types import ( + ModuleModel, CurrentWell, LabwareLocation, DeckSlotLocation, + ModuleLocation, OnLabwareLocation, AddressableAreaLocation, LabwareMovementStrategy, LabwareOffsetVector, LabwareMovementOffsetData, ) -from ..errors import LabwareMovementNotAllowedError, NotSupportedOnRobotType +from ..errors import ( + LabwareMovementNotAllowedError, + NotSupportedOnRobotType, + LabwareOffsetDoesNotExistError, +) from ..resources import labware_validation, fixture_validation from .command import ( AbstractCommandImpl, @@ -130,6 +136,7 @@ async def execute(self, params: MoveLabwareParams) -> _ExecuteReturn: # noqa: C ) definition_uri = current_labware.definitionUri post_drop_slide_offset: Optional[Point] = None + trash_lid_drop_offset: Optional[LabwareOffsetVector] = None if self._state_view.labware.is_fixed_trash(params.labwareId): raise LabwareMovementNotAllowedError( @@ -138,9 +145,11 @@ async def execute(self, params: MoveLabwareParams) -> _ExecuteReturn: # noqa: C if isinstance(params.newLocation, AddressableAreaLocation): area_name = params.newLocation.addressableAreaName - if not fixture_validation.is_gripper_waste_chute( - area_name - ) and not fixture_validation.is_deck_slot(area_name): + if ( + not fixture_validation.is_gripper_waste_chute(area_name) + and not fixture_validation.is_deck_slot(area_name) + and not fixture_validation.is_trash(area_name) + ): raise LabwareMovementNotAllowedError( f"Cannot move {current_labware.loadName} to addressable area {area_name}" ) @@ -162,6 +171,32 @@ async def execute(self, params: MoveLabwareParams) -> _ExecuteReturn: # noqa: C y=0, z=0, ) + elif fixture_validation.is_trash(area_name): + # When dropping labware in the trash bins we want to ensure they are lids + # and enforce a y-axis drop offset to ensure they fall within the trash bin + if labware_validation.validate_definition_is_lid( + self._state_view.labware.get_definition(params.labwareId) + ): + lid_disposable_offfets = ( + current_labware_definition.gripperOffsets.get( + "lidDisposalOffsets" + ) + ) + if lid_disposable_offfets is not None: + trash_lid_drop_offset = LabwareOffsetVector( + x=lid_disposable_offfets.dropOffset.x, + y=lid_disposable_offfets.dropOffset.y, + z=lid_disposable_offfets.dropOffset.z, + ) + else: + raise LabwareOffsetDoesNotExistError( + f"Labware Definition {current_labware.loadName} does not contain required field 'lidDisposalOffsets' of 'gripperOffsets'." + ) + else: + raise LabwareMovementNotAllowedError( + "Can only move labware with allowed role 'Lid' to a Trash Bin." + ) + elif isinstance(params.newLocation, DeckSlotLocation): self._state_view.addressable_areas.raise_if_area_not_in_deck_configuration( params.newLocation.slotName.id @@ -188,6 +223,13 @@ async def execute(self, params: MoveLabwareParams) -> _ExecuteReturn: # noqa: C raise LabwareMovementNotAllowedError( "Cannot move a labware onto itself." ) + # Validate labware for the absorbance reader + elif isinstance(available_new_location, ModuleLocation): + module = self._state_view.modules.get(available_new_location.moduleId) + if module is not None and module.model == ModuleModel.ABSORBANCE_READER_V1: + self._state_view.labware.raise_if_labware_incompatible_with_plate_reader( + current_labware_definition + ) # Allow propagation of ModuleNotLoadedError. new_offset_id = self._equipment.find_applicable_labware_offset_id( @@ -232,6 +274,9 @@ async def execute(self, params: MoveLabwareParams) -> _ExecuteReturn: # noqa: C dropOffset=params.dropOffset or LabwareOffsetVector(x=0, y=0, z=0), ) + if trash_lid_drop_offset: + user_offset_data.dropOffset += trash_lid_drop_offset + try: # Skips gripper moves when using virtual gripper await self._labware_movement.move_labware_with_gripper( diff --git a/api/src/opentrons/protocol_engine/commands/pipetting_common.py b/api/src/opentrons/protocol_engine/commands/pipetting_common.py index 2dafb4c81b2..6e0064211fa 100644 --- a/api/src/opentrons/protocol_engine/commands/pipetting_common.py +++ b/api/src/opentrons/protocol_engine/commands/pipetting_common.py @@ -1,12 +1,20 @@ """Common pipetting command base models.""" +from __future__ import annotations from opentrons_shared_data.errors import ErrorCodes from pydantic import BaseModel, Field -from typing import Literal, Optional, Tuple, TypedDict +from typing import Literal, Optional, Tuple, TypedDict, TYPE_CHECKING from opentrons.protocol_engine.errors.error_occurrence import ErrorOccurrence +from opentrons_shared_data.errors.exceptions import PipetteOverpressureError +from .command import Maybe, DefinedErrorData, SuccessData +from opentrons.protocol_engine.state.update_types import StateUpdate from ..types import WellLocation, LiquidHandlingWellLocation, DeckPoint +if TYPE_CHECKING: + from ..execution.pipetting import PipettingHandler + from ..resources import ModelUtils + class PipetteIdMixin(BaseModel): """Mixin for command requests that take a pipette ID.""" @@ -201,3 +209,44 @@ class TipPhysicallyAttachedError(ErrorOccurrence): errorCode: str = ErrorCodes.TIP_DROP_FAILED.value.code detail: str = ErrorCodes.TIP_DROP_FAILED.value.detail + + +PrepareForAspirateReturn = Maybe[ + SuccessData[BaseModel], DefinedErrorData[OverpressureError] +] + + +async def prepare_for_aspirate( + pipette_id: str, + pipetting: PipettingHandler, + model_utils: ModelUtils, + location_if_error: ErrorLocationInfo, +) -> PrepareForAspirateReturn: + """Execute pipetting.prepare_for_aspirate, handle errors, and marshal success.""" + state_update = StateUpdate() + try: + await pipetting.prepare_for_aspirate(pipette_id) + except PipetteOverpressureError as e: + state_update.set_fluid_unknown(pipette_id=pipette_id) + return PrepareForAspirateReturn.from_error( + DefinedErrorData( + public=OverpressureError( + id=model_utils.generate_id(), + createdAt=model_utils.get_timestamp(), + wrappedErrors=[ + ErrorOccurrence.from_failed( + id=model_utils.generate_id(), + createdAt=model_utils.get_timestamp(), + error=e, + ) + ], + errorInfo=location_if_error, + ), + state_update=state_update, + ) + ) + else: + state_update.set_fluid_empty(pipette_id=pipette_id) + return PrepareForAspirateReturn.from_result( + SuccessData(public=BaseModel(), state_update=state_update) + ) diff --git a/api/src/opentrons/protocol_engine/commands/prepare_to_aspirate.py b/api/src/opentrons/protocol_engine/commands/prepare_to_aspirate.py index f5525b3c90e..38f3a60516a 100644 --- a/api/src/opentrons/protocol_engine/commands/prepare_to_aspirate.py +++ b/api/src/opentrons/protocol_engine/commands/prepare_to_aspirate.py @@ -1,24 +1,20 @@ """Prepare to aspirate command request, result, and implementation models.""" from __future__ import annotations -from opentrons_shared_data.errors.exceptions import PipetteOverpressureError from pydantic import BaseModel from typing import TYPE_CHECKING, Optional, Type, Union from typing_extensions import Literal -from .pipetting_common import ( - OverpressureError, - PipetteIdMixin, -) +from .pipetting_common import OverpressureError, PipetteIdMixin, prepare_for_aspirate from .command import ( AbstractCommandImpl, BaseCommand, BaseCommandCreate, DefinedErrorData, SuccessData, + Maybe, ) from ..errors.error_occurrence import ErrorOccurrence -from ..state import update_types if TYPE_CHECKING: from ..execution import PipettingHandler, GantryMover @@ -46,6 +42,11 @@ class PrepareToAspirateResult(BaseModel): ] +_ExecuteMaybe = Maybe[ + SuccessData[PrepareToAspirateResult], DefinedErrorData[OverpressureError] +] + + class PrepareToAspirateImplementation( AbstractCommandImpl[PrepareToAspirateParams, _ExecuteReturn] ): @@ -62,44 +63,29 @@ def __init__( self._model_utils = model_utils self._gantry_mover = gantry_mover + def _transform_result(self, result: SuccessData[BaseModel]) -> _ExecuteMaybe: + return _ExecuteMaybe.from_result( + SuccessData( + public=PrepareToAspirateResult(), state_update=result.state_update + ) + ) + async def execute(self, params: PrepareToAspirateParams) -> _ExecuteReturn: """Prepare the pipette to aspirate.""" current_position = await self._gantry_mover.get_position(params.pipetteId) - state_update = update_types.StateUpdate() - try: - await self._pipetting_handler.prepare_for_aspirate( - pipette_id=params.pipetteId, - ) - except PipetteOverpressureError as e: - state_update.set_fluid_unknown(pipette_id=params.pipetteId) - return DefinedErrorData( - public=OverpressureError( - id=self._model_utils.generate_id(), - createdAt=self._model_utils.get_timestamp(), - wrappedErrors=[ - ErrorOccurrence.from_failed( - id=self._model_utils.generate_id(), - createdAt=self._model_utils.get_timestamp(), - error=e, - ) - ], - errorInfo=( - { - "retryLocation": ( - current_position.x, - current_position.y, - current_position.z, - ) - } - ), - ), - state_update=state_update, - ) - else: - state_update.set_fluid_empty(pipette_id=params.pipetteId) - return SuccessData( - public=PrepareToAspirateResult(), state_update=state_update - ) + prepare_result = await prepare_for_aspirate( + pipette_id=params.pipetteId, + pipetting=self._pipetting_handler, + model_utils=self._model_utils, + location_if_error={ + "retryLocation": ( + current_position.x, + current_position.y, + current_position.z, + ) + }, + ) + return prepare_result.and_then(self._transform_result).unwrap() class PrepareToAspirate( diff --git a/api/src/opentrons/protocol_engine/commands/unsafe/unsafe_place_labware.py b/api/src/opentrons/protocol_engine/commands/unsafe/unsafe_place_labware.py index 547b8416637..aa11555954d 100644 --- a/api/src/opentrons/protocol_engine/commands/unsafe/unsafe_place_labware.py +++ b/api/src/opentrons/protocol_engine/commands/unsafe/unsafe_place_labware.py @@ -1,10 +1,13 @@ """Place labware payload, result, and implementaiton.""" from __future__ import annotations -from pydantic import BaseModel, Field -from typing import TYPE_CHECKING, Optional, Type, cast +from typing import TYPE_CHECKING, Optional, Type from typing_extensions import Literal +from opentrons_shared_data.labware.types import LabwareUri +from opentrons_shared_data.labware.labware_definition import LabwareDefinition +from pydantic import BaseModel, Field + from opentrons.hardware_control.types import Axis, OT3Mount from opentrons.motion_planning.waypoints import get_gripper_labware_placement_waypoints from opentrons.protocol_engine.errors.exceptions import ( @@ -13,11 +16,14 @@ ) from opentrons.types import Point -from ...types import DeckSlotLocation, ModuleModel, OnDeckLabwareLocation +from ...types import ( + DeckSlotLocation, + ModuleModel, + OnDeckLabwareLocation, +) from ..command import AbstractCommandImpl, BaseCommand, BaseCommandCreate, SuccessData from ...errors.error_occurrence import ErrorOccurrence from ...resources import ensure_ot3_hardware -from ...state.update_types import StateUpdate from opentrons.hardware_control import HardwareControlAPI, OT3HardwareControlAPI @@ -32,7 +38,7 @@ class UnsafePlaceLabwareParams(BaseModel): """Payload required for an UnsafePlaceLabware command.""" - labwareId: str = Field(..., description="The id of the labware to place.") + labwareURI: str = Field(..., description="Labware URI for labware.") location: OnDeckLabwareLocation = Field( ..., description="Where to place the labware." ) @@ -71,8 +77,8 @@ async def execute( is pressed, get into error recovery, etc). Unlike the `moveLabware` command, where you pick a source and destination - location, this command takes the labwareId to be moved and location to - move it to. + location, this command takes the labwareURI of the labware to be moved + and location to move it to. """ ot3api = ensure_ot3_hardware(self._hardware_api) @@ -84,23 +90,37 @@ async def execute( "Cannot place labware when gripper is not gripping." ) - # Allow propagation of LabwareNotLoadedError. - labware_id = params.labwareId - definition_uri = self._state_view.labware.get(labware_id).definitionUri - final_offsets = self._state_view.labware.get_labware_gripper_offsets( - labware_id, None + location = self._state_view.geometry.ensure_valid_gripper_location( + params.location, + ) + + definition = self._state_view.labware.get_definition_by_uri( + # todo(mm, 2024-11-07): This is an unsafe cast from untrusted input. + # We need a str -> LabwareUri parse/validate function. + LabwareUri(params.labwareURI) + ) + + # todo(mm, 2024-11-06): This is only correct in the special case of an + # absorbance reader lid. Its definition currently puts the offsets for *itself* + # in the property that's normally meant for offsets for its *children.* + final_offsets = self._state_view.labware.get_child_gripper_offsets( + labware_definition=definition, slot_name=None + ) + drop_offset = ( + Point( + final_offsets.dropOffset.x, + final_offsets.dropOffset.y, + final_offsets.dropOffset.z, + ) + if final_offsets + else None ) - drop_offset = cast(Point, final_offsets.dropOffset) if final_offsets else None if isinstance(params.location, DeckSlotLocation): self._state_view.addressable_areas.raise_if_area_not_in_deck_configuration( params.location.slotName.id ) - location = self._state_view.geometry.ensure_valid_gripper_location( - params.location, - ) - # This is an absorbance reader, move the lid to its dock (staging area). if isinstance(location, DeckSlotLocation): module = self._state_view.modules.get_by_slot(location.slotName) @@ -109,30 +129,19 @@ async def execute( module.id ) - new_offset_id = self._equipment.find_applicable_labware_offset_id( - labware_definition_uri=definition_uri, - labware_location=location, - ) - # NOTE: When the estop is pressed, the gantry loses position, # so the robot needs to home x, y to sync. await ot3api.home(axes=[Axis.Z_L, Axis.Z_R, Axis.Z_G, Axis.X, Axis.Y]) - state_update = StateUpdate() # Place the labware down - await self._start_movement(ot3api, labware_id, location, drop_offset) + await self._start_movement(ot3api, definition, location, drop_offset) - state_update.set_labware_location( - labware_id=labware_id, - new_location=location, - new_offset_id=new_offset_id, - ) - return SuccessData(public=UnsafePlaceLabwareResult(), state_update=state_update) + return SuccessData(public=UnsafePlaceLabwareResult()) async def _start_movement( self, ot3api: OT3HardwareControlAPI, - labware_id: str, + labware_definition: LabwareDefinition, location: OnDeckLabwareLocation, drop_offset: Optional[Point], ) -> None: @@ -142,7 +151,7 @@ async def _start_movement( ) to_labware_center = self._state_view.geometry.get_labware_grip_point( - labware_id=labware_id, location=location + labware_definition=labware_definition, location=location ) movement_waypoints = get_gripper_labware_placement_waypoints( diff --git a/api/src/opentrons/protocol_engine/create_protocol_engine.py b/api/src/opentrons/protocol_engine/create_protocol_engine.py index 372972c1f50..5c21c70efef 100644 --- a/api/src/opentrons/protocol_engine/create_protocol_engine.py +++ b/api/src/opentrons/protocol_engine/create_protocol_engine.py @@ -8,6 +8,9 @@ from opentrons.protocol_engine.execution.error_recovery_hardware_state_synchronizer import ( ErrorRecoveryHardwareStateSynchronizer, ) +from opentrons.protocol_engine.resources.labware_data_provider import ( + LabwareDataProvider, +) from opentrons.util.async_helpers import async_context_manager_in_thread from opentrons_shared_data.robot import load as load_robot @@ -81,7 +84,7 @@ async def create_protocol_engine( module_data_provider = ModuleDataProvider() file_provider = file_provider or FileProvider() - return ProtocolEngine( + pe = ProtocolEngine( hardware_api=hardware_api, state_store=state_store, action_dispatcher=action_dispatcher, @@ -93,6 +96,20 @@ async def create_protocol_engine( file_provider=file_provider, ) + # todo(mm, 2024-11-08): This is a quick hack to support the absorbance reader, which + # expects the engine to have this special labware definition available. It would be + # cleaner for the `loadModule` command to do this I/O and insert the definition + # into state. That gets easier after https://opentrons.atlassian.net/browse/EXEC-756. + # + # NOTE: This needs to stay in sync with LabwareView.get_absorbance_reader_lid_definition(). + pe.add_labware_definition( + await LabwareDataProvider().get_labware_definition( + "opentrons_flex_lid_absorbance_plate_reader_module", "opentrons", 1 + ) + ) + + return pe + @contextlib.contextmanager def create_protocol_engine_in_thread( diff --git a/api/src/opentrons/protocol_engine/errors/__init__.py b/api/src/opentrons/protocol_engine/errors/__init__.py index e9f1acddeed..c6adf4bfc16 100644 --- a/api/src/opentrons/protocol_engine/errors/__init__.py +++ b/api/src/opentrons/protocol_engine/errors/__init__.py @@ -78,6 +78,7 @@ InvalidDispenseVolumeError, StorageLimitReachedError, InvalidLiquidError, + LiquidClassDoesNotExistError, ) from .error_occurrence import ErrorOccurrence, ProtocolCommandFailedError @@ -164,4 +165,5 @@ "OperationLocationNotInWellError", "InvalidDispenseVolumeError", "StorageLimitReachedError", + "LiquidClassDoesNotExistError", ] diff --git a/api/src/opentrons/protocol_engine/errors/exceptions.py b/api/src/opentrons/protocol_engine/errors/exceptions.py index 36b0d2ccbef..e5a17ea4da2 100644 --- a/api/src/opentrons/protocol_engine/errors/exceptions.py +++ b/api/src/opentrons/protocol_engine/errors/exceptions.py @@ -1155,3 +1155,15 @@ def __init__( ) -> None: """Build an StorageLimitReached.""" super().__init__(ErrorCodes.GENERAL_ERROR, message, detail, wrapping) + + +class LiquidClassDoesNotExistError(ProtocolEngineError): + """Raised when referencing a liquid class that has not been loaded.""" + + def __init__( + self, + message: Optional[str] = None, + details: Optional[Dict[str, Any]] = None, + wrapping: Optional[Sequence[EnumeratedError]] = None, + ) -> None: + super().__init__(ErrorCodes.GENERAL_ERROR, message, details, wrapping) diff --git a/api/src/opentrons/protocol_engine/execution/labware_movement.py b/api/src/opentrons/protocol_engine/execution/labware_movement.py index 8ede6f6085b..77de449c058 100644 --- a/api/src/opentrons/protocol_engine/execution/labware_movement.py +++ b/api/src/opentrons/protocol_engine/execution/labware_movement.py @@ -1,7 +1,9 @@ """Labware movement command handling.""" from __future__ import annotations -from typing import Optional, TYPE_CHECKING +from typing import Optional, TYPE_CHECKING, overload + +from opentrons_shared_data.labware.labware_definition import LabwareDefinition from opentrons.types import Point @@ -79,24 +81,64 @@ def __init__( ) ) + @overload async def move_labware_with_gripper( self, + *, labware_id: str, current_location: OnDeckLabwareLocation, new_location: OnDeckLabwareLocation, user_offset_data: LabwareMovementOffsetData, post_drop_slide_offset: Optional[Point], ) -> None: - """Move a loaded labware from one location to another using gripper.""" + ... + + @overload + async def move_labware_with_gripper( + self, + *, + labware_definition: LabwareDefinition, + current_location: OnDeckLabwareLocation, + new_location: OnDeckLabwareLocation, + user_offset_data: LabwareMovementOffsetData, + post_drop_slide_offset: Optional[Point], + ) -> None: + ... + + async def move_labware_with_gripper( # noqa: C901 + self, + *, + labware_id: str | None = None, + labware_definition: LabwareDefinition | None = None, + current_location: OnDeckLabwareLocation, + new_location: OnDeckLabwareLocation, + user_offset_data: LabwareMovementOffsetData, + post_drop_slide_offset: Optional[Point], + ) -> None: + """Physically move a labware from one location to another using the gripper. + + Generally, provide the `labware_id` of a loaded labware, and this method will + automatically look up its labware definition. If you're physically moving + something that has not been loaded as a labware (this is not common), + provide the `labware_definition` yourself instead. + """ use_virtual_gripper = self._state_store.config.use_virtual_gripper + if labware_definition is None: + assert labware_id is not None # From this method's @typing.overloads. + labware_definition = self._state_store.labware.get_definition(labware_id) + if use_virtual_gripper: - # During Analysis we will pass in hard coded estimates for certain positions only accessible during execution - self._state_store.geometry.check_gripper_labware_tip_collision( - gripper_homed_position_z=_GRIPPER_HOMED_POSITION_Z, - labware_id=labware_id, - current_location=current_location, - ) + # todo(mm, 2024-11-07): We should do this collision checking even when we + # only have a `labware_definition`, not a `labware_id`. Resolve when + # `check_gripper_labware_tip_collision()` can be made independent of `labware_id`. + if labware_id is not None: + self._state_store.geometry.check_gripper_labware_tip_collision( + # During Analysis we will pass in hard coded estimates for certain positions only accessible during execution + gripper_homed_position_z=_GRIPPER_HOMED_POSITION_Z, + labware_id=labware_id, + current_location=current_location, + ) return ot3api = ensure_ot3_hardware( @@ -119,14 +161,16 @@ async def move_labware_with_gripper( await ot3api.home(axes=[Axis.Z_L, Axis.Z_R, Axis.Z_G]) gripper_homed_position = await ot3api.gantry_position(mount=gripper_mount) - # Verify that no tip collisions will occur during the move - self._state_store.geometry.check_gripper_labware_tip_collision( - gripper_homed_position_z=gripper_homed_position.z, - labware_id=labware_id, - current_location=current_location, - ) + # todo(mm, 2024-11-07): We should do this collision checking even when we + # only have a `labware_definition`, not a `labware_id`. Resolve when + # `check_gripper_labware_tip_collision()` can be made independent of `labware_id`. + if labware_id is not None: + self._state_store.geometry.check_gripper_labware_tip_collision( + gripper_homed_position_z=gripper_homed_position.z, + labware_id=labware_id, + current_location=current_location, + ) - current_labware = self._state_store.labware.get_definition(labware_id) async with self._thermocycler_plate_lifter.lift_plate_for_labware_movement( labware_location=current_location ): @@ -135,14 +179,14 @@ async def move_labware_with_gripper( from_location=current_location, to_location=new_location, additional_offset_vector=user_offset_data, - current_labware=current_labware, + current_labware=labware_definition, ) ) from_labware_center = self._state_store.geometry.get_labware_grip_point( - labware_id=labware_id, location=current_location + labware_definition=labware_definition, location=current_location ) to_labware_center = self._state_store.geometry.get_labware_grip_point( - labware_id=labware_id, location=new_location + labware_definition=labware_definition, location=new_location ) movement_waypoints = get_gripper_labware_movement_waypoints( from_labware_center=from_labware_center, @@ -151,7 +195,9 @@ async def move_labware_with_gripper( offset_data=final_offsets, post_drop_slide_offset=post_drop_slide_offset, ) - labware_grip_force = self._state_store.labware.get_grip_force(labware_id) + labware_grip_force = self._state_store.labware.get_grip_force( + labware_definition + ) holding_labware = False for waypoint_data in movement_waypoints: if waypoint_data.jaw_open: @@ -174,9 +220,11 @@ async def move_labware_with_gripper( # should be holding labware if holding_labware: labware_bbox = self._state_store.labware.get_dimensions( - labware_id + labware_definition=labware_definition + ) + well_bbox = self._state_store.labware.get_well_bbox( + labware_definition=labware_definition ) - well_bbox = self._state_store.labware.get_well_bbox(labware_id) # todo(mm, 2024-09-26): This currently raises a lower-level 2015 FailedGripperPickupError. # Convert this to a higher-level 3001 LabwareDroppedError or 3002 LabwareNotPickedUpError, # depending on what waypoint we're at, to propagate a more specific error code to users. diff --git a/api/src/opentrons/protocol_engine/execution/movement.py b/api/src/opentrons/protocol_engine/execution/movement.py index 9e391160a25..be8bbbb8de2 100644 --- a/api/src/opentrons/protocol_engine/execution/movement.py +++ b/api/src/opentrons/protocol_engine/execution/movement.py @@ -4,9 +4,10 @@ import logging from typing import Optional, List, Union -from opentrons.types import Point, MountType +from opentrons.types import Point, MountType, StagingSlotName from opentrons.hardware_control import HardwareControlAPI from opentrons_shared_data.errors.exceptions import PositionUnknownError +from opentrons.protocol_engine.errors import LocationIsStagingSlotError from ..types import ( WellLocation, @@ -93,9 +94,13 @@ async def move_to_well( self._state_store.modules.get_heater_shaker_movement_restrictors() ) - dest_slot_int = self._state_store.geometry.get_ancestor_slot_name( - labware_id - ).as_int() + ancestor = self._state_store.geometry.get_ancestor_slot_name(labware_id) + if isinstance(ancestor, StagingSlotName): + raise LocationIsStagingSlotError( + "Cannot move to well on labware in Staging Area Slot." + ) + + dest_slot_int = ancestor.as_int() self._hs_movement_flagger.raise_if_movement_restricted( hs_movement_restrictors=hs_movement_restrictors, diff --git a/api/src/opentrons/protocol_engine/protocol_engine.py b/api/src/opentrons/protocol_engine/protocol_engine.py index 574c3d076f9..ba5219691bf 100644 --- a/api/src/opentrons/protocol_engine/protocol_engine.py +++ b/api/src/opentrons/protocol_engine/protocol_engine.py @@ -59,7 +59,6 @@ HardwareStoppedAction, ResetTipsAction, SetPipetteMovementSpeedAction, - AddAbsorbanceReaderLidAction, ) @@ -580,12 +579,6 @@ def add_addressable_area(self, addressable_area_name: str) -> None: AddAddressableAreaAction(addressable_area=area) ) - def add_absorbance_reader_lid(self, module_id: str, lid_id: str) -> None: - """Add an absorbance reader lid to the module state.""" - self._action_dispatcher.dispatch( - AddAbsorbanceReaderLidAction(module_id=module_id, lid_id=lid_id) - ) - def reset_tips(self, labware_id: str) -> None: """Reset the tip state of a given labware.""" # TODO(mm, 2023-03-10): Safely raise an error if the given labware isn't a diff --git a/api/src/opentrons/protocol_engine/resources/deck_data_provider.py b/api/src/opentrons/protocol_engine/resources/deck_data_provider.py index c67260a8001..72117c23075 100644 --- a/api/src/opentrons/protocol_engine/resources/deck_data_provider.py +++ b/api/src/opentrons/protocol_engine/resources/deck_data_provider.py @@ -17,11 +17,9 @@ DeckSlotLocation, DeckType, LabwareLocation, - AddressableAreaLocation, DeckConfigurationType, ) from .labware_data_provider import LabwareDataProvider -from ..resources import deck_configuration_provider @final @@ -71,43 +69,6 @@ async def get_deck_fixed_labware( slot = cast(Optional[str], fixture.get("slot")) if ( - deck_configuration is not None - and load_name is not None - and slot is not None - and slot not in DeckSlotName._value2member_map_ - ): - # The provided slot is likely to be an addressable area for Module-required labware Eg: Plate Reader Lid - for ( - cutout_id, - cutout_fixture_id, - opentrons_module_serial_number, - ) in deck_configuration: - provided_addressable_areas = ( - deck_configuration_provider.get_provided_addressable_area_names( - cutout_fixture_id=cutout_fixture_id, - cutout_id=cutout_id, - deck_definition=deck_definition, - ) - ) - if slot in provided_addressable_areas: - addressable_area_location = AddressableAreaLocation( - addressableAreaName=slot - ) - definition = await self._labware_data.get_labware_definition( - load_name=load_name, - namespace="opentrons", - version=1, - ) - - labware.append( - DeckFixedLabware( - labware_id=labware_id, - definition=definition, - location=addressable_area_location, - ) - ) - - elif ( load_fixed_trash and load_name is not None and slot is not None diff --git a/api/src/opentrons/protocol_engine/resources/file_provider.py b/api/src/opentrons/protocol_engine/resources/file_provider.py index e1299605e76..a224e15a1b7 100644 --- a/api/src/opentrons/protocol_engine/resources/file_provider.py +++ b/api/src/opentrons/protocol_engine/resources/file_provider.py @@ -66,7 +66,7 @@ def build_generic_csv( # noqa: C901 row.append(str(measurement.data[f"{plate_alpharows[i]}{j+1}"])) rows.append(row) for i in range(3): - rows.append([""]) + rows.append([]) rows.append(["", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12"]) for i in range(8): row = [plate_alpharows[i]] @@ -74,7 +74,7 @@ def build_generic_csv( # noqa: C901 row.append("") rows.append(row) for i in range(3): - rows.append([""]) + rows.append([]) rows.append( [ "", @@ -86,7 +86,7 @@ def build_generic_csv( # noqa: C901 ] ) for i in range(3): - rows.append([""]) + rows.append([]) rows.append( [ "", @@ -100,7 +100,7 @@ def build_generic_csv( # noqa: C901 ) rows.append(["1", "Sample 1", "", "", "", "1", "", "", "", "", "", ""]) for i in range(3): - rows.append([""]) + rows.append([]) # end of file metadata rows.append(["Protocol"]) @@ -109,13 +109,17 @@ def build_generic_csv( # noqa: C901 if self.reference_wavelength is not None: rows.append(["Reference Wavelength (nm)", str(self.reference_wavelength)]) rows.append(["Serial No.", self.serial_number]) - rows.append(["Measurement started at", str(self.start_time)]) - rows.append(["Measurement finished at", str(self.finish_time)]) + rows.append( + ["Measurement started at", self.start_time.strftime("%m %d %H:%M:%S %Y")] + ) + rows.append( + ["Measurement finished at", self.finish_time.strftime("%m %d %H:%M:%S %Y")] + ) # Ensure the filename adheres to ruleset contains the wavelength for a given measurement if filename.endswith(".csv"): filename = filename[:-4] - filename = filename + "_" + str(measurement.wavelength) + ".csv" + filename = filename + str(measurement.wavelength) + "nm.csv" return GenericCsvTransform.build( filename=filename, diff --git a/api/src/opentrons/protocol_engine/resources/fixture_validation.py b/api/src/opentrons/protocol_engine/resources/fixture_validation.py index 745df22d712..a17bf147f85 100644 --- a/api/src/opentrons/protocol_engine/resources/fixture_validation.py +++ b/api/src/opentrons/protocol_engine/resources/fixture_validation.py @@ -29,7 +29,12 @@ def is_drop_tip_waste_chute(addressable_area_name: str) -> bool: def is_trash(addressable_area_name: str) -> bool: """Check if an addressable area is a trash bin.""" - return addressable_area_name in {"movableTrash", "fixedTrash", "shortFixedTrash"} + return any( + [ + s in addressable_area_name + for s in {"movableTrash", "fixedTrash", "shortFixedTrash"} + ] + ) def is_staging_slot(addressable_area_name: str) -> bool: diff --git a/api/src/opentrons/protocol_engine/state/geometry.py b/api/src/opentrons/protocol_engine/state/geometry.py index c28eac25ebc..ed915530b90 100644 --- a/api/src/opentrons/protocol_engine/state/geometry.py +++ b/api/src/opentrons/protocol_engine/state/geometry.py @@ -265,32 +265,33 @@ def get_min_travel_z( return min_travel_z def get_labware_parent_nominal_position(self, labware_id: str) -> Point: - """Get the position of the labware's uncalibrated parent slot (deck, module, or another labware).""" + """Get the position of the labware's uncalibrated parent (deck slot, module, or another labware).""" try: addressable_area_name = self.get_ancestor_slot_name(labware_id).id except errors.LocationIsStagingSlotError: addressable_area_name = self._get_staging_slot_name(labware_id) except errors.LocationIsLidDockSlotError: addressable_area_name = self._get_lid_dock_slot_name(labware_id) - slot_pos = self._addressable_areas.get_addressable_area_position( + parent_pos = self._addressable_areas.get_addressable_area_position( addressable_area_name ) - labware_data = self._labware.get(labware_id) - offset = self._get_labware_position_offset(labware_id, labware_data.location) + offset_from_parent = self._get_offset_from_parent( + child_definition=self._labware.get_definition(labware_id), + parent=self._labware.get(labware_id).location, + ) return Point( - slot_pos.x + offset.x, - slot_pos.y + offset.y, - slot_pos.z + offset.z, + parent_pos.x + offset_from_parent.x, + parent_pos.y + offset_from_parent.y, + parent_pos.z + offset_from_parent.z, ) - def _get_labware_position_offset( - self, labware_id: str, labware_location: LabwareLocation + def _get_offset_from_parent( + self, child_definition: LabwareDefinition, parent: LabwareLocation ) -> LabwareOffsetVector: - """Gets the offset vector of a labware on the given location. + """Gets the offset vector of a labware placed on the given location. - NOTE: Not to be confused with LPC offset. - For labware on Deck Slot: returns an offset of (0, 0, 0) - For labware on a Module: returns the nominal offset for the labware's position when placed on the specified module (using slot-transformed labwareOffset @@ -301,40 +302,42 @@ def _get_labware_position_offset( on modules as well as stacking overlaps. Does not include module calibration offset or LPC offset. """ - if isinstance(labware_location, (AddressableAreaLocation, DeckSlotLocation)): + if isinstance(parent, (AddressableAreaLocation, DeckSlotLocation)): return LabwareOffsetVector(x=0, y=0, z=0) - elif isinstance(labware_location, ModuleLocation): - module_id = labware_location.moduleId - module_offset = self._modules.get_nominal_module_offset( + elif isinstance(parent, ModuleLocation): + module_id = parent.moduleId + module_to_child = self._modules.get_nominal_offset_to_child( module_id=module_id, addressable_areas=self._addressable_areas ) module_model = self._modules.get_connected_model(module_id) stacking_overlap = self._labware.get_module_overlap_offsets( - labware_id, module_model + child_definition, module_model ) return LabwareOffsetVector( - x=module_offset.x - stacking_overlap.x, - y=module_offset.y - stacking_overlap.y, - z=module_offset.z - stacking_overlap.z, + x=module_to_child.x - stacking_overlap.x, + y=module_to_child.y - stacking_overlap.y, + z=module_to_child.z - stacking_overlap.z, + ) + elif isinstance(parent, OnLabwareLocation): + on_labware = self._labware.get(parent.labwareId) + on_labware_dimensions = self._labware.get_dimensions( + labware_id=on_labware.id ) - elif isinstance(labware_location, OnLabwareLocation): - on_labware = self._labware.get(labware_location.labwareId) - on_labware_dimensions = self._labware.get_dimensions(on_labware.id) stacking_overlap = self._labware.get_labware_overlap_offsets( - labware_id=labware_id, below_labware_name=on_labware.loadName + definition=child_definition, below_labware_name=on_labware.loadName ) labware_offset = LabwareOffsetVector( x=stacking_overlap.x, y=stacking_overlap.y, z=on_labware_dimensions.z - stacking_overlap.z, ) - return labware_offset + self._get_labware_position_offset( - on_labware.id, on_labware.location + return labware_offset + self._get_offset_from_parent( + self._labware.get_definition(on_labware.id), on_labware.location ) else: raise errors.LabwareNotOnDeckError( - f"Cannot access labware {labware_id} since it is not on the deck. " - f"Either it has been loaded off-deck or its been moved off-deck." + "Cannot access labware since it is not on the deck. " + "Either it has been loaded off-deck or its been moved off-deck." ) def _normalize_module_calibration_offset( @@ -712,10 +715,12 @@ def _get_lid_dock_slot_name(self, labware_id: str) -> str: assert isinstance(labware_location, AddressableAreaLocation) return labware_location.addressableAreaName - def get_ancestor_slot_name(self, labware_id: str) -> DeckSlotName: + def get_ancestor_slot_name( + self, labware_id: str + ) -> Union[DeckSlotName, StagingSlotName]: """Get the slot name of the labware or the module that the labware is on.""" labware = self._labware.get(labware_id) - slot_name: DeckSlotName + slot_name: Union[DeckSlotName, StagingSlotName] if isinstance(labware.location, DeckSlotLocation): slot_name = labware.location.slotName @@ -727,18 +732,14 @@ def get_ancestor_slot_name(self, labware_id: str) -> DeckSlotName: slot_name = self.get_ancestor_slot_name(below_labware_id) elif isinstance(labware.location, AddressableAreaLocation): area_name = labware.location.addressableAreaName - # TODO we might want to eventually return some sort of staging slot name when we're ready to work through - # the linting nightmare it will create if self._labware.is_absorbance_reader_lid(labware_id): raise errors.LocationIsLidDockSlotError( "Cannot get ancestor slot name for labware on lid dock slot." ) - if fixture_validation.is_staging_slot(area_name): - raise errors.LocationIsStagingSlotError( - "Cannot get ancestor slot name for labware on staging slot." - ) - raise errors.LocationIs - slot_name = DeckSlotName.from_primitive(area_name) + elif fixture_validation.is_staging_slot(area_name): + slot_name = StagingSlotName.from_primitive(area_name) + else: + slot_name = DeckSlotName.from_primitive(area_name) elif labware.location == OFF_DECK_LOCATION: raise errors.LabwareNotOnDeckError( f"Labware {labware_id} does not have a slot associated with it" @@ -771,7 +772,7 @@ def ensure_location_not_occupied( def get_labware_grip_point( self, - labware_id: str, + labware_definition: LabwareDefinition, location: Union[ DeckSlotLocation, ModuleLocation, OnLabwareLocation, AddressableAreaLocation ], @@ -787,7 +788,7 @@ def get_labware_grip_point( z-position of labware bottom + grip height from labware bottom. """ grip_height_from_labware_bottom = ( - self._labware.get_grip_height_from_labware_bottom(labware_id) + self._labware.get_grip_height_from_labware_bottom(labware_definition) ) location_name: str @@ -813,7 +814,9 @@ def get_labware_grip_point( ).slotName.id else: # OnLabwareLocation location_name = self.get_ancestor_slot_name(location.labwareId).id - labware_offset = self._get_labware_position_offset(labware_id, location) + labware_offset = self._get_offset_from_parent( + child_definition=labware_definition, parent=location + ) # Get the calibrated offset if the on labware location is on top of a module, otherwise return empty one cal_offset = self._get_calibrated_module_offset(location) offset = LabwareOffsetVector( @@ -832,7 +835,9 @@ def get_labware_grip_point( ) def get_extra_waypoints( - self, location: Optional[CurrentPipetteLocation], to_slot: DeckSlotName + self, + location: Optional[CurrentPipetteLocation], + to_slot: Union[DeckSlotName, StagingSlotName], ) -> List[Tuple[float, float]]: """Get extra waypoints for movement if thermocycler needs to be dodged.""" if location is not None: @@ -891,8 +896,10 @@ def get_slot_item( return maybe_labware or maybe_module or maybe_fixture or None @staticmethod - def get_slot_column(slot_name: DeckSlotName) -> int: + def get_slot_column(slot_name: Union[DeckSlotName, StagingSlotName]) -> int: """Get the column number for the specified slot.""" + if isinstance(slot_name, StagingSlotName): + return 4 row_col_name = slot_name.to_ot3_equivalent() slot_name_match = WELL_NAME_PATTERN.match(row_col_name.value) assert ( @@ -1173,7 +1180,13 @@ def get_total_nominal_gripper_offset_for_move_type( ) assert isinstance( - ancestor, (DeckSlotLocation, ModuleLocation, OnLabwareLocation) + ancestor, + ( + DeckSlotLocation, + ModuleLocation, + OnLabwareLocation, + AddressableAreaLocation, + ), ), "No gripper offsets for off-deck labware" return ( direct_parent_offset.pickUpOffset @@ -1198,6 +1211,7 @@ def get_total_nominal_gripper_offset_for_move_type( extra_offset = LabwareOffsetVector(x=0, y=0, z=0) if ( isinstance(ancestor, ModuleLocation) + # todo(mm, 2024-11-06): Do not access private module state; only use public ModuleView methods. and self._modules._state.requested_model_by_id[ancestor.moduleId] == ModuleModel.THERMOCYCLER_MODULE_V2 and labware_validation.validate_definition_is_lid(current_labware) @@ -1220,7 +1234,13 @@ def get_total_nominal_gripper_offset_for_move_type( ) assert isinstance( - ancestor, (DeckSlotLocation, ModuleLocation, OnLabwareLocation) + ancestor, + ( + DeckSlotLocation, + ModuleLocation, + OnLabwareLocation, + AddressableAreaLocation, + ), ), "No gripper offsets for off-deck labware" return ( direct_parent_offset.dropOffset @@ -1230,6 +1250,23 @@ def get_total_nominal_gripper_offset_for_move_type( + extra_offset ) + # todo(mm, 2024-11-05): This may be incorrect because it does not take the following + # offsets into account, which *are* taken into account for the actual gripper movement: + # + # * The pickup offset in the definition of the parent of the gripped labware. + # * The "additional offset" or "user offset", e.g. the `pickUpOffset` and `dropOffset` + # params in the `moveLabware` command. + # + # And this *does* take these extra offsets into account: + # + # * The labware's Labware Position Check offset + # + # For robustness, we should combine this with `get_gripper_labware_movement_waypoints()`. + # + # We should also be more explicit about which offsets act to move the gripper paddles + # relative to the gripped labware, and which offsets act to change how the gripped + # labware sits atop its parent. Those have different effects on how far the gripped + # labware juts beyond the paddles while it's in transit. def check_gripper_labware_tip_collision( self, gripper_homed_position_z: float, @@ -1237,18 +1274,22 @@ def check_gripper_labware_tip_collision( current_location: OnDeckLabwareLocation, ) -> None: """Check for potential collision of tips against labware to be lifted.""" - # TODO(cb, 2024-01-22): Remove the 1 and 8 channel special case once we are doing X axis validation + labware_definition = self._labware.get_definition(labware_id) pipettes = self._pipettes.get_all() for pipette in pipettes: + # TODO(cb, 2024-01-22): Remove the 1 and 8 channel special case once we are doing X axis validation if self._pipettes.get_channels(pipette.id) in [1, 8]: return tip = self._pipettes.get_attached_tip(pipette.id) if tip: + # NOTE: This call to get_labware_highest_z() uses the labware's LPC offset, + # which is an inconsistency between this and the actual gripper movement. + # See the todo comment above this function. labware_top_z_when_gripped = gripper_homed_position_z + ( self.get_labware_highest_z(labware_id=labware_id) - self.get_labware_grip_point( - labware_id=labware_id, location=current_location + labware_definition=labware_definition, location=current_location ).z ) # TODO(cb, 2024-01-18): Utilizing the nozzle map and labware X coordinates verify if collisions will occur on the X axis (analysis will use hard coded data to measure from the gripper critical point to the pipette mount) @@ -1256,7 +1297,7 @@ def check_gripper_labware_tip_collision( _PIPETTE_HOMED_POSITION_Z - tip.length ) < labware_top_z_when_gripped: raise LabwareMovementNotAllowedError( - f"Cannot move labware '{self._labware.get(labware_id).loadName}' when {int(tip.volume)} µL tips are attached." + f"Cannot move labware '{labware_definition.parameters.loadName}' when {int(tip.volume)} µL tips are attached." ) return @@ -1296,6 +1337,7 @@ def _labware_gripper_offsets( DeckSlotLocation, ModuleLocation, AddressableAreaLocation, + OnLabwareLocation, ), ), "No gripper offsets for off-deck labware" @@ -1309,11 +1351,11 @@ def _labware_gripper_offsets( module_loc = self._modules.get_location(parent_location.moduleId) slot_name = module_loc.slotName - slot_based_offset = self._labware.get_labware_gripper_offsets( + slot_based_offset = self._labware.get_child_gripper_offsets( labware_id=labware_id, slot_name=slot_name.to_ot3_equivalent() ) - return slot_based_offset or self._labware.get_labware_gripper_offsets( + return slot_based_offset or self._labware.get_child_gripper_offsets( labware_id=labware_id, slot_name=None ) diff --git a/api/src/opentrons/protocol_engine/state/labware.py b/api/src/opentrons/protocol_engine/state/labware.py index 7cea4f9765b..419e9974d5c 100644 --- a/api/src/opentrons/protocol_engine/state/labware.py +++ b/api/src/opentrons/protocol_engine/state/labware.py @@ -13,6 +13,7 @@ NamedTuple, cast, Union, + overload, ) from opentrons.protocol_engine.state import update_types @@ -81,6 +82,10 @@ } +# The max height of the labware that can fit in a plate reader +_PLATE_READER_MAX_LABWARE_Z_MM = 16 + + class LabwareLoadParams(NamedTuple): """Parameters required to load a labware in Protocol Engine.""" @@ -227,10 +232,11 @@ def _set_labware_location(self, state_update: update_types.StateUpdate) -> None: if labware_location_update.new_location: new_location = labware_location_update.new_location - if isinstance( - new_location, AddressableAreaLocation - ) and fixture_validation.is_gripper_waste_chute( - new_location.addressableAreaName + if isinstance(new_location, AddressableAreaLocation) and ( + fixture_validation.is_gripper_waste_chute( + new_location.addressableAreaName + ) + or fixture_validation.is_trash(new_location.addressableAreaName) ): # If a labware has been moved into a waste chute it's been chuted away and is now technically off deck new_location = OFF_DECK_LOCATION @@ -625,10 +631,26 @@ def get_load_name(self, labware_id: str) -> str: definition = self.get_definition(labware_id) return definition.parameters.loadName - def get_dimensions(self, labware_id: str) -> Dimensions: + @overload + def get_dimensions(self, *, labware_definition: LabwareDefinition) -> Dimensions: + pass + + @overload + def get_dimensions(self, *, labware_id: str) -> Dimensions: + pass + + def get_dimensions( + self, + *, + labware_definition: LabwareDefinition | None = None, + labware_id: str | None = None, + ) -> Dimensions: """Get the labware's dimensions.""" - definition = self.get_definition(labware_id) - dims = definition.dimensions + if labware_definition is None: + assert labware_id is not None # From our @overloads. + labware_definition = self.get_definition(labware_id) + + dims = labware_definition.dimensions return Dimensions( x=dims.xDimension, @@ -637,10 +659,9 @@ def get_dimensions(self, labware_id: str) -> Dimensions: ) def get_labware_overlap_offsets( - self, labware_id: str, below_labware_name: str + self, definition: LabwareDefinition, below_labware_name: str ) -> OverlapOffset: """Get the labware's overlap with requested labware's load name.""" - definition = self.get_definition(labware_id) if below_labware_name in definition.stackingOffsetWithLabware.keys(): stacking_overlap = definition.stackingOffsetWithLabware.get( below_labware_name, OverlapOffset(x=0, y=0, z=0) @@ -654,10 +675,9 @@ def get_labware_overlap_offsets( ) def get_module_overlap_offsets( - self, labware_id: str, module_model: ModuleModel + self, definition: LabwareDefinition, module_model: ModuleModel ) -> OverlapOffset: """Get the labware's overlap with requested module model.""" - definition = self.get_definition(labware_id) stacking_overlap = definition.stackingOffsetWithModule.get( str(module_model.value) ) @@ -817,6 +837,24 @@ def raise_if_labware_in_location( f"Labware {labware.loadName} is already present at {location}." ) + def raise_if_labware_incompatible_with_plate_reader( + self, + labware_definition: LabwareDefinition, + ) -> None: + """Raise an error if the labware is not compatible with the plate reader.""" + load_name = labware_definition.parameters.loadName + number_of_wells = len(labware_definition.wells) + if number_of_wells != 96: + raise errors.LabwareMovementNotAllowedError( + f"Cannot move '{load_name}' into plate reader because the" + f" labware contains {number_of_wells} wells where 96 wells is expected." + ) + elif labware_definition.dimensions.zDimension > _PLATE_READER_MAX_LABWARE_Z_MM: + raise errors.LabwareMovementNotAllowedError( + f"Cannot move '{load_name}' into plate reader because the" + f" maximum allowed labware height is {_PLATE_READER_MAX_LABWARE_Z_MM}mm." + ) + def raise_if_labware_cannot_be_stacked( # noqa: C901 self, top_labware_definition: LabwareDefinition, bottom_labware_id: str ) -> None: @@ -900,22 +938,60 @@ def get_deck_default_gripper_offsets(self) -> Optional[LabwareMovementOffsetData else None ) - def get_labware_gripper_offsets( + def get_absorbance_reader_lid_definition(self) -> LabwareDefinition: + """Return the special labware definition for the plate reader lid. + + See todo comments in `create_protocol_engine(). + """ + # NOTE: This needs to stay in sync with create_protocol_engine(). + return self._state.definitions_by_uri[ + "opentrons/opentrons_flex_lid_absorbance_plate_reader_module/1" + ] + + @overload + def get_child_gripper_offsets( self, - labware_id: str, + *, + labware_definition: LabwareDefinition, slot_name: Optional[DeckSlotName], ) -> Optional[LabwareMovementOffsetData]: - """Get the labware's gripper offsets of the specified type. + pass + + @overload + def get_child_gripper_offsets( + self, *, labware_id: str, slot_name: Optional[DeckSlotName] + ) -> Optional[LabwareMovementOffsetData]: + pass + + def get_child_gripper_offsets( + self, + *, + labware_definition: Optional[LabwareDefinition] = None, + labware_id: Optional[str] = None, + slot_name: Optional[DeckSlotName], + ) -> Optional[LabwareMovementOffsetData]: + """Get the grip offsets that a labware says should be applied to children stacked atop it. + + Params: + labware_id: The ID of a parent labware (atop which another labware, the child, will be stacked). + slot_name: The ancestor slot that the parent labware is ultimately loaded into, + perhaps after going through a module in the middle. Returns: - If `slot_name` is provided, returns the gripper offsets that the labware definition + If `slot_name` is provided, returns the gripper offsets that the parent labware definition specifies just for that slot, or `None` if the labware definition doesn't have an exact match. - If `slot_name` is `None`, returns the gripper offsets that the labware + If `slot_name` is `None`, returns the gripper offsets that the parent labware definition designates as "default," or `None` if it doesn't designate any as such. """ - parsed_offsets = self.get_definition(labware_id).gripperOffsets + if labware_id is not None: + labware_definition = self.get_definition(labware_id) + else: + # Should be ensured by our @overloads. + assert labware_definition is not None + + parsed_offsets = labware_definition.gripperOffsets offset_key = slot_name.id if slot_name else "default" if parsed_offsets is None or offset_key not in parsed_offsets: @@ -930,20 +1006,22 @@ def get_labware_gripper_offsets( ), ) - def get_grip_force(self, labware_id: str) -> float: + def get_grip_force(self, labware_definition: LabwareDefinition) -> float: """Get the recommended grip force for gripping labware using gripper.""" - recommended_force = self.get_definition(labware_id).gripForce + recommended_force = labware_definition.gripForce return ( recommended_force if recommended_force is not None else LABWARE_GRIP_FORCE ) - def get_grip_height_from_labware_bottom(self, labware_id: str) -> float: + def get_grip_height_from_labware_bottom( + self, labware_definition: LabwareDefinition + ) -> float: """Get the recommended grip height from labware bottom, if present.""" - recommended_height = self.get_definition(labware_id).gripHeightFromLabwareBottom + recommended_height = labware_definition.gripHeightFromLabwareBottom return ( recommended_height if recommended_height is not None - else self.get_dimensions(labware_id).z / 2 + else self.get_dimensions(labware_definition=labware_definition).z / 2 ) @staticmethod @@ -986,7 +1064,7 @@ def _min_y_of_well(well_defn: WellDefinition) -> float: def _max_z_of_well(well_defn: WellDefinition) -> float: return well_defn.z + well_defn.depth - def get_well_bbox(self, labware_id: str) -> Dimensions: + def get_well_bbox(self, labware_definition: LabwareDefinition) -> Dimensions: """Get the bounding box implied by the wells. The bounding box of the labware that is implied by the wells is that required @@ -997,14 +1075,13 @@ def get_well_bbox(self, labware_id: str) -> Dimensions: This is used for the specific purpose of finding the reasonable uncertainty bounds of where and how a gripper will interact with a labware. """ - defn = self.get_definition(labware_id) max_x: Optional[float] = None min_x: Optional[float] = None max_y: Optional[float] = None min_y: Optional[float] = None max_z: Optional[float] = None - for well in defn.wells.values(): + for well in labware_definition.wells.values(): well_max_x = self._max_x_of_well(well) well_min_x = self._min_x_of_well(well) well_max_y = self._max_y_of_well(well) diff --git a/api/src/opentrons/protocol_engine/state/liquid_classes.py b/api/src/opentrons/protocol_engine/state/liquid_classes.py new file mode 100644 index 00000000000..7992735fecd --- /dev/null +++ b/api/src/opentrons/protocol_engine/state/liquid_classes.py @@ -0,0 +1,82 @@ +"""A data store of liquid classes.""" + +from __future__ import annotations + +import dataclasses +from typing import Dict +from typing_extensions import Optional + +from .. import errors +from ..actions import Action, get_state_updates +from ..types import LiquidClassRecord +from . import update_types +from ._abstract_store import HasState, HandlesActions + + +@dataclasses.dataclass +class LiquidClassState: + """Our state is a bidirectional mapping between IDs <-> LiquidClassRecords.""" + + # We use the bidirectional map to see if we've already assigned an ID to a liquid class when the + # engine is asked to store a new liquid class. + liquid_class_record_by_id: Dict[str, LiquidClassRecord] + liquid_class_record_to_id: Dict[LiquidClassRecord, str] + + +class LiquidClassStore(HasState[LiquidClassState], HandlesActions): + """Container for LiquidClassState.""" + + _state: LiquidClassState + + def __init__(self) -> None: + self._state = LiquidClassState( + liquid_class_record_by_id={}, + liquid_class_record_to_id={}, + ) + + def handle_action(self, action: Action) -> None: + """Update the state in response to the action.""" + for state_update in get_state_updates(action): + if state_update.liquid_class_loaded != update_types.NO_CHANGE: + self._handle_liquid_class_loaded_update( + state_update.liquid_class_loaded + ) + + def _handle_liquid_class_loaded_update( + self, state_update: update_types.LiquidClassLoadedUpdate + ) -> None: + # We're just a data store. All the validation and ID generation happens in the command implementation. + self._state.liquid_class_record_by_id[ + state_update.liquid_class_id + ] = state_update.liquid_class_record + self._state.liquid_class_record_to_id[ + state_update.liquid_class_record + ] = state_update.liquid_class_id + + +class LiquidClassView(HasState[LiquidClassState]): + """Read-only view of the LiquidClassState.""" + + _state: LiquidClassState + + def __init__(self, state: LiquidClassState) -> None: + self._state = state + + def get(self, liquid_class_id: str) -> LiquidClassRecord: + """Get the LiquidClassRecord with the given identifier.""" + try: + return self._state.liquid_class_record_by_id[liquid_class_id] + except KeyError as e: + raise errors.LiquidClassDoesNotExistError( + f"Liquid class ID {liquid_class_id} not found." + ) from e + + def get_id_for_liquid_class_record( + self, liquid_class_record: LiquidClassRecord + ) -> Optional[str]: + """See if the given LiquidClassRecord if already in the store, and if so, return its identifier.""" + return self._state.liquid_class_record_to_id.get(liquid_class_record) + + def get_all(self) -> Dict[str, LiquidClassRecord]: + """Get all the LiquidClassRecords in the store.""" + return self._state.liquid_class_record_by_id.copy() diff --git a/api/src/opentrons/protocol_engine/state/module_substates/absorbance_reader_substate.py b/api/src/opentrons/protocol_engine/state/module_substates/absorbance_reader_substate.py index 33b96aa0881..79bdbc50b60 100644 --- a/api/src/opentrons/protocol_engine/state/module_substates/absorbance_reader_substate.py +++ b/api/src/opentrons/protocol_engine/state/module_substates/absorbance_reader_substate.py @@ -9,6 +9,9 @@ AbsorbanceReaderMeasureMode = NewType("AbsorbanceReaderMeasureMode", str) +# todo(mm, 2024-11-08): frozen=True is getting pretty painful because ModuleStore has +# no type-safe way to modify just a single attribute. Consider unfreezing this +# (taking care to ensure that consumers of ModuleView still only get a read-only view). @dataclass(frozen=True) class AbsorbanceReaderSubState: """Absorbance-Plate-Reader-specific state.""" @@ -21,7 +24,6 @@ class AbsorbanceReaderSubState: configured_wavelengths: Optional[List[int]] measure_mode: Optional[AbsorbanceReaderMeasureMode] reference_wavelength: Optional[int] - lid_id: Optional[str] def raise_if_lid_status_not_expected(self, lid_on_expected: bool) -> None: """Raise if the lid status is not correct.""" diff --git a/api/src/opentrons/protocol_engine/state/modules.py b/api/src/opentrons/protocol_engine/state/modules.py index ca8973b405c..c61d4173ff1 100644 --- a/api/src/opentrons/protocol_engine/state/modules.py +++ b/api/src/opentrons/protocol_engine/state/modules.py @@ -26,13 +26,15 @@ get_west_slot, get_adjacent_staging_slot, ) +from opentrons.protocol_engine.actions.get_state_update import get_state_updates from opentrons.protocol_engine.commands.calibration.calibrate_module import ( CalibrateModuleResult, ) +from opentrons.protocol_engine.state import update_types from opentrons.protocol_engine.state.module_substates.absorbance_reader_substate import ( AbsorbanceReaderMeasureMode, ) -from opentrons.types import DeckSlotName, MountType +from opentrons.types import DeckSlotName, MountType, StagingSlotName from ..errors import ModuleNotConnectedError from ..types import ( @@ -67,7 +69,6 @@ Action, SucceedCommandAction, AddModuleAction, - AddAbsorbanceReaderLidAction, ) from ._abstract_store import HasState, HandlesActions from .module_substates import ( @@ -234,13 +235,14 @@ def handle_action(self, action: Action) -> None: requested_model=None, module_live_data=action.module_live_data, ) - elif isinstance(action, AddAbsorbanceReaderLidAction): - self._update_absorbance_reader_lid_id( - module_id=action.module_id, - lid_id=action.lid_id, - ) + + for state_update in get_state_updates(action): + self._handle_state_update(state_update) def _handle_command(self, command: Command) -> None: + # todo(mm, 2024-11-04): Delete this function. Port these isinstance() + # checks to the update_types.StateUpdate mechanism. + if isinstance(command.result, LoadModuleResult): slot_name = command.params.location.slotName self._add_module_substate( @@ -297,38 +299,40 @@ def _handle_command(self, command: Command) -> None: if isinstance( command.result, ( - absorbance_reader.CloseLidResult, - absorbance_reader.OpenLidResult, absorbance_reader.InitializeResult, absorbance_reader.ReadAbsorbanceResult, ), ): self._handle_absorbance_reader_commands(command) - def _update_absorbance_reader_lid_id( - self, - module_id: str, - lid_id: str, - ) -> None: - abs_substate = self._state.substate_by_module_id.get(module_id) - assert isinstance( - abs_substate, AbsorbanceReaderSubState - ), f"{module_id} is not an absorbance plate reader." + def _handle_state_update(self, state_update: update_types.StateUpdate) -> None: + if state_update.absorbance_reader_lid != update_types.NO_CHANGE: + module_id = state_update.absorbance_reader_lid.module_id + is_lid_on = state_update.absorbance_reader_lid.is_lid_on + + # Get current values: + absorbance_reader_substate = self._state.substate_by_module_id[module_id] + assert isinstance( + absorbance_reader_substate, AbsorbanceReaderSubState + ), f"{module_id} is not an absorbance plate reader." + configured = absorbance_reader_substate.configured + measure_mode = absorbance_reader_substate.measure_mode + configured_wavelengths = absorbance_reader_substate.configured_wavelengths + reference_wavelength = absorbance_reader_substate.reference_wavelength + data = absorbance_reader_substate.data - prev_state: AbsorbanceReaderSubState = abs_substate - self._state.substate_by_module_id[module_id] = AbsorbanceReaderSubState( - module_id=AbsorbanceReaderId(module_id), - configured=prev_state.configured, - measured=prev_state.measured, - is_lid_on=prev_state.is_lid_on, - data=prev_state.data, - measure_mode=prev_state.measure_mode, - configured_wavelengths=prev_state.configured_wavelengths, - reference_wavelength=prev_state.reference_wavelength, - lid_id=lid_id, - ) + self._state.substate_by_module_id[module_id] = AbsorbanceReaderSubState( + module_id=AbsorbanceReaderId(module_id), + configured=configured, + measured=True, + is_lid_on=is_lid_on, + measure_mode=measure_mode, + configured_wavelengths=configured_wavelengths, + reference_wavelength=reference_wavelength, + data=data, + ) - def _add_module_substate( # noqa: C901 + def _add_module_substate( self, module_id: str, serial_number: Optional[str], @@ -387,16 +391,6 @@ def _add_module_substate( # noqa: C901 module_id=MagneticBlockId(module_id) ) elif ModuleModel.is_absorbance_reader(actual_model): - lid_labware_id = None - slot = self._state.slot_by_module_id[module_id] - if slot is not None: - reader_addressable_area = f"absorbanceReaderV1{slot.value}" - for labware in self._state.deck_fixed_labware: - if labware.location == AddressableAreaLocation( - addressableAreaName=reader_addressable_area - ): - lid_labware_id = labware.labware_id - break self._state.substate_by_module_id[module_id] = AbsorbanceReaderSubState( module_id=AbsorbanceReaderId(module_id), configured=False, @@ -406,7 +400,6 @@ def _add_module_substate( # noqa: C901 measure_mode=None, configured_wavelengths=None, reference_wavelength=None, - lid_id=lid_labware_id, ) def _update_additional_slots_occupied_by_thermocycler( @@ -600,8 +593,6 @@ def _handle_absorbance_reader_commands( command: Union[ absorbance_reader.Initialize, absorbance_reader.ReadAbsorbance, - absorbance_reader.CloseLid, - absorbance_reader.OpenLid, ], ) -> None: module_id = command.params.moduleId @@ -616,8 +607,6 @@ def _handle_absorbance_reader_commands( configured_wavelengths = absorbance_reader_substate.configured_wavelengths reference_wavelength = absorbance_reader_substate.reference_wavelength is_lid_on = absorbance_reader_substate.is_lid_on - lid_id = absorbance_reader_substate.lid_id - data = absorbance_reader_substate.data if isinstance(command.result, absorbance_reader.InitializeResult): self._state.substate_by_module_id[module_id] = AbsorbanceReaderSubState( @@ -625,7 +614,6 @@ def _handle_absorbance_reader_commands( configured=True, measured=False, is_lid_on=is_lid_on, - lid_id=lid_id, measure_mode=AbsorbanceReaderMeasureMode(command.params.measureMode), configured_wavelengths=command.params.sampleWavelengths, reference_wavelength=command.params.referenceWavelength, @@ -637,39 +625,12 @@ def _handle_absorbance_reader_commands( configured=configured, measured=True, is_lid_on=is_lid_on, - lid_id=lid_id, measure_mode=measure_mode, configured_wavelengths=configured_wavelengths, reference_wavelength=reference_wavelength, data=command.result.data, ) - elif isinstance(command.result, absorbance_reader.OpenLidResult): - self._state.substate_by_module_id[module_id] = AbsorbanceReaderSubState( - module_id=AbsorbanceReaderId(module_id), - configured=configured, - measured=True, - is_lid_on=False, - lid_id=lid_id, - measure_mode=measure_mode, - configured_wavelengths=configured_wavelengths, - reference_wavelength=reference_wavelength, - data=data, - ) - - elif isinstance(command.result, absorbance_reader.CloseLidResult): - self._state.substate_by_module_id[module_id] = AbsorbanceReaderSubState( - module_id=AbsorbanceReaderId(module_id), - configured=configured, - measured=True, - is_lid_on=True, - lid_id=lid_id, - measure_mode=measure_mode, - configured_wavelengths=configured_wavelengths, - reference_wavelength=reference_wavelength, - data=data, - ) - class ModuleView(HasState[ModuleState]): """Read-only view of computed module state.""" @@ -883,12 +844,21 @@ def get_dimensions(self, module_id: str) -> ModuleDimensions: """Get the specified module's dimensions.""" return self.get_definition(module_id).dimensions - def get_nominal_module_offset( + def get_nominal_offset_to_child( self, module_id: str, + # todo(mm, 2024-11-07): A method of one view taking a sibling view as an argument + # is unusual, and may be bug-prone if the order in which the views are updated + # matters. If we need to compute something that depends on module info and + # addressable area info, can we do that computation in GeometryView instead of + # here? addressable_areas: AddressableAreaView, ) -> LabwareOffsetVector: - """Get the module's nominal offset vector computed with slot transform.""" + """Get the nominal offset from a module's location to its child labware's location. + + Includes the slot-specific transform. Does not include the child's + Labware Position Check offset. + """ if ( self.state.deck_type == DeckType.OT2_STANDARD or self.state.deck_type == DeckType.OT2_SHORT_TRASH @@ -996,7 +966,7 @@ def get_module_highest_z( default_lw_offset_point = self.get_definition(module_id).labwareOffset.z z_difference = module_height - default_lw_offset_point - nominal_transformed_lw_offset_z = self.get_nominal_module_offset( + nominal_transformed_lw_offset_z = self.get_nominal_offset_to_child( module_id=module_id, addressable_areas=addressable_areas ).z calibration_offset = self.get_module_calibration_offset(module_id) @@ -1124,8 +1094,8 @@ def calculate_magnet_height( def should_dodge_thermocycler( self, - from_slot: DeckSlotName, - to_slot: DeckSlotName, + from_slot: Union[DeckSlotName, StagingSlotName], + to_slot: Union[DeckSlotName, StagingSlotName], ) -> bool: """Decide if the requested path would cross the thermocycler, if installed. diff --git a/api/src/opentrons/protocol_engine/state/motion.py b/api/src/opentrons/protocol_engine/state/motion.py index c9aa146715b..0863c42a0c1 100644 --- a/api/src/opentrons/protocol_engine/state/motion.py +++ b/api/src/opentrons/protocol_engine/state/motion.py @@ -2,7 +2,7 @@ from dataclasses import dataclass from typing import List, Optional, Union -from opentrons.types import MountType, Point +from opentrons.types import MountType, Point, StagingSlotName from opentrons.hardware_control.types import CriticalPoint from opentrons.motion_planning.adjacent_slots_getters import ( get_east_west_slots, @@ -277,9 +277,13 @@ def check_pipette_blocking_hs_latch( current_location = self._pipettes.get_current_location() if current_location is not None: if isinstance(current_location, CurrentWell): - pipette_deck_slot = self._geometry.get_ancestor_slot_name( + ancestor = self._geometry.get_ancestor_slot_name( current_location.labware_id - ).as_int() + ) + if isinstance(ancestor, StagingSlotName): + # Staging Area Slots cannot intersect with the h/s + return False + pipette_deck_slot = ancestor.as_int() else: pipette_deck_slot = ( self._addressable_areas.get_addressable_area_base_slot( @@ -299,9 +303,13 @@ def check_pipette_blocking_hs_shaker( current_location = self._pipettes.get_current_location() if current_location is not None: if isinstance(current_location, CurrentWell): - pipette_deck_slot = self._geometry.get_ancestor_slot_name( + ancestor = self._geometry.get_ancestor_slot_name( current_location.labware_id - ).as_int() + ) + if isinstance(ancestor, StagingSlotName): + # Staging Area Slots cannot intersect with the h/s + return False + pipette_deck_slot = ancestor.as_int() else: pipette_deck_slot = ( self._addressable_areas.get_addressable_area_base_slot( @@ -324,6 +332,10 @@ def get_touch_tip_waypoints( """Get a list of touch points for a touch tip operation.""" mount = self._pipettes.get_mount(pipette_id) labware_slot = self._geometry.get_ancestor_slot_name(labware_id) + if isinstance(labware_slot, StagingSlotName): + raise errors.LocationIsStagingSlotError( + "Cannot perform Touch Tip on labware in Staging Area Slot." + ) next_to_module = self._modules.is_edge_move_unsafe(mount, labware_slot) edge_path_type = self._labware.get_edge_path_type( labware_id, well_name, mount, labware_slot, next_to_module diff --git a/api/src/opentrons/protocol_engine/state/update_types.py b/api/src/opentrons/protocol_engine/state/update_types.py index fa1febe1d2f..aed6e637f3f 100644 --- a/api/src/opentrons/protocol_engine/state/update_types.py +++ b/api/src/opentrons/protocol_engine/state/update_types.py @@ -13,6 +13,7 @@ LabwareLocation, TipGeometry, AspiratedFluid, + LiquidClassRecord, ) from opentrons.types import MountType from opentrons_shared_data.labware.labware_definition import LabwareDefinition @@ -244,6 +245,22 @@ class PipetteEmptyFluidUpdate: type: typing.Literal["empty"] = "empty" +@dataclasses.dataclass +class AbsorbanceReaderLidUpdate: + """An update to an absorbance reader's lid location.""" + + module_id: str + is_lid_on: bool + + +@dataclasses.dataclass +class LiquidClassLoadedUpdate: + """The state update from loading a liquid class.""" + + liquid_class_id: str + liquid_class_record: LiquidClassRecord + + @dataclasses.dataclass class StateUpdate: """Represents an update to perform on engine state.""" @@ -274,6 +291,10 @@ class StateUpdate: liquid_operated: LiquidOperatedUpdate | NoChangeType = NO_CHANGE + absorbance_reader_lid: AbsorbanceReaderLidUpdate | NoChangeType = NO_CHANGE + + liquid_class_loaded: LiquidClassLoadedUpdate | NoChangeType = NO_CHANGE + # These convenience functions let the caller avoid the boilerplate of constructing a # complicated dataclass tree. @@ -473,3 +494,9 @@ def set_fluid_empty(self, pipette_id: str) -> None: self.pipette_aspirated_fluid = PipetteEmptyFluidUpdate( type="empty", pipette_id=pipette_id ) + + def set_absorbance_reader_lid(self, module_id: str, is_lid_on: bool) -> None: + """Update an absorbance reader's lid location. See `AbsorbanceReaderLidUpdate`.""" + self.absorbance_reader_lid = AbsorbanceReaderLidUpdate( + module_id=module_id, is_lid_on=is_lid_on + ) diff --git a/api/src/opentrons/protocol_engine/types.py b/api/src/opentrons/protocol_engine/types.py index 73a2df980f9..1a11a99df86 100644 --- a/api/src/opentrons/protocol_engine/types.py +++ b/api/src/opentrons/protocol_engine/types.py @@ -36,7 +36,9 @@ from opentrons.hardware_control.modules import ( ModuleType as ModuleType, ) - +from opentrons_shared_data.liquid_classes.liquid_class_definition import ( + ByTipTypeSetting, +) from opentrons_shared_data.pipette.types import ( # noqa: F401 # convenience re-export of LabwareUri type LabwareUri as LabwareUri, @@ -842,6 +844,49 @@ class Liquid(BaseModel): displayColor: Optional[HexColor] +class LiquidClassRecord(ByTipTypeSetting, frozen=True): + """LiquidClassRecord is our internal representation of an (immutable) liquid class. + + Conceptually, a liquid class record is the tuple (name, pipette, tip, transfer properties). + We consider two liquid classes to be the same if every entry in that tuple is the same; and liquid + classes are different if any entry in the tuple is different. + + This class defines the tuple via inheritance so that we can reuse the definitions from shared_data. + """ + + liquidClassName: str = Field( + ..., + description="Identifier for the liquid of this liquid class, e.g. glycerol50.", + ) + pipetteModel: str = Field( + ..., + description="Identifier for the pipette of this liquid class.", + ) + # The other fields like tiprack ID, aspirate properties, etc. are pulled in from ByTipTypeSetting. + + def __hash__(self) -> int: + """Hash function for LiquidClassRecord.""" + # Within the Protocol Engine, LiquidClassRecords are immutable, and we'd like to be able to + # look up LiquidClassRecords by value, which involves hashing. However, Pydantic does not + # generate a usable hash function if any of the subfields (like Coordinate) are not frozen. + # So we have to implement the hash function ourselves. + # Our strategy is to recursively convert this object into a list of (key, value) tuples. + def dict_to_tuple(d: dict[str, Any]) -> tuple[tuple[str, Any], ...]: + return tuple( + ( + field_name, + dict_to_tuple(value) + if isinstance(value, dict) + else tuple(value) + if isinstance(value, list) + else value, + ) + for field_name, value in d.items() + ) + + return hash(dict_to_tuple(self.dict())) + + class SpeedRange(NamedTuple): """Minimum and maximum allowed speeds for a shaking module.""" diff --git a/api/src/opentrons/protocols/api_support/instrument.py b/api/src/opentrons/protocols/api_support/instrument.py index 0137b43a4c8..3299b8512f9 100644 --- a/api/src/opentrons/protocols/api_support/instrument.py +++ b/api/src/opentrons/protocols/api_support/instrument.py @@ -73,7 +73,7 @@ def tip_length_for( VALID_PIP_TIPRACK_VOL = { - "FLEX": {"p50": [50], "p1000": [50, 200, 1000]}, + "FLEX": {"p50": [50], "p200": [50, 200], "p1000": [50, 200, 1000]}, "OT2": { "p10": [10, 20], "p20": [10, 20], diff --git a/api/tests/opentrons/drivers/absorbance_reader/test_driver.py b/api/tests/opentrons/drivers/absorbance_reader/test_driver.py index 0284f277e2c..58552695f44 100644 --- a/api/tests/opentrons/drivers/absorbance_reader/test_driver.py +++ b/api/tests/opentrons/drivers/absorbance_reader/test_driver.py @@ -90,8 +90,39 @@ async def test_driver_get_device_info( info = await connected_driver.get_device_info() - mock_interface.get_device_information.assert_called_once() assert info == {"serial": "BYOMAA00013", "model": "ABS96", "version": "v1.0.2"} + mock_interface.get_device_information.assert_called_once() + mock_interface.reset_mock() + + # Test Device info with updated serial format + DEVICE_INFO.sn = "OPTMAA00034" + DEVICE_INFO.version = "Absorbance V1.0.2 2024-04-18" + + mock_interface.get_device_information.return_value = ( + MockErrorCode.NO_ERROR, + DEVICE_INFO, + ) + + info = await connected_driver.get_device_info() + + assert info == {"serial": "OPTMAA00034", "model": "ABS96", "version": "v1.0.2"} + mock_interface.get_device_information.assert_called_once() + mock_interface.reset_mock() + + # Test Device info with invalid serial format + DEVICE_INFO.sn = "YRFGHVMAA00034" + DEVICE_INFO.version = "Absorbance V1.0.2 2024-04-18" + + mock_interface.get_device_information.return_value = ( + MockErrorCode.NO_ERROR, + DEVICE_INFO, + ) + + info = await connected_driver.get_device_info() + + assert info == {"serial": "OPTMAA00000", "model": "ABS96", "version": "v1.0.2"} + mock_interface.get_device_information.assert_called_once() + mock_interface.reset_mock() @pytest.mark.parametrize( diff --git a/api/tests/opentrons/protocol_api/core/engine/test_absorbance_reader_core.py b/api/tests/opentrons/protocol_api/core/engine/test_absorbance_reader_core.py index fd537d4cad9..9bc195296a2 100644 --- a/api/tests/opentrons/protocol_api/core/engine/test_absorbance_reader_core.py +++ b/api/tests/opentrons/protocol_api/core/engine/test_absorbance_reader_core.py @@ -69,59 +69,67 @@ def test_initialize( ) -> None: """It should set the sample wavelength with the engine client.""" subject._ready_to_initialize = True - subject.initialize("single", [123]) + subject.initialize("single", [350]) decoy.verify( mock_engine_client.execute_command( cmd.absorbance_reader.InitializeParams( moduleId="1234", measureMode="single", - sampleWavelengths=[123], + sampleWavelengths=[350], referenceWavelength=None, ), ), times=1, ) - assert subject._initialized_value == [123] + assert subject._initialized_value == [350] # Test reference wavelength - subject.initialize("single", [124], 450) + subject.initialize("single", [350], 450) decoy.verify( mock_engine_client.execute_command( cmd.absorbance_reader.InitializeParams( moduleId="1234", measureMode="single", - sampleWavelengths=[124], + sampleWavelengths=[350], referenceWavelength=450, ), ), times=1, ) - assert subject._initialized_value == [124] + assert subject._initialized_value == [350] # Test initialize multi - subject.initialize("multi", [124, 125, 126]) + subject.initialize("multi", [350, 400, 450]) decoy.verify( mock_engine_client.execute_command( cmd.absorbance_reader.InitializeParams( moduleId="1234", measureMode="multi", - sampleWavelengths=[124, 125, 126], + sampleWavelengths=[350, 400, 450], referenceWavelength=None, ), ), times=1, ) - assert subject._initialized_value == [124, 125, 126] + assert subject._initialized_value == [350, 400, 450] def test_initialize_not_ready(subject: AbsorbanceReaderCore) -> None: """It should raise CannotPerformModuleAction if you dont call .close_lid() command.""" subject._ready_to_initialize = False with pytest.raises(CannotPerformModuleAction): - subject.initialize("single", [123]) + subject.initialize("single", [350]) + + +@pytest.mark.parametrize("wavelength", [-350, 0, 1200, "wda"]) +def test_invalid_wavelengths(wavelength: int, subject: AbsorbanceReaderCore) -> None: + """It should raise ValueError if you provide an invalid wavelengthi.""" + subject._ready_to_initialize = True + with pytest.raises(ValueError): + subject.initialize("single", [wavelength]) def test_read( @@ -129,7 +137,7 @@ def test_read( ) -> None: """It should call absorbance reader to read with the engine client.""" subject._ready_to_initialize = True - subject._initialized_value = [123] + subject._initialized_value = [350] substate = AbsorbanceReaderSubState( module_id=AbsorbanceReaderId(subject.module_id), configured=True, @@ -139,7 +147,6 @@ def test_read( configured_wavelengths=subject._initialized_value, measure_mode=AbsorbanceReaderMeasureMode("single"), reference_wavelength=None, - lid_id="pr_lid_labware", ) decoy.when( mock_engine_client.state.modules.get_absorbance_reader_substate( @@ -152,6 +159,7 @@ def test_read( mock_engine_client.execute_command( cmd.absorbance_reader.ReadAbsorbanceParams( moduleId="1234", + fileName=None, ), ), times=1, diff --git a/api/tests/opentrons/protocol_api/core/engine/test_deck_conflict.py b/api/tests/opentrons/protocol_api/core/engine/test_deck_conflict.py index a6f981733e5..208ac843b94 100644 --- a/api/tests/opentrons/protocol_api/core/engine/test_deck_conflict.py +++ b/api/tests/opentrons/protocol_api/core/engine/test_deck_conflict.py @@ -901,9 +901,9 @@ def test_valid_96_pipette_movement_for_tiprack_and_adapter( ) -> None: """It should raise appropriate error for unsuitable tiprack parent when moving 96 channel to it.""" decoy.when(mock_state_view.pipettes.get_channels("pipette-id")).then_return(96) - decoy.when(mock_state_view.labware.get_dimensions("adapter-id")).then_return( - Dimensions(x=0, y=0, z=100) - ) + decoy.when( + mock_state_view.labware.get_dimensions(labware_id="adapter-id") + ).then_return(Dimensions(x=0, y=0, z=100)) decoy.when(mock_state_view.labware.get_display_name("labware-id")).then_return( "A cool tiprack" ) @@ -913,9 +913,9 @@ def test_valid_96_pipette_movement_for_tiprack_and_adapter( decoy.when(mock_state_view.labware.get_location("labware-id")).then_return( tiprack_parent ) - decoy.when(mock_state_view.labware.get_dimensions("labware-id")).then_return( - tiprack_dim - ) + decoy.when( + mock_state_view.labware.get_dimensions(labware_id="labware-id") + ).then_return(tiprack_dim) decoy.when( mock_state_view.labware.get_has_quirk( labware_id="adapter-id", quirk="tiprackAdapterFor96Channel" diff --git a/api/tests/opentrons/protocol_api/test_liquid_class.py b/api/tests/opentrons/protocol_api/test_liquid_class.py index be0b432e32f..463889b3da6 100644 --- a/api/tests/opentrons/protocol_api/test_liquid_class.py +++ b/api/tests/opentrons/protocol_api/test_liquid_class.py @@ -22,7 +22,11 @@ def test_get_for_pipette_and_tip( """It should get the properties for the specified pipette and tip.""" liq_class = LiquidClass.create(minimal_liquid_class_def2) result = liq_class.get_for("p20_single_gen2", "opentrons_96_tiprack_20ul") - assert result.aspirate.flow_rate_by_volume == {"default": 50, "10": 40, "20": 30} + assert result.aspirate.flow_rate_by_volume.as_dict() == { + "default": 50.0, + 10.0: 40.0, + 20.0: 30.0, + } def test_get_for_raises_for_incorrect_pipette_or_tip( diff --git a/api/tests/opentrons/protocol_api/test_liquid_class_properties.py b/api/tests/opentrons/protocol_api/test_liquid_class_properties.py index b1699701f3c..7e9d7cc2f3b 100644 --- a/api/tests/opentrons/protocol_api/test_liquid_class_properties.py +++ b/api/tests/opentrons/protocol_api/test_liquid_class_properties.py @@ -1,5 +1,5 @@ """Tests for LiquidClass properties and related functions.""" - +import pytest from opentrons_shared_data import load_shared_data from opentrons_shared_data.liquid_classes.liquid_class_definition import ( LiquidClassSchemaV1, @@ -10,6 +10,7 @@ build_aspirate_properties, build_single_dispense_properties, build_multi_dispense_properties, + LiquidHandlingPropertyByVolume, ) @@ -30,10 +31,10 @@ def test_build_aspirate_settings() -> None: assert aspirate_properties.retract.position_reference.value == "well-top" assert aspirate_properties.retract.offset == Coordinate(x=0, y=0, z=5) assert aspirate_properties.retract.speed == 100 - assert aspirate_properties.retract.air_gap_by_volume == { - "default": 2, - "5": 3, - "10": 4, + assert aspirate_properties.retract.air_gap_by_volume.as_dict() == { + "default": 2.0, + 5.0: 3.0, + 10.0: 4.0, } assert aspirate_properties.retract.touch_tip.enabled is True assert aspirate_properties.retract.touch_tip.z_offset == 2 @@ -44,10 +45,10 @@ def test_build_aspirate_settings() -> None: assert aspirate_properties.position_reference.value == "well-bottom" assert aspirate_properties.offset == Coordinate(x=0, y=0, z=-5) - assert aspirate_properties.flow_rate_by_volume == { - "default": 50, - "10": 40, - "20": 30, + assert aspirate_properties.flow_rate_by_volume.as_dict() == { + "default": 50.0, + 10.0: 40.0, + 20.0: 30.0, } assert aspirate_properties.pre_wet is True assert aspirate_properties.mix.enabled is True @@ -77,10 +78,10 @@ def test_build_single_dispense_settings() -> None: assert single_dispense_properties.retract.position_reference.value == "well-top" assert single_dispense_properties.retract.offset == Coordinate(x=0, y=0, z=5) assert single_dispense_properties.retract.speed == 100 - assert single_dispense_properties.retract.air_gap_by_volume == { - "default": 2, - "5": 3, - "10": 4, + assert single_dispense_properties.retract.air_gap_by_volume.as_dict() == { + "default": 2.0, + 5.0: 3.0, + 10.0: 4.0, } assert single_dispense_properties.retract.touch_tip.enabled is True assert single_dispense_properties.retract.touch_tip.z_offset == 2 @@ -95,18 +96,18 @@ def test_build_single_dispense_settings() -> None: assert single_dispense_properties.position_reference.value == "well-bottom" assert single_dispense_properties.offset == Coordinate(x=0, y=0, z=-5) - assert single_dispense_properties.flow_rate_by_volume == { - "default": 50, - "10": 40, - "20": 30, + assert single_dispense_properties.flow_rate_by_volume.as_dict() == { + "default": 50.0, + 10.0: 40.0, + 20.0: 30.0, } assert single_dispense_properties.mix.enabled is True assert single_dispense_properties.mix.repetitions == 3 assert single_dispense_properties.mix.volume == 15 - assert single_dispense_properties.push_out_by_volume == { - "default": 5, - "10": 7, - "20": 10, + assert single_dispense_properties.push_out_by_volume.as_dict() == { + "default": 5.0, + 10.0: 7.0, + 20.0: 10.0, } assert single_dispense_properties.delay.enabled is True assert single_dispense_properties.delay.duration == 2.5 @@ -133,10 +134,10 @@ def test_build_multi_dispense_settings() -> None: assert multi_dispense_properties.retract.position_reference.value == "well-top" assert multi_dispense_properties.retract.offset == Coordinate(x=0, y=0, z=5) assert multi_dispense_properties.retract.speed == 100 - assert multi_dispense_properties.retract.air_gap_by_volume == { - "default": 2, - "5": 3, - "10": 4, + assert multi_dispense_properties.retract.air_gap_by_volume.as_dict() == { + "default": 2.0, + 5.0: 3.0, + 10.0: 4.0, } assert multi_dispense_properties.retract.touch_tip.enabled is True assert multi_dispense_properties.retract.touch_tip.z_offset == 2 @@ -150,18 +151,18 @@ def test_build_multi_dispense_settings() -> None: assert multi_dispense_properties.position_reference.value == "well-bottom" assert multi_dispense_properties.offset == Coordinate(x=0, y=0, z=-5) - assert multi_dispense_properties.flow_rate_by_volume == { - "default": 50, - "10": 40, - "20": 30, + assert multi_dispense_properties.flow_rate_by_volume.as_dict() == { + "default": 50.0, + 10.0: 40.0, + 20.0: 30.0, } - assert multi_dispense_properties.conditioning_by_volume == { - "default": 10, - "5": 5, + assert multi_dispense_properties.conditioning_by_volume.as_dict() == { + "default": 10.0, + 5.0: 5.0, } - assert multi_dispense_properties.disposal_by_volume == { - "default": 2, - "5": 3, + assert multi_dispense_properties.disposal_by_volume.as_dict() == { + "default": 2.0, + 5.0: 3.0, } assert multi_dispense_properties.delay.enabled is True assert multi_dispense_properties.delay.duration == 1 @@ -173,3 +174,31 @@ def test_build_multi_dispense_settings_none( """It should return None if there are no multi dispense properties in the model.""" transfer_settings = minimal_liquid_class_def2.byPipette[0].byTipType[0] assert build_multi_dispense_properties(transfer_settings.multiDispense) is None + + +def test_liquid_handling_property_by_volume() -> None: + """It should create a class that can interpolate values and add and delete new points.""" + subject = LiquidHandlingPropertyByVolume({"default": 42, "5": 50, "10.0": 250}) + assert subject.as_dict() == {"default": 42, 5.0: 50, 10.0: 250} + assert subject.default == 42.0 + assert subject.get_for_volume(7) == 130.0 + + subject.set_for_volume(volume=7, value=175.5) + assert subject.as_dict() == { + "default": 42, + 5.0: 50, + 10.0: 250, + 7.0: 175.5, + } + assert subject.get_for_volume(7) == 175.5 + + subject.delete_for_volume(7) + assert subject.as_dict() == {"default": 42, 5.0: 50, 10.0: 250} + assert subject.get_for_volume(7) == 130.0 + + with pytest.raises(KeyError, match="No value set for volume"): + subject.delete_for_volume(7) + + # Test bounds + assert subject.get_for_volume(1) == 50.0 + assert subject.get_for_volume(1000) == 250.0 diff --git a/api/tests/opentrons/protocol_api/test_protocol_context.py b/api/tests/opentrons/protocol_api/test_protocol_context.py index 2c8e8b158af..e804ac9dd11 100644 --- a/api/tests/opentrons/protocol_api/test_protocol_context.py +++ b/api/tests/opentrons/protocol_api/test_protocol_context.py @@ -49,6 +49,8 @@ from opentrons.protocols.api_support.deck_type import ( NoTrashDefinedError, ) +from opentrons.protocol_engine.errors import LabwareMovementNotAllowedError +from opentrons.protocol_engine.clients import SyncClient as EngineClient @pytest.fixture(autouse=True) @@ -101,6 +103,12 @@ def api_version() -> APIVersion: return MAX_SUPPORTED_VERSION +@pytest.fixture +def mock_engine_client(decoy: Decoy) -> EngineClient: + """Get a mock ProtocolEngine synchronous client.""" + return decoy.mock(cls=EngineClient) + + @pytest.fixture def subject( mock_core: ProtocolCore, @@ -944,6 +952,74 @@ def test_move_labware_off_deck_raises( subject.move_labware(labware=movable_labware, new_location=OFF_DECK) +def test_move_labware_to_trash_raises( + subject: ProtocolContext, + decoy: Decoy, + mock_core: ProtocolCore, + mock_core_map: LoadedCoreMap, + mock_engine_client: EngineClient, +) -> None: + """It should raise an LabwareMovementNotAllowedError if using move_labware to move something that is not a lid to a TrashBin.""" + mock_labware_core = decoy.mock(cls=LabwareCore) + trash_location = TrashBin( + location=DeckSlotName.SLOT_D3, + addressable_area_name="moveableTrashD3", + api_version=MAX_SUPPORTED_VERSION, + engine_client=mock_engine_client, + ) + + decoy.when(mock_labware_core.get_well_columns()).then_return([]) + + movable_labware = Labware( + core=mock_labware_core, + api_version=MAX_SUPPORTED_VERSION, + protocol_core=mock_core, + core_map=mock_core_map, + ) + + with pytest.raises(LabwareMovementNotAllowedError): + subject.move_labware(labware=movable_labware, new_location=trash_location) + + +def test_move_lid_to_trash_passes( + decoy: Decoy, + mock_core: ProtocolCore, + mock_core_map: LoadedCoreMap, + subject: ProtocolContext, + mock_engine_client: EngineClient, +) -> None: + """It should move a lid labware into a trashbin successfully.""" + mock_labware_core = decoy.mock(cls=LabwareCore) + trash_location = TrashBin( + location=DeckSlotName.SLOT_D3, + addressable_area_name="moveableTrashD3", + api_version=MAX_SUPPORTED_VERSION, + engine_client=mock_engine_client, + ) + + decoy.when(mock_labware_core.get_well_columns()).then_return([]) + decoy.when(mock_labware_core.is_lid()).then_return(True) + + movable_labware = Labware( + core=mock_labware_core, + api_version=MAX_SUPPORTED_VERSION, + protocol_core=mock_core, + core_map=mock_core_map, + ) + + subject.move_labware(labware=movable_labware, new_location=trash_location) + decoy.verify( + mock_core.move_labware( + labware_core=mock_labware_core, + new_location=trash_location, + use_gripper=False, + pause_for_manual_move=True, + pick_up_offset=None, + drop_offset=None, + ) + ) + + def test_load_trash_bin( decoy: Decoy, mock_core: ProtocolCore, diff --git a/api/tests/opentrons/protocol_api/test_validation.py b/api/tests/opentrons/protocol_api/test_validation.py index c7f35a1519e..9a111e6f81f 100644 --- a/api/tests/opentrons/protocol_api/test_validation.py +++ b/api/tests/opentrons/protocol_api/test_validation.py @@ -1,5 +1,5 @@ """Tests for Protocol API input validation.""" -from typing import ContextManager, List, Type, Union, Optional, Dict, Any +from typing import ContextManager, List, Type, Union, Optional, Dict, Sequence, Any from contextlib import nullcontext as do_not_raise from decoy import Decoy @@ -465,7 +465,7 @@ def test_validate_well_no_location(decoy: Decoy) -> None: assert result == expected_result -def test_validate_coordinates(decoy: Decoy) -> None: +def test_validate_well_coordinates(decoy: Decoy) -> None: """Should return a WellTarget with no location.""" input_location = Location(point=Point(x=1, y=1, z=2), labware=None) expected_result = subject.PointTarget(location=input_location, in_place=False) @@ -570,6 +570,67 @@ def test_validate_last_location_with_labware(decoy: Decoy) -> None: assert result == subject.PointTarget(location=input_last_location, in_place=True) +def test_ensure_boolean() -> None: + """It should return a boolean value.""" + assert subject.ensure_boolean(False) is False + + +@pytest.mark.parametrize("value", [0, "False", "f", 0.0]) +def test_ensure_boolean_raises(value: Union[str, int, float]) -> None: + """It should raise if the value is not a boolean.""" + with pytest.raises(ValueError, match="must be a boolean"): + subject.ensure_boolean(value) # type: ignore[arg-type] + + +@pytest.mark.parametrize("value", [-1.23, -1, 0, 0.0, 1, 1.23]) +def test_ensure_float(value: Union[int, float]) -> None: + """It should return a float value.""" + assert subject.ensure_float(value) == float(value) + + +def test_ensure_float_raises() -> None: + """It should raise if the value is not a float or an integer.""" + with pytest.raises(ValueError, match="must be a floating point"): + subject.ensure_float("1.23") # type: ignore[arg-type] + + +@pytest.mark.parametrize("value", [0, 0.1, 1, 1.0]) +def test_ensure_positive_float(value: Union[int, float]) -> None: + """It should return a positive float.""" + assert subject.ensure_positive_float(value) == float(value) + + +@pytest.mark.parametrize("value", [-1, -1.0, float("inf"), float("-inf"), float("nan")]) +def test_ensure_positive_float_raises(value: Union[int, float]) -> None: + """It should raise if value is not a positive float.""" + with pytest.raises(ValueError, match="(non-infinite|positive float)"): + subject.ensure_positive_float(value) + + +def test_ensure_positive_int() -> None: + """It should return a positive int.""" + assert subject.ensure_positive_int(42) == 42 + + +@pytest.mark.parametrize("value", [1.0, -1.0, -1]) +def test_ensure_positive_int_raises(value: Union[int, float]) -> None: + """It should raise if value is not a positive integer.""" + with pytest.raises(ValueError, match="integer"): + subject.ensure_positive_int(value) # type: ignore[arg-type] + + +def test_validate_coordinates() -> None: + """It should validate the coordinates and return them as a tuple.""" + assert subject.validate_coordinates([1, 2.0, 3.3]) == (1.0, 2.0, 3.3) + + +@pytest.mark.parametrize("value", [[1, 2.0], [1, 2.0, 3.3, 4.2], ["1", 2, 3]]) +def test_validate_coordinates_raises(value: Sequence[Union[int, float, str]]) -> None: + """It should raise if value is not a valid sequence of three numbers.""" + with pytest.raises(ValueError, match="(exactly three|must be floats)"): + subject.validate_coordinates(value) # type: ignore[arg-type] + + @pytest.mark.parametrize( argnames=["axis_map", "robot_type", "is_96_channel", "expected_axis_map"], argvalues=[ diff --git a/api/tests/opentrons/protocol_api_integration/conftest.py b/api/tests/opentrons/protocol_api_integration/conftest.py new file mode 100644 index 00000000000..fa98ccbb039 --- /dev/null +++ b/api/tests/opentrons/protocol_api_integration/conftest.py @@ -0,0 +1,28 @@ +"""Fixtures for protocol api integration tests.""" + +import pytest +from _pytest.fixtures import SubRequest +from typing import Generator + +from opentrons import simulate, protocol_api +from opentrons.protocol_api.core.engine import ENGINE_CORE_API_VERSION + + +@pytest.fixture +def simulated_protocol_context( + request: SubRequest, +) -> Generator[protocol_api.ProtocolContext, None, None]: + """Return a protocol context with requested version and robot.""" + version, robot_type = request.param + context = simulate.get_protocol_api(version=version, robot_type=robot_type) + try: + yield context + finally: + if context.api_version >= ENGINE_CORE_API_VERSION: + # TODO(jbl, 2024-11-14) this is a hack of a hack to close the hardware and the PE thread when a test is + # complete. At some point this should be replaced with a more holistic way of safely cleaning up these + # threads so they don't leak and cause tests to fail when `get_protocol_api` is called too many times. + simulate._LIVE_PROTOCOL_ENGINE_CONTEXTS.close() + else: + # If this is a non-PE context we need to clean up the hardware thread manually + context._hw_manager.hardware.clean_up() diff --git a/api/tests/opentrons/protocol_api_integration/test_liquid_classes.py b/api/tests/opentrons/protocol_api_integration/test_liquid_classes.py index eed90cc2478..1a6e19f85be 100644 --- a/api/tests/opentrons/protocol_api_integration/test_liquid_classes.py +++ b/api/tests/opentrons/protocol_api_integration/test_liquid_classes.py @@ -3,22 +3,30 @@ from decoy import Decoy from opentrons_shared_data.robot.types import RobotTypeEnum -from opentrons import simulate +from opentrons.protocol_api import ProtocolContext from opentrons.config import feature_flags as ff @pytest.mark.ot2_only +@pytest.mark.parametrize( + "simulated_protocol_context", [("2.20", "OT-2")], indirect=True +) def test_liquid_class_creation_and_property_fetching( - decoy: Decoy, mock_feature_flags: None + decoy: Decoy, + mock_feature_flags: None, + simulated_protocol_context: ProtocolContext, ) -> None: """It should create the liquid class and provide access to its properties.""" decoy.when(ff.allow_liquid_classes(RobotTypeEnum.OT2)).then_return(True) - protocol_context = simulate.get_protocol_api(version="2.20", robot_type="OT-2") - pipette_left = protocol_context.load_instrument("p20_single_gen2", mount="left") - pipette_right = protocol_context.load_instrument("p300_multi", mount="right") - tiprack = protocol_context.load_labware("opentrons_96_tiprack_20ul", "1") + pipette_left = simulated_protocol_context.load_instrument( + "p20_single_gen2", mount="left" + ) + pipette_right = simulated_protocol_context.load_instrument( + "p300_multi", mount="right" + ) + tiprack = simulated_protocol_context.load_labware("opentrons_96_tiprack_20ul", "1") - glycerol_50 = protocol_context.define_liquid_class("fixture_glycerol50") + glycerol_50 = simulated_protocol_context.define_liquid_class("fixture_glycerol50") assert glycerol_50.name == "fixture_glycerol50" assert glycerol_50.display_name == "Glycerol 50%" @@ -27,7 +35,7 @@ def test_liquid_class_creation_and_property_fetching( assert ( glycerol_50.get_for( pipette_left.name, tiprack.load_name - ).dispense.flow_rate_by_volume["default"] + ).dispense.flow_rate_by_volume.default == 50 ) assert ( @@ -50,11 +58,13 @@ def test_liquid_class_creation_and_property_fetching( glycerol_50.display_name = "bar" # type: ignore with pytest.raises(ValueError, match="Liquid class definition not found"): - protocol_context.define_liquid_class("non-existent-liquid") + simulated_protocol_context.define_liquid_class("non-existent-liquid") -def test_liquid_class_feature_flag() -> None: +@pytest.mark.parametrize( + "simulated_protocol_context", [("2.20", "OT-2")], indirect=True +) +def test_liquid_class_feature_flag(simulated_protocol_context: ProtocolContext) -> None: """It should raise a not implemented error without the allowLiquidClass flag set.""" - protocol_context = simulate.get_protocol_api(version="2.20", robot_type="OT-2") with pytest.raises(NotImplementedError): - protocol_context.define_liquid_class("fixture_glycerol50") + simulated_protocol_context.define_liquid_class("fixture_glycerol50") diff --git a/api/tests/opentrons/protocol_api_integration/test_modules.py b/api/tests/opentrons/protocol_api_integration/test_modules.py new file mode 100644 index 00000000000..72ee8ed8c52 --- /dev/null +++ b/api/tests/opentrons/protocol_api_integration/test_modules.py @@ -0,0 +1,83 @@ +"""Tests for modules.""" + +import typing +import pytest + +from opentrons import protocol_api + + +@pytest.mark.parametrize( + "simulated_protocol_context", [("2.21", "Flex")], indirect=True +) +def test_absorbance_reader_labware_load_conflict( + simulated_protocol_context: protocol_api.ProtocolContext, +) -> None: + """It should prevent loading a labware onto a closed absorbance reader.""" + module = simulated_protocol_context.load_module("absorbanceReaderV1", "A3") + + # The lid should be treated as initially closed. + with pytest.raises(Exception): + module.load_labware("opentrons_96_wellplate_200ul_pcr_full_skirt") + + module.open_lid() # type: ignore[union-attr] + # Should not raise after opening the lid. + labware_1 = module.load_labware("opentrons_96_wellplate_200ul_pcr_full_skirt") + + simulated_protocol_context.move_labware(labware_1, protocol_api.OFF_DECK) + + # Should raise after closing the lid again. + module.close_lid() # type: ignore[union-attr] + with pytest.raises(Exception): + module.load_labware("opentrons_96_wellplate_200ul_pcr_full_skirt") + + +@pytest.mark.parametrize( + "simulated_protocol_context", [("2.21", "Flex")], indirect=True +) +def test_absorbance_reader_labware_move_conflict( + simulated_protocol_context: protocol_api.ProtocolContext, +) -> None: + """It should prevent moving a labware onto a closed absorbance reader.""" + module = simulated_protocol_context.load_module("absorbanceReaderV1", "A3") + labware = simulated_protocol_context.load_labware( + "opentrons_96_wellplate_200ul_pcr_full_skirt", "A1" + ) + + with pytest.raises(Exception): + # The lid should be treated as initially closed. + simulated_protocol_context.move_labware(labware, module, use_gripper=True) + + module.open_lid() # type: ignore[union-attr] + # Should not raise after opening the lid. + simulated_protocol_context.move_labware(labware, module, use_gripper=True) + + simulated_protocol_context.move_labware(labware, "A1", use_gripper=True) + + # Should raise after closing the lid again. + module.close_lid() # type: ignore[union-attr] + with pytest.raises(Exception): + simulated_protocol_context.move_labware(labware, module, use_gripper=True) + + +@pytest.mark.parametrize( + "simulated_protocol_context", [("2.21", "Flex")], indirect=True +) +def test_absorbance_reader_read_preconditions( + simulated_protocol_context: protocol_api.ProtocolContext, +) -> None: + """Test the preconditions for triggering an absorbance reader read.""" + module = typing.cast( + protocol_api.AbsorbanceReaderContext, + simulated_protocol_context.load_module("absorbanceReaderV1", "A3"), + ) + + with pytest.raises(Exception, match="initialize"): + module.read() # .initialize() must be called first. + + with pytest.raises(Exception, match="close"): + module.initialize("single", [500]) # .close_lid() must be called first. + + module.close_lid() + module.initialize("single", [500]) + + module.read() # Should not raise now. diff --git a/api/tests/opentrons/protocol_api_integration/test_pipette_movement_deck_conflicts.py b/api/tests/opentrons/protocol_api_integration/test_pipette_movement_deck_conflicts.py index cad2bffddf9..2b7fc11ca91 100644 --- a/api/tests/opentrons/protocol_api_integration/test_pipette_movement_deck_conflicts.py +++ b/api/tests/opentrons/protocol_api_integration/test_pipette_movement_deck_conflicts.py @@ -2,54 +2,59 @@ import pytest -from opentrons import simulate -from opentrons.protocol_api import COLUMN, ALL, SINGLE, ROW +from opentrons.protocol_api import COLUMN, ALL, SINGLE, ROW, ProtocolContext from opentrons.protocol_api.core.engine.pipette_movement_conflict import ( PartialTipMovementNotAllowedError, ) @pytest.mark.ot3_only -def test_deck_conflicts_for_96_ch_a12_column_configuration() -> None: +@pytest.mark.parametrize( + "simulated_protocol_context", [("2.16", "Flex")], indirect=True +) +def test_deck_conflicts_for_96_ch_a12_column_configuration( + simulated_protocol_context: ProtocolContext, +) -> None: """It should raise errors for the expected deck conflicts.""" - protocol_context = simulate.get_protocol_api(version="2.16", robot_type="Flex") - trash_labware = protocol_context.load_labware( + trash_labware = simulated_protocol_context.load_labware( "opentrons_1_trash_3200ml_fixed", "A3" ) - badly_placed_tiprack = protocol_context.load_labware( + badly_placed_tiprack = simulated_protocol_context.load_labware( "opentrons_flex_96_tiprack_50ul", "C2" ) - well_placed_tiprack = protocol_context.load_labware( + well_placed_tiprack = simulated_protocol_context.load_labware( "opentrons_flex_96_tiprack_50ul", "C1" ) - tiprack_on_adapter = protocol_context.load_labware( + tiprack_on_adapter = simulated_protocol_context.load_labware( "opentrons_flex_96_tiprack_50ul", "C3", adapter="opentrons_flex_96_tiprack_adapter", ) - thermocycler = protocol_context.load_module("thermocyclerModuleV2") - tc_adjacent_plate = protocol_context.load_labware( + thermocycler = simulated_protocol_context.load_module("thermocyclerModuleV2") + tc_adjacent_plate = simulated_protocol_context.load_labware( "opentrons_96_wellplate_200ul_pcr_full_skirt", "A2" ) accessible_plate = thermocycler.load_labware( "opentrons_96_wellplate_200ul_pcr_full_skirt" ) - instrument = protocol_context.load_instrument("flex_96channel_1000", mount="left") + instrument = simulated_protocol_context.load_instrument( + "flex_96channel_1000", mount="left" + ) instrument.trash_container = trash_labware # ############ SHORT LABWARE ################ # These labware should be to the west of tall labware to avoid any partial tip deck conflicts - badly_placed_labware = protocol_context.load_labware( + badly_placed_labware = simulated_protocol_context.load_labware( "nest_96_wellplate_200ul_flat", "D2" ) - well_placed_labware = protocol_context.load_labware( + well_placed_labware = simulated_protocol_context.load_labware( "nest_96_wellplate_200ul_flat", "D3" ) # ############ TALL LABWARE ############## - protocol_context.load_labware( + simulated_protocol_context.load_labware( "opentrons_10_tuberack_falcon_4x50ml_6x15ml_conical", "D1" ) @@ -104,24 +109,30 @@ def test_deck_conflicts_for_96_ch_a12_column_configuration() -> None: @pytest.mark.ot3_only -def test_close_shave_deck_conflicts_for_96_ch_a12_column_configuration() -> None: +@pytest.mark.parametrize( + "simulated_protocol_context", [("2.20", "Flex")], indirect=True +) +def test_close_shave_deck_conflicts_for_96_ch_a12_column_configuration( + simulated_protocol_context: ProtocolContext, +) -> None: """Shouldn't raise errors for "almost collision"s.""" - protocol_context = simulate.get_protocol_api(version="2.20", robot_type="Flex") - res12 = protocol_context.load_labware("nest_12_reservoir_15ml", "C3") + res12 = simulated_protocol_context.load_labware("nest_12_reservoir_15ml", "C3") # Mag block and tiprack adapter are very close to the destination reservoir labware - protocol_context.load_module("magneticBlockV1", "D2") - protocol_context.load_labware( + simulated_protocol_context.load_module("magneticBlockV1", "D2") + simulated_protocol_context.load_labware( "opentrons_flex_96_tiprack_200ul", "B3", adapter="opentrons_flex_96_tiprack_adapter", ) - tiprack_8 = protocol_context.load_labware("opentrons_flex_96_tiprack_200ul", "B2") - hs = protocol_context.load_module("heaterShakerModuleV1", "C1") + tiprack_8 = simulated_protocol_context.load_labware( + "opentrons_flex_96_tiprack_200ul", "B2" + ) + hs = simulated_protocol_context.load_module("heaterShakerModuleV1", "C1") hs_adapter = hs.load_adapter("opentrons_96_deep_well_adapter") deepwell = hs_adapter.load_labware("nest_96_wellplate_2ml_deep") - protocol_context.load_trash_bin("A3") - p1000_96 = protocol_context.load_instrument("flex_96channel_1000") + simulated_protocol_context.load_trash_bin("A3") + p1000_96 = simulated_protocol_context.load_instrument("flex_96channel_1000") p1000_96.configure_nozzle_layout(style=SINGLE, start="A12", tip_racks=[tiprack_8]) hs.close_labware_latch() # type: ignore[union-attr] @@ -135,16 +146,28 @@ def test_close_shave_deck_conflicts_for_96_ch_a12_column_configuration() -> None @pytest.mark.ot3_only -def test_deck_conflicts_for_96_ch_a1_column_configuration() -> None: +@pytest.mark.parametrize( + "simulated_protocol_context", [("2.16", "Flex")], indirect=True +) +def test_deck_conflicts_for_96_ch_a1_column_configuration( + simulated_protocol_context: ProtocolContext, +) -> None: """It should raise errors for expected deck conflicts.""" - protocol = simulate.get_protocol_api(version="2.16", robot_type="Flex") - instrument = protocol.load_instrument("flex_96channel_1000", mount="left") - trash_labware = protocol.load_labware("opentrons_1_trash_3200ml_fixed", "A3") + instrument = simulated_protocol_context.load_instrument( + "flex_96channel_1000", mount="left" + ) + trash_labware = simulated_protocol_context.load_labware( + "opentrons_1_trash_3200ml_fixed", "A3" + ) instrument.trash_container = trash_labware - badly_placed_tiprack = protocol.load_labware("opentrons_flex_96_tiprack_50ul", "C2") - well_placed_tiprack = protocol.load_labware("opentrons_flex_96_tiprack_50ul", "A1") - tiprack_on_adapter = protocol.load_labware( + badly_placed_tiprack = simulated_protocol_context.load_labware( + "opentrons_flex_96_tiprack_50ul", "C2" + ) + well_placed_tiprack = simulated_protocol_context.load_labware( + "opentrons_flex_96_tiprack_50ul", "A1" + ) + tiprack_on_adapter = simulated_protocol_context.load_labware( "opentrons_flex_96_tiprack_50ul", "C3", adapter="opentrons_flex_96_tiprack_adapter", @@ -152,11 +175,15 @@ def test_deck_conflicts_for_96_ch_a1_column_configuration() -> None: # ############ SHORT LABWARE ################ # These labware should be to the east of tall labware to avoid any partial tip deck conflicts - badly_placed_plate = protocol.load_labware("nest_96_wellplate_200ul_flat", "B1") - well_placed_plate = protocol.load_labware("nest_96_wellplate_200ul_flat", "B3") + badly_placed_plate = simulated_protocol_context.load_labware( + "nest_96_wellplate_200ul_flat", "B1" + ) + well_placed_plate = simulated_protocol_context.load_labware( + "nest_96_wellplate_200ul_flat", "B3" + ) # ############ TALL LABWARE ############### - my_tuberack = protocol.load_labware( + my_tuberack = simulated_protocol_context.load_labware( "opentrons_10_tuberack_falcon_4x50ml_6x15ml_conical", "B2" ) @@ -208,7 +235,7 @@ def test_deck_conflicts_for_96_ch_a1_column_configuration() -> None: instrument.drop_tip() instrument.trash_container = None # type: ignore - protocol.load_trash_bin("C1") + simulated_protocol_context.load_trash_bin("C1") # This doesn't raise an error because it now treats the trash bin as an addressable area # and the bounds check doesn't yet check moves to addressable areas. @@ -229,28 +256,38 @@ def test_deck_conflicts_for_96_ch_a1_column_configuration() -> None: @pytest.mark.ot3_only -def test_deck_conflicts_for_96_ch_and_reservoirs() -> None: +@pytest.mark.parametrize( + "simulated_protocol_context", [("2.20", "Flex")], indirect=True +) +def test_deck_conflicts_for_96_ch_and_reservoirs( + simulated_protocol_context: ProtocolContext, +) -> None: """It should raise errors for expected deck conflicts when moving to reservoirs. This test checks that the critical point of the pipette is taken into account, specifically when it differs from the primary nozzle. """ - protocol = simulate.get_protocol_api(version="2.20", robot_type="Flex") - instrument = protocol.load_instrument("flex_96channel_1000", mount="left") + instrument = simulated_protocol_context.load_instrument( + "flex_96channel_1000", mount="left" + ) # trash_labware = protocol.load_labware("opentrons_1_trash_3200ml_fixed", "A3") # instrument.trash_container = trash_labware - protocol.load_trash_bin("A3") - right_tiprack = protocol.load_labware("opentrons_flex_96_tiprack_50ul", "C3") - front_tiprack = protocol.load_labware("opentrons_flex_96_tiprack_50ul", "D2") + simulated_protocol_context.load_trash_bin("A3") + right_tiprack = simulated_protocol_context.load_labware( + "opentrons_flex_96_tiprack_50ul", "C3" + ) + front_tiprack = simulated_protocol_context.load_labware( + "opentrons_flex_96_tiprack_50ul", "D2" + ) # Tall deck item in B3 - protocol.load_labware( + simulated_protocol_context.load_labware( "opentrons_flex_96_tiprack_50ul", "B3", adapter="opentrons_flex_96_tiprack_adapter", ) # Tall deck item in B1 - protocol.load_labware( + simulated_protocol_context.load_labware( "opentrons_flex_96_tiprack_50ul", "B1", adapter="opentrons_flex_96_tiprack_adapter", @@ -258,8 +295,12 @@ def test_deck_conflicts_for_96_ch_and_reservoirs() -> None: # ############ RESERVOIRS ################ # These labware should be to the east of tall labware to avoid any partial tip deck conflicts - reservoir_1_well = protocol.load_labware("nest_1_reservoir_195ml", "C2") - reservoir_12_well = protocol.load_labware("nest_12_reservoir_15ml", "B2") + reservoir_1_well = simulated_protocol_context.load_labware( + "nest_1_reservoir_195ml", "C2" + ) + reservoir_12_well = simulated_protocol_context.load_labware( + "nest_12_reservoir_15ml", "B2" + ) # ########### Use COLUMN A1 Config ############# instrument.configure_nozzle_layout(style=COLUMN, start="A1") diff --git a/api/tests/opentrons/protocol_api_integration/test_trashes.py b/api/tests/opentrons/protocol_api_integration/test_trashes.py index 18dfa62170d..1166ba01c70 100644 --- a/api/tests/opentrons/protocol_api_integration/test_trashes.py +++ b/api/tests/opentrons/protocol_api_integration/test_trashes.py @@ -1,46 +1,42 @@ """Tests for the APIs around waste chutes and trash bins.""" -from opentrons import protocol_api, simulate +from opentrons import protocol_api from opentrons.protocols.api_support.types import APIVersion from opentrons.protocols.api_support.util import UnsupportedAPIError import contextlib from typing import ContextManager, Optional, Type -from typing_extensions import Literal import re import pytest @pytest.mark.parametrize( - ("version", "robot_type", "expected_trash_class"), + ("simulated_protocol_context", "expected_trash_class"), [ - ("2.13", "OT-2", protocol_api.Labware), - ("2.14", "OT-2", protocol_api.Labware), - ("2.15", "OT-2", protocol_api.Labware), + (("2.13", "OT-2"), protocol_api.Labware), + (("2.14", "OT-2"), protocol_api.Labware), + (("2.15", "OT-2"), protocol_api.Labware), pytest.param( - "2.15", - "Flex", + ("2.15", "Flex"), protocol_api.Labware, marks=pytest.mark.ot3_only, # Simulating a Flex protocol requires a Flex hardware API. ), pytest.param( - "2.16", - "OT-2", + ("2.16", "OT-2"), protocol_api.TrashBin, ), pytest.param( - "2.16", - "Flex", + ("2.16", "Flex"), None, marks=pytest.mark.ot3_only, # Simulating a Flex protocol requires a Flex hardware API. ), ], + indirect=["simulated_protocol_context"], ) def test_fixed_trash_presence( - robot_type: Literal["OT-2", "Flex"], - version: str, + simulated_protocol_context: protocol_api.ProtocolContext, expected_trash_class: Optional[Type[object]], ) -> None: """Test the presence of the fixed trash. @@ -49,9 +45,10 @@ def test_fixed_trash_presence( For those that do, ProtocolContext.fixed_trash and InstrumentContext.trash_container should point to it. The type of the object depends on the API version. """ - protocol = simulate.get_protocol_api(version=version, robot_type=robot_type) - instrument = protocol.load_instrument( - "p300_single_gen2" if robot_type == "OT-2" else "flex_1channel_50", + instrument = simulated_protocol_context.load_instrument( + "p300_single_gen2" + if simulated_protocol_context._core.robot_type == "OT-2 Standard" + else "flex_1channel_50", mount="left", ) @@ -59,46 +56,53 @@ def test_fixed_trash_presence( with pytest.raises( UnsupportedAPIError, match=re.escape( - "Error 4002 API_REMOVED (UnsupportedAPIError): Fixed Trash is not available after API version 2.16. You are currently using API version 2.16. Fixed trash is no longer supported on Flex protocols." + "Error 4002 API_REMOVED (UnsupportedAPIError): Fixed Trash is not available after API version 2.16." + " You are currently using API version 2.16. Fixed trash is no longer supported on Flex protocols." ), ): - protocol.fixed_trash + simulated_protocol_context.fixed_trash with pytest.raises(Exception, match="No trash container has been defined"): instrument.trash_container else: - assert isinstance(protocol.fixed_trash, expected_trash_class) - assert instrument.trash_container is protocol.fixed_trash + assert isinstance(simulated_protocol_context.fixed_trash, expected_trash_class) + assert instrument.trash_container is simulated_protocol_context.fixed_trash @pytest.mark.ot3_only # Simulating a Flex protocol requires a Flex hardware API. -def test_trash_search() -> None: +@pytest.mark.parametrize( + "simulated_protocol_context", [("2.16", "Flex")], indirect=True +) +def test_trash_search(simulated_protocol_context: protocol_api.ProtocolContext) -> None: """Test the automatic trash search for protocols without a fixed trash.""" - protocol = simulate.get_protocol_api(version="2.16", robot_type="Flex") - instrument = protocol.load_instrument("flex_1channel_50", mount="left") + instrument = simulated_protocol_context.load_instrument( + "flex_1channel_50", mount="left" + ) # By default, there should be no trash. with pytest.raises( UnsupportedAPIError, match=re.escape( - "Error 4002 API_REMOVED (UnsupportedAPIError): Fixed Trash is not available after API version 2.16. You are currently using API version 2.16. Fixed trash is no longer supported on Flex protocols." + "Error 4002 API_REMOVED (UnsupportedAPIError): Fixed Trash is not available after API version 2.16." + " You are currently using API version 2.16. Fixed trash is no longer supported on Flex protocols." ), ): - protocol.fixed_trash + simulated_protocol_context.fixed_trash with pytest.raises(Exception, match="No trash container has been defined"): instrument.trash_container - loaded_first = protocol.load_trash_bin("A1") - loaded_second = protocol.load_trash_bin("B1") + loaded_first = simulated_protocol_context.load_trash_bin("A1") + loaded_second = simulated_protocol_context.load_trash_bin("B1") # After loading some trashes, there should still be no protocol.fixed_trash... with pytest.raises( UnsupportedAPIError, match=re.escape( - "Error 4002 API_REMOVED (UnsupportedAPIError): Fixed Trash is not available after API version 2.16. You are currently using API version 2.16. Fixed trash is no longer supported on Flex protocols." + "Error 4002 API_REMOVED (UnsupportedAPIError): Fixed Trash is not available after API version 2.16." + " You are currently using API version 2.16. Fixed trash is no longer supported on Flex protocols." ), ): - protocol.fixed_trash + simulated_protocol_context.fixed_trash # ...but instrument.trash_container should automatically update to point to # the first trash that we loaded. assert instrument.trash_container is loaded_first @@ -109,40 +113,36 @@ def test_trash_search() -> None: @pytest.mark.parametrize( - ("version", "robot_type", "expect_load_to_succeed"), + ("simulated_protocol_context", "expect_load_to_succeed"), [ pytest.param( - "2.13", - "OT-2", + ("2.13", "OT-2"), False, # This xfail (the system does let you load a labware onto slot 12, and does not raise) # is surprising to me. It may be be a bug in old PAPI versions. marks=pytest.mark.xfail(strict=True, raises=pytest.fail.Exception), ), - ("2.14", "OT-2", False), - ("2.15", "OT-2", False), + (("2.14", "OT-2"), False), + (("2.15", "OT-2"), False), pytest.param( - "2.15", - "Flex", + ("2.15", "Flex"), False, marks=pytest.mark.ot3_only, # Simulating a Flex protocol requires a Flex hardware API. ), pytest.param( - "2.16", - "OT-2", + ("2.16", "OT-2"), False, ), pytest.param( - "2.16", - "Flex", + ("2.16", "Flex"), True, marks=pytest.mark.ot3_only, # Simulating a Flex protocol requires a Flex hardware API. ), ], + indirect=["simulated_protocol_context"], ) def test_fixed_trash_load_conflicts( - robot_type: Literal["Flex", "OT-2"], - version: str, + simulated_protocol_context: protocol_api.ProtocolContext, expect_load_to_succeed: bool, ) -> None: """Test loading something onto the location historically used for the fixed trash. @@ -150,14 +150,12 @@ def test_fixed_trash_load_conflicts( In configurations where there is a fixed trash, this should be disallowed. In configurations without a fixed trash, this should be allowed. """ - protocol = simulate.get_protocol_api(version=version, robot_type=robot_type) - if expect_load_to_succeed: expected_error: ContextManager[object] = contextlib.nullcontext() else: # If we're expecting an error, it'll be a LocationIsOccupied for 2.15 and below, otherwise # it will fail with an IncompatibleAddressableAreaError, since slot 12 will not be in the deck config - if APIVersion.from_string(version) < APIVersion(2, 16): + if simulated_protocol_context.api_version < APIVersion(2, 16): error_name = "LocationIsOccupiedError" else: error_name = "IncompatibleAddressableAreaError" @@ -169,4 +167,6 @@ def test_fixed_trash_load_conflicts( ) with expected_error: - protocol.load_labware("opentrons_96_wellplate_200ul_pcr_full_skirt", 12) + simulated_protocol_context.load_labware( + "opentrons_96_wellplate_200ul_pcr_full_skirt", 12 + ) diff --git a/api/tests/opentrons/protocol_engine/commands/test_prepare_to_aspirate.py b/api/tests/opentrons/protocol_engine/commands/test_prepare_to_aspirate.py index 2de35e38332..f9eded1ffa0 100644 --- a/api/tests/opentrons/protocol_engine/commands/test_prepare_to_aspirate.py +++ b/api/tests/opentrons/protocol_engine/commands/test_prepare_to_aspirate.py @@ -34,14 +34,19 @@ def subject( async def test_prepare_to_aspirate_implementation( - decoy: Decoy, subject: PrepareToAspirateImplementation, pipetting: PipettingHandler + decoy: Decoy, + gantry_mover: GantryMover, + subject: PrepareToAspirateImplementation, + pipetting: PipettingHandler, ) -> None: """A PrepareToAspirate command should have an executing implementation.""" data = PrepareToAspirateParams(pipetteId="some id") + position = Point(x=1, y=2, z=3) decoy.when(await pipetting.prepare_for_aspirate(pipette_id="some id")).then_return( None ) + decoy.when(await gantry_mover.get_position("some id")).then_return(position) result = await subject.execute(data) assert result == SuccessData( diff --git a/api/tests/opentrons/protocol_engine/execution/test_labware_movement_handler.py b/api/tests/opentrons/protocol_engine/execution/test_labware_movement_handler.py index 6032bad81b8..3377e39b666 100644 --- a/api/tests/opentrons/protocol_engine/execution/test_labware_movement_handler.py +++ b/api/tests/opentrons/protocol_engine/execution/test_labware_movement_handler.py @@ -2,10 +2,11 @@ from __future__ import annotations from datetime import datetime +from typing import TYPE_CHECKING, Union, Optional, Tuple +from unittest.mock import sentinel -import pytest from decoy import Decoy, matchers -from typing import TYPE_CHECKING, Union, Optional, Tuple +import pytest from opentrons.protocol_engine.execution import EquipmentHandler, MovementHandler from opentrons.hardware_control import HardwareControlAPI @@ -133,7 +134,7 @@ async def set_up_decoy_hardware_gripper( decoy.when(ot3_hardware_api.hardware_gripper.jaw_width).then_return(89) decoy.when( - state_store.labware.get_grip_force("my-teleporting-labware") + state_store.labware.get_grip_force(sentinel.my_teleporting_labware_def) ).then_return(100) decoy.when(state_store.labware.get_labware_offset("new-offset-id")).then_return( @@ -195,6 +196,10 @@ async def test_raise_error_if_gripper_pickup_failed( starting_location = DeckSlotLocation(slotName=DeckSlotName.SLOT_1) to_location = DeckSlotLocation(slotName=DeckSlotName.SLOT_2) + decoy.when( + state_store.labware.get_definition("my-teleporting-labware") + ).then_return(sentinel.my_teleporting_labware_def) + mock_tc_context_manager = decoy.mock(name="mock_tc_context_manager") decoy.when( thermocycler_plate_lifter.lift_plate_for_labware_movement( @@ -217,22 +222,27 @@ async def test_raise_error_if_gripper_pickup_failed( decoy.when( state_store.geometry.get_labware_grip_point( - labware_id="my-teleporting-labware", location=starting_location + labware_definition=sentinel.my_teleporting_labware_def, + location=starting_location, ) ).then_return(Point(101, 102, 119.5)) decoy.when( state_store.geometry.get_labware_grip_point( - labware_id="my-teleporting-labware", location=to_location + labware_definition=sentinel.my_teleporting_labware_def, location=to_location ) ).then_return(Point(201, 202, 219.5)) decoy.when( - state_store.labware.get_dimensions(labware_id="my-teleporting-labware") + state_store.labware.get_dimensions( + labware_definition=sentinel.my_teleporting_labware_def + ) ).then_return(Dimensions(x=100, y=85, z=0)) decoy.when( - state_store.labware.get_well_bbox(labware_id="my-teleporting-labware") + state_store.labware.get_well_bbox( + labware_definition=sentinel.my_teleporting_labware_def + ) ).then_return(Dimensions(x=99, y=80, z=1)) await subject.move_labware_with_gripper( @@ -320,6 +330,10 @@ async def test_move_labware_with_gripper( # smoke test for gripper labware movement with actual labware and make this a unit test. await set_up_decoy_hardware_gripper(decoy, ot3_hardware_api, state_store) + decoy.when( + state_store.labware.get_definition("my-teleporting-labware") + ).then_return(sentinel.my_teleporting_labware_def) + user_offset_data, final_offset_data = hardware_gripper_offset_data current_labware = state_store.labware.get_definition( labware_id="my-teleporting-labware" @@ -334,21 +348,26 @@ async def test_move_labware_with_gripper( ).then_return(final_offset_data) decoy.when( - state_store.labware.get_dimensions(labware_id="my-teleporting-labware") + state_store.labware.get_dimensions( + labware_definition=sentinel.my_teleporting_labware_def + ) ).then_return(Dimensions(x=100, y=85, z=0)) decoy.when( - state_store.labware.get_well_bbox(labware_id="my-teleporting-labware") + state_store.labware.get_well_bbox( + labware_definition=sentinel.my_teleporting_labware_def + ) ).then_return(Dimensions(x=99, y=80, z=1)) decoy.when( state_store.geometry.get_labware_grip_point( - labware_id="my-teleporting-labware", location=from_location + labware_definition=sentinel.my_teleporting_labware_def, + location=from_location, ) ).then_return(Point(101, 102, 119.5)) decoy.when( state_store.geometry.get_labware_grip_point( - labware_id="my-teleporting-labware", location=to_location + labware_definition=sentinel.my_teleporting_labware_def, location=to_location ) ).then_return(Point(201, 202, 219.5)) mock_tc_context_manager = decoy.mock(name="mock_tc_context_manager") diff --git a/api/tests/opentrons/protocol_engine/resources/test_deck_data_provider.py b/api/tests/opentrons/protocol_engine/resources/test_deck_data_provider.py index 3c8552cdd6f..e051f155113 100644 --- a/api/tests/opentrons/protocol_engine/resources/test_deck_data_provider.py +++ b/api/tests/opentrons/protocol_engine/resources/test_deck_data_provider.py @@ -10,8 +10,6 @@ from opentrons.protocol_engine.types import ( DeckSlotLocation, DeckType, - DeckConfigurationType, - AddressableAreaLocation, ) from opentrons.protocol_engine.resources import ( LabwareDataProvider, @@ -135,56 +133,3 @@ async def test_get_deck_labware_fixtures_ot3_standard( definition=ot3_fixed_trash_def, ) ] - - -def _make_deck_config_with_plate_reader() -> DeckConfigurationType: - return [ - ("cutoutA1", "singleLeftSlot", None), - ("cutoutB1", "singleLeftSlot", None), - ("cutoutC1", "singleLeftSlot", None), - ("cutoutD1", "singleLeftSlot", None), - ("cutoutA2", "singleCenterSlot", None), - ("cutoutB2", "singleCenterSlot", None), - ("cutoutC2", "singleCenterSlot", None), - ("cutoutD2", "singleCenterSlot", None), - ("cutoutA3", "singleRightSlot", None), - ("cutoutB3", "singleRightSlot", None), - ("cutoutC3", "singleRightSlot", None), - ("cutoutD3", "absorbanceReaderV1", "abc123"), - ] - - -async def test_get_deck_labware_fixtures_ot3_standard_for_plate_reader( - decoy: Decoy, - ot3_standard_deck_def: DeckDefinitionV5, - ot3_absorbance_reader_lid: LabwareDefinition, - mock_labware_data_provider: LabwareDataProvider, -) -> None: - """It should get a lis including the Plate Reader Lid for our deck fixed labware.""" - subject = DeckDataProvider( - deck_type=DeckType.OT3_STANDARD, labware_data=mock_labware_data_provider - ) - - decoy.when( - await mock_labware_data_provider.get_labware_definition( - load_name="opentrons_flex_lid_absorbance_plate_reader_module", - namespace="opentrons", - version=1, - ) - ).then_return(ot3_absorbance_reader_lid) - - deck_config = _make_deck_config_with_plate_reader() - - result = await subject.get_deck_fixed_labware( - False, ot3_standard_deck_def, deck_config - ) - - assert result == [ - DeckFixedLabware( - labware_id="absorbanceReaderV1LidD3", - location=AddressableAreaLocation( - addressableAreaName="absorbanceReaderV1D3" - ), - definition=ot3_absorbance_reader_lid, - ) - ] diff --git a/api/tests/opentrons/protocol_engine/state/test_geometry_view.py b/api/tests/opentrons/protocol_engine/state/test_geometry_view.py index 3f7ad59bda2..42ee037c1ce 100644 --- a/api/tests/opentrons/protocol_engine/state/test_geometry_view.py +++ b/api/tests/opentrons/protocol_engine/state/test_geometry_view.py @@ -1,15 +1,18 @@ """Test state getters for retrieving geometry views of state.""" import inspect import json +from datetime import datetime +from math import isclose +from typing import cast, List, Tuple, Optional, NamedTuple, Dict +from unittest.mock import sentinel + +import pytest +from decoy import Decoy + from opentrons.protocol_engine.state.update_types import ( LoadedLabwareUpdate, StateUpdate, ) -import pytest -from math import isclose -from decoy import Decoy -from typing import cast, List, Tuple, Optional, NamedTuple, Dict -from datetime import datetime from opentrons_shared_data.deck.types import DeckDefinitionV5 from opentrons_shared_data.deck import load as load_deck @@ -17,7 +20,7 @@ from opentrons_shared_data.pipette import pipette_definition from opentrons.calibration_storage.helpers import uri_from_details from opentrons.protocols.models import LabwareDefinition -from opentrons.types import Point, DeckSlotName, MountType +from opentrons.types import Point, DeckSlotName, MountType, StagingSlotName from opentrons_shared_data.pipette.types import PipetteNameType from opentrons_shared_data.labware.labware_definition import ( Dimensions as LabwareDimensions, @@ -366,6 +369,9 @@ def test_get_labware_parent_position_on_module( ) decoy.when(mock_labware_view.get("labware-id")).then_return(labware_data) + decoy.when(mock_labware_view.get_definition("labware-id")).then_return( + sentinel.labware_def + ) decoy.when(mock_module_view.get_location("module-id")).then_return( DeckSlotLocation(slotName=DeckSlotName.SLOT_3) ) @@ -378,7 +384,7 @@ def test_get_labware_parent_position_on_module( ) decoy.when( - mock_module_view.get_nominal_module_offset( + mock_module_view.get_nominal_offset_to_child( module_id="module-id", addressable_areas=mock_addressable_area_view, ) @@ -389,7 +395,7 @@ def test_get_labware_parent_position_on_module( ) decoy.when( mock_labware_view.get_module_overlap_offsets( - "labware-id", ModuleModel.THERMOCYCLER_MODULE_V2 + sentinel.labware_def, ModuleModel.THERMOCYCLER_MODULE_V2 ) ).then_return(OverlapOffset(x=1, y=2, z=3)) decoy.when(mock_module_view.get_module_calibration_offset("module-id")).then_return( @@ -420,6 +426,11 @@ def test_get_labware_parent_position_on_labware( location=OnLabwareLocation(labwareId="adapter-id"), offsetId=None, ) + decoy.when(mock_labware_view.get("labware-id")).then_return(labware_data) + decoy.when(mock_labware_view.get_definition(labware_data.id)).then_return( + sentinel.labware_def + ) + adapter_data = LoadedLabware( id="adapter-id", loadName="xyz", @@ -427,37 +438,41 @@ def test_get_labware_parent_position_on_labware( location=ModuleLocation(moduleId="module-id"), offsetId=None, ) - decoy.when(mock_labware_view.get("labware-id")).then_return(labware_data) + decoy.when(mock_labware_view.get("adapter-id")).then_return(adapter_data) + decoy.when(mock_labware_view.get_definition(adapter_data.id)).then_return( + sentinel.adapter_def + ) + decoy.when(mock_module_view.get_location("module-id")).then_return( DeckSlotLocation(slotName=DeckSlotName.SLOT_3) ) decoy.when( mock_addressable_area_view.get_addressable_area_position(DeckSlotName.SLOT_3.id) ).then_return(Point(1, 2, 3)) - decoy.when(mock_labware_view.get("adapter-id")).then_return(adapter_data) - decoy.when(mock_labware_view.get_dimensions("adapter-id")).then_return( + + decoy.when(mock_labware_view.get_dimensions(labware_id="adapter-id")).then_return( Dimensions(x=123, y=456, z=5) ) decoy.when( - mock_labware_view.get_labware_overlap_offsets("labware-id", "xyz") + mock_labware_view.get_labware_overlap_offsets(sentinel.labware_def, "xyz") ).then_return(OverlapOffset(x=1, y=2, z=2)) decoy.when(mock_labware_view.get_deck_definition()).then_return( ot2_standard_deck_def ) decoy.when( - mock_module_view.get_nominal_module_offset( + mock_module_view.get_nominal_offset_to_child( module_id="module-id", addressable_areas=mock_addressable_area_view, ) ).then_return(LabwareOffsetVector(x=1, y=2, z=3)) decoy.when(mock_module_view.get_connected_model("module-id")).then_return( - ModuleModel.MAGNETIC_MODULE_V2 + sentinel.connected_model ) decoy.when( mock_labware_view.get_module_overlap_offsets( - "adapter-id", ModuleModel.MAGNETIC_MODULE_V2 + sentinel.adapter_def, sentinel.connected_model ) ).then_return(OverlapOffset(x=-3, y=-2, z=-1)) @@ -637,7 +652,7 @@ def test_get_module_labware_highest_z( ot2_standard_deck_def ) decoy.when( - mock_module_view.get_nominal_module_offset( + mock_module_view.get_nominal_offset_to_child( module_id="module-id", addressable_areas=mock_addressable_area_view, ) @@ -654,7 +669,7 @@ def test_get_module_labware_highest_z( ) decoy.when( mock_labware_view.get_module_overlap_offsets( - "labware-id", ModuleModel.MAGNETIC_MODULE_V2 + well_plate_def, ModuleModel.MAGNETIC_MODULE_V2 ) ).then_return(OverlapOffset(x=0, y=0, z=0)) @@ -1003,24 +1018,28 @@ def test_get_highest_z_in_slot_with_stacked_labware_on_slot( decoy.when(mock_labware_view.get_definition("top-labware-id")).then_return( well_plate_def ) + decoy.when(mock_labware_view.get_definition("middle-labware-id")).then_return( + sentinel.middle_labware_def + ) + decoy.when( mock_labware_view.get_labware_offset_vector("top-labware-id") ).then_return(top_lw_lpc_offset) - decoy.when(mock_labware_view.get_dimensions("middle-labware-id")).then_return( - Dimensions(x=10, y=20, z=30) - ) - decoy.when(mock_labware_view.get_dimensions("bottom-labware-id")).then_return( - Dimensions(x=11, y=12, z=13) - ) + decoy.when( + mock_labware_view.get_dimensions(labware_id="middle-labware-id") + ).then_return(Dimensions(x=10, y=20, z=30)) + decoy.when( + mock_labware_view.get_dimensions(labware_id="bottom-labware-id") + ).then_return(Dimensions(x=11, y=12, z=13)) decoy.when( mock_labware_view.get_labware_overlap_offsets( - "top-labware-id", below_labware_name="middle-labware-name" + well_plate_def, below_labware_name="middle-labware-name" ) ).then_return(OverlapOffset(x=4, y=5, z=6)) decoy.when( mock_labware_view.get_labware_overlap_offsets( - "middle-labware-id", below_labware_name="bottom-labware-name" + sentinel.middle_labware_def, below_labware_name="bottom-labware-name" ) ).then_return(OverlapOffset(x=7, y=8, z=9)) @@ -1099,16 +1118,20 @@ def test_get_highest_z_in_slot_with_labware_stack_on_module( ) decoy.when(mock_labware_view.get("adapter-id")).then_return(adapter) + decoy.when(mock_labware_view.get_definition("adapter-id")).then_return( + sentinel.adapter_def + ) decoy.when(mock_labware_view.get("top-labware-id")).then_return(top_labware) + decoy.when( mock_labware_view.get_labware_offset_vector("top-labware-id") ).then_return(top_lw_lpc_offset) - decoy.when(mock_labware_view.get_dimensions("adapter-id")).then_return( + decoy.when(mock_labware_view.get_dimensions(labware_id="adapter-id")).then_return( Dimensions(x=10, y=20, z=30) ) decoy.when( mock_labware_view.get_labware_overlap_offsets( - labware_id="top-labware-id", below_labware_name="adapter-name" + definition=well_plate_def, below_labware_name="adapter-name" ) ).then_return(OverlapOffset(x=4, y=5, z=6)) @@ -1116,7 +1139,7 @@ def test_get_highest_z_in_slot_with_labware_stack_on_module( DeckSlotLocation(slotName=DeckSlotName.SLOT_3) ) decoy.when( - mock_module_view.get_nominal_module_offset( + mock_module_view.get_nominal_offset_to_child( module_id="module-id", addressable_areas=mock_addressable_area_view, ) @@ -1127,7 +1150,7 @@ def test_get_highest_z_in_slot_with_labware_stack_on_module( decoy.when( mock_labware_view.get_module_overlap_offsets( - "adapter-id", ModuleModel.TEMPERATURE_MODULE_V2 + sentinel.adapter_def, ModuleModel.TEMPERATURE_MODULE_V2 ) ).then_return(OverlapOffset(x=1.1, y=2.2, z=3.3)) @@ -1333,7 +1356,7 @@ def test_get_module_labware_well_position( ot2_standard_deck_def ) decoy.when( - mock_module_view.get_nominal_module_offset( + mock_module_view.get_nominal_offset_to_child( module_id="module-id", addressable_areas=mock_addressable_area_view, ) @@ -1349,7 +1372,7 @@ def test_get_module_labware_well_position( ) decoy.when( mock_labware_view.get_module_overlap_offsets( - "labware-id", ModuleModel.MAGNETIC_MODULE_V2 + well_plate_def, ModuleModel.MAGNETIC_MODULE_V2 ) ).then_return(OverlapOffset(x=0, y=0, z=0)) @@ -2189,6 +2212,33 @@ def test_get_ancestor_slot_name( assert subject.get_ancestor_slot_name("labware-2") == DeckSlotName.SLOT_1 +def test_get_ancestor_slot_for_labware_stack_in_staging_area_slot( + decoy: Decoy, + mock_labware_view: LabwareView, + subject: GeometryView, +) -> None: + """It should get name of ancestor slot of a stack of labware in a staging area slot.""" + decoy.when(mock_labware_view.get("labware-1")).then_return( + LoadedLabware( + id="labware-1", + loadName="load-name", + definitionUri="1234", + location=AddressableAreaLocation( + addressableAreaName=StagingSlotName.SLOT_D4.id + ), + ) + ) + decoy.when(mock_labware_view.get("labware-2")).then_return( + LoadedLabware( + id="labware-2", + loadName="load-name", + definitionUri="1234", + location=OnLabwareLocation(labwareId="labware-1"), + ) + ) + assert subject.get_ancestor_slot_name("labware-2") == StagingSlotName.SLOT_D4 + + def test_ensure_location_not_occupied_raises( decoy: Decoy, mock_labware_view: LabwareView, @@ -2228,21 +2278,22 @@ def test_ensure_location_not_occupied_raises( def test_get_labware_grip_point( decoy: Decoy, mock_labware_view: LabwareView, - mock_module_view: ModuleView, mock_addressable_area_view: AddressableAreaView, - ot2_standard_deck_def: DeckDefinitionV5, subject: GeometryView, ) -> None: """It should get the grip point of the labware at the specified location.""" decoy.when( - mock_labware_view.get_grip_height_from_labware_bottom("labware-id") + mock_labware_view.get_grip_height_from_labware_bottom( + sentinel.labware_definition + ) ).then_return(100) decoy.when( mock_addressable_area_view.get_addressable_area_center(DeckSlotName.SLOT_1.id) ).then_return(Point(x=101, y=102, z=103)) labware_center = subject.get_labware_grip_point( - labware_id="labware-id", location=DeckSlotLocation(slotName=DeckSlotName.SLOT_1) + labware_definition=sentinel.labware_definition, + location=DeckSlotLocation(slotName=DeckSlotName.SLOT_1), ) assert labware_center == Point(101.0, 102.0, 203) @@ -2251,20 +2302,10 @@ def test_get_labware_grip_point( def test_get_labware_grip_point_on_labware( decoy: Decoy, mock_labware_view: LabwareView, - mock_module_view: ModuleView, mock_addressable_area_view: AddressableAreaView, - ot2_standard_deck_def: DeckDefinitionV5, subject: GeometryView, ) -> None: """It should get the grip point of a labware on another labware.""" - decoy.when(mock_labware_view.get(labware_id="labware-id")).then_return( - LoadedLabware( - id="labware-id", - loadName="above-name", - definitionUri="1234", - location=OnLabwareLocation(labwareId="below-id"), - ) - ) decoy.when(mock_labware_view.get(labware_id="below-id")).then_return( LoadedLabware( id="below-id", @@ -2274,14 +2315,16 @@ def test_get_labware_grip_point_on_labware( ) ) - decoy.when(mock_labware_view.get_dimensions("below-id")).then_return( + decoy.when(mock_labware_view.get_dimensions(labware_id="below-id")).then_return( Dimensions(x=1000, y=1001, z=11) ) decoy.when( - mock_labware_view.get_grip_height_from_labware_bottom("labware-id") + mock_labware_view.get_grip_height_from_labware_bottom( + labware_definition=sentinel.definition + ) ).then_return(100) decoy.when( - mock_labware_view.get_labware_overlap_offsets("labware-id", "below-name") + mock_labware_view.get_labware_overlap_offsets(sentinel.definition, "below-name") ).then_return(OverlapOffset(x=0, y=1, z=6)) decoy.when( @@ -2289,7 +2332,8 @@ def test_get_labware_grip_point_on_labware( ).then_return(Point(x=5, y=9, z=10)) grip_point = subject.get_labware_grip_point( - labware_id="labware-id", location=OnLabwareLocation(labwareId="below-id") + labware_definition=sentinel.definition, + location=OnLabwareLocation(labwareId="below-id"), ) assert grip_point == Point(5, 10, 115.0) @@ -2305,7 +2349,9 @@ def test_get_labware_grip_point_for_labware_on_module( ) -> None: """It should return the grip point for labware directly on a module.""" decoy.when( - mock_labware_view.get_grip_height_from_labware_bottom("labware-id") + mock_labware_view.get_grip_height_from_labware_bottom( + sentinel.labware_definition + ) ).then_return(500) decoy.when(mock_module_view.get_location("module-id")).then_return( DeckSlotLocation(slotName=DeckSlotName.SLOT_4) @@ -2314,7 +2360,7 @@ def test_get_labware_grip_point_for_labware_on_module( ot2_standard_deck_def ) decoy.when( - mock_module_view.get_nominal_module_offset( + mock_module_view.get_nominal_offset_to_child( module_id="module-id", addressable_areas=mock_addressable_area_view, ) @@ -2324,7 +2370,7 @@ def test_get_labware_grip_point_for_labware_on_module( ) decoy.when( mock_labware_view.get_module_overlap_offsets( - "labware-id", ModuleModel.MAGNETIC_MODULE_V2 + sentinel.labware_definition, ModuleModel.MAGNETIC_MODULE_V2 ) ).then_return(OverlapOffset(x=10, y=20, z=30)) decoy.when(mock_module_view.get_module_calibration_offset("module-id")).then_return( @@ -2337,7 +2383,8 @@ def test_get_labware_grip_point_for_labware_on_module( mock_addressable_area_view.get_addressable_area_center(DeckSlotName.SLOT_4.id) ).then_return(Point(100, 200, 300)) result_grip_point = subject.get_labware_grip_point( - labware_id="labware-id", location=ModuleLocation(moduleId="module-id") + labware_definition=sentinel.labware_definition, + location=ModuleLocation(moduleId="module-id"), ) assert result_grip_point == Point(x=191, y=382, z=1073) @@ -2723,7 +2770,7 @@ def test_get_stacked_labware_total_nominal_offset_slot_specific( DeckSlotLocation(slotName=DeckSlotName.SLOT_C1) ) decoy.when( - mock_labware_view.get_labware_gripper_offsets( + mock_labware_view.get_child_gripper_offsets( labware_id="adapter-id", slot_name=DeckSlotName.SLOT_C1 ) ).then_return( @@ -2775,12 +2822,12 @@ def test_get_stacked_labware_total_nominal_offset_default( DeckSlotLocation(slotName=DeckSlotName.SLOT_4) ) decoy.when( - mock_labware_view.get_labware_gripper_offsets( + mock_labware_view.get_child_gripper_offsets( labware_id="adapter-id", slot_name=DeckSlotName.SLOT_C1 ) ).then_return(None) decoy.when( - mock_labware_view.get_labware_gripper_offsets( + mock_labware_view.get_child_gripper_offsets( labware_id="adapter-id", slot_name=None ) ).then_return( @@ -2888,12 +2935,12 @@ def test_check_gripper_labware_tip_collision( ) ).then_return(Point(x=11, y=22, z=33)) decoy.when( - mock_labware_view.get_grip_height_from_labware_bottom("labware-id") + mock_labware_view.get_grip_height_from_labware_bottom(definition) ).then_return(1.0) decoy.when(mock_labware_view.get_definition("labware-id")).then_return(definition) decoy.when( subject.get_labware_grip_point( - labware_id="labware-id", + labware_definition=definition, location=DeckSlotLocation(slotName=DeckSlotName.SLOT_1), ) ).then_return(Point(x=100.0, y=100.0, z=0.0)) diff --git a/api/tests/opentrons/protocol_engine/state/test_labware_view.py b/api/tests/opentrons/protocol_engine/state/test_labware_view.py index d6b05b7b027..56113aff419 100644 --- a/api/tests/opentrons/protocol_engine/state/test_labware_view.py +++ b/api/tests/opentrons/protocol_engine/state/test_labware_view.py @@ -686,19 +686,14 @@ def test_get_dimensions(well_plate_def: LabwareDefinition) -> None: def test_get_labware_overlap_offsets() -> None: """It should get the labware overlap offsets.""" - subject = get_labware_view( - labware_by_id={"plate-id": plate}, - definitions_by_uri={ - "some-plate-uri": LabwareDefinition.construct( # type: ignore[call-arg] - stackingOffsetWithLabware={ - "bottom-labware-name": SharedDataOverlapOffset(x=1, y=2, z=3) - } - ) - }, - ) - + subject = get_labware_view() result = subject.get_labware_overlap_offsets( - labware_id="plate-id", below_labware_name="bottom-labware-name" + definition=LabwareDefinition.construct( # type: ignore[call-arg] + stackingOffsetWithLabware={ + "bottom-labware-name": SharedDataOverlapOffset(x=1, y=2, z=3) + } + ), + below_labware_name="bottom-labware-name", ) assert result == OverlapOffset(x=1, y=2, z=3) @@ -777,15 +772,12 @@ def test_get_module_overlap_offsets( """It should get the labware overlap offsets.""" subject = get_labware_view( deck_definition=spec_deck_definition, - labware_by_id={"plate-id": plate}, - definitions_by_uri={ - "some-plate-uri": LabwareDefinition.construct( # type: ignore[call-arg] - stackingOffsetWithModule=stacking_offset_with_module - ) - }, ) result = subject.get_module_overlap_offsets( - labware_id="plate-id", module_model=module_model + definition=LabwareDefinition.construct( # type: ignore[call-arg] + stackingOffsetWithModule=stacking_offset_with_module + ), + module_model=module_model, ) assert result == expected_offset @@ -1530,10 +1522,9 @@ def test_get_labware_gripper_offsets( ) assert ( - subject.get_labware_gripper_offsets(labware_id="plate-id", slot_name=None) - is None + subject.get_child_gripper_offsets(labware_id="plate-id", slot_name=None) is None ) - assert subject.get_labware_gripper_offsets( + assert subject.get_child_gripper_offsets( labware_id="adapter-plate-id", slot_name=DeckSlotName.SLOT_D1 ) == LabwareMovementOffsetData( pickUpOffset=LabwareOffsetVector(x=0, y=0, z=0), @@ -1570,13 +1561,13 @@ def test_get_labware_gripper_offsets_default_no_slots( ) assert ( - subject.get_labware_gripper_offsets( + subject.get_child_gripper_offsets( labware_id="labware-id", slot_name=DeckSlotName.SLOT_D1 ) is None ) - assert subject.get_labware_gripper_offsets( + assert subject.get_child_gripper_offsets( labware_id="labware-id", slot_name=None ) == LabwareMovementOffsetData( pickUpOffset=LabwareOffsetVector(x=1, y=2, z=3), @@ -1589,16 +1580,10 @@ def test_get_grip_force( reservoir_def: LabwareDefinition, ) -> None: """It should get the grip force, if present, from labware definition or return default.""" - subject = get_labware_view( - labware_by_id={"flex-tiprack-id": flex_tiprack, "reservoir-id": reservoir}, - definitions_by_uri={ - "some-flex-tiprack-uri": flex_50uL_tiprack, - "some-reservoir-uri": reservoir_def, - }, - ) + subject = get_labware_view() - assert subject.get_grip_force("flex-tiprack-id") == 16 # from definition - assert subject.get_grip_force("reservoir-id") == 15 # default + assert subject.get_grip_force(flex_50uL_tiprack) == 16 # from definition + assert subject.get_grip_force(reservoir_def) == 15 # default def test_get_grip_height_from_labware_bottom( @@ -1606,20 +1591,11 @@ def test_get_grip_height_from_labware_bottom( reservoir_def: LabwareDefinition, ) -> None: """It should get the grip height, if present, from labware definition or return default.""" - subject = get_labware_view( - labware_by_id={"plate-id": plate, "reservoir-id": reservoir}, - definitions_by_uri={ - "some-plate-uri": well_plate_def, - "some-reservoir-uri": reservoir_def, - }, - ) - + subject = get_labware_view() assert ( - subject.get_grip_height_from_labware_bottom("plate-id") == 12.2 + subject.get_grip_height_from_labware_bottom(well_plate_def) == 12.2 ) # from definition - assert ( - subject.get_grip_height_from_labware_bottom("reservoir-id") == 15.7 - ) # default + assert subject.get_grip_height_from_labware_bottom(reservoir_def) == 15.7 # default @pytest.mark.parametrize( @@ -1638,18 +1614,7 @@ def test_calculates_well_bounding_box( ) -> None: """It should be able to calculate well bounding boxes.""" definition = LabwareDefinition.parse_obj(load_definition(labware_to_check, 1)) - labware = LoadedLabware( - id="test-labware-id", - loadName=labware_to_check, - location=DeckSlotLocation(slotName=DeckSlotName.SLOT_1), - definitionUri="test-labware-uri", - offsetId=None, - displayName="Fancy Plate Name", - ) - subject = get_labware_view( - labware_by_id={"test-labware-id": labware}, - definitions_by_uri={"test-labware-uri": definition}, - ) - assert subject.get_well_bbox("test-labware-id").x == pytest.approx(well_bbox.x) - assert subject.get_well_bbox("test-labware-id").y == pytest.approx(well_bbox.y) - assert subject.get_well_bbox("test-labware-id").z == pytest.approx(well_bbox.z) + subject = get_labware_view() + assert subject.get_well_bbox(definition).x == pytest.approx(well_bbox.x) + assert subject.get_well_bbox(definition).y == pytest.approx(well_bbox.y) + assert subject.get_well_bbox(definition).z == pytest.approx(well_bbox.z) diff --git a/api/tests/opentrons/protocol_engine/state/test_liquid_class_store.py b/api/tests/opentrons/protocol_engine/state/test_liquid_class_store.py new file mode 100644 index 00000000000..f9032acdb94 --- /dev/null +++ b/api/tests/opentrons/protocol_engine/state/test_liquid_class_store.py @@ -0,0 +1,60 @@ +"""Liquid state store tests.""" +import pytest + +from opentrons_shared_data.liquid_classes.liquid_class_definition import ( + LiquidClassSchemaV1, +) +from opentrons.protocol_engine import actions +from opentrons.protocol_engine.commands import Comment +from opentrons.protocol_engine.state import update_types +from opentrons.protocol_engine.state.liquid_classes import LiquidClassStore +from opentrons.protocol_engine.types import LiquidClassRecord + + +@pytest.fixture +def subject() -> LiquidClassStore: + """The LiquidClassStore test subject.""" + return LiquidClassStore() + + +def test_handles_add_liquid_class( + subject: LiquidClassStore, minimal_liquid_class_def2: LiquidClassSchemaV1 +) -> None: + """Should add the LiquidClassRecord to the store.""" + pipette_0 = minimal_liquid_class_def2.byPipette[0] + by_tip_type_0 = pipette_0.byTipType[0] + liquid_class_record = LiquidClassRecord( + liquidClassName=minimal_liquid_class_def2.liquidClassName, + pipetteModel=pipette_0.pipetteModel, + tiprack=by_tip_type_0.tiprack, + aspirate=by_tip_type_0.aspirate, + singleDispense=by_tip_type_0.singleDispense, + multiDispense=by_tip_type_0.multiDispense, + ) + + subject.handle_action( + actions.SucceedCommandAction( + # TODO(dc): this is a placeholder command, LoadLiquidClassCommand coming soon + command=Comment.construct(), # type: ignore[call-arg] + state_update=update_types.StateUpdate( + liquid_class_loaded=update_types.LiquidClassLoadedUpdate( + liquid_class_id="liquid-class-id", + liquid_class_record=liquid_class_record, + ), + ), + ) + ) + + assert len(subject.state.liquid_class_record_by_id) == 1 + assert ( + subject.state.liquid_class_record_by_id["liquid-class-id"] + == liquid_class_record + ) + + assert len(subject.state.liquid_class_record_to_id) == 1 + # Make sure that LiquidClassRecords are hashable, and that we can query for LiquidClassRecords by value: + assert ( + subject.state.liquid_class_record_to_id[liquid_class_record] + == "liquid-class-id" + ) + # If this fails with an error like "TypeError: unhashable type: AspirateProperties", then you broke something. diff --git a/api/tests/opentrons/protocol_engine/state/test_liquid_class_view.py b/api/tests/opentrons/protocol_engine/state/test_liquid_class_view.py new file mode 100644 index 00000000000..d80f40a5d0c --- /dev/null +++ b/api/tests/opentrons/protocol_engine/state/test_liquid_class_view.py @@ -0,0 +1,62 @@ +"""Liquid view tests.""" +import pytest + +from opentrons_shared_data.liquid_classes.liquid_class_definition import ( + LiquidClassSchemaV1, +) + +from opentrons.protocol_engine.state.liquid_classes import ( + LiquidClassState, + LiquidClassView, +) +from opentrons.protocol_engine.types import LiquidClassRecord + + +@pytest.fixture +def liquid_class_record( + minimal_liquid_class_def2: LiquidClassSchemaV1, +) -> LiquidClassRecord: + """An example LiquidClassRecord for tests.""" + pipette_0 = minimal_liquid_class_def2.byPipette[0] + by_tip_type_0 = pipette_0.byTipType[0] + return LiquidClassRecord( + liquidClassName=minimal_liquid_class_def2.liquidClassName, + pipetteModel=pipette_0.pipetteModel, + tiprack=by_tip_type_0.tiprack, + aspirate=by_tip_type_0.aspirate, + singleDispense=by_tip_type_0.singleDispense, + multiDispense=by_tip_type_0.multiDispense, + ) + + +@pytest.fixture +def subject(liquid_class_record: LiquidClassRecord) -> LiquidClassView: + """The LiquidClassView test subject.""" + state = LiquidClassState( + liquid_class_record_by_id={"liquid-class-id": liquid_class_record}, + liquid_class_record_to_id={liquid_class_record: "liquid-class-id"}, + ) + return LiquidClassView(state) + + +def test_get_by_id( + subject: LiquidClassView, liquid_class_record: LiquidClassRecord +) -> None: + """Should look up LiquidClassRecord by ID.""" + assert subject.get("liquid-class-id") == liquid_class_record + + +def test_get_by_liquid_class_record( + subject: LiquidClassView, liquid_class_record: LiquidClassRecord +) -> None: + """Should look up existing ID given a LiquidClassRecord.""" + assert ( + subject.get_id_for_liquid_class_record(liquid_class_record) == "liquid-class-id" + ) + + +def test_get_all( + subject: LiquidClassView, liquid_class_record: LiquidClassRecord +) -> None: + """Should get all LiquidClassRecords in the store.""" + assert subject.get_all() == {"liquid-class-id": liquid_class_record} diff --git a/api/tests/opentrons/protocol_engine/state/test_module_view.py b/api/tests/opentrons/protocol_engine/state/test_module_view.py index 3a5f14f1516..66152a57240 100644 --- a/api/tests/opentrons/protocol_engine/state/test_module_view.py +++ b/api/tests/opentrons/protocol_engine/state/test_module_view.py @@ -406,7 +406,7 @@ def test_get_module_offset_for_ot2_standard( }, ) assert ( - subject.get_nominal_module_offset("module-id", get_addressable_area_view()) + subject.get_nominal_offset_to_child("module-id", get_addressable_area_view()) == expected_offset ) @@ -470,7 +470,7 @@ def test_get_module_offset_for_ot3_standard( }, ) - result_offset = subject.get_nominal_module_offset( + result_offset = subject.get_nominal_offset_to_child( "module-id", get_addressable_area_view( deck_configuration=None, diff --git a/app-shell/build/release-notes-internal.md b/app-shell/build/release-notes-internal.md index b7780dc8c88..be1008ec824 100644 --- a/app-shell/build/release-notes-internal.md +++ b/app-shell/build/release-notes-internal.md @@ -1,6 +1,10 @@ For more details about this release, please see the full [technical changelog][]. [technical change log]: https://github.com/Opentrons/opentrons/releases +## Internal Release 2.2.0-alpha.1 + +This internal release, pulled from the `edge` branch, contains features being developed for 8.2.0. It's for internal testing only. + ## Internal Release 2.2.0-alpha.0 This internal release, pulled from the `edge` branch, contains features being developed for 8.2.0. It's for internal testing only. diff --git a/app-shell/src/robot-update/index.ts b/app-shell/src/robot-update/index.ts index 6e6d9b03363..01afaa15960 100644 --- a/app-shell/src/robot-update/index.ts +++ b/app-shell/src/robot-update/index.ts @@ -254,7 +254,7 @@ export function checkForRobotUpdate( }) }) .then(() => - cleanupReleaseFiles(cacheDirForMachineFiles(target), CURRENT_VERSION) + cleanupReleaseFiles(cacheDirForMachine(target), CURRENT_VERSION) ) } diff --git a/app/src/assets/localization/en/error_recovery.json b/app/src/assets/localization/en/error_recovery.json index 746305e2578..392c9c694c3 100644 --- a/app/src/assets/localization/en/error_recovery.json +++ b/app/src/assets/localization/en/error_recovery.json @@ -18,7 +18,8 @@ "continue": "Continue", "continue_run_now": "Continue run now", "continue_to_drop_tip": "Continue to drop tip", - "door_open_gripper_home": "The robot door must be closed for the gripper to home its Z-axis before you can continue manually moving labware.", + "do_you_need_to_blowout": "First, do you need to blow out aspirated liquid?", + "door_open_robot_home": "The robot needs to safely move to its home location before you manually move the labware.", "ensure_lw_is_accurately_placed": "Ensure labware is accurately placed in the slot to prevent further errors.", "error": "Error", "error_details": "Error details", @@ -49,13 +50,13 @@ "manually_move_lw_on_deck": "Manually move labware on deck", "manually_replace_lw_and_retry": "Manually replace labware on deck and retry step", "manually_replace_lw_on_deck": "Manually replace labware on deck", + "na": "N/A", "next_step": "Next step", "next_try_another_action": "Next, you can try another recovery action or cancel the run.", "no_liquid_detected": "No liquid detected", "overpressure_is_usually_caused": "Overpressure is usually caused by a tip contacting labware, a clog, or moving viscous liquid too quickly", "pick_up_tips": "Pick up tips", "pipette_overpressure": "Pipette overpressure", - "do_you_need_to_blowout": "First, do you need to blowout aspirated liquid?", "proceed_to_cancel": "Proceed to cancel", "proceed_to_tip_selection": "Proceed to tip selection", "recovery_action_failed": "{{action}} failed", @@ -66,8 +67,8 @@ "remove_any_attached_tips": "Remove any attached tips", "replace_tips_and_select_loc_partial_tip": "Replace tips and select the last location used for partial tip pickup.", "replace_tips_and_select_location": "It's best to replace tips and select the last location used for tip pickup.", - "replace_used_tips_in_rack_location": "Replace used tips in rack location {{location}} in Slot {{slot}}", - "replace_with_new_tip_rack": "Replace with new tip rack in Slot {{slot}}", + "replace_used_tips_in_rack_location": "Replace used tips in rack location {{location}} in {{slot}}", + "replace_with_new_tip_rack": "Replace with new tip rack in {{slot}}", "resume": "Resume", "retry_dropping_tip": "Retry dropping tip", "retry_now": "Retry now", @@ -76,6 +77,7 @@ "retry_with_new_tips": "Retry with new tips", "retry_with_same_tips": "Retry with same tips", "retrying_step_succeeded": "Retrying step {{step}} succeeded.", + "retrying_step_succeeded_na": "Retrying current step succeeded.", "return_to_menu": "Return to menu", "robot_door_is_open": "Robot door is open", "robot_is_canceling_run": "Robot is canceling the run", @@ -93,6 +95,7 @@ "skip_to_next_step_new_tips": "Skip to next step with new tips", "skip_to_next_step_same_tips": "Skip to next step with same tips", "skipping_to_step_succeeded": "Skipping to step {{step}} succeeded.", + "skipping_to_step_succeeded_na": "Skipping to next step succeeded.", "stand_back": "Stand back, robot is in motion", "stand_back_picking_up_tips": "Stand back, picking up tips", "stand_back_resuming": "Stand back, resuming current step", diff --git a/app/src/assets/localization/en/protocol_command_text.json b/app/src/assets/localization/en/protocol_command_text.json index c2ee88bcd5a..4ff0039bbbd 100644 --- a/app/src/assets/localization/en/protocol_command_text.json +++ b/app/src/assets/localization/en/protocol_command_text.json @@ -3,8 +3,8 @@ "absorbance_reader_initialize": "Initializing Absorbance Reader to perform {{mode}} measurement at {{wavelengths}}", "absorbance_reader_open_lid": "Opening Absorbance Reader lid", "absorbance_reader_read": "Reading plate in Absorbance Reader", - "adapter_in_mod_in_slot": "{{adapter}} on {{module}} in {{slot}}", - "adapter_in_slot": "{{adapter}} in {{slot}}", + "adapter_in_mod_in_slot": "{{adapter}} on {{module}} in Slot {{slot}}", + "adapter_in_slot": "{{adapter}} in Slot {{slot}}", "air_gap_in_place": "Air gapping {{volume}} µL", "aspirate": "Aspirating {{volume}} µL from well {{well_name}} of {{labware}} in {{labware_location}} at {{flow_rate}} µL/sec", "aspirate_in_place": "Aspirating {{volume}} µL in place at {{flow_rate}} µL/sec ", @@ -32,14 +32,10 @@ "engaging_magnetic_module": "Engaging Magnetic Module", "fixed_trash": "Fixed Trash", "home_gantry": "Homing all gantry, pipette, and plunger axes", + "in_location": "in {{location}}", "latching_hs_latch": "Latching labware on Heater-Shaker", "left": "Left", - "load_labware_info_protocol_setup": "Load {{labware}} in {{module_name}} in Slot {{slot_name}}", - "load_labware_info_protocol_setup_adapter": "Load {{labware}} in {{adapter_name}} in Slot {{slot_name}}", - "load_labware_info_protocol_setup_adapter_module": "Load {{labware}} in {{adapter_name}} in {{module_name}} in Slot {{slot_name}}", - "load_labware_info_protocol_setup_adapter_off_deck": "Load {{labware}} in {{adapter_name}} off deck", - "load_labware_info_protocol_setup_no_module": "Load {{labware}} in Slot {{slot_name}}", - "load_labware_info_protocol_setup_off_deck": "Load {{labware}} off deck", + "load_labware_to_display_location": "Load {{labware}} {{display_location}}", "load_liquids_info_protocol_setup": "Load {{liquid}} into {{labware}}", "load_module_protocol_setup": "Load {{module}} in Slot {{slot_name}}", "load_pipette_protocol_setup": "Load {{pipette_name}} in {{mount_name}} Mount", @@ -59,6 +55,7 @@ "notes": "notes", "off_deck": "off deck", "offdeck": "offdeck", + "on_location": "on {{location}}", "opening_tc_lid": "Opening Thermocycler lid", "pause": "Pause", "pause_on": "Pause on {{robot_name}}", diff --git a/app/src/assets/localization/en/quick_transfer.json b/app/src/assets/localization/en/quick_transfer.json index 32efac281bc..c986da098c1 100644 --- a/app/src/assets/localization/en/quick_transfer.json +++ b/app/src/assets/localization/en/quick_transfer.json @@ -39,7 +39,7 @@ "create_new_transfer": "Create new quick transfer", "create_transfer": "Create transfer", "delay": "Delay", - "delay_before_aspirating": "Delay before aspirating", + "delay_after_aspirating": "Delay after aspirating", "delay_before_dispensing": "Delay before dispensing", "delay_duration_s": "Delay duration (seconds)", "delay_position_mm": "Delay position from bottom of well (mm)", @@ -130,7 +130,7 @@ "tip_position_value": "{{position}} mm from the bottom", "tip_rack": "Tip rack", "touch_tip": "Touch tip", - "touch_tip_before_aspirating": "Touch tip before aspirating", + "touch_tip_after_aspirating": "Touch tip after aspirating", "touch_tip_before_dispensing": "Touch tip before dispensing", "touch_tip_position_mm": "Touch tip position from bottom of well (mm)", "touch_tip_value": "{{position}} mm from bottom", diff --git a/app/src/assets/localization/en/run_details.json b/app/src/assets/localization/en/run_details.json index 28df0734619..98443901364 100644 --- a/app/src/assets/localization/en/run_details.json +++ b/app/src/assets/localization/en/run_details.json @@ -62,6 +62,7 @@ "module_controls": "Module Controls", "module_slot_number": "Slot {{slot_number}}", "move_labware": "Move Labware", + "na": "N/A", "name": "Name", "no_files_included": "No protocol files included", "no_of_error": "{{count}} error", @@ -144,6 +145,7 @@ "status_succeeded": "Completed", "step": "Step", "step_failed": "Step failed", + "step_na": "Step: N/A", "step_number": "Step {{step_number}}:", "steps_total": "{{count}} steps total", "stored_labware_offset_data": "Stored Labware Offset data that applies to this protocol", diff --git a/app/src/assets/localization/zh/protocol_command_text.json b/app/src/assets/localization/zh/protocol_command_text.json index 74ab15b69b7..9d976c2bc88 100644 --- a/app/src/assets/localization/zh/protocol_command_text.json +++ b/app/src/assets/localization/zh/protocol_command_text.json @@ -28,12 +28,6 @@ "home_gantry": "复位所有龙门架、移液器和柱塞轴", "latching_hs_latch": "在热震荡模块上锁定实验耗材", "left": "左", - "load_labware_info_protocol_setup_adapter_module": "在{{module_name}}的甲板槽{{slot_name}}上加载适配器{{adapter_name}}中的{{labware}}", - "load_labware_info_protocol_setup_adapter_off_deck": "在板外加载适配器{{adapter_name}}中的{{labware}}", - "load_labware_info_protocol_setup_adapter": "在甲板槽{{slot_name}}上加载适配器{{adapter_name}}中的{{labware}}", - "load_labware_info_protocol_setup_no_module": "在甲板槽{{slot_name}}中加载{{labware}}", - "load_labware_info_protocol_setup_off_deck": "在板外加载{{labware}}", - "load_labware_info_protocol_setup": "在{{module_name}}的甲板槽{{slot_name}}中加载{{labware}}", "load_liquids_info_protocol_setup": "将{{liquid}}加载到{{labware}}中", "load_module_protocol_setup": "在甲板槽{{slot_name}}中加载模块{{module}}", "load_pipette_protocol_setup": "在{{mount_name}}支架上加载{{pipette_name}}", diff --git a/app/src/assets/localization/zh/quick_transfer.json b/app/src/assets/localization/zh/quick_transfer.json index 4a1e2779d52..f57d7315651 100644 --- a/app/src/assets/localization/zh/quick_transfer.json +++ b/app/src/assets/localization/zh/quick_transfer.json @@ -39,7 +39,6 @@ "create_new_transfer": "创建新的快速移液命令", "create_to_get_started": "创建新的快速移液以开始操作。", "create_transfer": "创建移液命令", - "delay_before_aspirating": "吸取前的延迟", "delay_before_dispensing": "分液前的延迟", "delay_duration_s": "延迟时长(秒)", "delay_position_mm": "距孔底延迟时的位置(mm)", @@ -132,7 +131,6 @@ "tip_rack": "吸头盒", "too_many_pins_body": "删除一个快速移液,以便向您的固定列表中添加更多传输。", "too_many_pins_header": "您已达到上限!", - "touch_tip_before_aspirating": "在吸液前做碰壁动作", "touch_tip_before_dispensing": "在分液前做碰壁动作", "touch_tip_position_mm": "在孔底部做碰壁动作的高度(mm)", "touch_tip_value": "距底部 {{position}} mm", diff --git a/app/src/assets/localization/zh/run_details.json b/app/src/assets/localization/zh/run_details.json index 00d584bb4ba..2bfa9c1a5e1 100644 --- a/app/src/assets/localization/zh/run_details.json +++ b/app/src/assets/localization/zh/run_details.json @@ -52,12 +52,6 @@ "labware": "耗材", "left": "左", "listed_values": "列出的值仅供查看", - "load_labware_info_protocol_setup_adapter_off_deck": "在甲板外的{{adapter_name}}上加载{{labware}}", - "load_labware_info_protocol_setup_adapter": "在{{slot_name}}号板位中的{{adapter_name}}中加载{{labware}}", - "load_labware_info_protocol_setup_no_module": "在{{slot_name}}号板位中加载{{labware}}", - "load_labware_info_protocol_setup_off_deck": "在甲板外加载{{labware}}", - "load_labware_info_protocol_setup_plural": "在{{module_name}}中加载{{labware}}", - "load_labware_info_protocol_setup": "在{{slot_name}}号板位中的{{module_name}}中加载{{labware}}", "load_liquids_info_protocol_setup": "将{{liquid}}加载到{{labware}}中", "load_module_protocol_setup_plural": "加载{{module}}", "load_module_protocol_setup": "在{{slot_name}}号板位中加载{{module}}", diff --git a/app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getLoadCommandText.ts b/app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getLoadCommandText.ts index cba135218c8..52663e94305 100644 --- a/app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getLoadCommandText.ts +++ b/app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getLoadCommandText.ts @@ -8,13 +8,11 @@ import { import { getPipetteNameOnMount } from '../getPipetteNameOnMount' import { getLiquidDisplayName } from '../getLiquidDisplayName' -import { getLabwareName } from '/app/local-resources/labware' import { - getModuleModel, - getModuleDisplayLocation, -} from '/app/local-resources/modules' + getLabwareName, + getLabwareDisplayLocation, +} from '/app/local-resources/labware' -import type { LoadLabwareRunTimeCommand } from '@opentrons/shared-data' import type { GetCommandText } from '../..' export const getLoadCommandText = ({ @@ -53,103 +51,27 @@ export const getLoadCommandText = ({ }) } case 'loadLabware': { - if ( - command.params.location !== 'offDeck' && - 'moduleId' in command.params.location - ) { - const moduleModel = - commandTextData != null - ? getModuleModel( - commandTextData.modules ?? [], - command.params.location.moduleId - ) - : null - const moduleName = - moduleModel != null ? getModuleDisplayName(moduleModel) : '' - - return t('load_labware_info_protocol_setup', { - count: - moduleModel != null - ? getOccludedSlotCountForModule( - getModuleType(moduleModel), - robotType - ) - : 1, - labware: command.result?.definition.metadata.displayName, - slot_name: - commandTextData != null - ? getModuleDisplayLocation( - commandTextData.modules ?? [], - command.params.location.moduleId - ) - : null, - module_name: moduleName, - }) - } else if ( - command.params.location !== 'offDeck' && - 'labwareId' in command.params.location - ) { - const labwareId = command.params.location.labwareId - const labwareName = command.result?.definition.metadata.displayName - const matchingAdapter = commandTextData?.commands.find( - (command): command is LoadLabwareRunTimeCommand => - command.commandType === 'loadLabware' && - command.result?.labwareId === labwareId - ) - const adapterName = - matchingAdapter?.result?.definition.metadata.displayName - const adapterLoc = matchingAdapter?.params.location - if (adapterLoc === 'offDeck') { - return t('load_labware_info_protocol_setup_adapter_off_deck', { - labware: labwareName, - adapter_name: adapterName, - }) - } else if (adapterLoc != null && 'slotName' in adapterLoc) { - return t('load_labware_info_protocol_setup_adapter', { - labware: labwareName, - adapter_name: adapterName, - slot_name: adapterLoc?.slotName, - }) - } else if (adapterLoc != null && 'moduleId' in adapterLoc) { - const moduleModel = - commandTextData != null - ? getModuleModel( - commandTextData.modules ?? [], - adapterLoc?.moduleId ?? '' - ) - : null - const moduleName = - moduleModel != null ? getModuleDisplayName(moduleModel) : '' - return t('load_labware_info_protocol_setup_adapter_module', { - labware: labwareName, - adapter_name: adapterName, - module_name: moduleName, - slot_name: - commandTextData != null - ? getModuleDisplayLocation( - commandTextData.modules ?? [], - adapterLoc?.moduleId ?? '' - ) - : null, - }) - } else { - // shouldn't reach here, adapter shouldn't have location type labwareId - return '' - } - } else { - const labware = - command.result?.definition.metadata.displayName ?? - command.params.displayName - return command.params.location === 'offDeck' - ? t('load_labware_info_protocol_setup_off_deck', { labware }) - : t('load_labware_info_protocol_setup_no_module', { - labware, - slot_name: - 'addressableAreaName' in command.params.location - ? command.params.location.addressableAreaName - : command.params.location.slotName, - }) + const location = getLabwareDisplayLocation({ + location: command.params.location, + robotType, + allRunDefs, + loadedLabwares: commandTextData?.labware ?? [], + loadedModules: commandTextData?.modules ?? [], + t, + }) + const labwareName = command.result?.definition.metadata.displayName + // use in preposition for modules and slots, on for labware and adapters + let displayLocation = t('in_location', { location }) + if (command.params.location === 'offDeck') { + displayLocation = location + } else if ('labwareId' in command.params.location) { + displayLocation = t('on_location', { location }) } + + return t('load_labware_to_display_location', { + labware: labwareName, + display_location: displayLocation, + }) } case 'reloadLabware': { const { labwareId } = command.params diff --git a/app/src/local-resources/dom-utils/hooks/__tests__/useScrollPosition.test.ts b/app/src/local-resources/dom-utils/hooks/__tests__/useScrollPosition.test.ts new file mode 100644 index 00000000000..f5a47b2518c --- /dev/null +++ b/app/src/local-resources/dom-utils/hooks/__tests__/useScrollPosition.test.ts @@ -0,0 +1,76 @@ +import { renderHook, act } from '@testing-library/react' +import { describe, it, expect, vi, beforeEach } from 'vitest' + +import { useScrollPosition } from '../useScrollPosition' + +describe('useScrollPosition', () => { + const mockObserve = vi.fn() + const mockDisconnect = vi.fn() + let intersectionCallback: (entries: IntersectionObserverEntry[]) => void + + beforeEach(() => { + vi.stubGlobal( + 'IntersectionObserver', + vi.fn(callback => { + intersectionCallback = callback + return { + observe: mockObserve, + disconnect: mockDisconnect, + unobserve: vi.fn(), + } + }) + ) + }) + + it('should return initial state and ref', () => { + const { result } = renderHook(() => useScrollPosition()) + + expect(result.current.isScrolled).toBe(false) + expect(result.current.scrollRef).toBeDefined() + expect(result.current.scrollRef.current).toBe(null) + }) + + it('should observe when ref is set', async () => { + const { result } = renderHook(() => useScrollPosition()) + + const div = document.createElement('div') + + await act(async () => { + // @ts-expect-error we're forcibly setting readonly ref + result.current.scrollRef.current = div + + const observer = new IntersectionObserver(intersectionCallback) + observer.observe(div) + }) + + expect(mockObserve).toHaveBeenCalledWith(div) + }) + + it('should update isScrolled when intersection changes for both scrolled and unscrolled cases', () => { + const { result } = renderHook(() => useScrollPosition()) + + act(() => { + intersectionCallback([ + { isIntersecting: false } as IntersectionObserverEntry, + ]) + }) + + expect(result.current.isScrolled).toBe(true) + + act(() => { + intersectionCallback([ + { isIntersecting: true } as IntersectionObserverEntry, + ]) + }) + + expect(result.current.isScrolled).toBe(false) + }) + + it('should disconnect observer on unmount', () => { + const { unmount } = renderHook(() => useScrollPosition()) + + unmount() + + expect(mockDisconnect).toHaveBeenCalled() + }) +}) diff --git a/app/src/local-resources/dom-utils/hooks/index.ts b/app/src/local-resources/dom-utils/hooks/index.ts new file mode 100644 index 00000000000..2098c90e0c3 --- /dev/null +++ b/app/src/local-resources/dom-utils/hooks/index.ts @@ -0,0 +1 @@ +export * from './useScrollPosition' diff --git a/app/src/local-resources/dom-utils/hooks/useScrollPosition.ts b/app/src/local-resources/dom-utils/hooks/useScrollPosition.ts new file mode 100644 index 00000000000..8b3aa945947 --- /dev/null +++ b/app/src/local-resources/dom-utils/hooks/useScrollPosition.ts @@ -0,0 +1,27 @@ +import { useRef, useState, useEffect } from 'react' + +import type { RefObject } from 'react' + +export function useScrollPosition(): { + scrollRef: RefObject + isScrolled: boolean +} { + const scrollRef = useRef(null) + const [isScrolled, setIsScrolled] = useState(false) + + useEffect(() => { + const observer = new IntersectionObserver(([entry]) => { + setIsScrolled(!entry.isIntersecting) + }) + + if (scrollRef.current != null) { + observer.observe(scrollRef.current) + } + + return () => { + observer.disconnect() + } + }, []) + + return { scrollRef, isScrolled } +} diff --git a/app/src/local-resources/dom-utils/index.ts b/app/src/local-resources/dom-utils/index.ts new file mode 100644 index 00000000000..fc78d35129c --- /dev/null +++ b/app/src/local-resources/dom-utils/index.ts @@ -0,0 +1 @@ +export * from './hooks' diff --git a/app/src/local-resources/labware/utils/__tests__/getLabwareDisplayLocation.test.tsx b/app/src/local-resources/labware/utils/__tests__/getLabwareDisplayLocation.test.tsx index 22e02478ded..ca4b095f00e 100644 --- a/app/src/local-resources/labware/utils/__tests__/getLabwareDisplayLocation.test.tsx +++ b/app/src/local-resources/labware/utils/__tests__/getLabwareDisplayLocation.test.tsx @@ -125,7 +125,7 @@ describe('getLabwareDisplayLocation with translations', () => { }, }) - screen.getByText('Mock Adapter in D1') + screen.getByText('Mock Adapter in Slot D1') }) it('should return a slot-only location when detailLevel is "slot-only"', () => { @@ -168,6 +168,6 @@ describe('getLabwareDisplayLocation with translations', () => { }, }) - screen.getByText('Mock Adapter on Temperature Module in 2') + screen.getByText('Mock Adapter on Temperature Module in Slot 2') }) }) diff --git a/app/src/local-resources/labware/utils/getLabwareDisplayLocation.ts b/app/src/local-resources/labware/utils/getLabwareDisplayLocation.ts index d70e6d19d42..2e02199e667 100644 --- a/app/src/local-resources/labware/utils/getLabwareDisplayLocation.ts +++ b/app/src/local-resources/labware/utils/getLabwareDisplayLocation.ts @@ -1,180 +1,93 @@ import { - getLabwareDefURI, - getLabwareDisplayName, getModuleDisplayName, getModuleType, getOccludedSlotCountForModule, + THERMOCYCLER_MODULE_V1, + THERMOCYCLER_MODULE_V2, } from '@opentrons/shared-data' - -import { - getModuleModel, - getModuleDisplayLocation, -} from '/app/local-resources/modules' +import { getLabwareLocation } from './getLabwareLocation' import type { TFunction } from 'i18next' import type { - LabwareDefinition2, - LabwareLocation, - RobotType, -} from '@opentrons/shared-data' -import type { LoadedLabwares } from '/app/local-resources/labware' -import type { LoadedModules } from '/app/local-resources/modules' + LocationSlotOnlyParams, + LocationFullParams, +} from './getLabwareLocation' -interface LabwareDisplayLocationBaseParams { - location: LabwareLocation | null - loadedModules: LoadedModules - loadedLabwares: LoadedLabwares - robotType: RobotType +export interface DisplayLocationSlotOnlyParams extends LocationSlotOnlyParams { t: TFunction isOnDevice?: boolean } -export interface LabwareDisplayLocationSlotOnly - extends LabwareDisplayLocationBaseParams { - detailLevel: 'slot-only' -} - -export interface LabwareDisplayLocationFull - extends LabwareDisplayLocationBaseParams { - detailLevel?: 'full' - allRunDefs: LabwareDefinition2[] +export interface DisplayLocationFullParams extends LocationFullParams { + t: TFunction + isOnDevice?: boolean } -export type LabwareDisplayLocationParams = - | LabwareDisplayLocationSlotOnly - | LabwareDisplayLocationFull +export type DisplayLocationParams = + | DisplayLocationSlotOnlyParams + | DisplayLocationFullParams // detailLevel applies to nested labware. If 'full', return copy that includes the actual peripheral that nests the // labware, ex, "in module XYZ in slot C1". // If 'slot-only', return only the slot name, ex "in slot C1". export function getLabwareDisplayLocation( - params: LabwareDisplayLocationParams + params: DisplayLocationParams ): string { - const { - loadedLabwares, - loadedModules, - location, - robotType, - t, - isOnDevice = false, - detailLevel = 'full', - } = params + const { t, isOnDevice = false } = params + const locationResult = getLabwareLocation(params) - if (location == null) { - console.error('Cannot get labware display location. No location provided.') + if (locationResult == null) { return '' - } else if (location === 'offDeck') { - return t('off_deck') - } else if ('slotName' in location) { - return isOnDevice - ? location.slotName - : t('slot', { slot_name: location.slotName }) - } else if ('addressableAreaName' in location) { - return isOnDevice - ? location.addressableAreaName - : t('slot', { slot_name: location.addressableAreaName }) - } else if ('moduleId' in location) { - const moduleModel = getModuleModel(loadedModules, location.moduleId) - if (moduleModel == null) { - console.error('labware is located on an unknown module model') - return '' - } - const slotName = getModuleDisplayLocation(loadedModules, location.moduleId) + } - if (detailLevel === 'slot-only') { - return t('slot', { slot_name: slotName }) - } + const { slotName, moduleModel, adapterName } = locationResult - return isOnDevice - ? `${getModuleDisplayName(moduleModel)}, ${slotName}` - : t('module_in_slot', { - count: getOccludedSlotCountForModule( - getModuleType(moduleModel), - robotType - ), - module: getModuleDisplayName(moduleModel), - slot_name: slotName, - }) - } else if ('labwareId' in location) { - if (!Array.isArray(loadedLabwares)) { - console.error('Cannot get display location from loaded labwares object') - return '' + if (slotName === 'offDeck') { + return t('off_deck') + } + // Simple slot location + else if (moduleModel == null && adapterName == null) { + return isOnDevice ? slotName : t('slot', { slot_name: slotName }) + } + // Module location without adapter + else if (moduleModel != null && adapterName == null) { + if (params.detailLevel === 'slot-only') { + return moduleModel === THERMOCYCLER_MODULE_V1 || + moduleModel === THERMOCYCLER_MODULE_V2 + ? t('slot', { slot_name: 'A1+B1' }) + : t('slot', { slot_name: slotName }) + } else { + return isOnDevice + ? `${getModuleDisplayName(moduleModel)}, ${slotName}` + : t('module_in_slot', { + count: getOccludedSlotCountForModule( + getModuleType(moduleModel), + params.robotType + ), + module: getModuleDisplayName(moduleModel), + slot_name: slotName, + }) } - const adapter = loadedLabwares.find(lw => lw.id === location.labwareId) - - if (adapter == null) { - console.error('labware is located on an unknown adapter') - return '' - } else if (detailLevel === 'slot-only') { - return getLabwareDisplayLocation({ - ...params, - location: adapter.location, + } + // Adapter locations + else if (adapterName != null) { + if (moduleModel == null) { + return t('adapter_in_slot', { + adapter: adapterName, + slot: slotName, }) - } else if (detailLevel === 'full') { - const { allRunDefs } = params as LabwareDisplayLocationFull - const adapterDef = allRunDefs.find( - def => getLabwareDefURI(def) === adapter?.definitionUri - ) - const adapterDisplayName = - adapterDef != null ? getLabwareDisplayName(adapterDef) : '' - - if (adapter.location === 'offDeck') { - return t('off_deck') - } else if ( - 'slotName' in adapter.location || - 'addressableAreaName' in adapter.location - ) { - const slotName = - 'slotName' in adapter.location - ? adapter.location.slotName - : adapter.location.addressableAreaName - return t('adapter_in_slot', { - adapter: adapterDisplayName, - slot: slotName, - }) - } else if ('moduleId' in adapter.location) { - const moduleIdUnderAdapter = adapter.location.moduleId - - if (!Array.isArray(loadedModules)) { - console.error( - 'Cannot get display location from loaded modules object' - ) - return '' - } - - const moduleModel = loadedModules.find( - module => module.id === moduleIdUnderAdapter - )?.model - if (moduleModel == null) { - console.error('labware is located on an adapter on an unknown module') - return '' - } - const slotName = getModuleDisplayLocation( - loadedModules, - adapter.location.moduleId - ) - - return t('adapter_in_mod_in_slot', { - count: getOccludedSlotCountForModule( - getModuleType(moduleModel), - robotType - ), - module: getModuleDisplayName(moduleModel), - adapter: adapterDisplayName, - slot: slotName, - }) - } else { - console.error( - 'Unhandled adapter location for determining display location.' - ) - return '' - } } else { - console.error('Unhandled detail level for determining display location.') - return '' + return t('adapter_in_mod_in_slot', { + count: getOccludedSlotCountForModule( + getModuleType(moduleModel), + params.robotType + ), + module: getModuleDisplayName(moduleModel), + adapter: adapterName, + slot: slotName, + }) } } else { - console.error('display location could not be established: ', location) return '' } } diff --git a/app/src/local-resources/labware/utils/getLabwareLocation.ts b/app/src/local-resources/labware/utils/getLabwareLocation.ts new file mode 100644 index 00000000000..aec9e30a186 --- /dev/null +++ b/app/src/local-resources/labware/utils/getLabwareLocation.ts @@ -0,0 +1,152 @@ +import { getLabwareDefURI, getLabwareDisplayName } from '@opentrons/shared-data' + +import { + getModuleDisplayLocation, + getModuleModel, +} from '/app/local-resources/modules' + +import type { + LabwareDefinition2, + LabwareLocation, + ModuleModel, + RobotType, +} from '@opentrons/shared-data' +import type { LoadedLabwares } from '/app/local-resources/labware' +import type { LoadedModules } from '/app/local-resources/modules' + +export interface LocationResult { + slotName: string + moduleModel?: ModuleModel + adapterName?: string +} + +interface BaseParams { + location: LabwareLocation | null + loadedModules: LoadedModules + loadedLabwares: LoadedLabwares + robotType: RobotType +} + +export interface LocationSlotOnlyParams extends BaseParams { + detailLevel: 'slot-only' +} + +export interface LocationFullParams extends BaseParams { + allRunDefs: LabwareDefinition2[] + detailLevel?: 'full' +} + +export type GetLabwareLocationParams = + | LocationSlotOnlyParams + | LocationFullParams + +// detailLevel returns additional information about the module and adapter in the same location, if applicable. +// if 'slot-only', returns the underlying slot location. +export function getLabwareLocation( + params: GetLabwareLocationParams +): LocationResult | null { + const { + loadedLabwares, + loadedModules, + location, + detailLevel = 'full', + } = params + + if (location == null) { + return null + } else if (location === 'offDeck') { + return { slotName: 'offDeck' } + } else if ('slotName' in location) { + return { slotName: location.slotName } + } else if ('addressableAreaName' in location) { + return { slotName: location.addressableAreaName } + } else if ('moduleId' in location) { + const moduleModel = getModuleModel(loadedModules, location.moduleId) + if (moduleModel == null) { + console.error('labware is located on an unknown module model') + return null + } + const slotName = getModuleDisplayLocation(loadedModules, location.moduleId) + + return { + slotName, + moduleModel, + } + } else if ('labwareId' in location) { + if (!Array.isArray(loadedLabwares)) { + console.error('Cannot get location from loaded labwares object') + return null + } + + const adapter = loadedLabwares.find(lw => lw.id === location.labwareId) + + if (adapter == null) { + console.error('labware is located on an unknown adapter') + return null + } else if (detailLevel === 'slot-only') { + return getLabwareLocation({ + ...params, + location: adapter.location, + }) + } else if (detailLevel === 'full') { + const { allRunDefs } = params as LocationFullParams + const adapterDef = allRunDefs.find( + def => getLabwareDefURI(def) === adapter?.definitionUri + ) + const adapterName = + adapterDef != null ? getLabwareDisplayName(adapterDef) : '' + + if (adapter.location === 'offDeck') { + return { slotName: 'offDeck', adapterName } + } else if ( + 'slotName' in adapter.location || + 'addressableAreaName' in adapter.location + ) { + const slotName = + 'slotName' in adapter.location + ? adapter.location.slotName + : adapter.location.addressableAreaName + return { slotName, adapterName } + } else if ('moduleId' in adapter.location) { + const moduleIdUnderAdapter = adapter.location.moduleId + + if (!Array.isArray(loadedModules)) { + console.error('Cannot get location from loaded modules object') + return null + } + + const moduleModel = loadedModules.find( + module => module.id === moduleIdUnderAdapter + )?.model + + if (moduleModel == null) { + console.error('labware is located on an adapter on an unknown module') + return null + } + + const slotName = getModuleDisplayLocation( + loadedModules, + adapter.location.moduleId + ) + + return { + slotName, + moduleModel, + adapterName, + } + } else if ('labwareId' in adapter.location) { + return getLabwareLocation({ + ...params, + location: adapter.location, + }) + } else { + return null + } + } else { + console.error('Unhandled detailLevel.') + return null + } + } else { + return null + } +} diff --git a/app/src/local-resources/labware/utils/index.ts b/app/src/local-resources/labware/utils/index.ts index 73879e0956b..290e953d50f 100644 --- a/app/src/local-resources/labware/utils/index.ts +++ b/app/src/local-resources/labware/utils/index.ts @@ -5,3 +5,4 @@ export * from './getLabwareDefinitionsFromCommands' export * from './getLabwareName' export * from './getLoadedLabware' export * from './getLabwareDisplayLocation' +export * from './getLabwareLocation' diff --git a/app/src/molecules/Command/__fixtures__/mockRobotSideAnalysis.json b/app/src/molecules/Command/__fixtures__/mockRobotSideAnalysis.json index cd2bd35c802..848be62365c 100644 --- a/app/src/molecules/Command/__fixtures__/mockRobotSideAnalysis.json +++ b/app/src/molecules/Command/__fixtures__/mockRobotSideAnalysis.json @@ -71,6 +71,15 @@ "slotName": "5" }, "displayName": "NEST 1 Well Reservoir 195 mL" + }, + { + "id": "29444782-bdc8-4ad8-92fe-5e28872e85e5:opentrons/opentrons_96_flat_bottom_adapter/1", + "loadName": "opentrons_96_flat_bottom_adapter", + "definitionUri": "opentrons/opentrons_96_flat_bottom_adapter/1", + "location": { + "slotName": "2" + }, + "displayName": "Opentrons 96 Flat Bottom Adapter" } ], "modules": [ diff --git a/app/src/molecules/Command/__tests__/CommandText.test.tsx b/app/src/molecules/Command/__tests__/CommandText.test.tsx index f2762b622d7..483e739bbcb 100644 --- a/app/src/molecules/Command/__tests__/CommandText.test.tsx +++ b/app/src/molecules/Command/__tests__/CommandText.test.tsx @@ -553,9 +553,15 @@ describe('CommandText', () => { ) }) it('renders correct text for loadLabware in adapter', () => { + const flatBottomAdapterCommand = mockCommandTextData.commands.find( + c => + c.commandType === 'loadLabware' && + c.params.loadName === 'opentrons_96_flat_bottom_adapter' + ) + renderWithProviders( { } ) screen.getByText( - 'Load mock displayName in Opentrons 96 Flat Bottom Adapter in Slot 2' + 'Load mock displayName on Opentrons 96 Flat Bottom Adapter in Slot 2' ) }) it('renders correct text for loadLabware off deck', () => { diff --git a/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderModalContainer/RunHeaderModalContainer.tsx b/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderModalContainer/RunHeaderModalContainer.tsx index 3be4f607208..0c306339f69 100644 --- a/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderModalContainer/RunHeaderModalContainer.tsx +++ b/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderModalContainer/RunHeaderModalContainer.tsx @@ -51,7 +51,7 @@ export function RunHeaderModalContainer( ) : null} diff --git a/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderModalContainer/hooks/useMissingStepsModal.ts b/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderModalContainer/hooks/useMissingStepsModal.ts index d0506c55153..a25a201ef13 100644 --- a/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderModalContainer/hooks/useMissingStepsModal.ts +++ b/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderModalContainer/hooks/useMissingStepsModal.ts @@ -4,7 +4,11 @@ import { useConditionalConfirm } from '@opentrons/components' import { useIsHeaterShakerInProtocol } from '/app/organisms/ModuleCard/hooks' import { isAnyHeaterShakerShaking } from '../modals' -import { getMissingSetupSteps } from '/app/redux/protocol-runs' +import { + getMissingSetupSteps, + MODULE_SETUP_STEP_KEY, + ROBOT_CALIBRATION_STEP_KEY, +} from '/app/redux/protocol-runs' import type { UseConditionalConfirmResult } from '@opentrons/components' import type { RunStatus, AttachedModule } from '@opentrons/api-client' @@ -12,6 +16,11 @@ import type { ConfirmMissingStepsModalProps } from '../modals' import type { State } from '/app/redux/types' import type { StepKey } from '/app/redux/protocol-runs' +const UNCONFIRMABLE_MISSING_STEPS = new Set([ + ROBOT_CALIBRATION_STEP_KEY, + MODULE_SETUP_STEP_KEY, +]) + interface UseMissingStepsModalProps { runStatus: RunStatus | null attachedModules: AttachedModule[] @@ -47,9 +56,14 @@ export function useMissingStepsModal({ !isHeaterShakerShaking && (runStatus === RUN_STATUS_IDLE || runStatus === RUN_STATUS_STOPPED) + // Certain steps are not confirmed by the app, so don't include these in the modal. + const reportableMissingSetupSteps = missingSetupSteps.filter( + step => !UNCONFIRMABLE_MISSING_STEPS.has(step) + ) + const conditionalConfirmUtils = useConditionalConfirm( handleProceedToRunClick, - missingSetupSteps.length !== 0 + reportableMissingSetupSteps.length !== 0 ) const modalProps: ConfirmMissingStepsModalProps = { @@ -59,7 +73,7 @@ export function useMissingStepsModal({ ? conditionalConfirmUtils.confirm() : handleProceedToRunClick() }, - missingSteps: missingSetupSteps, + missingSteps: reportableMissingSetupSteps, } return conditionalConfirmUtils.showConfirmation diff --git a/app/src/organisms/Desktop/Devices/ProtocolRun/SetupLiquids/SetupLiquidsMap.tsx b/app/src/organisms/Desktop/Devices/ProtocolRun/SetupLiquids/SetupLiquidsMap.tsx index 1b556692f8d..68ce1bdfc22 100644 --- a/app/src/organisms/Desktop/Devices/ProtocolRun/SetupLiquids/SetupLiquidsMap.tsx +++ b/app/src/organisms/Desktop/Devices/ProtocolRun/SetupLiquids/SetupLiquidsMap.tsx @@ -9,6 +9,7 @@ import { Flex, JUSTIFY_CENTER, LabwareRender, + Box, } from '@opentrons/components' import { FLEX_ROBOT_TYPE, @@ -130,60 +131,62 @@ export function SetupLiquidsMap( alignItems={ALIGN_CENTER} justifyContent={JUSTIFY_CENTER} > - - {map(labwareRenderInfo, ({ x, y }, labwareId) => { - const { - topLabwareId, - topLabwareDefinition, - topLabwareDisplayName, - } = getTopLabwareInfo(labwareId, loadLabwareCommands) - const wellFill = getWellFillFromLabwareId( - topLabwareId ?? '', - liquids, - labwareByLiquidId - ) - const labwareHasLiquid = !isEmpty(wellFill) - return topLabwareDefinition != null ? ( - - { - setHoverLabwareId(topLabwareId) - }} - onMouseLeave={() => { - setHoverLabwareId('') - }} - onClick={() => { - if (labwareHasLiquid) { - setLiquidDetailsLabwareId(topLabwareId) - } - }} - cursor={labwareHasLiquid ? 'pointer' : ''} - > - - - - - ) : null - })} - + + + {map(labwareRenderInfo, ({ x, y }, labwareId) => { + const { + topLabwareId, + topLabwareDefinition, + topLabwareDisplayName, + } = getTopLabwareInfo(labwareId, loadLabwareCommands) + const wellFill = getWellFillFromLabwareId( + topLabwareId ?? '', + liquids, + labwareByLiquidId + ) + const labwareHasLiquid = !isEmpty(wellFill) + return topLabwareDefinition != null ? ( + + { + setHoverLabwareId(topLabwareId) + }} + onMouseLeave={() => { + setHoverLabwareId('') + }} + onClick={() => { + if (labwareHasLiquid) { + setLiquidDetailsLabwareId(topLabwareId) + } + }} + cursor={labwareHasLiquid ? 'pointer' : ''} + > + + + + + ) : null + })} + + {liquidDetailsLabwareId != null && ( @@ -46,7 +46,9 @@ export function ProtocolParameters({ ) : ( - + )} ) diff --git a/app/src/organisms/Desktop/RunProgressMeter/__tests__/RunProgressMeter.test.tsx b/app/src/organisms/Desktop/RunProgressMeter/__tests__/RunProgressMeter.test.tsx index 4217ce8618a..3618b0ce586 100644 --- a/app/src/organisms/Desktop/RunProgressMeter/__tests__/RunProgressMeter.test.tsx +++ b/app/src/organisms/Desktop/RunProgressMeter/__tests__/RunProgressMeter.test.tsx @@ -114,7 +114,7 @@ describe('RunProgressMeter', () => { it('should show only the total count of commands in run and not show the meter when protocol is non-deterministic', () => { vi.mocked(useCommandQuery).mockReturnValue({ data: null } as any) render(props) - expect(screen.getByText('Current Step ?/?:')).toBeTruthy() + expect(screen.getByText('Current Step N/A:')).toBeTruthy() expect(screen.queryByText('MOCK PROGRESS BAR')).toBeFalsy() }) it('should give the correct info when run status is idle', () => { diff --git a/app/src/organisms/Desktop/RunProgressMeter/hooks/useRunProgressCopy.tsx b/app/src/organisms/Desktop/RunProgressMeter/hooks/useRunProgressCopy.tsx index 8c522b4ff22..65e2f27d6b3 100644 --- a/app/src/organisms/Desktop/RunProgressMeter/hooks/useRunProgressCopy.tsx +++ b/app/src/organisms/Desktop/RunProgressMeter/hooks/useRunProgressCopy.tsx @@ -109,7 +109,9 @@ export function useRunProgressCopy({ if (runStatus === RUN_STATUS_IDLE) { return `${stepType}:` } else if (isTerminalStatus && currentStepNumber == null) { - return `${stepType}: N/A` + return `${stepType}: ${t('na')}` + } else if (hasRunDiverged) { + return `${stepType} ${t('na')}:` } else { const getCountString = (): string => { const current = currentStepNumber ?? '?' diff --git a/app/src/organisms/Desktop/SystemLanguagePreferenceModal/index.tsx b/app/src/organisms/Desktop/SystemLanguagePreferenceModal/index.tsx index 59ae8640f92..1a3a0d7d9ba 100644 --- a/app/src/organisms/Desktop/SystemLanguagePreferenceModal/index.tsx +++ b/app/src/organisms/Desktop/SystemLanguagePreferenceModal/index.tsx @@ -27,6 +27,10 @@ import { getSystemLanguage } from '/app/redux/shell' import type { DropdownOption } from '@opentrons/components' import type { Dispatch } from '/app/redux/types' +type ArrayElement< + ArrayType extends readonly unknown[] +> = ArrayType extends ReadonlyArray ? ElementType : never + export function SystemLanguagePreferenceModal(): JSX.Element | null { const { i18n, t } = useTranslation(['app_settings', 'shared', 'branded']) const enableLocalization = useFeatureFlag('enableLocalization') @@ -83,13 +87,31 @@ export function SystemLanguagePreferenceModal(): JSX.Element | null { useEffect(() => { if (systemLanguage != null) { // prefer match entire locale, then match just language e.g. zh-Hant and zh-CN - const matchedSystemLanguageOption = - LANGUAGES.find(lng => lng.value === systemLanguage) ?? - LANGUAGES.find( - lng => - new Intl.Locale(lng.value).language === - new Intl.Locale(systemLanguage).language - ) + const matchSystemLanguage: () => ArrayElement< + typeof LANGUAGES + > | null = () => { + try { + return ( + LANGUAGES.find(lng => lng.value === systemLanguage) ?? + LANGUAGES.find( + lng => + new Intl.Locale(lng.value).language === + new Intl.Locale(systemLanguage).language + ) ?? + null + ) + } catch (error: unknown) { + // Sometimes the language that we get from the shell will not be something + // js i18n can understand. Specifically, some linux systems will have their + // locale set to "C" (https://www.gnu.org/software/libc/manual/html_node/Standard-Locales.html) + // and that will cause Intl.Locale to throw. In this case, we'll treat it as + // unset and fall back to our default. + console.log(`Failed to search languages: ${error}`) + return null + } + } + const matchedSystemLanguageOption = matchSystemLanguage() + if (matchedSystemLanguageOption != null) { // initial current option: set to detected system language setCurrentOption(matchedSystemLanguageOption) diff --git a/app/src/organisms/DropTipWizardFlows/DropTipWizardFlows.tsx b/app/src/organisms/DropTipWizardFlows/DropTipWizardFlows.tsx index b6b60315936..921e0fc04c3 100644 --- a/app/src/organisms/DropTipWizardFlows/DropTipWizardFlows.tsx +++ b/app/src/organisms/DropTipWizardFlows/DropTipWizardFlows.tsx @@ -68,9 +68,11 @@ export function DropTipWizardFlows( // after it closes. useEffect(() => { return () => { - dropTipWithTypeUtils.dropTipCommands.handleCleanUpAndClose() + if (issuedCommandsType === 'setup') { + void dropTipWithTypeUtils.dropTipCommands.handleCleanUpAndClose() + } } - }, []) + }, [issuedCommandsType]) return ( Promise moveToAddressableArea: ( addressableArea: AddressableAreaName, - stayAtHighestPossibleZ?: boolean + isPredefinedLocation: boolean // Is a predefined location in "choose location." ) => Promise handleJog: (axis: Axis, dir: Sign, step: StepSize) => void blowoutOrDropTip: ( @@ -102,60 +106,62 @@ export function useDropTipCommands({ const moveToAddressableArea = ( addressableArea: AddressableAreaName, - stayAtHighestPossibleZ = true // Generally false when moving to a waste chute or trash bin. + isPredefinedLocation: boolean ): Promise => { return new Promise((resolve, reject) => { - const addressableAreaFromConfig = getAddressableAreaFromConfig( - addressableArea, - deckConfig, - instrumentModelSpecs.channels, - robotType - ) - - if (addressableAreaFromConfig != null) { - const moveToAACommand = buildMoveToAACommand( - addressableAreaFromConfig, - pipetteId, - stayAtHighestPossibleZ - ) - return chainRunCommands( - isFlex - ? [ - ENGAGE_AXES, - UPDATE_ESTIMATORS_EXCEPT_PLUNGERS, - Z_HOME, - moveToAACommand, - ] - : [Z_HOME, moveToAACommand], - true - ) - .then((commandData: CommandData[]) => { - const error = commandData[0].data.error - if (error != null) { - setErrorDetails({ - runCommandError: error, - message: `Error moving to position: ${error.detail}`, - }) - } - }) - .then(resolve) - .catch(error => { - if ( - fixitCommandTypeUtils != null && - issuedCommandsType === 'fixit' - ) { - fixitCommandTypeUtils.errorOverrides.generalFailure() - } + Promise.resolve() + .then(() => { + const addressableAreaFromConfig = getAddressableAreaFromConfig( + addressableArea, + deckConfig, + instrumentModelSpecs.channels, + robotType + ) + + if (addressableAreaFromConfig == null) { + throw new Error('invalid addressable area.') + } - reject( - new Error(`Error issuing move to addressable area: ${error}`) - ) - }) - } else { - setErrorDetails({ - message: `Error moving to position: invalid addressable area.`, + const moveToAACommand = buildMoveToAACommand( + addressableAreaFromConfig, + pipetteId, + isPredefinedLocation, + issuedCommandsType + ) + + return chainRunCommands( + isFlex + ? [ + ENGAGE_AXES, + UPDATE_ESTIMATORS_EXCEPT_PLUNGERS, + Z_HOME, + moveToAACommand, + ] + : [Z_HOME, moveToAACommand], + false + ) + }) + .then((commandData: CommandData[]) => { + const error = commandData[0].data.error + if (error != null) { + // eslint-disable-next-line @typescript-eslint/no-throw-literal + throw error + } + resolve() + }) + .catch(error => { + if (fixitCommandTypeUtils != null && issuedCommandsType === 'fixit') { + fixitCommandTypeUtils.errorOverrides.generalFailure() + } else { + setErrorDetails({ + runCommandError: error, + message: error.detail + ? `Error moving to position: ${error.detail}` + : 'Error moving to position: invalid addressable area.', + }) + } + reject(error) }) - } }) } @@ -222,7 +228,7 @@ export function useDropTipCommands({ currentRoute === DT_ROUTES.BLOWOUT ? buildBlowoutCommands(instrumentModelSpecs, isFlex, pipetteId) : buildDropTipInPlaceCommand(isFlex, pipetteId), - true + false ) .then((commandData: CommandData[]) => { const error = commandData[0].data.error @@ -386,11 +392,16 @@ const buildBlowoutCommands = ( const buildMoveToAACommand = ( addressableAreaFromConfig: AddressableAreaName, pipetteId: string | null, - stayAtHighestPossibleZ: boolean + isPredefinedLocation: boolean, + commandType: IssuedCommandsType ): CreateCommand => { + // Always ensure the user does all the jogging if choosing a custom location on the deck. + const stayAtHighestPossibleZ = !isPredefinedLocation + // Because we can never be certain about which tip is attached outside a protocol run, always assume the most // conservative estimate, a 1000ul tip. - const zOffset = stayAtHighestPossibleZ ? 0 : 88 + const zOffset = commandType === 'setup' && !stayAtHighestPossibleZ ? 88 : 0 + return { commandType: 'moveToAddressableArea', params: { diff --git a/app/src/organisms/DropTipWizardFlows/steps/ChooseDeckLocation.tsx b/app/src/organisms/DropTipWizardFlows/steps/ChooseDeckLocation.tsx index 2519e1a6e0b..0bc5e6eaa67 100644 --- a/app/src/organisms/DropTipWizardFlows/steps/ChooseDeckLocation.tsx +++ b/app/src/organisms/DropTipWizardFlows/steps/ChooseDeckLocation.tsx @@ -36,7 +36,7 @@ export function ChooseDeckLocation({ )?.id if (deckSlot != null) { - void moveToAddressableArea(deckSlot).then(() => { + void moveToAddressableArea(deckSlot, false).then(() => { proceedWithConditionalClose() }) } diff --git a/app/src/organisms/DropTipWizardFlows/steps/ChooseLocation.tsx b/app/src/organisms/DropTipWizardFlows/steps/ChooseLocation.tsx index e53f0006bf0..1e5aadcafdf 100644 --- a/app/src/organisms/DropTipWizardFlows/steps/ChooseLocation.tsx +++ b/app/src/organisms/DropTipWizardFlows/steps/ChooseLocation.tsx @@ -16,8 +16,10 @@ import { import { BLOWOUT_SUCCESS, DROP_TIP_SUCCESS, DT_ROUTES } from '../constants' import { DropTipFooterButtons } from '../shared' +import type { FlattenSimpleInterpolation } from 'styled-components' import type { AddressableAreaName } from '@opentrons/shared-data' import type { + DropTipModalStyle, DropTipWizardContainerProps, ValidDropTipBlowoutLocation, } from '../types' @@ -32,6 +34,7 @@ interface ChooseLocationProps extends DropTipWizardContainerProps { } export function ChooseLocation({ + issuedCommandsType, dropTipCommandLocations, dropTipCommands, goBackRunValid, @@ -97,7 +100,7 @@ export function ChooseLocation({ toggleIsRobotPipetteMoving() void moveToAddressableArea( selectedLocation?.slotName as AddressableAreaName, - false + true ).then(() => { void blowoutOrDropTip(currentRoute, () => { const successStep = @@ -128,13 +131,7 @@ export function ChooseLocation({ } return ( - + { + return modalStyle === 'simple' + ? containerStyleSimple(numLocations) + : CONTAINER_STYLE_INTERVENTION +} + const CONTAINER_STYLE_BASE = ` overflow: ${OVERFLOW_AUTO}; flex-direction: ${DIRECTION_COLUMN}; @@ -181,12 +189,14 @@ const CONTAINER_STYLE_INTERVENTION = css` ${CONTAINER_STYLE_BASE} ` -const CONTAINER_STYLE_SIMPLE = css` +const containerStyleSimple = ( + numLocations: number +): FlattenSimpleInterpolation => css` ${CONTAINER_STYLE_BASE} justify-content: ${JUSTIFY_SPACE_BETWEEN}; @media ${RESPONSIVENESS.touchscreenMediaQuerySpecs} { - height: 80%; + height: ${numLocations >= 4 ? '80%' : '100%'}; flex-grow: 0; } ` diff --git a/app/src/organisms/ErrorRecoveryFlows/ErrorRecoveryWizard.tsx b/app/src/organisms/ErrorRecoveryFlows/ErrorRecoveryWizard.tsx index e1dd7c5add2..1c62471380d 100644 --- a/app/src/organisms/ErrorRecoveryFlows/ErrorRecoveryWizard.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/ErrorRecoveryWizard.tsx @@ -82,7 +82,7 @@ export function ErrorRecoveryWizard( recoveryCommands, routeUpdateActions, } = props - const errorKind = getErrorKind(failedCommand?.byRunRecord ?? null) + const errorKind = getErrorKind(failedCommand) useInitialPipetteHome({ hasLaunchedRecovery, diff --git a/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/__tests__/ManageTips.test.tsx b/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/__tests__/ManageTips.test.tsx index 39b3ab57256..941b19081c7 100644 --- a/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/__tests__/ManageTips.test.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/__tests__/ManageTips.test.tsx @@ -282,7 +282,7 @@ describe('useDropTipFlowUtils', () => { testingRender(result.current.copyOverrides.beforeBeginningTopText as any) - screen.getByText('First, do you need to blowout aspirated liquid?') + screen.getByText('First, do you need to blow out aspirated liquid?') testingRender(result.current.copyOverrides.tipDropCompleteBtnCopy as any) diff --git a/app/src/organisms/ErrorRecoveryFlows/RecoverySplash.tsx b/app/src/organisms/ErrorRecoveryFlows/RecoverySplash.tsx index c9006f5d552..7a17b443508 100644 --- a/app/src/organisms/ErrorRecoveryFlows/RecoverySplash.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/RecoverySplash.tsx @@ -85,7 +85,7 @@ export function RecoverySplash(props: RecoverySplashProps): JSX.Element | null { resumePausedRecovery, } = props const { t } = useTranslation('error_recovery') - const errorKind = getErrorKind(failedCommand?.byRunRecord ?? null) + const errorKind = getErrorKind(failedCommand) const title = useErrorName(errorKind) const { makeToast } = useToaster() diff --git a/app/src/organisms/ErrorRecoveryFlows/__fixtures__/index.ts b/app/src/organisms/ErrorRecoveryFlows/__fixtures__/index.ts index c79e270bbed..1a815b99c1e 100644 --- a/app/src/organisms/ErrorRecoveryFlows/__fixtures__/index.ts +++ b/app/src/organisms/ErrorRecoveryFlows/__fixtures__/index.ts @@ -53,7 +53,7 @@ export const mockPickUpTipLabware: LoadedLabware = { // TODO: jh(08-07-24): update the "byAnalysis" mockFailedCommand. export const mockRecoveryContentProps: RecoveryContentProps = { - failedCommandByRunRecord: mockFailedCommand, + unvalidatedFailedCommand: mockFailedCommand, failedCommand: { byRunRecord: mockFailedCommand, byAnalysis: mockFailedCommand, diff --git a/app/src/organisms/ErrorRecoveryFlows/__tests__/ErrorRecoveryFlows.test.tsx b/app/src/organisms/ErrorRecoveryFlows/__tests__/ErrorRecoveryFlows.test.tsx index d73d402585d..04719afca56 100644 --- a/app/src/organisms/ErrorRecoveryFlows/__tests__/ErrorRecoveryFlows.test.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/__tests__/ErrorRecoveryFlows.test.tsx @@ -146,7 +146,7 @@ describe('ErrorRecoveryFlows', () => { beforeEach(() => { props = { runStatus: RUN_STATUS_AWAITING_RECOVERY, - failedCommandByRunRecord: mockFailedCommand, + unvalidatedFailedCommand: mockFailedCommand, runId: 'MOCK_RUN_ID', protocolAnalysis: null, } diff --git a/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useCleanupRecoveryState.test.ts b/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useCleanupRecoveryState.test.ts index f7ba3682799..9f9628546cc 100644 --- a/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useCleanupRecoveryState.test.ts +++ b/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useCleanupRecoveryState.test.ts @@ -11,7 +11,7 @@ describe('useCleanupRecoveryState', () => { beforeEach(() => { mockSetRM = vi.fn() props = { - isTakeover: false, + isActiveUser: false, stashedMapRef: { current: { route: RECOVERY_MAP.MANUAL_FILL_AND_SKIP.ROUTE, @@ -22,7 +22,7 @@ describe('useCleanupRecoveryState', () => { } }) - it('does not modify state when isTakeover is false', () => { + it('does not modify state when user was never active', () => { renderHook(() => useCleanupRecoveryState(props)) expect(props.stashedMapRef.current).toEqual({ @@ -32,10 +32,26 @@ describe('useCleanupRecoveryState', () => { expect(mockSetRM).not.toHaveBeenCalled() }) - it('resets state when isTakeover is true', () => { - props.isTakeover = true + it('does not modify state when user becomes active', () => { + props.isActiveUser = true + renderHook(() => useCleanupRecoveryState(props)) + expect(props.stashedMapRef.current).toEqual({ + route: RECOVERY_MAP.MANUAL_FILL_AND_SKIP.ROUTE, + step: RECOVERY_MAP.MANUAL_FILL_AND_SKIP.STEPS.SKIP, + }) + expect(mockSetRM).not.toHaveBeenCalled() + }) + + it('resets state when user becomes inactive after being active', () => { + const { rerender } = renderHook( + ({ isActiveUser }) => useCleanupRecoveryState({ ...props, isActiveUser }), + { initialProps: { isActiveUser: true } } + ) + + rerender({ isActiveUser: false }) + expect(props.stashedMapRef.current).toBeNull() expect(mockSetRM).toHaveBeenCalledWith({ route: RECOVERY_MAP.OPTION_SELECTION.ROUTE, @@ -44,9 +60,13 @@ describe('useCleanupRecoveryState', () => { }) it('handles case when stashedMapRef.current is already null', () => { - props.isTakeover = true + const { rerender } = renderHook( + ({ isActiveUser }) => useCleanupRecoveryState({ ...props, isActiveUser }), + { initialProps: { isActiveUser: true } } + ) + props.stashedMapRef.current = null - renderHook(() => useCleanupRecoveryState(props)) + rerender({ isActiveUser: false }) expect(props.stashedMapRef.current).toBeNull() expect(mockSetRM).toHaveBeenCalledWith({ @@ -55,19 +75,21 @@ describe('useCleanupRecoveryState', () => { }) }) - it('does not reset state when isTakeover changes from true to false', () => { + it('does not reset state on subsequent inactive states', () => { const { rerender } = renderHook( - ({ isTakeover }) => useCleanupRecoveryState({ ...props, isTakeover }), - { initialProps: { isTakeover: true } } + ({ isActiveUser }) => useCleanupRecoveryState({ ...props, isActiveUser }), + { initialProps: { isActiveUser: true } } ) + rerender({ isActiveUser: false }) mockSetRM.mockClear() + props.stashedMapRef.current = { route: RECOVERY_MAP.MANUAL_FILL_AND_SKIP.ROUTE, step: RECOVERY_MAP.MANUAL_FILL_AND_SKIP.STEPS.SKIP, } - rerender({ isTakeover: false }) + rerender({ isActiveUser: false }) expect(props.stashedMapRef.current).toEqual({ route: RECOVERY_MAP.MANUAL_FILL_AND_SKIP.ROUTE, @@ -75,4 +97,25 @@ describe('useCleanupRecoveryState', () => { }) expect(mockSetRM).not.toHaveBeenCalled() }) + + it('resets state only after a full active->inactive cycle', () => { + const { rerender } = renderHook( + ({ isActiveUser }) => useCleanupRecoveryState({ ...props, isActiveUser }), + { initialProps: { isActiveUser: false } } + ) + + rerender({ isActiveUser: true }) + expect(mockSetRM).not.toHaveBeenCalled() + expect(props.stashedMapRef.current).toEqual({ + route: RECOVERY_MAP.MANUAL_FILL_AND_SKIP.ROUTE, + step: RECOVERY_MAP.MANUAL_FILL_AND_SKIP.STEPS.SKIP, + }) + + rerender({ isActiveUser: false }) + expect(props.stashedMapRef.current).toBeNull() + expect(mockSetRM).toHaveBeenCalledWith({ + route: RECOVERY_MAP.OPTION_SELECTION.ROUTE, + step: RECOVERY_MAP.OPTION_SELECTION.STEPS.SELECT, + }) + }) }) diff --git a/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useDeckMapUtils.test.ts b/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useDeckMapUtils.test.ts index 7e51669bd9a..1a6d07ba634 100644 --- a/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useDeckMapUtils.test.ts +++ b/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useDeckMapUtils.test.ts @@ -9,6 +9,7 @@ import { } from '@opentrons/shared-data' import { mockPickUpTipLabware } from '../../__fixtures__' +import { getLabwareLocation } from '/app/local-resources/labware' import { getIsLabwareMatch, getSlotNameAndLwLocFrom, @@ -16,6 +17,7 @@ import { getRunCurrentModulesInfo, getRunCurrentLabwareOnDeck, getRunCurrentModulesOnDeck, + updateLabwareInModules, } from '../useDeckMapUtils' import type { LabwareDefinition2 } from '@opentrons/shared-data' @@ -29,6 +31,7 @@ vi.mock('@opentrons/shared-data', async importOriginal => { getModuleDef2: vi.fn(), } }) +vi.mock('/app/local-resources/labware') describe('getRunCurrentModulesOnDeck', () => { const mockLabwareDef: LabwareDefinition2 = { @@ -49,12 +52,13 @@ describe('getRunCurrentModulesOnDeck', () => { moduleDef: mockModuleDef, slotName: 'A1', nestedLabwareDef: mockLabwareDef, - nestedLabwareSlotName: 'MOCK_MODULE_ID', + nestedLabwareSlotName: 'A1', }, ] beforeEach(() => { vi.mocked(getModuleDef2).mockReturnValue({ model: 'MOCK_MODEL' } as any) + vi.mocked(getLabwareLocation).mockReturnValue({ slotName: 'A1' }) }) it('should return an array of RunCurrentModulesOnDeck objects', () => { @@ -64,9 +68,11 @@ describe('getRunCurrentModulesOnDeck', () => { location: { moduleId: 'MOCK_MODULE_ID' }, }, } as any + vi.mocked(getLabwareLocation).mockReturnValue({ slotName: 'A1' }) const result = getRunCurrentModulesOnDeck({ failedLabwareUtils: mockPickUpTipLabwareSameSlot, + runRecord: {} as any, currentModulesInfo: mockCurrentModulesInfo, }) @@ -76,13 +82,14 @@ describe('getRunCurrentModulesOnDeck', () => { moduleLocation: { slotName: 'A1' }, innerProps: {}, nestedLabwareDef: mockLabwareDef, - highlight: 'MOCK_MODULE_ID', + highlight: 'A1', }, ]) }) it('should set highlight to null if getIsLabwareMatch returns false', () => { const result = getRunCurrentModulesOnDeck({ failedLabwareUtils: mockFailedLabwareUtils, + runRecord: {} as any, currentModulesInfo: [ { ...mockCurrentModulesInfo[0], @@ -95,8 +102,11 @@ describe('getRunCurrentModulesOnDeck', () => { }) it('should set highlight to null if nestedLabwareDef is null', () => { + vi.mocked(getLabwareLocation).mockReturnValue(null) + const result = getRunCurrentModulesOnDeck({ failedLabwareUtils: mockFailedLabwareUtils, + runRecord: {} as any, currentModulesInfo: [ { ...mockCurrentModulesInfo[0], nestedLabwareDef: null }, ], @@ -126,8 +136,10 @@ describe('getRunCurrentLabwareOnDeck', () => { } as any it('should return a valid RunCurrentLabwareOnDeck with a labware highlight if the labware is the pickUpTipLabware', () => { + vi.mocked(getLabwareLocation).mockReturnValue({ slotName: 'A1' }) const result = getRunCurrentLabwareOnDeck({ currentLabwareInfo: [mockCurrentLabwareInfo], + runRecord: {} as any, failedLabwareUtils: mockFailedLabwareUtils, }) @@ -141,6 +153,7 @@ describe('getRunCurrentLabwareOnDeck', () => { }) it('should set highlight to null if getIsLabwareMatch returns false', () => { + vi.mocked(getLabwareLocation).mockReturnValue(null) const result = getRunCurrentLabwareOnDeck({ failedLabwareUtils: { ...mockFailedLabwareUtils, @@ -149,6 +162,7 @@ describe('getRunCurrentLabwareOnDeck', () => { location: { slotName: 'B1' }, }, }, + runRecord: {} as any, currentLabwareInfo: [mockCurrentLabwareInfo], }) @@ -201,6 +215,7 @@ describe('getRunCurrentModulesInfo', () => { }) it('should return an array of RunCurrentModuleInfo objects for each module in runRecord.data.modules', () => { + vi.mocked(getLabwareLocation).mockReturnValue({ slotName: 'A1' }) const result = getRunCurrentModulesInfo({ runRecord: mockRunRecord, deckDef: mockDeckDef, @@ -214,7 +229,7 @@ describe('getRunCurrentModulesInfo', () => { moduleId: mockModule.id, moduleDef: 'MOCK_MODULE_DEF', nestedLabwareDef: 'MOCK_LW_DEF', - nestedLabwareSlotName: 'MOCK_MODULE_ID', + nestedLabwareSlotName: 'A1', slotName: mockModule.location.slotName, }, ]) @@ -311,46 +326,64 @@ describe('getRunCurrentLabwareInfo', () => { describe('getSlotNameAndLwLocFrom', () => { it('should return [null, null] if location is null', () => { - const result = getSlotNameAndLwLocFrom(null, false) + const result = getSlotNameAndLwLocFrom(null, {} as any, false) expect(result).toEqual([null, null]) }) it('should return [null, null] if location is "offDeck"', () => { - const result = getSlotNameAndLwLocFrom('offDeck', false) + const result = getSlotNameAndLwLocFrom('offDeck', {} as any, false) expect(result).toEqual([null, null]) }) it('should return [null, null] if location has a moduleId and excludeModules is true', () => { - const result = getSlotNameAndLwLocFrom({ moduleId: 'MOCK_MODULE_ID' }, true) + const result = getSlotNameAndLwLocFrom( + { moduleId: 'MOCK_MODULE_ID' }, + {} as any, + true + ) expect(result).toEqual([null, null]) }) - it('should return [moduleId, { moduleId }] if location has a moduleId and excludeModules is false', () => { + it('should return [baseSlot, { moduleId }] if location has a moduleId and excludeModules is false', () => { + vi.mocked(getLabwareLocation).mockReturnValue({ slotName: 'A1' }) const result = getSlotNameAndLwLocFrom( { moduleId: 'MOCK_MODULE_ID' }, + {} as any, false ) - expect(result).toEqual(['MOCK_MODULE_ID', { moduleId: 'MOCK_MODULE_ID' }]) + expect(result).toEqual(['A1', { moduleId: 'MOCK_MODULE_ID' }]) }) - it('should return [labwareId, { labwareId }] if location has a labwareId', () => { - const result = getSlotNameAndLwLocFrom({ labwareId: 'MOCK_LW_ID' }, false) - expect(result).toEqual(['MOCK_LW_ID', { labwareId: 'MOCK_LW_ID' }]) + it('should return [baseSlot, { labwareId }] if location has a labwareId', () => { + vi.mocked(getLabwareLocation).mockReturnValue({ slotName: 'A1' }) + const result = getSlotNameAndLwLocFrom( + { labwareId: 'MOCK_LW_ID' }, + {} as any, + false + ) + expect(result).toEqual(['A1', { labwareId: 'MOCK_LW_ID' }]) }) it('should return [addressableAreaName, { addressableAreaName }] if location has an addressableAreaName', () => { - const result = getSlotNameAndLwLocFrom({ addressableAreaName: 'A1' }, false) + vi.mocked(getLabwareLocation).mockReturnValue({ slotName: 'A1' }) + const result = getSlotNameAndLwLocFrom( + { addressableAreaName: 'A1' }, + {} as any, + false + ) expect(result).toEqual(['A1', { addressableAreaName: 'A1' }]) }) it('should return [slotName, { slotName }] if location has a slotName', () => { - const result = getSlotNameAndLwLocFrom({ slotName: 'A1' }, false) + vi.mocked(getLabwareLocation).mockReturnValue({ slotName: 'A1' }) + const result = getSlotNameAndLwLocFrom({ slotName: 'A1' }, {} as any, false) expect(result).toEqual(['A1', { slotName: 'A1' }]) }) it('should return [null, null] if location does not match any known location type', () => { const result = getSlotNameAndLwLocFrom( { unknownProperty: 'MOCK_VALUE' } as any, + {} as any, false ) expect(result).toEqual([null, null]) @@ -358,57 +391,90 @@ describe('getSlotNameAndLwLocFrom', () => { }) describe('getIsLabwareMatch', () => { + beforeEach(() => { + vi.mocked(getLabwareLocation).mockReturnValue(null) + }) + it('should return false if pickUpTipLabware is null', () => { - const result = getIsLabwareMatch('A1', null) + const result = getIsLabwareMatch('A1', {} as any, null) expect(result).toBe(false) }) it('should return false if pickUpTipLabware location is a string', () => { - const result = getIsLabwareMatch('offdeck', { location: 'offdeck' } as any) + const result = getIsLabwareMatch( + 'offdeck', + {} as any, + { location: 'offdeck' } as any + ) expect(result).toBe(false) }) it('should return false if pickUpTipLabware location has a moduleId', () => { - const result = getIsLabwareMatch('A1', { - location: { moduleId: 'MOCK_MODULE_ID' }, - } as any) + const result = getIsLabwareMatch( + 'A1', + {} as any, + { + location: { moduleId: 'MOCK_MODULE_ID' }, + } as any + ) expect(result).toBe(false) }) it('should return true if pickUpTipLabware location slotName matches the provided slotName', () => { - const result = getIsLabwareMatch('A1', { - location: { slotName: 'A1' }, - } as any) + vi.mocked(getLabwareLocation).mockReturnValue({ slotName: 'A1' }) + const result = getIsLabwareMatch( + 'A1', + {} as any, + { + location: { slotName: 'A1' }, + } as any + ) expect(result).toBe(true) }) it('should return false if pickUpTipLabware location slotName does not match the provided slotName', () => { - const result = getIsLabwareMatch('A1', { - location: { slotName: 'A2' }, - } as any) + const result = getIsLabwareMatch( + 'A1', + {} as any, + { + location: { slotName: 'A2' }, + } as any + ) expect(result).toBe(false) }) it('should return true if pickUpTipLabware location labwareId matches the provided slotName', () => { - const result = getIsLabwareMatch('lwId', { - location: { labwareId: 'lwId' }, - } as any) + vi.mocked(getLabwareLocation).mockReturnValue({ slotName: 'C1' }) + + const result = getIsLabwareMatch( + 'C1', + {} as any, + { + location: { labwareId: 'lwId' }, + } as any + ) expect(result).toBe(true) }) it('should return false if pickUpTipLabware location labwareId does not match the provided slotName', () => { - const result = getIsLabwareMatch('lwId', { - location: { labwareId: 'lwId2' }, - } as any) + const result = getIsLabwareMatch( + 'lwId', + {} as any, + { + location: { labwareId: 'lwId2' }, + } as any + ) expect(result).toBe(false) }) it('should return true if pickUpTipLabware location addressableAreaName matches the provided slotName', () => { + vi.mocked(getLabwareLocation).mockReturnValue({ slotName: 'B1' }) + const slotName = 'B1' const pickUpTipLabware = { location: { addressableAreaName: 'B1' }, } as any - const result = getIsLabwareMatch(slotName, pickUpTipLabware) + const result = getIsLabwareMatch(slotName, {} as any, pickUpTipLabware) expect(result).toBe(true) }) @@ -417,7 +483,7 @@ describe('getIsLabwareMatch', () => { const pickUpTipLabware = { location: { addressableAreaName: 'B2' }, } as any - const result = getIsLabwareMatch(slotName, pickUpTipLabware) + const result = getIsLabwareMatch(slotName, {} as any, pickUpTipLabware) expect(result).toBe(false) }) @@ -426,7 +492,165 @@ describe('getIsLabwareMatch', () => { const pickUpTipLabware = { location: { unknownProperty: 'someValue' }, } as any - const result = getIsLabwareMatch(slotName, pickUpTipLabware) + const result = getIsLabwareMatch(slotName, {} as any, pickUpTipLabware) expect(result).toBe(false) }) }) + +describe('updateLabwareInModules', () => { + const mockLabwareDef: LabwareDefinition2 = { + ...(fixture96Plate as LabwareDefinition2), + metadata: { + displayName: 'Mock Labware Definition', + displayCategory: 'wellPlate', + displayVolumeUnits: 'mL', + }, + } + + const mockModule = { + moduleModel: 'temperatureModuleV2', + moduleLocation: { slotName: 'A1' }, + innerProps: {}, + nestedLabwareDef: null, + highlight: null, + } as any + + const mockLabware = { + labwareDef: mockLabwareDef, + labwareLocation: { slotName: 'A1' }, + slotName: 'A1', + } + + it('should update module with nested labware when they share the same slot', () => { + const result = updateLabwareInModules({ + runCurrentModules: [mockModule], + currentLabwareInfo: [mockLabware], + }) + + expect(result.updatedModules).toEqual([ + { + ...mockModule, + nestedLabwareDef: mockLabwareDef, + }, + ]) + expect(result.remainingLabware).toEqual([]) + }) + + it('should keep labware separate when slots do not match', () => { + const labwareInDifferentSlot = { + ...mockLabware, + labwareLocation: { slotName: 'B1' }, + slotName: 'B1', + } + + const result = updateLabwareInModules({ + runCurrentModules: [mockModule], + currentLabwareInfo: [labwareInDifferentSlot], + }) + + expect(result.updatedModules).toEqual([mockModule]) + expect(result.remainingLabware).toEqual([labwareInDifferentSlot]) + }) + + it('should handle multiple modules and labware', () => { + const mockModuleB1 = { + ...mockModule, + moduleLocation: { slotName: 'B1' }, + } + + const labwareB1 = { + ...mockLabware, + labwareLocation: { slotName: 'B1' }, + slotName: 'B1', + } + + const labwareC1 = { + ...mockLabware, + labwareLocation: { slotName: 'C1' }, + slotName: 'C1', + } + + const result = updateLabwareInModules({ + runCurrentModules: [mockModule, mockModuleB1], + currentLabwareInfo: [mockLabware, labwareB1, labwareC1], + }) + + expect(result.updatedModules).toEqual([ + { + ...mockModule, + nestedLabwareDef: mockLabwareDef, + }, + { + ...mockModuleB1, + nestedLabwareDef: mockLabwareDef, + }, + ]) + expect(result.remainingLabware).toEqual([labwareC1]) + }) + + it('should handle empty modules array', () => { + const result = updateLabwareInModules({ + runCurrentModules: [], + currentLabwareInfo: [mockLabware], + }) + + expect(result.updatedModules).toEqual([]) + expect(result.remainingLabware).toEqual([mockLabware]) + }) + + it('should handle empty labware array', () => { + const result = updateLabwareInModules({ + runCurrentModules: [mockModule], + currentLabwareInfo: [], + }) + + expect(result.updatedModules).toEqual([mockModule]) + expect(result.remainingLabware).toEqual([]) + }) + + it('should handle multiple labware in same slot, nesting only one with module', () => { + const labwareA1Second = { + ...mockLabware, + labwareDef: { + ...mockLabwareDef, + metadata: { + ...mockLabwareDef.metadata, + displayName: 'Second Labware', + }, + }, + } + + const result = updateLabwareInModules({ + runCurrentModules: [mockModule], + currentLabwareInfo: [mockLabware, labwareA1Second], + }) + + expect(result.updatedModules).toEqual([ + { + ...mockModule, + nestedLabwareDef: mockLabwareDef, + }, + ]) + expect(result.remainingLabware).toEqual([]) + }) + + it('should preserve module properties when updating with nested labware', () => { + const moduleWithProperties = { + ...mockModule, + innerProps: { lidMotorState: 'open' }, + highlight: 'someHighlight', + } + + const result = updateLabwareInModules({ + runCurrentModules: [moduleWithProperties], + currentLabwareInfo: [mockLabware], + }) + + expect(result.updatedModules).toEqual([ + { + ...moduleWithProperties, + nestedLabwareDef: mockLabwareDef, + }, + ]) + }) +}) diff --git a/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useFailedLabwareUtils.test.tsx b/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useFailedLabwareUtils.test.tsx index a24afb09b29..f8559163adb 100644 --- a/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useFailedLabwareUtils.test.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useFailedLabwareUtils.test.tsx @@ -7,7 +7,6 @@ import { getRelevantWellName, getRelevantFailedLabwareCmdFrom, useRelevantFailedLwLocations, - useInitialSelectedLocationsFrom, } from '../useFailedLabwareUtils' import { DEFINED_ERROR_TYPES } from '../../constants' @@ -93,7 +92,7 @@ describe('getRelevantFailedLabwareCmdFrom', () => { }, } const result = getRelevantFailedLabwareCmdFrom({ - failedCommandByRunRecord: failedLiquidProbeCommand, + failedCommand: { byRunRecord: failedLiquidProbeCommand } as any, }) expect(result).toEqual(failedLiquidProbeCommand) }) @@ -118,11 +117,13 @@ describe('getRelevantFailedLabwareCmdFrom', () => { overpressureErrorKinds.forEach(([commandType, errorType]) => { const result = getRelevantFailedLabwareCmdFrom({ - failedCommandByRunRecord: { - ...failedCommand, - commandType, - error: { isDefined: true, errorType }, - }, + failedCommand: { + byRunRecord: { + ...failedCommand, + commandType, + error: { isDefined: true, errorType }, + }, + } as any, runCommands, }) expect(result).toBe(pickUpTipCommand) @@ -139,27 +140,33 @@ describe('getRelevantFailedLabwareCmdFrom', () => { }, } const result = getRelevantFailedLabwareCmdFrom({ - failedCommandByRunRecord: failedGripperCommand, + failedCommand: { byRunRecord: failedGripperCommand } as any, }) expect(result).toEqual(failedGripperCommand) }) it('should return null for GENERAL_ERROR error kind', () => { const result = getRelevantFailedLabwareCmdFrom({ - failedCommandByRunRecord: { - ...failedCommand, - error: { errorType: 'literally anything else' }, - }, + failedCommand: { + byRunRecord: { + ...failedCommand, + error: { + errorType: 'literally anything else', + }, + }, + } as any, }) expect(result).toBeNull() }) it('should return null for unhandled error kinds', () => { const result = getRelevantFailedLabwareCmdFrom({ - failedCommandByRunRecord: { - ...failedCommand, - error: { errorType: 'SOME_UNHANDLED_ERROR' }, - }, + failedCommand: { + byRunRecord: { + ...failedCommand, + error: { errorType: 'SOME_UNHANDLED_ERROR' }, + }, + } as any, }) expect(result).toBeNull() }) @@ -242,22 +249,3 @@ describe('useRelevantFailedLwLocations', () => { expect(result.current.newLoc).toStrictEqual({ slotName: 'C2' }) }) }) - -describe('useInitialSelectedLocationsFrom', () => { - it('updates result if the relevant command changes', () => { - const cmd = { commandType: 'pickUpTip', params: { wellName: 'A1' } } as any - const cmd2 = { commandType: 'pickUpTip', params: { wellName: 'A2' } } as any - - const { result, rerender } = renderHook((cmd: any) => - useInitialSelectedLocationsFrom(cmd) - ) - - rerender(cmd) - - expect(result.current).toStrictEqual({ A1: null }) - - rerender(cmd2) - - expect(result.current).toStrictEqual({ A2: null }) - }) -}) diff --git a/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useHomeGripperZAxis.test.ts b/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useHomeGripper.test.ts similarity index 77% rename from app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useHomeGripperZAxis.test.ts rename to app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useHomeGripper.test.ts index 32f5d939eb8..32de0f0096d 100644 --- a/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useHomeGripperZAxis.test.ts +++ b/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useHomeGripper.test.ts @@ -6,9 +6,7 @@ import { RECOVERY_MAP } from '/app/organisms/ErrorRecoveryFlows/constants' describe('useHomeGripper', () => { const mockRecoveryCommands = { - updatePositionEstimatorsAndHomeGripper: vi - .fn() - .mockResolvedValue(undefined), + homeExceptPlungers: vi.fn().mockResolvedValue(undefined), } const mockRouteUpdateActions = { @@ -45,9 +43,7 @@ describe('useHomeGripper', () => { expect(mockRouteUpdateActions.handleMotionRouting).toHaveBeenCalledWith( true ) - expect( - mockRecoveryCommands.updatePositionEstimatorsAndHomeGripper - ).toHaveBeenCalled() + expect(mockRecoveryCommands.homeExceptPlungers).toHaveBeenCalled() expect(mockRouteUpdateActions.handleMotionRouting).toHaveBeenCalledWith( false ) @@ -64,9 +60,7 @@ describe('useHomeGripper', () => { }) expect(mockRouteUpdateActions.goBackPrevStep).toHaveBeenCalled() - expect( - mockRecoveryCommands.updatePositionEstimatorsAndHomeGripper - ).not.toHaveBeenCalled() + expect(mockRecoveryCommands.homeExceptPlungers).not.toHaveBeenCalled() }) it('should not home again if already homed once', async () => { @@ -83,18 +77,14 @@ describe('useHomeGripper', () => { await new Promise(resolve => setTimeout(resolve, 0)) }) - expect( - mockRecoveryCommands.updatePositionEstimatorsAndHomeGripper - ).toHaveBeenCalledTimes(1) + expect(mockRecoveryCommands.homeExceptPlungers).toHaveBeenCalledTimes(1) rerender() - expect( - mockRecoveryCommands.updatePositionEstimatorsAndHomeGripper - ).toHaveBeenCalledTimes(1) + expect(mockRecoveryCommands.homeExceptPlungers).toHaveBeenCalledTimes(1) }) - it('should reset hasHomedOnce when step changes to non-manual gripper step and back', async () => { + it('should only reset hasHomedOnce when step changes to non-manual gripper step', async () => { const { rerender } = renderHook( ({ recoveryMap }) => { useHomeGripper({ @@ -113,9 +103,7 @@ describe('useHomeGripper', () => { await new Promise(resolve => setTimeout(resolve, 0)) }) - expect( - mockRecoveryCommands.updatePositionEstimatorsAndHomeGripper - ).toHaveBeenCalledTimes(1) + expect(mockRecoveryCommands.homeExceptPlungers).toHaveBeenCalledTimes(1) rerender({ recoveryMap: { step: 'SOME_OTHER_STEP' } as any }) @@ -123,14 +111,14 @@ describe('useHomeGripper', () => { await new Promise(resolve => setTimeout(resolve, 0)) }) + expect(mockRecoveryCommands.homeExceptPlungers).toHaveBeenCalledTimes(1) + rerender({ recoveryMap: mockRecoveryMap }) await act(async () => { await new Promise(resolve => setTimeout(resolve, 0)) }) - expect( - mockRecoveryCommands.updatePositionEstimatorsAndHomeGripper - ).toHaveBeenCalledTimes(2) + expect(mockRecoveryCommands.homeExceptPlungers).toHaveBeenCalledTimes(1) }) }) diff --git a/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useRecoveryCommands.test.ts b/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useRecoveryCommands.test.ts index 4079e8a8f1e..ca2e086d9fd 100644 --- a/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useRecoveryCommands.test.ts +++ b/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useRecoveryCommands.test.ts @@ -16,8 +16,7 @@ import { buildPickUpTips, buildIgnorePolicyRules, isAssumeFalsePositiveResumeKind, - UPDATE_ESTIMATORS_EXCEPT_PLUNGERS, - HOME_GRIPPER_Z, + HOME_EXCEPT_PLUNGERS, } from '../useRecoveryCommands' import { RECOVERY_MAP, ERROR_KINDS } from '../../constants' import { getErrorKind } from '/app/organisms/ErrorRecoveryFlows/utils' @@ -56,7 +55,11 @@ describe('useRecoveryCommands', () => { const props = { runId: mockRunId, - failedCommandByRunRecord: mockFailedCommand, + failedCommand: { + byRunRecord: mockFailedCommand, + byAnalysis: mockFailedCommand, + }, + unvalidatedFailedCommand: mockFailedCommand, failedLabwareUtils: mockFailedLabwareUtils, routeUpdateActions: mockRouteUpdateActions, recoveryToastUtils: { makeSuccessToast: mockMakeSuccessToast } as any, @@ -144,24 +147,29 @@ describe('useRecoveryCommands', () => { 'prepareToAspirate', ] as const).forEach(inPlaceCommandType => { it(`Should move to retryLocation if failed command is ${inPlaceCommandType} and error is appropriate when retrying`, async () => { - const { result } = renderHook(() => - useRecoveryCommands({ - runId: mockRunId, - failedCommandByRunRecord: { - ...mockFailedCommand, - commandType: inPlaceCommandType, - params: { - pipetteId: 'mock-pipette-id', - }, - error: { - errorType: 'overpressure', - errorCode: '3006', - isDefined: true, - errorInfo: { - retryLocation: [1, 2, 3], - }, + const { result } = renderHook(() => { + const failedCommand = { + ...mockFailedCommand, + commandType: inPlaceCommandType, + params: { + pipetteId: 'mock-pipette-id', + }, + error: { + errorType: 'overpressure', + errorCode: '3006', + isDefined: true, + errorInfo: { + retryLocation: [1, 2, 3], }, }, + } + return useRecoveryCommands({ + runId: mockRunId, + failedCommand: { + byRunRecord: failedCommand, + byAnalysis: failedCommand, + }, + unvalidatedFailedCommand: failedCommand, failedLabwareUtils: mockFailedLabwareUtils, routeUpdateActions: mockRouteUpdateActions, recoveryToastUtils: {} as any, @@ -171,7 +179,7 @@ describe('useRecoveryCommands', () => { } as any, selectedRecoveryOption: RECOVERY_MAP.RETRY_NEW_TIPS.ROUTE, }) - ) + }) await act(async () => { await result.current.retryFailedCommand() }) @@ -245,7 +253,7 @@ describe('useRecoveryCommands', () => { const testProps = { ...props, - failedCommandByRunRecord: mockFailedCmdWithPipetteId, + unvalidatedFailedCommand: mockFailedCmdWithPipetteId, failedLabwareUtils: { ...mockFailedLabwareUtils, failedLabware: mockFailedLabware, @@ -281,11 +289,11 @@ describe('useRecoveryCommands', () => { const { result } = renderHook(() => useRecoveryCommands(props)) await act(async () => { - await result.current.updatePositionEstimatorsAndHomeGripper() + await result.current.homeExceptPlungers() }) expect(mockChainRunCommands).toHaveBeenCalledWith( - [UPDATE_ESTIMATORS_EXCEPT_PLUNGERS, HOME_GRIPPER_Z], + [HOME_EXCEPT_PLUNGERS], false ) }) @@ -312,7 +320,7 @@ describe('useRecoveryCommands', () => { const testProps = { ...props, - failedCommandByRunRecord: mockFailedCommandWithError, + unvalidatedFailedCommand: mockFailedCommandWithError, } const { result, rerender } = renderHook(() => @@ -349,7 +357,7 @@ describe('useRecoveryCommands', () => { const testProps = { ...props, - failedCommandByRunRecord: mockFailedCommandWithError, + unvalidatedFailedCommand: mockFailedCommandWithError, } mockUpdateErrorRecoveryPolicy.mockRejectedValueOnce( diff --git a/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useRecoveryToasts.test.tsx b/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useRecoveryToasts.test.tsx index c572618bbcc..c9874eb5532 100644 --- a/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useRecoveryToasts.test.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useRecoveryToasts.test.tsx @@ -30,7 +30,11 @@ let mockMakeToast: Mock const DEFAULT_PROPS: BuildToast = { isOnDevice: false, - currentStepCount: 1, + stepCounts: { + currentStepNumber: 1, + hasRunDiverged: false, + totalStepCount: 1, + }, selectedRecoveryOption: RECOVERY_MAP.RETRY_SAME_TIPS.ROUTE, commandTextData: { commands: [] } as any, robotType: FLEX_ROBOT_TYPE, @@ -187,13 +191,11 @@ describe('getStepNumber', () => { }) it('should handle a falsy currentStepCount', () => { - expect(getStepNumber(RECOVERY_MAP.RETRY_SAME_TIPS.ROUTE, null)).toBe('?') + expect(getStepNumber(RECOVERY_MAP.RETRY_SAME_TIPS.ROUTE, null)).toBe(null) }) it('should handle unknown recovery option', () => { - expect(getStepNumber('UNKNOWN_OPTION' as any, 3)).toBe( - 'HANDLE RECOVERY TOAST OPTION EXPLICITLY.' - ) + expect(getStepNumber('UNKNOWN_OPTION' as any, 3)).toBeNull() }) }) @@ -234,17 +236,17 @@ describe('useRecoveryFullCommandText', () => { expect(result.current).toBeNull() }) - it('should return stepNumber if it is a string', () => { + it('should return null if there is no current step count', () => { const { result } = renderHook(() => useRecoveryFullCommandText({ robotType: FLEX_ROBOT_TYPE, - stepNumber: '?', + stepNumber: null, commandTextData: { commands: [] } as any, allRunDefs: [], }) ) - expect(result.current).toBe('?') + expect(result.current).toBeNull() }) it('should truncate TC command', () => { diff --git a/app/src/organisms/ErrorRecoveryFlows/hooks/useCleanupRecoveryState.ts b/app/src/organisms/ErrorRecoveryFlows/hooks/useCleanupRecoveryState.ts index 3d01e2356c5..f6fd0eb15db 100644 --- a/app/src/organisms/ErrorRecoveryFlows/hooks/useCleanupRecoveryState.ts +++ b/app/src/organisms/ErrorRecoveryFlows/hooks/useCleanupRecoveryState.ts @@ -1,4 +1,4 @@ -import { useEffect } from 'react' +import { useState } from 'react' import { RECOVERY_MAP } from '../constants' @@ -9,25 +9,28 @@ import type { } from '../hooks' export interface UseCleanupProps { - isTakeover: ERUtilsProps['showTakeover'] + isActiveUser: ERUtilsProps['isActiveUser'] stashedMapRef: UseRouteUpdateActionsResult['stashedMapRef'] setRM: UseRecoveryRoutingResult['setRM'] } -// When certain events (ex, a takeover) occur, reset state that needs to be reset. +// When certain events (ex, someone terminates this app's recovery session) occur, reset state that needs to be reset. export function useCleanupRecoveryState({ - isTakeover, + isActiveUser, stashedMapRef, setRM, }: UseCleanupProps): void { - useEffect(() => { - if (isTakeover) { - stashedMapRef.current = null + const [wasActiveUser, setWasActiveUser] = useState(false) - setRM({ - route: RECOVERY_MAP.OPTION_SELECTION.ROUTE, - step: RECOVERY_MAP.OPTION_SELECTION.STEPS.SELECT, - }) - } - }, [isTakeover]) + if (isActiveUser && !wasActiveUser) { + setWasActiveUser(true) + } else if (!isActiveUser && wasActiveUser) { + setWasActiveUser(false) + + stashedMapRef.current = null + setRM({ + route: RECOVERY_MAP.OPTION_SELECTION.ROUTE, + step: RECOVERY_MAP.OPTION_SELECTION.STEPS.SELECT, + }) + } } diff --git a/app/src/organisms/ErrorRecoveryFlows/hooks/useDeckMapUtils.ts b/app/src/organisms/ErrorRecoveryFlows/hooks/useDeckMapUtils.ts index 06453d06d08..458747f5b07 100644 --- a/app/src/organisms/ErrorRecoveryFlows/hooks/useDeckMapUtils.ts +++ b/app/src/organisms/ErrorRecoveryFlows/hooks/useDeckMapUtils.ts @@ -1,6 +1,7 @@ import { useMemo } from 'react' import { + FLEX_ROBOT_TYPE, getDeckDefFromRobotType, getFixedTrashLabwareDefinition, getModuleDef2, @@ -14,6 +15,7 @@ import { getRunLabwareRenderInfo, getRunModuleRenderInfo, } from '/app/organisms/InterventionModal/utils' +import { getLabwareLocation } from '/app/local-resources/labware' import type { Run } from '@opentrons/api-client' import type { @@ -41,7 +43,7 @@ interface UseDeckMapUtilsProps { protocolAnalysis: ErrorRecoveryFlowsProps['protocolAnalysis'] failedLabwareUtils: UseFailedLabwareUtilsResult labwareDefinitionsByUri: ERUtilsProps['labwareDefinitionsByUri'] - runRecord?: Run + runRecord: Run | undefined } export interface UseDeckMapUtilsResult { @@ -69,6 +71,8 @@ export function useDeckMapUtils({ const deckConfig = getSimplestDeckConfigForProtocol(protocolAnalysis) const deckDef = getDeckDefFromRobotType(robotType) + // TODO(jh, 11-05-24): Revisit this logic along with deckmap interfaces after deck map redesign. + const currentModulesInfo = useMemo( () => getRunCurrentModulesInfo({ @@ -83,6 +87,7 @@ export function useDeckMapUtils({ () => getRunCurrentModulesOnDeck({ failedLabwareUtils, + runRecord, currentModulesInfo, }), [runId, protocolAnalysis, runRecord, deckDef, failedLabwareUtils] @@ -93,13 +98,19 @@ export function useDeckMapUtils({ [runRecord, labwareDefinitionsByUri] ) + const { updatedModules, remainingLabware } = useMemo( + () => updateLabwareInModules({ runCurrentModules, currentLabwareInfo }), + [runCurrentModules, currentLabwareInfo] + ) + const runCurrentLabware = useMemo( () => getRunCurrentLabwareOnDeck({ failedLabwareUtils, - currentLabwareInfo, + runRecord, + currentLabwareInfo: remainingLabware, }), - [runId, protocolAnalysis, runRecord, deckDef, failedLabwareUtils] + [failedLabwareUtils, currentLabwareInfo] ) const movedLabwareDef = @@ -133,7 +144,7 @@ export function useDeckMapUtils({ return { deckConfig, - modulesOnDeck: runCurrentModules.map( + modulesOnDeck: updatedModules.map( ({ moduleModel, moduleLocation, innerProps, nestedLabwareDef }) => ({ moduleModel, moduleLocation, @@ -145,7 +156,7 @@ export function useDeckMapUtils({ labwareLocation, definition, })), - highlightLabwareEventuallyIn: [...runCurrentModules, ...runCurrentLabware] + highlightLabwareEventuallyIn: [...updatedModules, ...runCurrentLabware] .map(el => el.highlight) .filter(maybeSlot => maybeSlot != null) as string[], kind: 'intervention', @@ -176,9 +187,11 @@ interface RunCurrentModulesOnDeck { // Builds the necessary module object expected by BaseDeck. export function getRunCurrentModulesOnDeck({ failedLabwareUtils, + runRecord, currentModulesInfo, }: { failedLabwareUtils: UseDeckMapUtilsProps['failedLabwareUtils'] + runRecord: UseDeckMapUtilsProps['runRecord'] currentModulesInfo: RunCurrentModuleInfo[] }): Array { const { failedLabware } = failedLabwareUtils @@ -193,7 +206,11 @@ export function getRunCurrentModulesOnDeck({ : {}, nestedLabwareDef, - highlight: getIsLabwareMatch(nestedLabwareSlotName, failedLabware) + highlight: getIsLabwareMatch( + nestedLabwareSlotName, + runRecord, + failedLabware + ) ? nestedLabwareSlotName : null, }) @@ -205,11 +222,15 @@ interface RunCurrentLabwareOnDeck { definition: LabwareDefinition2 } // Builds the necessary labware object expected by BaseDeck. +// Note that while this highlights all labware in the failed labware slot, the result is later filtered to render +// only the topmost labware. export function getRunCurrentLabwareOnDeck({ currentLabwareInfo, + runRecord, failedLabwareUtils, }: { failedLabwareUtils: UseDeckMapUtilsProps['failedLabwareUtils'] + runRecord: UseDeckMapUtilsProps['runRecord'] currentLabwareInfo: RunCurrentLabwareInfo[] }): Array { const { failedLabware } = failedLabwareUtils @@ -218,7 +239,9 @@ export function getRunCurrentLabwareOnDeck({ ({ slotName, labwareDef, labwareLocation }) => ({ labwareLocation, definition: labwareDef, - highlight: getIsLabwareMatch(slotName, failedLabware) ? slotName : null, + highlight: getIsLabwareMatch(slotName, runRecord, failedLabware) + ? slotName + : null, }) ) } @@ -267,7 +290,11 @@ export const getRunCurrentModulesInfo = ({ ) const nestedLwLoc = nestedLabware?.location ?? null - const [nestedLwSlotName] = getSlotNameAndLwLocFrom(nestedLwLoc, false) + const [nestedLwSlotName] = getSlotNameAndLwLocFrom( + nestedLwLoc, + runRecord, + false + ) if (slotPosition == null) { return acc @@ -306,24 +333,60 @@ export function getRunCurrentLabwareInfo({ if (runRecord == null || labwareDefinitionsByUri == null) { return [] } else { - return runRecord.data.labware.reduce((acc: RunCurrentLabwareInfo[], lw) => { - const loc = lw.location - const [slotName, labwareLocation] = getSlotNameAndLwLocFrom(loc, true) // Exclude modules since handled separately. - const labwareDef = getLabwareDefinition(lw, labwareDefinitionsByUri) - - if (slotName == null || labwareLocation == null) { - return acc - } else { - return [ - ...acc, - { - labwareDef, - slotName, - labwareLocation: labwareLocation, - }, - ] + const allLabware = runRecord.data.labware.reduce( + (acc: RunCurrentLabwareInfo[], lw) => { + const loc = lw.location + const [slotName, labwareLocation] = getSlotNameAndLwLocFrom( + loc, + runRecord, + true + ) // Exclude modules since handled separately. + const labwareDef = getLabwareDefinition(lw, labwareDefinitionsByUri) + + if (slotName == null || labwareLocation == null) { + return acc + } else { + return [ + ...acc, + { + labwareDef, + slotName, + labwareLocation: labwareLocation, + }, + ] + } + }, + [] + ) + + // Group labware by slotName + const labwareBySlot = allLabware.reduce< + Record + >((acc, labware) => { + const slot = labware.slotName + if (!acc[slot]) { + acc[slot] = [] } - }, []) + acc[slot].push(labware) + return acc + }, {}) + + // For each slot, return either: + // 1. The first labware with 'labwareId' in its location if it exists + // 2. The first labware in the slot if no labware has 'labwareId' + return Object.values(labwareBySlot).map(slotLabware => { + const labwareWithId = slotLabware.find( + lw => + typeof lw.labwareLocation !== 'string' && + 'labwareId' in lw.labwareLocation + ) + return labwareWithId != null + ? { + ...labwareWithId, + labwareLocation: { slotName: labwareWithId.slotName }, + } + : slotLabware[0] + }) } } @@ -341,8 +404,18 @@ const getLabwareDefinition = ( // Get the slotName for on deck labware. export function getSlotNameAndLwLocFrom( location: LabwareLocation | null, + runRecord: UseDeckMapUtilsProps['runRecord'], excludeModules: boolean ): [string | null, LabwareLocation | null] { + const baseSlot = + getLabwareLocation({ + location, + detailLevel: 'slot-only', + loadedLabwares: runRecord?.data?.labware ?? [], + loadedModules: runRecord?.data?.modules ?? [], + robotType: FLEX_ROBOT_TYPE, + })?.slotName ?? null + if (location == null || location === 'offDeck') { return [null, null] } else if ('moduleId' in location) { @@ -350,17 +423,17 @@ export function getSlotNameAndLwLocFrom( return [null, null] } else { const moduleId = location.moduleId - return [moduleId, { moduleId }] + return [baseSlot, { moduleId }] } } else if ('labwareId' in location) { const labwareId = location.labwareId - return [labwareId, { labwareId }] + return [baseSlot, { labwareId }] } else if ('addressableAreaName' in location) { const addressableAreaName = location.addressableAreaName - return [addressableAreaName, { addressableAreaName }] + return [baseSlot, { addressableAreaName }] } else if ('slotName' in location) { const slotName = location.slotName - return [slotName, { slotName }] + return [baseSlot, { slotName }] } else { return [null, null] } @@ -369,9 +442,19 @@ export function getSlotNameAndLwLocFrom( // Whether the slotName labware is the same as the pickUpTipLabware. export function getIsLabwareMatch( slotName: string, + runRecord: UseDeckMapUtilsProps['runRecord'], pickUpTipLabware: LoadedLabware | null ): boolean { - const location = pickUpTipLabware?.location + const location = pickUpTipLabware?.location ?? null + + const slotLocation = + getLabwareLocation({ + location, + detailLevel: 'slot-only', + loadedLabwares: runRecord?.data?.labware ?? [], + loadedModules: runRecord?.data?.modules ?? [], + robotType: FLEX_ROBOT_TYPE, + })?.slotName ?? null if (location == null) { return false @@ -379,13 +462,44 @@ export function getIsLabwareMatch( // This is the "off deck" case, which we do not render (and therefore return false). else if (typeof location === 'string') { return false - } else if ('moduleId' in location) { - return location.moduleId === slotName - } else if ('slotName' in location) { - return location.slotName === slotName - } else if ('labwareId' in location) { - return location.labwareId === slotName - } else if ('addressableAreaName' in location) { - return location.addressableAreaName === slotName - } else return false + } else { + return slotLocation === slotName + } +} + +// If any labware share a slot with a module, the labware should be nested within the module for rendering purposes. +// This prevents issues such as TC nested labware rendering in "B1" instead of the special-cased location. +export function updateLabwareInModules({ + runCurrentModules, + currentLabwareInfo, +}: { + runCurrentModules: ReturnType + currentLabwareInfo: ReturnType +}): { + updatedModules: ReturnType + remainingLabware: ReturnType +} { + const usedSlots = new Set() + + const updatedModules = runCurrentModules.map(moduleInfo => { + const labwareInSameLoc = currentLabwareInfo.find( + lw => moduleInfo.moduleLocation.slotName === lw.slotName + ) + + if (labwareInSameLoc != null) { + usedSlots.add(labwareInSameLoc.slotName) + return { + ...moduleInfo, + nestedLabwareDef: labwareInSameLoc.labwareDef, + } + } else { + return moduleInfo + } + }) + + const remainingLabware = currentLabwareInfo.filter( + lw => !usedSlots.has(lw.slotName) + ) + + return { updatedModules, remainingLabware } } diff --git a/app/src/organisms/ErrorRecoveryFlows/hooks/useERUtils.ts b/app/src/organisms/ErrorRecoveryFlows/hooks/useERUtils.ts index 57691a30e55..533b30aa6c4 100644 --- a/app/src/organisms/ErrorRecoveryFlows/hooks/useERUtils.ts +++ b/app/src/organisms/ErrorRecoveryFlows/hooks/useERUtils.ts @@ -50,7 +50,7 @@ export type ERUtilsProps = Omit & { isOnDevice: boolean robotType: RobotType failedCommand: ReturnType - showTakeover: boolean + isActiveUser: UseRecoveryTakeoverResult['isActiveUser'] allRunDefs: LabwareDefinition2[] labwareDefinitionsByUri: LabwareDefinitionsByUri | null } @@ -85,8 +85,9 @@ export function useERUtils({ isOnDevice, robotType, runStatus, - showTakeover, + isActiveUser, allRunDefs, + unvalidatedFailedCommand, labwareDefinitionsByUri, }: ERUtilsProps): ERUtilsResults { const { data: attachedInstruments } = useInstrumentsQuery() @@ -100,7 +101,6 @@ export function useERUtils({ cursor: 0, pageLength: 999, }) - const failedCommandByRunRecord = failedCommand?.byRunRecord ?? null const stepCounts = useRunningStepCounts(runId, runCommands) @@ -120,7 +120,7 @@ export function useERUtils({ ) const recoveryToastUtils = useRecoveryToasts({ - currentStepCount: stepCounts.currentStepNumber, + stepCounts, selectedRecoveryOption: currentRecoveryOptionUtils.selectedRecoveryOption, isOnDevice, commandTextData: protocolAnalysis, @@ -152,7 +152,7 @@ export function useERUtils({ }) const failedLabwareUtils = useFailedLabwareUtils({ - failedCommandByRunRecord, + failedCommand, protocolAnalysis, failedPipetteInfo, runRecord, @@ -161,7 +161,8 @@ export function useERUtils({ const recoveryCommands = useRecoveryCommands({ runId, - failedCommandByRunRecord, + failedCommand, + unvalidatedFailedCommand, failedLabwareUtils, routeUpdateActions, recoveryToastUtils, @@ -192,7 +193,7 @@ export function useERUtils({ ) useCleanupRecoveryState({ - isTakeover: showTakeover, + isActiveUser, setRM, stashedMapRef: routeUpdateActions.stashedMapRef, }) diff --git a/app/src/organisms/ErrorRecoveryFlows/hooks/useFailedLabwareUtils.ts b/app/src/organisms/ErrorRecoveryFlows/hooks/useFailedLabwareUtils.ts index 9ce04df1bdf..bc077d4c624 100644 --- a/app/src/organisms/ErrorRecoveryFlows/hooks/useFailedLabwareUtils.ts +++ b/app/src/organisms/ErrorRecoveryFlows/hooks/useFailedLabwareUtils.ts @@ -1,4 +1,4 @@ -import { useMemo, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import without from 'lodash/without' import { useTranslation } from 'react-i18next' @@ -28,12 +28,12 @@ import type { MoveLabwareRunTimeCommand, LabwareLocation, } from '@opentrons/shared-data' -import type { LabwareDisplayLocationSlotOnly } from '/app/local-resources/labware' +import type { DisplayLocationSlotOnlyParams } from '/app/local-resources/labware' import type { ErrorRecoveryFlowsProps } from '..' -import type { ERUtilsProps } from './useERUtils' +import type { FailedCommandBySource } from './useRetainedFailedCommandBySource' interface UseFailedLabwareUtilsProps { - failedCommandByRunRecord: ERUtilsProps['failedCommandByRunRecord'] + failedCommand: FailedCommandBySource | null protocolAnalysis: ErrorRecoveryFlowsProps['protocolAnalysis'] failedPipetteInfo: PipetteData | null runCommands?: CommandsData @@ -67,16 +67,17 @@ export type UseFailedLabwareUtilsResult = UseTipSelectionUtilsResult & { * For no liquid detected errors, the relevant labware is the well in which no liquid was detected. */ export function useFailedLabwareUtils({ - failedCommandByRunRecord, + failedCommand, protocolAnalysis, failedPipetteInfo, runCommands, runRecord, }: UseFailedLabwareUtilsProps): UseFailedLabwareUtilsResult { + const failedCommandByRunRecord = failedCommand?.byRunRecord ?? null const recentRelevantFailedLabwareCmd = useMemo( () => getRelevantFailedLabwareCmdFrom({ - failedCommandByRunRecord, + failedCommand, runCommands, }), [failedCommandByRunRecord?.key, runCommands?.meta.totalLength] @@ -129,16 +130,17 @@ type FailedCommandRelevantLabware = | null interface RelevantFailedLabwareCmd { - failedCommandByRunRecord: ErrorRecoveryFlowsProps['failedCommandByRunRecord'] + failedCommand: FailedCommandBySource | null runCommands?: CommandsData } // Return the actual command that contains the info relating to the relevant labware. export function getRelevantFailedLabwareCmdFrom({ - failedCommandByRunRecord, + failedCommand, runCommands, }: RelevantFailedLabwareCmd): FailedCommandRelevantLabware { - const errorKind = getErrorKind(failedCommandByRunRecord) + const failedCommandByRunRecord = failedCommand?.byRunRecord ?? null + const errorKind = getErrorKind(failedCommand) switch (errorKind) { case ERROR_KINDS.NO_LIQUID_DETECTED: @@ -161,7 +163,7 @@ export function getRelevantFailedLabwareCmdFrom({ // Returns the most recent pickUpTip command for the pipette used in the failed command, if any. function getRelevantPickUpTipCommand( - failedCommandByRunRecord: ErrorRecoveryFlowsProps['failedCommandByRunRecord'], + failedCommandByRunRecord: FailedCommandBySource['byRunRecord'] | null, runCommands?: CommandsData ): Omit | null { if ( @@ -211,14 +213,25 @@ function useTipSelectionUtils( ): UseTipSelectionUtilsResult { const [selectedLocs, setSelectedLocs] = useState(null) - const initialLocs = useInitialSelectedLocationsFrom( - recentRelevantFailedLabwareCmd - ) - - // Set the initial locs when they first become available or update. - if (selectedLocs !== initialLocs) { - setSelectedLocs(initialLocs) - } + // Note that while other commands may have a wellName associated with them, + // we are only interested in wells for the purposes of tip picking up. + // Support state updates if the underlying well data changes, since this data is lazily retrieved and may change shortly + // after Error Recovery launches. + const initialWellName = + recentRelevantFailedLabwareCmd != null && + recentRelevantFailedLabwareCmd.commandType === 'pickUpTip' + ? recentRelevantFailedLabwareCmd.params.wellName + : null + useEffect(() => { + if ( + recentRelevantFailedLabwareCmd != null && + recentRelevantFailedLabwareCmd.commandType === 'pickUpTip' + ) { + setSelectedLocs({ + [recentRelevantFailedLabwareCmd.params.wellName]: null, + }) + } + }, [initialWellName]) const deselectTips = (locations: string[]): void => { setSelectedLocs(prevLocs => @@ -253,28 +266,6 @@ function useTipSelectionUtils( } } -// Set the initial well selection to be the last pickup tip location for the pipette used in the failed command. -export function useInitialSelectedLocationsFrom( - recentRelevantFailedLabwareCmd: FailedCommandRelevantLabware -): WellGroup | null { - const [initialWells, setInitialWells] = useState(null) - - // Note that while other commands may have a wellName associated with them, - // we are only interested in wells for the purposes of tip picking up. - // Support state updates if the underlying data changes, since this data is lazily loaded and may change shortly - // after Error Recovery launches. - if ( - recentRelevantFailedLabwareCmd != null && - recentRelevantFailedLabwareCmd.commandType === 'pickUpTip' && - (initialWells == null || - !(recentRelevantFailedLabwareCmd.params.wellName in initialWells)) - ) { - setInitialWells({ [recentRelevantFailedLabwareCmd.params.wellName]: null }) - } - - return initialWells -} - // Get the name of the relevant labware relevant to the failed command, if any. export function getFailedCmdRelevantLabware( protocolAnalysis: ErrorRecoveryFlowsProps['protocolAnalysis'], @@ -344,9 +335,10 @@ export function getRelevantWellName( export type GetRelevantLwLocationsParams = Pick< UseFailedLabwareUtilsProps, - 'runRecord' | 'failedCommandByRunRecord' + 'runRecord' > & { failedLabware: UseFailedLabwareUtilsResult['failedLabware'] + failedCommandByRunRecord: FailedCommandBySource['byRunRecord'] | null } export function useRelevantFailedLwLocations({ @@ -356,10 +348,7 @@ export function useRelevantFailedLwLocations({ }: GetRelevantLwLocationsParams): RelevantFailedLabwareLocations { const { t } = useTranslation('protocol_command_text') - const BASE_DISPLAY_PARAMS: Omit< - LabwareDisplayLocationSlotOnly, - 'location' - > = { + const BASE_DISPLAY_PARAMS: Omit = { loadedLabwares: runRecord?.data?.labware ?? [], loadedModules: runRecord?.data?.modules ?? [], robotType: FLEX_ROBOT_TYPE, diff --git a/app/src/organisms/ErrorRecoveryFlows/hooks/useFailedPipetteUtils.ts b/app/src/organisms/ErrorRecoveryFlows/hooks/useFailedPipetteUtils.ts index f997592f8cd..5535cd46799 100644 --- a/app/src/organisms/ErrorRecoveryFlows/hooks/useFailedPipetteUtils.ts +++ b/app/src/organisms/ErrorRecoveryFlows/hooks/useFailedPipetteUtils.ts @@ -8,7 +8,7 @@ import type { Run, PipetteData, } from '@opentrons/api-client' -import type { ErrorRecoveryFlowsProps } from '/app/organisms/ErrorRecoveryFlows' +import type { FailedCommandBySource } from './useRetainedFailedCommandBySource' export interface UseFailedPipetteUtilsParams extends UseFailedCommandPipetteInfoProps { @@ -61,7 +61,7 @@ export function useFailedPipetteUtils( interface UseFailedCommandPipetteInfoProps { runRecord: Run | undefined attachedInstruments: Instruments | undefined - failedCommandByRunRecord: ErrorRecoveryFlowsProps['failedCommandByRunRecord'] + failedCommandByRunRecord: FailedCommandBySource['byRunRecord'] | null } // /instruments data for the pipette used in the failedCommand, if any. diff --git a/app/src/organisms/ErrorRecoveryFlows/hooks/useHomeGripper.ts b/app/src/organisms/ErrorRecoveryFlows/hooks/useHomeGripper.ts index b165e59ebd4..55fe64fdcc4 100644 --- a/app/src/organisms/ErrorRecoveryFlows/hooks/useHomeGripper.ts +++ b/app/src/organisms/ErrorRecoveryFlows/hooks/useHomeGripper.ts @@ -20,7 +20,7 @@ export function useHomeGripper({ useLayoutEffect(() => { const { handleMotionRouting, goBackPrevStep } = routeUpdateActions - const { updatePositionEstimatorsAndHomeGripper } = recoveryCommands + const { homeExceptPlungers } = recoveryCommands if (!hasHomedOnce) { if (isManualGripperStep) { @@ -28,17 +28,13 @@ export function useHomeGripper({ void goBackPrevStep() } else { void handleMotionRouting(true) - .then(() => updatePositionEstimatorsAndHomeGripper()) + .then(() => homeExceptPlungers()) + .then(() => handleMotionRouting(false)) .then(() => { setHasHomedOnce(true) }) - .finally(() => handleMotionRouting(false)) } } - } else { - if (!isManualGripperStep) { - setHasHomedOnce(false) - } } }, [step, hasHomedOnce, isDoorOpen, isManualGripperStep]) } diff --git a/app/src/organisms/ErrorRecoveryFlows/hooks/useRecoveryCommands.ts b/app/src/organisms/ErrorRecoveryFlows/hooks/useRecoveryCommands.ts index 7614dec4be3..4ce5194aca4 100644 --- a/app/src/organisms/ErrorRecoveryFlows/hooks/useRecoveryCommands.ts +++ b/app/src/organisms/ErrorRecoveryFlows/hooks/useRecoveryCommands.ts @@ -36,11 +36,13 @@ import type { UseRouteUpdateActionsResult } from './useRouteUpdateActions' import type { RecoveryToasts } from './useRecoveryToasts' import type { UseRecoveryAnalyticsResult } from '/app/redux-resources/analytics' import type { CurrentRecoveryOptionUtils } from './useRecoveryRouting' -import type { ErrorRecoveryFlowsProps } from '../index' +import type { ErrorRecoveryFlowsProps } from '..' +import type { FailedCommandBySource } from './useRetainedFailedCommandBySource' interface UseRecoveryCommandsParams { runId: string - failedCommandByRunRecord: ErrorRecoveryFlowsProps['failedCommandByRunRecord'] + failedCommand: FailedCommandBySource | null + unvalidatedFailedCommand: ErrorRecoveryFlowsProps['unvalidatedFailedCommand'] failedLabwareUtils: UseFailedLabwareUtilsResult routeUpdateActions: UseRouteUpdateActionsResult recoveryToastUtils: RecoveryToasts @@ -67,7 +69,7 @@ export interface UseRecoveryCommandsResult { /* A non-terminal recovery command */ releaseGripperJaws: () => Promise /* A non-terminal recovery command */ - updatePositionEstimatorsAndHomeGripper: () => Promise + homeExceptPlungers: () => Promise /* A non-terminal recovery command */ moveLabwareWithoutPause: () => Promise } @@ -76,7 +78,8 @@ export interface UseRecoveryCommandsResult { // Returns commands with a "fixit" intent. Commands may or may not terminate Error Recovery. See each command docstring for details. export function useRecoveryCommands({ runId, - failedCommandByRunRecord, + failedCommand, + unvalidatedFailedCommand, failedLabwareUtils, routeUpdateActions, recoveryToastUtils, @@ -88,7 +91,7 @@ export function useRecoveryCommands({ const { proceedToRouteAndStep } = routeUpdateActions const { chainRunCommands } = useChainRunCommands( runId, - failedCommandByRunRecord?.id + unvalidatedFailedCommand?.id ) const { mutateAsync: resumeRunFromRecovery, @@ -137,23 +140,23 @@ export function useRecoveryCommands({ IN_PLACE_COMMAND_TYPES.includes( (failedCommand as InPlaceCommand).commandType ) - return failedCommandByRunRecord != null - ? isInPlace(failedCommandByRunRecord) - ? failedCommandByRunRecord.error?.isDefined && - failedCommandByRunRecord.error?.errorType === 'overpressure' && + return unvalidatedFailedCommand != null + ? isInPlace(unvalidatedFailedCommand) + ? unvalidatedFailedCommand.error?.isDefined && + unvalidatedFailedCommand.error?.errorType === 'overpressure' && // Paranoia: this value comes from the wire and may be unevenly implemented - typeof failedCommandByRunRecord.error?.errorInfo?.retryLocation?.at( + typeof unvalidatedFailedCommand.error?.errorInfo?.retryLocation?.at( 0 ) === 'number' ? { commandType: 'moveToCoordinates', intent: 'fixit', params: { - pipetteId: failedCommandByRunRecord.params?.pipetteId, + pipetteId: unvalidatedFailedCommand.params?.pipetteId, coordinates: { - x: failedCommandByRunRecord.error.errorInfo.retryLocation[0], - y: failedCommandByRunRecord.error.errorInfo.retryLocation[1], - z: failedCommandByRunRecord.error.errorInfo.retryLocation[2], + x: unvalidatedFailedCommand.error.errorInfo.retryLocation[0], + y: unvalidatedFailedCommand.error.errorInfo.retryLocation[1], + z: unvalidatedFailedCommand.error.errorInfo.retryLocation[2], }, }, } @@ -163,7 +166,7 @@ export function useRecoveryCommands({ } const retryFailedCommand = useCallback((): Promise => { - const { commandType, params } = failedCommandByRunRecord as FailedCommand // Null case is handled before command could be issued. + const { commandType, params } = unvalidatedFailedCommand as FailedCommand // Null case is handled before command could be issued. return chainRunRecoveryCommands( [ // move back to the location of the command if it is an in-place command @@ -171,7 +174,7 @@ export function useRecoveryCommands({ { commandType, params }, // retry the command that failed ].filter(c => c != null) as CreateCommand[] ) // the created command is the same command that failed - }, [chainRunRecoveryCommands, failedCommandByRunRecord?.key]) + }, [chainRunRecoveryCommands, unvalidatedFailedCommand?.key]) // Homes the Z-axis of all attached pipettes. const homePipetteZAxes = useCallback((): Promise => { @@ -184,7 +187,7 @@ export function useRecoveryCommands({ const pickUpTipCmd = buildPickUpTips( selectedTipLocations, - failedCommandByRunRecord, + unvalidatedFailedCommand, failedLabware ) @@ -193,7 +196,7 @@ export function useRecoveryCommands({ } else { return chainRunRecoveryCommands([pickUpTipCmd]) } - }, [chainRunRecoveryCommands, failedCommandByRunRecord, failedLabwareUtils]) + }, [chainRunRecoveryCommands, unvalidatedFailedCommand, failedLabwareUtils]) const ignoreErrorKindThisRun = (ignoreErrors: boolean): Promise => { setIgnoreErrors(ignoreErrors) @@ -204,16 +207,16 @@ export function useRecoveryCommands({ // If the request to update the policy fails, route to the error modal. const handleIgnoringErrorKind = useCallback((): Promise => { if (ignoreErrors) { - if (failedCommandByRunRecord?.error != null) { + if (unvalidatedFailedCommand?.error != null) { const ifMatch: IfMatchType = isAssumeFalsePositiveResumeKind( - failedCommandByRunRecord + failedCommand ) ? 'assumeFalsePositiveAndContinue' : 'ignoreAndContinue' const ignorePolicyRules = buildIgnorePolicyRules( - failedCommandByRunRecord.commandType, - failedCommandByRunRecord.error.errorType, + unvalidatedFailedCommand.commandType, + unvalidatedFailedCommand.error.errorType, ifMatch ) @@ -232,8 +235,8 @@ export function useRecoveryCommands({ return Promise.resolve() } }, [ - failedCommandByRunRecord?.error?.errorType, - failedCommandByRunRecord?.commandType, + unvalidatedFailedCommand?.error?.errorType, + unvalidatedFailedCommand?.commandType, ignoreErrors, ]) @@ -262,7 +265,7 @@ export function useRecoveryCommands({ }, [runId]) const handleResumeAction = (): Promise => { - if (isAssumeFalsePositiveResumeKind(failedCommandByRunRecord)) { + if (isAssumeFalsePositiveResumeKind(failedCommand)) { return resumeRunFromRecoveryAssumingFalsePositive(runId) } else { return resumeRunFromRecovery(runId) @@ -291,25 +294,20 @@ export function useRecoveryCommands({ return chainRunRecoveryCommands([RELEASE_GRIPPER_JAW]) }, [chainRunRecoveryCommands]) - const updatePositionEstimatorsAndHomeGripper = useCallback((): Promise< - CommandData[] - > => { - return chainRunRecoveryCommands([ - UPDATE_ESTIMATORS_EXCEPT_PLUNGERS, - HOME_GRIPPER_Z, - ]) + const homeExceptPlungers = useCallback((): Promise => { + return chainRunRecoveryCommands([HOME_EXCEPT_PLUNGERS]) }, [chainRunRecoveryCommands]) const moveLabwareWithoutPause = useCallback((): Promise => { const moveLabwareCmd = buildMoveLabwareWithoutPause( - failedCommandByRunRecord + unvalidatedFailedCommand ) if (moveLabwareCmd == null) { return Promise.reject(new Error('Invalid use of MoveLabware command')) } else { return chainRunRecoveryCommands([moveLabwareCmd]) } - }, [chainRunRecoveryCommands, failedCommandByRunRecord]) + }, [chainRunRecoveryCommands, unvalidatedFailedCommand]) return { resumeRun, @@ -318,7 +316,7 @@ export function useRecoveryCommands({ homePipetteZAxes, pickUpTips, releaseGripperJaws, - updatePositionEstimatorsAndHomeGripper, + homeExceptPlungers, moveLabwareWithoutPause, skipFailedCommand, ignoreErrorKindThisRun, @@ -326,9 +324,9 @@ export function useRecoveryCommands({ } export function isAssumeFalsePositiveResumeKind( - failedCommandByRunRecord: UseRecoveryCommandsParams['failedCommandByRunRecord'] + failedCommand: UseRecoveryCommandsParams['failedCommand'] ): boolean { - const errorKind = getErrorKind(failedCommandByRunRecord) + const errorKind = getErrorKind(failedCommand) switch (errorKind) { case ERROR_KINDS.TIP_NOT_DETECTED: @@ -357,9 +355,11 @@ export const UPDATE_ESTIMATORS_EXCEPT_PLUNGERS: CreateCommand = { params: { axes: ['x', 'y', 'extensionZ'] }, } -export const HOME_GRIPPER_Z: CreateCommand = { +export const HOME_EXCEPT_PLUNGERS: CreateCommand = { commandType: 'home', - params: { axes: ['extensionZ'] }, + params: { + axes: ['extensionJaw', 'extensionZ', 'leftZ', 'rightZ', 'x', 'y'], + }, } const buildMoveLabwareWithoutPause = ( diff --git a/app/src/organisms/ErrorRecoveryFlows/hooks/useRecoveryToasts.ts b/app/src/organisms/ErrorRecoveryFlows/hooks/useRecoveryToasts.ts index 2edf732bfdd..6daf6998ae5 100644 --- a/app/src/organisms/ErrorRecoveryFlows/hooks/useRecoveryToasts.ts +++ b/app/src/organisms/ErrorRecoveryFlows/hooks/useRecoveryToasts.ts @@ -10,7 +10,7 @@ import type { UseCommandTextStringParams } from '/app/local-resources/commands' export type BuildToast = Omit & { isOnDevice: boolean - currentStepCount: StepCounts['currentStepNumber'] + stepCounts: StepCounts selectedRecoveryOption: CurrentRecoveryOptionUtils['selectedRecoveryOption'] } @@ -21,15 +21,16 @@ export interface RecoveryToasts { // Provides methods for rendering success/failure toasts after performing a terminal recovery command. export function useRecoveryToasts({ - currentStepCount, + stepCounts, isOnDevice, selectedRecoveryOption, ...rest }: BuildToast): RecoveryToasts { + const { currentStepNumber, hasRunDiverged } = stepCounts const { makeToast } = useToaster() const displayType = isOnDevice ? 'odd' : 'desktop' - const stepNumber = getStepNumber(selectedRecoveryOption, currentStepCount) + const stepNumber = getStepNumber(selectedRecoveryOption, currentStepNumber) const desktopFullCommandText = useRecoveryFullCommandText({ ...rest, @@ -46,7 +47,8 @@ export function useRecoveryToasts({ ? desktopFullCommandText : recoveryToastText // The "heading" of the toast message. Currently, this text is only present on the desktop toasts. - const headingText = displayType === 'desktop' ? recoveryToastText : undefined + const headingText = + displayType === 'desktop' && !hasRunDiverged ? recoveryToastText : undefined const makeSuccessToast = (): void => { if (selectedRecoveryOption !== RECOVERY_MAP.CANCEL_RUN.ROUTE) { @@ -73,12 +75,18 @@ export function useRecoveryToastText({ }): string { const { t } = useTranslation('error_recovery') - const currentStepReturnVal = t('retrying_step_succeeded', { - step: stepNumber, - }) as string - const nextStepReturnVal = t('skipping_to_step_succeeded', { - step: stepNumber, - }) as string + const currentStepReturnVal = + stepNumber != null + ? t('retrying_step_succeeded', { + step: stepNumber, + }) + : t('retrying_step_succeeded_na') + const nextStepReturnVal = + stepNumber != null + ? t('skipping_to_step_succeeded', { + step: stepNumber, + }) + : t('skipping_to_step_succeeded_na') const toastText = handleRecoveryOptionAction( selectedRecoveryOption, @@ -102,7 +110,7 @@ export function useRecoveryFullCommandText( ): string | null { const { commandTextData, stepNumber } = props - const relevantCmdIdx = typeof stepNumber === 'number' ? stepNumber : -1 + const relevantCmdIdx = stepNumber ?? -1 const relevantCmd = commandTextData?.commands[relevantCmdIdx] ?? null const { commandText, kind } = useCommandTextString({ @@ -110,8 +118,8 @@ export function useRecoveryFullCommandText( command: relevantCmd, }) - if (typeof stepNumber === 'string') { - return stepNumber + if (stepNumber == null) { + return null } // Occurs when the relevantCmd doesn't exist, ex, we "skip" the last command of a run. else if (relevantCmd === null) { @@ -129,12 +137,12 @@ export function useRecoveryFullCommandText( // Return the user-facing step number, 0 indexed. If the step number cannot be determined, return '?'. export function getStepNumber( selectedRecoveryOption: BuildToast['selectedRecoveryOption'], - currentStepCount: BuildToast['currentStepCount'] -): number | string { - const currentStepReturnVal = currentStepCount ?? '?' + currentStepCount: BuildToast['stepCounts']['currentStepNumber'] +): number | null { + const currentStepReturnVal = currentStepCount ?? null // There is always a next protocol step after a command that can error, therefore, we don't need to handle that. const nextStepReturnVal = - typeof currentStepCount === 'number' ? currentStepCount + 1 : '?' + typeof currentStepCount === 'number' ? currentStepCount + 1 : null return handleRecoveryOptionAction( selectedRecoveryOption, @@ -149,7 +157,7 @@ function handleRecoveryOptionAction( selectedRecoveryOption: CurrentRecoveryOptionUtils['selectedRecoveryOption'], currentStepReturnVal: T, nextStepReturnVal: T -): T | string { +): T | null { switch (selectedRecoveryOption) { case RECOVERY_MAP.MANUAL_FILL_AND_SKIP.ROUTE: case RECOVERY_MAP.SKIP_STEP_WITH_SAME_TIPS.ROUTE: @@ -163,8 +171,9 @@ function handleRecoveryOptionAction( case RECOVERY_MAP.RETRY_STEP.ROUTE: case RECOVERY_MAP.MANUAL_REPLACE_AND_RETRY.ROUTE: return currentStepReturnVal - default: - return 'HANDLE RECOVERY TOAST OPTION EXPLICITLY.' + default: { + return null + } } } diff --git a/app/src/organisms/ErrorRecoveryFlows/hooks/useRetainedFailedCommandBySource.ts b/app/src/organisms/ErrorRecoveryFlows/hooks/useRetainedFailedCommandBySource.ts index c967d4968b1..90afa5851da 100644 --- a/app/src/organisms/ErrorRecoveryFlows/hooks/useRetainedFailedCommandBySource.ts +++ b/app/src/organisms/ErrorRecoveryFlows/hooks/useRetainedFailedCommandBySource.ts @@ -16,7 +16,7 @@ export interface FailedCommandBySource { * In order to reduce misuse, bundle the failedCommand into "run" and "analysis" versions. */ export function useRetainedFailedCommandBySource( - failedCommandByRunRecord: ErrorRecoveryFlowsProps['failedCommandByRunRecord'], + failedCommandByRunRecord: ErrorRecoveryFlowsProps['unvalidatedFailedCommand'], protocolAnalysis: ErrorRecoveryFlowsProps['protocolAnalysis'] ): FailedCommandBySource | null { // In some cases, Error Recovery (by the app definition) persists when Error Recovery (by the server definition) does diff --git a/app/src/organisms/ErrorRecoveryFlows/index.tsx b/app/src/organisms/ErrorRecoveryFlows/index.tsx index 124c4fea65f..6461ae773fc 100644 --- a/app/src/organisms/ErrorRecoveryFlows/index.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/index.tsx @@ -109,17 +109,21 @@ export function useErrorRecoveryFlows( export interface ErrorRecoveryFlowsProps { runId: string runStatus: RunStatus | null - failedCommandByRunRecord: FailedCommand | null + /* In some parts of Error Recovery, such as "retry failed command" during a generic error flow, we want to utilize + * information derived from the failed command from the run record even if there is no matching command in protocol analysis. + * Using a failed command that is not matched to a protocol analysis command is unsafe in most circumstances (ie, in + * non-generic recovery flows. Prefer using failedCommandBySource in most circumstances. */ + unvalidatedFailedCommand: FailedCommand | null protocolAnalysis: CompletedProtocolAnalysis | null } export function ErrorRecoveryFlows( props: ErrorRecoveryFlowsProps ): JSX.Element | null { - const { protocolAnalysis, runStatus, failedCommandByRunRecord } = props + const { protocolAnalysis, runStatus, unvalidatedFailedCommand } = props const failedCommandBySource = useRetainedFailedCommandBySource( - failedCommandByRunRecord, + unvalidatedFailedCommand, protocolAnalysis ) @@ -156,7 +160,7 @@ export function ErrorRecoveryFlows( toggleERWizAsActiveUser, isOnDevice, robotType, - showTakeover, + isActiveUser, failedCommand: failedCommandBySource, allRunDefs, labwareDefinitionsByUri, diff --git a/app/src/organisms/ErrorRecoveryFlows/shared/ErrorDetailsModal.tsx b/app/src/organisms/ErrorRecoveryFlows/shared/ErrorDetailsModal.tsx index b988c83971b..7eb207a9fe7 100644 --- a/app/src/organisms/ErrorRecoveryFlows/shared/ErrorDetailsModal.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/shared/ErrorDetailsModal.tsx @@ -44,7 +44,7 @@ export function useErrorDetailsModal(): { type ErrorDetailsModalProps = Omit< ErrorRecoveryFlowsProps, - 'failedCommandByRunRecord' + 'unvalidatedFailedCommand' > & ERUtilsResults & { toggleModal: () => void @@ -57,7 +57,7 @@ type ErrorDetailsModalProps = Omit< export function ErrorDetailsModal(props: ErrorDetailsModalProps): JSX.Element { const { failedCommand, toggleModal, isOnDevice } = props - const errorKind = getErrorKind(failedCommand?.byRunRecord ?? null) + const errorKind = getErrorKind(failedCommand) const errorName = useErrorName(errorKind) const isNotificationErrorKind = (): boolean => { diff --git a/app/src/organisms/ErrorRecoveryFlows/shared/RecoveryDoorOpenSpecial.tsx b/app/src/organisms/ErrorRecoveryFlows/shared/RecoveryDoorOpenSpecial.tsx index d05db0a0b60..4331a976d5e 100644 --- a/app/src/organisms/ErrorRecoveryFlows/shared/RecoveryDoorOpenSpecial.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/shared/RecoveryDoorOpenSpecial.tsx @@ -46,7 +46,7 @@ export function RecoveryDoorOpenSpecial({ switch (selectedRecoveryOption) { case RECOVERY_MAP.MANUAL_REPLACE_AND_RETRY.ROUTE: case RECOVERY_MAP.MANUAL_MOVE_AND_SKIP.ROUTE: - return t('door_open_gripper_home') + return t('door_open_robot_home') default: { console.error( `Unhandled special-cased door open subtext on route ${selectedRecoveryOption}.` diff --git a/app/src/organisms/ErrorRecoveryFlows/shared/StepInfo.tsx b/app/src/organisms/ErrorRecoveryFlows/shared/StepInfo.tsx index 8381865e3c3..bbc12ce0429 100644 --- a/app/src/organisms/ErrorRecoveryFlows/shared/StepInfo.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/shared/StepInfo.tsx @@ -30,7 +30,7 @@ export function StepInfo({ ...styleProps }: StepInfoProps): JSX.Element { const { t } = useTranslation('error_recovery') - const { currentStepNumber, totalStepCount } = stepCounts + const { currentStepNumber, totalStepCount, hasRunDiverged } = stepCounts const currentCopy = currentStepNumber ?? '?' const totalCopy = totalStepCount ?? '?' @@ -38,6 +38,11 @@ export function StepInfo({ const desktopStyleDefaulted = desktopStyle ?? 'bodyDefaultRegular' const oddStyleDefaulted = oddStyle ?? 'bodyTextRegular' + const buildAtStepCopy = (): string => + hasRunDiverged + ? `${t('at_step')}: N/A` + : `${t('at_step')} ${currentCopy}/${totalCopy}: ` + return ( - {`${t('at_step')} ${currentCopy}/${totalCopy}: `} + {buildAtStepCopy()} {failedCommand?.byAnalysis != null && protocolAnalysis != null ? ( { switch (selectedRecoveryOption) { diff --git a/app/src/organisms/ErrorRecoveryFlows/shared/TwoColTextAndFailedStepNextStep.tsx b/app/src/organisms/ErrorRecoveryFlows/shared/TwoColTextAndFailedStepNextStep.tsx index 57d251bdede..21dbed8e639 100644 --- a/app/src/organisms/ErrorRecoveryFlows/shared/TwoColTextAndFailedStepNextStep.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/shared/TwoColTextAndFailedStepNextStep.tsx @@ -71,7 +71,11 @@ export function TwoColTextAndFailedStepNextStep( leftColBodyText )} - + {!props.stepCounts.hasRunDiverged ? ( + + ) : ( + + )} { render(props) screen.getByText('Close the robot door') screen.getByText( - 'The robot door must be closed for the gripper to home its Z-axis before you can continue manually moving labware.' + 'The robot needs to safely move to its home location before you manually move the labware.' ) }) diff --git a/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/RetryStepInfo.test.tsx b/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/RetryStepInfo.test.tsx index 03ecd64299b..94f77910657 100644 --- a/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/RetryStepInfo.test.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/RetryStepInfo.test.tsx @@ -29,6 +29,7 @@ describe('RetryStepInfo', () => { resumeRun: mockResumeRun, } as any, errorKind: ERROR_KINDS.GENERAL_ERROR, + stepCounts: { hasRunDiverged: false }, } as any }) diff --git a/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/SkipStepInfo.test.tsx b/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/SkipStepInfo.test.tsx index e1ac4cf1adc..28ef4177648 100644 --- a/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/SkipStepInfo.test.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/SkipStepInfo.test.tsx @@ -28,6 +28,7 @@ describe('SkipStepInfo', () => { currentRecoveryOptionUtils: { selectedRecoveryOption: RECOVERY_MAP.SKIP_STEP_WITH_SAME_TIPS.ROUTE, } as any, + stepCounts: { hasRunDiverged: false }, } as any }) diff --git a/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/TwoColLwInfoAndDeck.test.tsx b/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/TwoColLwInfoAndDeck.test.tsx index 2f24fc0f3bb..15094e5aacb 100644 --- a/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/TwoColLwInfoAndDeck.test.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/TwoColLwInfoAndDeck.test.tsx @@ -49,7 +49,11 @@ describe('TwoColLwInfoAndDeck', () => { failedLabwareUtils: { relevantWellName: 'A1', failedLabware: { location: 'C1' }, - failedLabwareLocations: { newLoc: {}, currentLoc: {} }, + failedLabwareLocations: { + newLoc: {}, + currentLoc: {}, + displayNameCurrentLoc: 'Slot C1', + }, }, deckMapUtils: { movedLabwareDef: {}, diff --git a/app/src/organisms/ErrorRecoveryFlows/utils/__tests__/getErrorKind.test.ts b/app/src/organisms/ErrorRecoveryFlows/utils/__tests__/getErrorKind.test.ts index fb9eea82c63..e9b5722ffa8 100644 --- a/app/src/organisms/ErrorRecoveryFlows/utils/__tests__/getErrorKind.test.ts +++ b/app/src/organisms/ErrorRecoveryFlows/utils/__tests__/getErrorKind.test.ts @@ -71,13 +71,17 @@ describe('getErrorKind', () => { ])( 'returns $expectedError for $commandType with errorType $errorType', ({ commandType, errorType, expectedError, isDefined = true }) => { - const result = getErrorKind({ + const runRecordFailedCommand = { commandType, error: { isDefined, errorType, } as RunCommandError, - } as RunTimeCommand) + } as RunTimeCommand + + const result = getErrorKind({ + byRunRecord: runRecordFailedCommand, + } as any) expect(result).toEqual(expectedError) } ) diff --git a/app/src/organisms/ErrorRecoveryFlows/utils/getErrorKind.ts b/app/src/organisms/ErrorRecoveryFlows/utils/getErrorKind.ts index 30fc4783473..1dc5e023a6c 100644 --- a/app/src/organisms/ErrorRecoveryFlows/utils/getErrorKind.ts +++ b/app/src/organisms/ErrorRecoveryFlows/utils/getErrorKind.ts @@ -1,18 +1,24 @@ import { ERROR_KINDS, DEFINED_ERROR_TYPES } from '../constants' -import type { RunTimeCommand } from '@opentrons/shared-data' import type { ErrorKind } from '../types' +import type { FailedCommandBySource } from '/app/organisms/ErrorRecoveryFlows/hooks' /** * Given server-side information about a failed command, * decide which UI flow to present to recover from it. + * + * NOTE IMPORTANT: Any failed command by run record must have an equivalent protocol analysis command or default + * to the fallback general error. Prefer using FailedCommandBySource for this reason. */ -export function getErrorKind(failedCommand: RunTimeCommand | null): ErrorKind { - const commandType = failedCommand?.commandType - const errorIsDefined = failedCommand?.error?.isDefined ?? false - const errorType = failedCommand?.error?.errorType +export function getErrorKind( + failedCommand: FailedCommandBySource | null +): ErrorKind { + const failedCommandByRunRecord = failedCommand?.byRunRecord ?? null + const commandType = failedCommandByRunRecord?.commandType + const errorIsDefined = failedCommandByRunRecord?.error?.isDefined ?? false + const errorType = failedCommandByRunRecord?.error?.errorType - if (errorIsDefined) { + if (Boolean(errorIsDefined)) { if ( commandType === 'prepareToAspirate' && errorType === DEFINED_ERROR_TYPES.OVERPRESSURE diff --git a/app/src/organisms/LabwarePositionCheck/ResultsSummary.tsx b/app/src/organisms/LabwarePositionCheck/ResultsSummary.tsx index 98f88fac2bd..eafda1a2c8a 100644 --- a/app/src/organisms/LabwarePositionCheck/ResultsSummary.tsx +++ b/app/src/organisms/LabwarePositionCheck/ResultsSummary.tsx @@ -29,6 +29,7 @@ import { SPACING, LegacyStyledText, TYPOGRAPHY, + DIRECTION_ROW, } from '@opentrons/components' import { PythonLabwareOffsetSnippet } from '/app/molecules/PythonLabwareOffsetSnippet' import { @@ -373,16 +374,18 @@ export const TerseOffsetTable = (props: OffsetTableProps): JSX.Element => { return ( - - {location.moduleModel != null ? ( - - ) : null} + + + {location.moduleModel != null ? ( + + ) : null} + ) => { return renderWithProviders( @@ -56,6 +40,10 @@ describe('Navigation', () => { isUsbConnected: false, connectionStatus: 'Not connected', }) + vi.mocked(useScrollPosition).mockReturnValue({ + isScrolled: false, + scrollRef: {} as any, + }) }) it('should render text and they have attribute', () => { render(props) diff --git a/app/src/organisms/ODD/Navigation/index.tsx b/app/src/organisms/ODD/Navigation/index.tsx index a49d88a8ca9..8b60946f929 100644 --- a/app/src/organisms/ODD/Navigation/index.tsx +++ b/app/src/organisms/ODD/Navigation/index.tsx @@ -27,6 +27,7 @@ import { TYPOGRAPHY, } from '@opentrons/components' import { ODD_FOCUS_VISIBLE } from '/app/atoms/buttons/constants' +import { useScrollPosition } from '/app/local-resources/dom-utils' import { useNetworkConnection } from '/app/resources/networking/hooks/useNetworkConnection' import { getLocalRobot } from '/app/redux/discovery' @@ -92,15 +93,7 @@ export function Navigation(props: NavigationProps): JSX.Element { setShowNavMenu(openMenu) } - const scrollRef = React.useRef(null) - const [isScrolled, setIsScrolled] = React.useState(false) - - const observer = new IntersectionObserver(([entry]) => { - setIsScrolled(!entry.isIntersecting) - }) - if (scrollRef.current != null) { - observer.observe(scrollRef.current) - } + const { scrollRef, isScrolled } = useScrollPosition() const navBarScrollRef = React.useRef(null) React.useEffect(() => { diff --git a/app/src/organisms/ODD/ProtocolSetup/ProtocolSetupLabware/LabwareMapView.tsx b/app/src/organisms/ODD/ProtocolSetup/ProtocolSetupLabware/LabwareMapView.tsx index 339ad981daa..d311a6aab5a 100644 --- a/app/src/organisms/ODD/ProtocolSetup/ProtocolSetupLabware/LabwareMapView.tsx +++ b/app/src/organisms/ODD/ProtocolSetup/ProtocolSetupLabware/LabwareMapView.tsx @@ -73,7 +73,6 @@ export function LabwareMapView(props: LabwareMapViewProps): JSX.Element { } : undefined, highlightLabware: true, - highlightShadowLabware: isLabwareStacked, moduleChildren: null, stacked: isLabwareStacked, } diff --git a/app/src/organisms/ODD/QuickTransferFlow/QuickTransferAdvancedSettings/Delay.tsx b/app/src/organisms/ODD/QuickTransferFlow/QuickTransferAdvancedSettings/Delay.tsx index 56aad4d7820..0692cc904ac 100644 --- a/app/src/organisms/ODD/QuickTransferFlow/QuickTransferAdvancedSettings/Delay.tsx +++ b/app/src/organisms/ODD/QuickTransferFlow/QuickTransferAdvancedSettings/Delay.tsx @@ -176,7 +176,7 @@ export function Delay(props: DelayProps): JSX.Element { { it('renders the first delay screen, continue, and back buttons', () => { render(props) - screen.getByText('Delay before aspirating') + screen.getByText('Delay after aspirating') screen.getByTestId('ChildNavigation_Primary_Button') screen.getByText('Enabled') screen.getByText('Disabled') diff --git a/app/src/organisms/ODD/QuickTransferFlow/__tests__/QuickTransferAdvancedSettings/TouchTip.test.tsx b/app/src/organisms/ODD/QuickTransferFlow/__tests__/QuickTransferAdvancedSettings/TouchTip.test.tsx index cc2b463c9a7..cc30db0a54f 100644 --- a/app/src/organisms/ODD/QuickTransferFlow/__tests__/QuickTransferAdvancedSettings/TouchTip.test.tsx +++ b/app/src/organisms/ODD/QuickTransferFlow/__tests__/QuickTransferAdvancedSettings/TouchTip.test.tsx @@ -83,7 +83,7 @@ describe('TouchTip', () => { it('renders the first touch tip screen, continue, and back buttons', () => { render(props) - screen.getByText('Touch tip before aspirating') + screen.getByText('Touch tip after aspirating') screen.getByTestId('ChildNavigation_Primary_Button') screen.getByText('Enabled') screen.getByText('Disabled') diff --git a/app/src/organisms/ODD/RunningProtocol/CurrentRunningProtocolCommand.tsx b/app/src/organisms/ODD/RunningProtocol/CurrentRunningProtocolCommand.tsx index be9e5e25cbb..2866c3f95dd 100644 --- a/app/src/organisms/ODD/RunningProtocol/CurrentRunningProtocolCommand.tsx +++ b/app/src/organisms/ODD/RunningProtocol/CurrentRunningProtocolCommand.tsx @@ -172,13 +172,14 @@ export function CurrentRunningProtocolCommand({ } const currentRunStatus = t(`status_${runStatus}`) - const { currentStepNumber, totalStepCount } = useRunningStepCounts( - runId, - mostRecentCommandData - ) - const stepCounterCopy = `${t('step')} ${currentStepNumber ?? '?'}/${ - totalStepCount ?? '?' - }` + const { + currentStepNumber, + totalStepCount, + hasRunDiverged, + } = useRunningStepCounts(runId, mostRecentCommandData) + const stepCounterCopy = hasRunDiverged + ? `${t('step_na')}` + : `${t('step')} ${currentStepNumber ?? '?'}/${totalStepCount ?? '?'}` const onStop = (): void => { if (runStatus === RUN_STATUS_RUNNING) pauseRun() diff --git a/app/src/organisms/ODD/RunningProtocol/__tests__/CurrentRunningProtocolCommand.test.tsx b/app/src/organisms/ODD/RunningProtocol/__tests__/CurrentRunningProtocolCommand.test.tsx index 54d241ff9af..581df7c013c 100644 --- a/app/src/organisms/ODD/RunningProtocol/__tests__/CurrentRunningProtocolCommand.test.tsx +++ b/app/src/organisms/ODD/RunningProtocol/__tests__/CurrentRunningProtocolCommand.test.tsx @@ -125,7 +125,7 @@ describe('CurrentRunningProtocolCommand', () => { }) render(props) - screen.getByText('Step ?/?') + screen.getByText('Step: N/A') }) // ToDo (kj:04/10/2023) once we fix the track event stuff, we can implement tests diff --git a/app/src/pages/ODD/ProtocolDetails/__tests__/ProtocolDetails.test.tsx b/app/src/pages/ODD/ProtocolDetails/__tests__/ProtocolDetails.test.tsx index dfe517c58aa..675d8b038a8 100644 --- a/app/src/pages/ODD/ProtocolDetails/__tests__/ProtocolDetails.test.tsx +++ b/app/src/pages/ODD/ProtocolDetails/__tests__/ProtocolDetails.test.tsx @@ -24,21 +24,10 @@ import { Deck } from '../Deck' import { Hardware } from '../Hardware' import { Labware } from '../Labware' import { Parameters } from '../Parameters' +import { useScrollPosition } from '/app/local-resources/dom-utils' import type { HostConfig } from '@opentrons/api-client' -// Mock IntersectionObserver -class IntersectionObserver { - observe = vi.fn() - disconnect = vi.fn() - unobserve = vi.fn() -} - -Object.defineProperty(window, 'IntersectionObserver', { - writable: true, - configurable: true, - value: IntersectionObserver, -}) vi.mock( '/app/organisms/ODD/ProtocolSetup/ProtocolSetupParameters/ProtocolSetupParameters' ) @@ -55,6 +44,7 @@ vi.mock('../Hardware') vi.mock('../Labware') vi.mock('../Parameters') vi.mock('/app/redux/config') +vi.mock('/app/local-resources/dom-utils') const MOCK_HOST_CONFIG = {} as HostConfig const mockCreateRun = vi.fn((id: string) => {}) @@ -119,6 +109,10 @@ describe('ODDProtocolDetails', () => { vi.mocked(getProtocol).mockResolvedValue({ data: { links: { referencingRuns: [{ id: '1' }, { id: '2' }] } }, } as any) + vi.mocked(useScrollPosition).mockReturnValue({ + isScrolled: false, + scrollRef: {} as any, + }) }) afterEach(() => { vi.resetAllMocks() diff --git a/app/src/pages/ODD/ProtocolDetails/index.tsx b/app/src/pages/ODD/ProtocolDetails/index.tsx index 0088c0c76d4..133210ff218 100644 --- a/app/src/pages/ODD/ProtocolDetails/index.tsx +++ b/app/src/pages/ODD/ProtocolDetails/index.tsx @@ -1,4 +1,4 @@ -import { useState, useRef } from 'react' +import { useState } from 'react' import last from 'lodash/last' import { useTranslation } from 'react-i18next' import { useQueryClient } from 'react-query' @@ -56,6 +56,7 @@ import { Hardware } from './Hardware' import { Labware } from './Labware' import { Liquids } from './Liquids' import { formatTimeWithUtcLabel } from '/app/resources/runs' +import { useScrollPosition } from '/app/local-resources/dom-utils' import type { Protocol } from '@opentrons/api-client' import type { OddModalHeaderBaseProps } from '/app/molecules/OddModal/types' @@ -335,14 +336,7 @@ export function ProtocolDetails(): JSX.Element | null { }) // Watch for scrolling to toggle dropshadow - const scrollRef = useRef(null) - const [isScrolled, setIsScrolled] = useState(false) - const observer = new IntersectionObserver(([entry]) => { - setIsScrolled(!entry.isIntersecting) - }) - if (scrollRef.current != null) { - observer.observe(scrollRef.current) - } + const { scrollRef, isScrolled } = useScrollPosition() let pinnedProtocolIds = useSelector(getPinnedProtocolIds) ?? [] const pinned = pinnedProtocolIds.includes(protocolId) diff --git a/app/src/pages/ODD/ProtocolSetup/__tests__/ProtocolSetup.test.tsx b/app/src/pages/ODD/ProtocolSetup/__tests__/ProtocolSetup.test.tsx index 96f39a280f9..438f856b41d 100644 --- a/app/src/pages/ODD/ProtocolSetup/__tests__/ProtocolSetup.test.tsx +++ b/app/src/pages/ODD/ProtocolSetup/__tests__/ProtocolSetup.test.tsx @@ -64,22 +64,11 @@ import { } from '/app/resources/runs' import { mockConnectableRobot } from '/app/redux/discovery/__fixtures__' import { mockRunTimeParameterData } from '/app/organisms/ODD/ProtocolSetup/__fixtures__' +import { useScrollPosition } from '/app/local-resources/dom-utils' import type { UseQueryResult } from 'react-query' import type * as SharedData from '@opentrons/shared-data' import type { NavigateFunction } from 'react-router-dom' -// Mock IntersectionObserver -class IntersectionObserver { - observe = vi.fn() - disconnect = vi.fn() - unobserve = vi.fn() -} - -Object.defineProperty(window, 'IntersectionObserver', { - writable: true, - configurable: true, - value: IntersectionObserver, -}) let mockNavigate = vi.fn() @@ -125,6 +114,7 @@ vi.mock('../ConfirmSetupStepsCompleteModal') vi.mock('/app/redux-resources/analytics') vi.mock('/app/redux-resources/robots') vi.mock('/app/resources/modules') +vi.mock('/app/local-resources/dom-utils') const render = (path = '/') => { return renderWithProviders( @@ -334,6 +324,10 @@ describe('ProtocolSetup', () => { when(vi.mocked(useTrackProtocolRunEvent)) .calledWith(RUN_ID, ROBOT_NAME) .thenReturn({ trackProtocolRunEvent: mockTrackProtocolRunEvent }) + vi.mocked(useScrollPosition).mockReturnValue({ + isScrolled: false, + scrollRef: {} as any, + }) }) it('should render text, image, and buttons', () => { diff --git a/app/src/pages/ODD/ProtocolSetup/index.tsx b/app/src/pages/ODD/ProtocolSetup/index.tsx index a9c45be592f..03a03626a55 100644 --- a/app/src/pages/ODD/ProtocolSetup/index.tsx +++ b/app/src/pages/ODD/ProtocolSetup/index.tsx @@ -81,6 +81,7 @@ import { useModuleCalibrationStatus, useProtocolAnalysisErrors, } from '/app/resources/runs' +import { useScrollPosition } from '/app/local-resources/dom-utils' import type { Run } from '@opentrons/api-client' import type { CutoutFixtureId, CutoutId } from '@opentrons/shared-data' @@ -129,14 +130,7 @@ function PrepareToRun({ const { t, i18n } = useTranslation(['protocol_setup', 'shared']) const navigate = useNavigate() const { makeSnackbar } = useToaster() - const scrollRef = React.useRef(null) - const [isScrolled, setIsScrolled] = React.useState(false) - const observer = new IntersectionObserver(([entry]) => { - setIsScrolled(!entry.isIntersecting) - }) - if (scrollRef.current != null) { - observer.observe(scrollRef.current) - } + const { scrollRef, isScrolled } = useScrollPosition() const protocolId = runRecord?.data?.protocolId ?? null const { data: protocolRecord } = useProtocolQuery(protocolId, { @@ -764,6 +758,7 @@ export function ProtocolSetup(): JSX.Element { const [providedFixtureOptions, setProvidedFixtureOptions] = React.useState< CutoutFixtureId[] >([]) + // TODO(jh 10-31-24): Refactor the below to utilize useMissingStepsModal. const [labwareConfirmed, setLabwareConfirmed] = React.useState(false) const [liquidsConfirmed, setLiquidsConfirmed] = React.useState(false) const [offsetsConfirmed, setOffsetsConfirmed] = React.useState(false) diff --git a/app/src/pages/ODD/QuickTransferDetails/__tests__/QuickTransferDetails.test.tsx b/app/src/pages/ODD/QuickTransferDetails/__tests__/QuickTransferDetails.test.tsx index 9d1848ee31e..33203e4dc4f 100644 --- a/app/src/pages/ODD/QuickTransferDetails/__tests__/QuickTransferDetails.test.tsx +++ b/app/src/pages/ODD/QuickTransferDetails/__tests__/QuickTransferDetails.test.tsx @@ -26,21 +26,10 @@ import { QuickTransferDetails } from '..' import { Deck } from '../Deck' import { Hardware } from '../Hardware' import { Labware } from '../Labware' +import { useScrollPosition } from '/app/local-resources/dom-utils' import type { HostConfig } from '@opentrons/api-client' -// Mock IntersectionObserver -class IntersectionObserver { - observe = vi.fn() - disconnect = vi.fn() - unobserve = vi.fn() -} - -Object.defineProperty(window, 'IntersectionObserver', { - writable: true, - configurable: true, - value: IntersectionObserver, -}) vi.mock('/app/organisms/ODD/ProtocolSetup/ProtocolSetupParameters') vi.mock('@opentrons/api-client') vi.mock('@opentrons/react-api-client') @@ -55,6 +44,7 @@ vi.mock('../Deck') vi.mock('../Hardware') vi.mock('../Labware') vi.mock('/app/redux/config') +vi.mock('/app/local-resources/dom-utils') const MOCK_HOST_CONFIG = {} as HostConfig const mockCreateRun = vi.fn((id: string) => {}) @@ -125,6 +115,10 @@ describe('ODDQuickTransferDetails', () => { }, } as any) when(vi.mocked(useHost)).calledWith().thenReturn(MOCK_HOST_CONFIG) + vi.mocked(useScrollPosition).mockReturnValue({ + isScrolled: false, + scrollRef: {} as any, + }) }) afterEach(() => { vi.resetAllMocks() diff --git a/app/src/pages/ODD/QuickTransferDetails/index.tsx b/app/src/pages/ODD/QuickTransferDetails/index.tsx index 7095fd47ddb..5989ec1f29a 100644 --- a/app/src/pages/ODD/QuickTransferDetails/index.tsx +++ b/app/src/pages/ODD/QuickTransferDetails/index.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef } from 'react' +import { useState, useEffect } from 'react' import last from 'lodash/last' import { useTranslation } from 'react-i18next' import { useQueryClient } from 'react-query' @@ -57,6 +57,7 @@ import { Deck } from './Deck' import { Hardware } from './Hardware' import { Labware } from './Labware' import { formatTimeWithUtcLabel } from '/app/resources/runs' +import { useScrollPosition } from '/app/local-resources/dom-utils' import type { Protocol } from '@opentrons/api-client' import type { Dispatch } from '/app/redux/types' @@ -321,14 +322,7 @@ export function QuickTransferDetails(): JSX.Element | null { }) // Watch for scrolling to toggle dropshadow - const scrollRef = useRef(null) - const [isScrolled, setIsScrolled] = useState(false) - const observer = new IntersectionObserver(([entry]) => { - setIsScrolled(!entry.isIntersecting) - }) - if (scrollRef.current != null) { - observer.observe(scrollRef.current) - } + const { scrollRef, isScrolled } = useScrollPosition() let pinnedTransferIds = useSelector(getPinnedQuickTransferIds) ?? [] const pinned = pinnedTransferIds.includes(transferId) diff --git a/app/src/pages/ODD/RunningProtocol/index.tsx b/app/src/pages/ODD/RunningProtocol/index.tsx index 4c63302564e..33d9d515930 100644 --- a/app/src/pages/ODD/RunningProtocol/index.tsx +++ b/app/src/pages/ODD/RunningProtocol/index.tsx @@ -168,7 +168,7 @@ export function RunningProtocol(): JSX.Element { ) : null} diff --git a/app/src/redux/protocol-runs/selectors.ts b/app/src/redux/protocol-runs/selectors.ts index ca91c7a71ab..14149b603bb 100644 --- a/app/src/redux/protocol-runs/selectors.ts +++ b/app/src/redux/protocol-runs/selectors.ts @@ -76,6 +76,7 @@ export const getSetupStepsMissing: ( ) as Types.StepMap } +// Reports all missing setup steps, including those validated on the robot. export const getMissingSetupSteps: ( state: State, runId: string diff --git a/app/src/resources/modules/hooks/usePlacePlateReaderLid.ts b/app/src/resources/modules/hooks/usePlacePlateReaderLid.ts index 0e4dabcb660..6b9ce1bca0b 100644 --- a/app/src/resources/modules/hooks/usePlacePlateReaderLid.ts +++ b/app/src/resources/modules/hooks/usePlacePlateReaderLid.ts @@ -36,7 +36,7 @@ export function usePlacePlateReaderLid( const location = placeLabware.location const loadModuleCommand = buildLoadModuleCommand(location as ModuleLocation) const placeLabwareCommand = buildPlaceLabwareCommand( - placeLabware.labwareId as string, + placeLabware.labwareURI as string, location ) commandsToExecute = [loadModuleCommand, placeLabwareCommand] @@ -72,11 +72,11 @@ const buildLoadModuleCommand = (location: ModuleLocation): CreateCommand => { } const buildPlaceLabwareCommand = ( - labwareId: string, + labwareURI: string, location: OnDeckLabwareLocation ): CreateCommand => { return { commandType: 'unsafe/placeLabware' as const, - params: { labwareId, location }, + params: { labwareURI, location }, } } diff --git a/app/src/resources/runs/useMostRecentCompletedAnalysis.ts b/app/src/resources/runs/useMostRecentCompletedAnalysis.ts index e5188af8d38..7a3f9eefb7e 100644 --- a/app/src/resources/runs/useMostRecentCompletedAnalysis.ts +++ b/app/src/resources/runs/useMostRecentCompletedAnalysis.ts @@ -8,7 +8,6 @@ import { useNotifyRunQuery } from '/app/resources/runs' import type { CompletedProtocolAnalysis } from '@opentrons/shared-data' -// TODO(jh, 06-17-24): This is used elsewhere in the app and should probably live in something like resources. export function useMostRecentCompletedAnalysis( runId: string | null ): CompletedProtocolAnalysis | null { diff --git a/app/src/resources/runs/useNotifyRunQuery.ts b/app/src/resources/runs/useNotifyRunQuery.ts index ba742b69e03..27b15a2d8e2 100644 --- a/app/src/resources/runs/useNotifyRunQuery.ts +++ b/app/src/resources/runs/useNotifyRunQuery.ts @@ -20,7 +20,7 @@ export function useNotifyRunQuery( const httpQueryResult = useRunQuery(runId, queryOptionsNotify, hostOverride) - if (shouldRefetch) { + if (shouldRefetch && runId != null) { void httpQueryResult.refetch() } diff --git a/app/src/resources/runs/useRunStatus.ts b/app/src/resources/runs/useRunStatus.ts index a1a1d5dc7cd..221819da76e 100644 --- a/app/src/resources/runs/useRunStatus.ts +++ b/app/src/resources/runs/useRunStatus.ts @@ -1,9 +1,7 @@ -import { useRef } from 'react' import { RUN_ACTION_TYPE_PLAY, RUN_STATUS_IDLE, RUN_STATUS_RUNNING, - RUN_STATUSES_TERMINAL, } from '@opentrons/api-client' import { useNotifyRunQuery } from './useNotifyRunQuery' import { DEFAULT_STATUS_REFETCH_INTERVAL } from './constants' @@ -15,14 +13,8 @@ export function useRunStatus( runId: string | null, options?: UseQueryOptions ): RunStatus | null { - const lastRunStatus = useRef(null) - const { data } = useNotifyRunQuery(runId ?? null, { refetchInterval: DEFAULT_STATUS_REFETCH_INTERVAL, - enabled: - lastRunStatus.current == null || - !(RUN_STATUSES_TERMINAL as RunStatus[]).includes(lastRunStatus.current), - onSuccess: data => (lastRunStatus.current = data?.data?.status ?? null), ...options, }) diff --git a/components/src/atoms/Checkbox/index.tsx b/components/src/atoms/Checkbox/index.tsx index 02fa36da6d4..8ace61cb0bf 100644 --- a/components/src/atoms/Checkbox/index.tsx +++ b/components/src/atoms/Checkbox/index.tsx @@ -13,7 +13,6 @@ import { } from '../../styles' import { RESPONSIVENESS, SPACING } from '../../ui-style-constants' import { StyledText } from '../StyledText' -import { truncateString } from '../../utils' export interface CheckboxProps { /** checkbox is checked if value is true */ @@ -41,7 +40,6 @@ export function Checkbox(props: CheckboxProps): JSX.Element { width = FLEX_MAX_CONTENT, type = 'round', } = props - const truncatedLabel = truncateString(labelText, 25) const CHECKBOX_STYLE = css` width: ${width}; @@ -89,7 +87,7 @@ export function Checkbox(props: CheckboxProps): JSX.Element { css={CHECKBOX_STYLE} > - {truncatedLabel} + {labelText} diff --git a/components/src/atoms/InputField/index.tsx b/components/src/atoms/InputField/index.tsx index fc1e1a4560f..59b404b476f 100644 --- a/components/src/atoms/InputField/index.tsx +++ b/components/src/atoms/InputField/index.tsx @@ -63,8 +63,8 @@ export interface InputFieldProps { /** if true, clear out value and add '-' placeholder */ isIndeterminate?: boolean /** if input type is number, these are the min and max values */ - max?: number - min?: number + max?: number | string + min?: number | string /** horizontal text alignment for title, input, and (sub)captions */ textAlign?: | typeof TYPOGRAPHY.textAlignLeft diff --git a/components/src/atoms/ListItem/__tests__/ListItem.test.tsx b/components/src/atoms/ListItem/__tests__/ListItem.test.tsx index 9cb34e3524f..ce15aec5d8e 100644 --- a/components/src/atoms/ListItem/__tests__/ListItem.test.tsx +++ b/components/src/atoms/ListItem/__tests__/ListItem.test.tsx @@ -33,7 +33,7 @@ describe('ListItem', () => { render(props) screen.getByText('mock listitem content') const listItem = screen.getByTestId('ListItem_noActive') - expect(listItem).toHaveStyle(`backgroundColor: ${COLORS.grey30}`) + expect(listItem).toHaveStyle(`backgroundColor: ${COLORS.grey20}`) expect(listItem).toHaveStyle(`borderRadius: ${BORDERS.borderRadius4}`) }) it('should render correct style - success', () => { diff --git a/components/src/atoms/ListItem/index.tsx b/components/src/atoms/ListItem/index.tsx index cb61f0a4d3c..31beea0040c 100644 --- a/components/src/atoms/ListItem/index.tsx +++ b/components/src/atoms/ListItem/index.tsx @@ -28,7 +28,7 @@ const LISTITEM_PROPS_BY_TYPE: Record< backgroundColor: COLORS.red35, }, noActive: { - backgroundColor: COLORS.grey30, + backgroundColor: COLORS.grey20, }, success: { backgroundColor: COLORS.green35, diff --git a/components/src/hardware-sim/Deck/MoveLabwareOnDeck.tsx b/components/src/hardware-sim/Deck/MoveLabwareOnDeck.tsx index 3dec72c574f..171beb0e597 100644 --- a/components/src/hardware-sim/Deck/MoveLabwareOnDeck.tsx +++ b/components/src/hardware-sim/Deck/MoveLabwareOnDeck.tsx @@ -1,12 +1,12 @@ import * as React from 'react' import styled from 'styled-components' -import flatMap from 'lodash/flatMap' import { animated, useSpring, easings } from '@react-spring/web' import { getDeckDefFromRobotType, getModuleDef2, getPositionFromSlotId, } from '@opentrons/shared-data' +import { LabwareRender } from '../Labware' import { COLORS } from '../../helix-design-system' import { IDENTITY_AFFINE_TRANSFORM, multiplyMatrices } from '../utils' @@ -14,7 +14,6 @@ import { BaseDeck } from '../BaseDeck' import type { LoadedLabware, - LabwareWell, LoadedModule, Coordinates, LabwareDefinition2, @@ -127,7 +126,6 @@ function getLabwareCoordinates({ } } -const OUTLINE_THICKNESS_MM = 3 const SPLASH_Y_BUFFER_MM = 10 interface MoveLabwareOnDeckProps extends StyleProps { @@ -212,7 +210,9 @@ export function MoveLabwareOnDeck( loop: true, }) - if (deckDef == null) return null + if (deckDef == null) { + return null + } return ( - - {flatMap( - movedLabwareDef.ordering, - (row: string[], i: number, c: string[][]) => - row.map(wellName => ( - - )) - )} + - ) : ( - - ) -} diff --git a/components/src/hardware-sim/Deck/RobotCoordsText.tsx b/components/src/hardware-sim/Deck/RobotCoordsText.tsx index 73240e3fbca..92dabd1cfc4 100644 --- a/components/src/hardware-sim/Deck/RobotCoordsText.tsx +++ b/components/src/hardware-sim/Deck/RobotCoordsText.tsx @@ -1,17 +1,31 @@ import type * as React from 'react' +import { css } from 'styled-components' export interface RobotCoordsTextProps extends React.ComponentProps<'text'> { x: number y: number children?: React.ReactNode + canHighlight?: boolean } /** SVG text reflected to use take robot coordinates as props */ // TODO: Ian 2019-05-07 reconcile this with Brian's version export function RobotCoordsText(props: RobotCoordsTextProps): JSX.Element { - const { x, y, children, ...additionalProps } = props + const { x, y, children, canHighlight = true, ...additionalProps } = props return ( - + {children} ) diff --git a/components/src/hardware-sim/Labware/labwareInternals/WellLabels.tsx b/components/src/hardware-sim/Labware/labwareInternals/WellLabels.tsx index bc6d0764768..59fa2723c34 100644 --- a/components/src/hardware-sim/Labware/labwareInternals/WellLabels.tsx +++ b/components/src/hardware-sim/Labware/labwareInternals/WellLabels.tsx @@ -73,6 +73,7 @@ const Labels = (props: { ? highlightColor : fillColor } + canHighlight={false} > {(props.isLetterColumn === true ? /[A-Z]+/g : /\d+/g).exec( wellName diff --git a/components/src/icons/icon-data.ts b/components/src/icons/icon-data.ts index 9c97c5bee91..b5e928005dc 100644 --- a/components/src/icons/icon-data.ts +++ b/components/src/icons/icon-data.ts @@ -803,6 +803,11 @@ export const ICON_DATA_BY_NAME: Record< 'M12 22C9.72 22 7.81 21.22 6.29 19.65C4.76 18.08 4 16.13 4 13.8C4 12.13 4.66 10.32 5.99 8.36C7.32 6.4 9.32 4.28 12 2C14.68 4.28 16.69 6.4 18.01 8.36C19.33 10.32 20 12.13 20 13.8C20 16.13 19.24 18.08 17.71 19.65C16.18 21.22 14.28 22 12 22ZM12 20C13.73 20 15.17 19.41 16.3 18.24C17.43 17.07 18 15.59 18 13.8C18 12.58 17.5 11.21 16.49 9.67C15.48 8.14 13.99 6.46 12 4.64C10.02 6.46 8.52 8.13 7.51 9.67C6.5 11.2 6 12.58 6 13.8C6 15.58 6.57 17.06 7.7 18.24C8.83 19.42 10.27 20 12 20Z M8.55 11.42C7.95 12.38 7.63 13.29 7.63 14.12C7.63 15.4 8.05 16.46 8.88 17.32C9.71 18.18 10.75 18.6 12 18.6C13.25 18.6 14.29 18.17 15.12 17.32C15.95 16.46 16.37 15.4 16.37 14.12C16.37 13.29 16.05 12.39 15.45 11.42H8.55Z', viewBox: '0 0 24 24', }, + 'well-order': { + path: + 'M16.9506 5.10701L13.2894 7.9553C13.1824 8.05084 13 7.983 13 7.84844V5.75H6.81068L15.9627 14.902C16.1772 15.1165 16.2413 15.4391 16.1252 15.7193C16.0092 15.9996 15.7357 16.1823 15.4323 16.1823H5.12116C4.70694 16.1823 4.37116 15.8465 4.37116 15.4323C4.37116 15.0181 4.70694 14.6823 5.12116 14.6823H13.6217L4.46969 5.53033C4.25519 5.31583 4.19103 4.99324 4.30711 4.71299C4.4232 4.43273 4.69668 4.25 5.00002 4.25H13V2.15131C13 2.01675 13.183 1.94947 13.2894 2.04445L16.9506 4.8933C17.0165 4.9521 17.0165 5.04821 16.9506 5.10701Z', + viewBox: '0 0 20 20', + }, wifi: { path: 'M3.16848 9.22683C4.91915 7.43693 7.33915 6.33359 9.99973 6.33359C12.6604 6.33359 15.0804 7.43697 16.8311 9.22693L17.9996 8.03818C15.9522 5.95529 13.1239 4.66699 9.99973 4.66699C6.87563 4.66699 4.0473 5.95525 2 8.03809L3.16848 9.22683ZM6.1685 12.2783C7.15141 11.2696 8.51069 10.6495 9.99953 10.6495C11.4886 10.6495 12.848 11.2698 13.8309 12.2787L14.9994 11.0899C13.7199 9.78811 11.9521 8.98291 9.99953 8.98291C8.04712 8.98291 6.27954 9.78795 5 11.0895L6.1685 12.2783ZM10.0002 14.9654C9.6831 14.9654 9.38403 15.1024 9.16876 15.3306L8.00012 14.1417C8.51196 13.6209 9.2191 13.2988 10.0002 13.2988C10.7811 13.2988 11.4881 13.6208 11.9999 14.1414L10.8313 15.3303C10.6161 15.1023 10.3171 14.9654 10.0002 14.9654Z', diff --git a/components/src/molecules/DropdownMenu/index.tsx b/components/src/molecules/DropdownMenu/index.tsx index 5dfa6a985f4..851e759abca 100644 --- a/components/src/molecules/DropdownMenu/index.tsx +++ b/components/src/molecules/DropdownMenu/index.tsx @@ -206,6 +206,7 @@ export function DropdownMenu(props: DropdownMenuProps): JSX.Element { flexDirection={DIRECTION_COLUMN} ref={dropDownMenuWrapperRef} gridGap={SPACING.spacing4} + width={width} > {title !== null ? ( {title} @@ -284,7 +285,7 @@ export function DropdownMenu(props: DropdownMenuProps): JSX.Element { {filterOptions.map((option, index) => ( { diff --git a/components/src/molecules/InfoScreen/index.tsx b/components/src/molecules/InfoScreen/index.tsx index 5c5d64a0365..a70e2c409d7 100644 --- a/components/src/molecules/InfoScreen/index.tsx +++ b/components/src/molecules/InfoScreen/index.tsx @@ -1,23 +1,29 @@ import { BORDERS, COLORS } from '../../helix-design-system' -import { SPACING, TYPOGRAPHY } from '../../ui-style-constants/index' -import { LegacyStyledText } from '../../atoms/StyledText' +import { SPACING } from '../../ui-style-constants/index' +import { StyledText } from '../../atoms/StyledText' import { Icon } from '../../icons' import { Flex } from '../../primitives' -import { ALIGN_CENTER, DIRECTION_COLUMN } from '../../styles' +import { ALIGN_CENTER, DIRECTION_COLUMN, JUSTIFY_CENTER } from '../../styles' interface InfoScreenProps { content: string + subContent?: string backgroundColor?: string + height?: string } export function InfoScreen({ content, + subContent, backgroundColor = COLORS.grey30, + height, }: InfoScreenProps): JSX.Element { return ( - - {content} - + + {content} + {subContent != null ? ( + + {subContent} + + ) : null} + ) } diff --git a/hardware-testing/Makefile b/hardware-testing/Makefile index 1249243415e..87edd408aa7 100755 --- a/hardware-testing/Makefile +++ b/hardware-testing/Makefile @@ -99,6 +99,7 @@ test-photometric-multi: test-photometric: $(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 + $(python) -m hardware_testing.gravimetric --photometric --simulate --pipette 200 --channels 96 --tip 50 --trials 1 .PHONY: test-gravimetric-single test-gravimetric-single: @@ -121,6 +122,7 @@ test-gravimetric-multi: .PHONY: test-gravimetric-96 test-gravimetric-96: $(python) -m hardware_testing.gravimetric --simulate --pipette 1000 --channels 96 --trials 2 --no-blank + $(python) -m hardware_testing.gravimetric --simulate --pipette 200 --channels 96 --trials 2 --no-blank .PHONY: test-gravimetric test-gravimetric: @@ -138,6 +140,7 @@ test-production-qc: $(python) -m hardware_testing.production_qc.robot_assembly_qc_ot3 --simulate $(python) -m hardware_testing.production_qc.gripper_assembly_qc_ot3 --simulate $(python) -m hardware_testing.production_qc.ninety_six_assembly_qc_ot3 --simulate + $(python) -m hardware_testing.production_qc.ninety_six_assembly_qc_ot3 --simulate --pipette 200 $(python) -m hardware_testing.production_qc.stress_test_qc_ot3 --simulate $(python) -m hardware_testing.production_qc.firmware_check --simulate $(python) -m hardware_testing.production_qc.belt_calibration_ot3 --simulate diff --git a/hardware-testing/hardware_testing/gravimetric/__main__.py b/hardware-testing/hardware_testing/gravimetric/__main__.py index 4b08596ebf5..f297f0e7e3a 100644 --- a/hardware-testing/hardware_testing/gravimetric/__main__.py +++ b/hardware-testing/hardware_testing/gravimetric/__main__.py @@ -12,6 +12,7 @@ from hardware_testing.data import create_run_id_and_start_time, ui, get_git_description from hardware_testing.protocols.gravimetric_lpc.gravimetric import ( gravimetric_ot3_p1000_96, + gravimetric_ot3_p200_96, gravimetric_ot3_p1000_multi, gravimetric_ot3_p1000_single, gravimetric_ot3_p50_single, @@ -26,6 +27,7 @@ photometric_ot3_p1000_single, photometric_ot3_p50_multi, photometric_ot3_p1000_96, + photometric_ot3_p200_96, photometric_ot3_p50_single, ) @@ -56,6 +58,9 @@ 1: gravimetric_ot3_p50_single, 8: gravimetric_ot3_p50_multi, }, + 200: { + 96: gravimetric_ot3_p200_96, + }, 1000: { 1: gravimetric_ot3_p1000_single, 8: gravimetric_ot3_p1000_multi, @@ -68,6 +73,12 @@ 1: {50: gravimetric_ot3_p50_single}, 8: {50: gravimetric_ot3_p50_multi_50ul_tip_increment}, }, + 200: { + 96: { + 50: gravimetric_ot3_p200_96, + 200: gravimetric_ot3_p200_96, + }, + }, 1000: { 1: { 50: gravimetric_ot3_p1000_single, @@ -92,6 +103,9 @@ 1: "p50_single_flex", 8: "p50_multi_flex", }, + 200: { + 96: "p200_96_flex", + }, 1000: { 1: "p1000_single_flex", 8: "p1000_multi_flex", @@ -109,6 +123,9 @@ 50: photometric_ot3_p50_multi, }, }, + 200: { + 96: {50: photometric_ot3_p200_96, 200: photometric_ot3_p200_96}, + }, 1000: { 1: { 50: photometric_ot3_p1000_single, @@ -553,7 +570,7 @@ def _main( 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("--pipette", type=int, choices=[50, 200, 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) diff --git a/hardware-testing/hardware_testing/gravimetric/config.py b/hardware-testing/hardware_testing/gravimetric/config.py index 304087748d1..968de3ecca7 100644 --- a/hardware-testing/hardware_testing/gravimetric/config.py +++ b/hardware-testing/hardware_testing/gravimetric/config.py @@ -102,6 +102,20 @@ class PhotometricConfig(VolumetricConfig): }, }, }, + 200: { + 96: { + 50: { + "mount_speed": 5, + "plunger_speed": 20, + "sensor_threshold_pascals": 15, + }, + 200: { + "mount_speed": 5, + "plunger_speed": 20, + "sensor_threshold_pascals": 15, + }, + } + }, 1000: { 1: { 50: { @@ -199,6 +213,10 @@ def _get_liquid_probe_settings( ], }, 96: { + 200: [ + (50, [1.0, 50.0]), # T50 + (200, [200.0]), # T200 + ], 1000: [ # P1000 (50, [5.0]), # T50 (200, [200.0]), # T200 @@ -260,6 +278,10 @@ def _get_liquid_probe_settings( ], }, 96: { + 200: [ + (50, [1.0, 5.0]), # T50 + (200, [200.0]), # T200 + ], 1000: [ # P1000 (50, [5.0]), # T50 (200, [200.0]), # T200 @@ -340,6 +362,21 @@ def _get_liquid_probe_settings( }, }, 96: { + 200: { + 50: { # T50 + 1.0: (2.5, 2.0), + 2.0: (2.5, 2.0), + 3.0: (2.5, 2.0), + 5.0: (2.5, 2.0), + 10.0: (3.1, 1.7), + 50.0: (1.5, 0.75), + }, + 200: { # T200 + 5.0: (2.5, 4.0), + 50.0: (1.5, 2.0), + 200.0: (1.4, 0.9), + }, + }, 1000: { # P1000 50: { # T50 1.0: (2.5, 2.0), diff --git a/hardware-testing/hardware_testing/gravimetric/increments.py b/hardware-testing/hardware_testing/gravimetric/increments.py index bbe79d0785f..76c90a45b44 100644 --- a/hardware-testing/hardware_testing/gravimetric/increments.py +++ b/hardware-testing/hardware_testing/gravimetric/increments.py @@ -266,6 +266,62 @@ }, }, 96: { + 200: { + 50: { + "default": [ + 2.000, + 3.000, + 4.000, + 5.000, + 6.000, + 7.000, + 8.000, + 9.000, + 10.000, + 15.000, + 25.000, + 40.000, + 60.000, + ], + "lowVolumeDefault": [ + 1.100, + 1.200, + 1.370, + 1.700, + 2.040, + 2.660, + 3.470, + 3.960, + 4.350, + 4.800, + 5.160, + 5.890, + 6.730, + 8.200, + 10.020, + 11.100, + 14.910, + 28.940, + 48.27, + ], + }, + 200: { + "default": [ + 2.000, + 3.000, + 4.000, + 5.000, + 6.000, + 7.000, + 8.000, + 9.000, + 10.000, + 50.000, + 100.000, + 220.000, + ], + }, + }, 1000: { # FIXME: need to update based on DVT data 50: { "default": [ @@ -322,7 +378,7 @@ 1075.00, ], }, - } + }, }, } diff --git a/hardware-testing/hardware_testing/gravimetric/liquid_class/defaults.py b/hardware-testing/hardware_testing/gravimetric/liquid_class/defaults.py index a37f21b1b36..c7021e60585 100644 --- a/hardware-testing/hardware_testing/gravimetric/liquid_class/defaults.py +++ b/hardware-testing/hardware_testing/gravimetric/liquid_class/defaults.py @@ -357,6 +357,66 @@ ), }, }, + 200: { # P200 + 50: { # T50 + 5: DispenseSettings( # 5uL + 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, + z_retract_discontinuity=_default_retract_discontinuity, + z_retract_height=_default_retract_mm, + blow_out_submerged=5, + ), + 10: DispenseSettings( # 10uL + 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, + z_retract_discontinuity=_default_retract_discontinuity, + z_retract_height=_default_retract_mm, + blow_out_submerged=5, + ), + 50: DispenseSettings( # 50uL + 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, + z_retract_discontinuity=_default_retract_discontinuity, + z_retract_height=_default_retract_mm, + blow_out_submerged=5, + ), + }, + 200: { # T200 + 5: DispenseSettings( # 5uL + 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, + z_retract_discontinuity=_default_retract_discontinuity, + z_retract_height=_default_retract_mm, + blow_out_submerged=5, + ), + 50: DispenseSettings( # 50uL + 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, + z_retract_discontinuity=_default_retract_discontinuity, + z_retract_height=_default_retract_mm, + blow_out_submerged=5, + ), + 200: DispenseSettings( # 200uL + 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, + z_retract_discontinuity=_default_retract_discontinuity, + z_retract_height=_default_retract_mm, + blow_out_submerged=5, + ), + }, + }, }, } @@ -728,6 +788,72 @@ ), }, }, + 200: { # P200 + 50: { # T50 + 5: AspirateSettings( # 5uL + 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, + z_retract_discontinuity=_default_retract_discontinuity, + z_retract_height=_default_retract_mm, + leading_air_gap=0, + trailing_air_gap=0.1, + ), + 10: AspirateSettings( # 10uL + 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, + z_retract_discontinuity=_default_retract_discontinuity, + z_retract_height=_default_retract_mm, + leading_air_gap=0, + trailing_air_gap=0.1, + ), + 50: AspirateSettings( # 50uL + 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, + z_retract_discontinuity=_default_retract_discontinuity, + z_retract_height=_default_retract_mm, + leading_air_gap=0, + trailing_air_gap=0.1, + ), + }, + 200: { # T200 + 5: AspirateSettings( # 5uL + 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, + z_retract_discontinuity=_default_retract_discontinuity, + z_retract_height=_default_retract_mm, + leading_air_gap=0, + trailing_air_gap=2, + ), + 50: AspirateSettings( # 50uL + 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, + z_retract_discontinuity=_default_retract_discontinuity, + z_retract_height=_default_retract_mm, + leading_air_gap=0, + trailing_air_gap=3.5, + ), + 200: AspirateSettings( # 200uL + 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, + z_retract_discontinuity=_default_retract_discontinuity, + z_retract_height=_default_retract_mm, + leading_air_gap=0, + trailing_air_gap=2, + ), + }, + }, }, } diff --git a/hardware-testing/hardware_testing/labware/hellma_reference_plate/1.json b/hardware-testing/hardware_testing/labware/hellma_reference_plate/1.json new file mode 100644 index 00000000000..3b56adf18bc --- /dev/null +++ b/hardware-testing/hardware_testing/labware/hellma_reference_plate/1.json @@ -0,0 +1,1127 @@ +{ + "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": "Hellma", + "brandId": [ + "666-R013 Reference Plate" + ] + }, + "metadata": { + "displayName": "Hellma Reference Plate", + "displayCategory": "wellPlate", + "displayVolumeUnits": "µL", + "tags": [] + }, + "dimensions": { + "xDimension": 127, + "yDimension": 85.5, + "zDimension": 13 + }, + "wells": { + "A1": { + "depth": 1, + "totalLiquidVolume": 1, + "shape": "circular", + "diameter": 6.6, + "x": 14.38, + "y": 74.26, + "z": 12 + }, + "B1": { + "depth": 1, + "totalLiquidVolume": 1, + "shape": "circular", + "diameter": 6.6, + "x": 14.38, + "y": 65.26, + "z": 12 + }, + "C1": { + "depth": 1, + "totalLiquidVolume": 1, + "shape": "circular", + "diameter": 6.6, + "x": 14.38, + "y": 56.26, + "z": 12 + }, + "D1": { + "depth": 1, + "totalLiquidVolume": 1, + "shape": "circular", + "diameter": 6.6, + "x": 14.38, + "y": 47.26, + "z": 12 + }, + "E1": { + "depth": 1, + "totalLiquidVolume": 1, + "shape": "circular", + "diameter": 6.6, + "x": 14.38, + "y": 38.26, + "z": 12 + }, + "F1": { + "depth": 1, + "totalLiquidVolume": 1, + "shape": "circular", + "diameter": 6.6, + "x": 14.38, + "y": 29.26, + "z": 12 + }, + "G1": { + "depth": 1, + "totalLiquidVolume": 1, + "shape": "circular", + "diameter": 6.6, + "x": 14.38, + "y": 20.26, + "z": 12 + }, + "H1": { + "depth": 1, + "totalLiquidVolume": 1, + "shape": "circular", + "diameter": 6.6, + "x": 14.38, + "y": 11.26, + "z": 12 + }, + "A2": { + "depth": 1, + "totalLiquidVolume": 1, + "shape": "circular", + "diameter": 6.6, + "x": 23.38, + "y": 74.26, + "z": 12 + }, + "B2": { + "depth": 1, + "totalLiquidVolume": 1, + "shape": "circular", + "diameter": 6.6, + "x": 23.38, + "y": 65.26, + "z": 12 + }, + "C2": { + "depth": 1, + "totalLiquidVolume": 1, + "shape": "circular", + "diameter": 6.6, + "x": 23.38, + "y": 56.26, + "z": 12 + }, + "D2": { + "depth": 1, + "totalLiquidVolume": 1, + "shape": "circular", + "diameter": 6.6, + "x": 23.38, + "y": 47.26, + "z": 12 + }, + "E2": { + "depth": 1, + "totalLiquidVolume": 1, + "shape": "circular", + "diameter": 6.6, + "x": 23.38, + "y": 38.26, + "z": 12 + }, + "F2": { + "depth": 1, + "totalLiquidVolume": 1, + "shape": "circular", + "diameter": 6.6, + "x": 23.38, + "y": 29.26, + "z": 12 + }, + "G2": { + "depth": 1, + "totalLiquidVolume": 1, + "shape": "circular", + "diameter": 6.6, + "x": 23.38, + "y": 20.26, + "z": 12 + }, + "H2": { + "depth": 1, + "totalLiquidVolume": 1, + "shape": "circular", + "diameter": 6.6, + "x": 23.38, + "y": 11.26, + "z": 12 + }, + "A3": { + "depth": 1, + "totalLiquidVolume": 1, + "shape": "circular", + "diameter": 6.6, + "x": 32.38, + "y": 74.26, + "z": 12 + }, + "B3": { + "depth": 1, + "totalLiquidVolume": 1, + "shape": "circular", + "diameter": 6.6, + "x": 32.38, + "y": 65.26, + "z": 12 + }, + "C3": { + "depth": 1, + "totalLiquidVolume": 1, + "shape": "circular", + "diameter": 6.6, + "x": 32.38, + "y": 56.26, + "z": 12 + }, + "D3": { + "depth": 1, + "totalLiquidVolume": 1, + "shape": "circular", + "diameter": 6.6, + "x": 32.38, + "y": 47.26, + "z": 12 + }, + "E3": { + "depth": 1, + "totalLiquidVolume": 1, + "shape": "circular", + "diameter": 6.6, + "x": 32.38, + "y": 38.26, + "z": 12 + }, + "F3": { + "depth": 1, + "totalLiquidVolume": 1, + "shape": "circular", + "diameter": 6.6, + "x": 32.38, + "y": 29.26, + "z": 12 + }, + "G3": { + "depth": 1, + "totalLiquidVolume": 1, + "shape": "circular", + "diameter": 6.6, + "x": 32.38, + "y": 20.26, + "z": 12 + }, + "H3": { + "depth": 1, + "totalLiquidVolume": 1, + "shape": "circular", + "diameter": 6.6, + "x": 32.38, + "y": 11.26, + "z": 12 + }, + "A4": { + "depth": 1, + "totalLiquidVolume": 1, + "shape": "circular", + "diameter": 6.6, + "x": 41.38, + "y": 74.26, + "z": 12 + }, + "B4": { + "depth": 1, + "totalLiquidVolume": 1, + "shape": "circular", + "diameter": 6.6, + "x": 41.38, + "y": 65.26, + "z": 12 + }, + "C4": { + "depth": 1, + "totalLiquidVolume": 1, + "shape": "circular", + "diameter": 6.6, + "x": 41.38, + "y": 56.26, + "z": 12 + }, + "D4": { + "depth": 1, + "totalLiquidVolume": 1, + "shape": "circular", + "diameter": 6.6, + "x": 41.38, + "y": 47.26, + "z": 12 + }, + "E4": { + "depth": 1, + "totalLiquidVolume": 1, + "shape": "circular", + "diameter": 6.6, + "x": 41.38, + "y": 38.26, + "z": 12 + }, + "F4": { + "depth": 1, + "totalLiquidVolume": 1, + "shape": "circular", + "diameter": 6.6, + "x": 41.38, + "y": 29.26, + "z": 12 + }, + "G4": { + "depth": 1, + "totalLiquidVolume": 1, + "shape": "circular", + "diameter": 6.6, + "x": 41.38, + "y": 20.26, + "z": 12 + }, + "H4": { + "depth": 1, + "totalLiquidVolume": 1, + "shape": "circular", + "diameter": 6.6, + "x": 41.38, + "y": 11.26, + "z": 12 + }, + "A5": { + "depth": 1, + "totalLiquidVolume": 1, + "shape": "circular", + "diameter": 6.6, + "x": 50.38, + "y": 74.26, + "z": 12 + }, + "B5": { + "depth": 1, + "totalLiquidVolume": 1, + "shape": "circular", + "diameter": 6.6, + "x": 50.38, + "y": 65.26, + "z": 12 + }, + "C5": { + "depth": 1, + "totalLiquidVolume": 1, + "shape": "circular", + "diameter": 6.6, + "x": 50.38, + "y": 56.26, + "z": 12 + }, + "D5": { + "depth": 1, + "totalLiquidVolume": 1, + "shape": "circular", + "diameter": 6.6, + "x": 50.38, + "y": 47.26, + "z": 12 + }, + "E5": { + "depth": 1, + "totalLiquidVolume": 1, + "shape": "circular", + "diameter": 6.6, + "x": 50.38, + "y": 38.26, + "z": 12 + }, + "F5": { + "depth": 1, + "totalLiquidVolume": 1, + "shape": "circular", + "diameter": 6.6, + "x": 50.38, + "y": 29.26, + "z": 12 + }, + "G5": { + "depth": 1, + "totalLiquidVolume": 1, + "shape": "circular", + "diameter": 6.6, + "x": 50.38, + "y": 20.26, + "z": 12 + }, + "H5": { + "depth": 1, + "totalLiquidVolume": 1, + "shape": "circular", + "diameter": 6.6, + "x": 50.38, + "y": 11.26, + "z": 12 + }, + "A6": { + "depth": 1, + "totalLiquidVolume": 1, + "shape": "circular", + "diameter": 6.6, + "x": 59.38, + "y": 74.26, + "z": 12 + }, + "B6": { + "depth": 1, + "totalLiquidVolume": 1, + "shape": "circular", + "diameter": 6.6, + "x": 59.38, + "y": 65.26, + "z": 12 + }, + "C6": { + "depth": 1, + "totalLiquidVolume": 1, + "shape": "circular", + "diameter": 6.6, + "x": 59.38, + "y": 56.26, + "z": 12 + }, + "D6": { + "depth": 1, + "totalLiquidVolume": 1, + "shape": "circular", + "diameter": 6.6, + "x": 59.38, + "y": 47.26, + "z": 12 + }, + "E6": { + "depth": 1, + "totalLiquidVolume": 1, + "shape": "circular", + "diameter": 6.6, + "x": 59.38, + "y": 38.26, + "z": 12 + }, + "F6": { + "depth": 1, + "totalLiquidVolume": 1, + "shape": "circular", + "diameter": 6.6, + "x": 59.38, + "y": 29.26, + "z": 12 + }, + "G6": { + "depth": 1, + "totalLiquidVolume": 1, + "shape": "circular", + "diameter": 6.6, + "x": 59.38, + "y": 20.26, + "z": 12 + }, + "H6": { + "depth": 1, + "totalLiquidVolume": 1, + "shape": "circular", + "diameter": 6.6, + "x": 59.38, + "y": 11.26, + "z": 12 + }, + "A7": { + "depth": 1, + "totalLiquidVolume": 1, + "shape": "circular", + "diameter": 6.6, + "x": 68.38, + "y": 74.26, + "z": 12 + }, + "B7": { + "depth": 1, + "totalLiquidVolume": 1, + "shape": "circular", + "diameter": 6.6, + "x": 68.38, + "y": 65.26, + "z": 12 + }, + "C7": { + "depth": 1, + "totalLiquidVolume": 1, + "shape": "circular", + "diameter": 6.6, + "x": 68.38, + "y": 56.26, + "z": 12 + }, + "D7": { + "depth": 1, + "totalLiquidVolume": 1, + "shape": "circular", + "diameter": 6.6, + "x": 68.38, + "y": 47.26, + "z": 12 + }, + "E7": { + "depth": 1, + "totalLiquidVolume": 1, + "shape": "circular", + "diameter": 6.6, + "x": 68.38, + "y": 38.26, + "z": 12 + }, + "F7": { + "depth": 1, + "totalLiquidVolume": 1, + "shape": "circular", + "diameter": 6.6, + "x": 68.38, + "y": 29.26, + "z": 12 + }, + "G7": { + "depth": 1, + "totalLiquidVolume": 1, + "shape": "circular", + "diameter": 6.6, + "x": 68.38, + "y": 20.26, + "z": 12 + }, + "H7": { + "depth": 1, + "totalLiquidVolume": 1, + "shape": "circular", + "diameter": 6.6, + "x": 68.38, + "y": 11.26, + "z": 12 + }, + "A8": { + "depth": 1, + "totalLiquidVolume": 1, + "shape": "circular", + "diameter": 6.6, + "x": 77.38, + "y": 74.26, + "z": 12 + }, + "B8": { + "depth": 1, + "totalLiquidVolume": 1, + "shape": "circular", + "diameter": 6.6, + "x": 77.38, + "y": 65.26, + "z": 12 + }, + "C8": { + "depth": 1, + "totalLiquidVolume": 1, + "shape": "circular", + "diameter": 6.6, + "x": 77.38, + "y": 56.26, + "z": 12 + }, + "D8": { + "depth": 1, + "totalLiquidVolume": 1, + "shape": "circular", + "diameter": 6.6, + "x": 77.38, + "y": 47.26, + "z": 12 + }, + "E8": { + "depth": 1, + "totalLiquidVolume": 1, + "shape": "circular", + "diameter": 6.6, + "x": 77.38, + "y": 38.26, + "z": 12 + }, + "F8": { + "depth": 1, + "totalLiquidVolume": 1, + "shape": "circular", + "diameter": 6.6, + "x": 77.38, + "y": 29.26, + "z": 12 + }, + "G8": { + "depth": 1, + "totalLiquidVolume": 1, + "shape": "circular", + "diameter": 6.6, + "x": 77.38, + "y": 20.26, + "z": 12 + }, + "H8": { + "depth": 1, + "totalLiquidVolume": 1, + "shape": "circular", + "diameter": 6.6, + "x": 77.38, + "y": 11.26, + "z": 12 + }, + "A9": { + "depth": 1, + "totalLiquidVolume": 1, + "shape": "circular", + "diameter": 6.6, + "x": 86.38, + "y": 74.26, + "z": 12 + }, + "B9": { + "depth": 1, + "totalLiquidVolume": 1, + "shape": "circular", + "diameter": 6.6, + "x": 86.38, + "y": 65.26, + "z": 12 + }, + "C9": { + "depth": 1, + "totalLiquidVolume": 1, + "shape": "circular", + "diameter": 6.6, + "x": 86.38, + "y": 56.26, + "z": 12 + }, + "D9": { + "depth": 1, + "totalLiquidVolume": 1, + "shape": "circular", + "diameter": 6.6, + "x": 86.38, + "y": 47.26, + "z": 12 + }, + "E9": { + "depth": 1, + "totalLiquidVolume": 1, + "shape": "circular", + "diameter": 6.6, + "x": 86.38, + "y": 38.26, + "z": 12 + }, + "F9": { + "depth": 1, + "totalLiquidVolume": 1, + "shape": "circular", + "diameter": 6.6, + "x": 86.38, + "y": 29.26, + "z": 12 + }, + "G9": { + "depth": 1, + "totalLiquidVolume": 1, + "shape": "circular", + "diameter": 6.6, + "x": 86.38, + "y": 20.26, + "z": 12 + }, + "H9": { + "depth": 1, + "totalLiquidVolume": 1, + "shape": "circular", + "diameter": 6.6, + "x": 86.38, + "y": 11.26, + "z": 12 + }, + "A10": { + "depth": 1, + "totalLiquidVolume": 1, + "shape": "circular", + "diameter": 6.6, + "x": 95.38, + "y": 74.26, + "z": 12 + }, + "B10": { + "depth": 1, + "totalLiquidVolume": 1, + "shape": "circular", + "diameter": 6.6, + "x": 95.38, + "y": 65.26, + "z": 12 + }, + "C10": { + "depth": 1, + "totalLiquidVolume": 1, + "shape": "circular", + "diameter": 6.6, + "x": 95.38, + "y": 56.26, + "z": 12 + }, + "D10": { + "depth": 1, + "totalLiquidVolume": 1, + "shape": "circular", + "diameter": 6.6, + "x": 95.38, + "y": 47.26, + "z": 12 + }, + "E10": { + "depth": 1, + "totalLiquidVolume": 1, + "shape": "circular", + "diameter": 6.6, + "x": 95.38, + "y": 38.26, + "z": 12 + }, + "F10": { + "depth": 1, + "totalLiquidVolume": 1, + "shape": "circular", + "diameter": 6.6, + "x": 95.38, + "y": 29.26, + "z": 12 + }, + "G10": { + "depth": 1, + "totalLiquidVolume": 1, + "shape": "circular", + "diameter": 6.6, + "x": 95.38, + "y": 20.26, + "z": 12 + }, + "H10": { + "depth": 1, + "totalLiquidVolume": 1, + "shape": "circular", + "diameter": 6.6, + "x": 95.38, + "y": 11.26, + "z": 12 + }, + "A11": { + "depth": 1, + "totalLiquidVolume": 1, + "shape": "circular", + "diameter": 6.6, + "x": 104.38, + "y": 74.26, + "z": 12 + }, + "B11": { + "depth": 1, + "totalLiquidVolume": 1, + "shape": "circular", + "diameter": 6.6, + "x": 104.38, + "y": 65.26, + "z": 12 + }, + "C11": { + "depth": 1, + "totalLiquidVolume": 1, + "shape": "circular", + "diameter": 6.6, + "x": 104.38, + "y": 56.26, + "z": 12 + }, + "D11": { + "depth": 1, + "totalLiquidVolume": 1, + "shape": "circular", + "diameter": 6.6, + "x": 104.38, + "y": 47.26, + "z": 12 + }, + "E11": { + "depth": 1, + "totalLiquidVolume": 1, + "shape": "circular", + "diameter": 6.6, + "x": 104.38, + "y": 38.26, + "z": 12 + }, + "F11": { + "depth": 1, + "totalLiquidVolume": 1, + "shape": "circular", + "diameter": 6.6, + "x": 104.38, + "y": 29.26, + "z": 12 + }, + "G11": { + "depth": 1, + "totalLiquidVolume": 1, + "shape": "circular", + "diameter": 6.6, + "x": 104.38, + "y": 20.26, + "z": 12 + }, + "H11": { + "depth": 1, + "totalLiquidVolume": 1, + "shape": "circular", + "diameter": 6.6, + "x": 104.38, + "y": 11.26, + "z": 12 + }, + "A12": { + "depth": 1, + "totalLiquidVolume": 1, + "shape": "circular", + "diameter": 6.6, + "x": 113.38, + "y": 74.26, + "z": 12 + }, + "B12": { + "depth": 1, + "totalLiquidVolume": 1, + "shape": "circular", + "diameter": 6.6, + "x": 113.38, + "y": 65.26, + "z": 12 + }, + "C12": { + "depth": 1, + "totalLiquidVolume": 1, + "shape": "circular", + "diameter": 6.6, + "x": 113.38, + "y": 56.26, + "z": 12 + }, + "D12": { + "depth": 1, + "totalLiquidVolume": 1, + "shape": "circular", + "diameter": 6.6, + "x": 113.38, + "y": 47.26, + "z": 12 + }, + "E12": { + "depth": 1, + "totalLiquidVolume": 1, + "shape": "circular", + "diameter": 6.6, + "x": 113.38, + "y": 38.26, + "z": 12 + }, + "F12": { + "depth": 1, + "totalLiquidVolume": 1, + "shape": "circular", + "diameter": 6.6, + "x": 113.38, + "y": 29.26, + "z": 12 + }, + "G12": { + "depth": 1, + "totalLiquidVolume": 1, + "shape": "circular", + "diameter": 6.6, + "x": 113.38, + "y": 20.26, + "z": 12 + }, + "H12": { + "depth": 1, + "totalLiquidVolume": 1, + "shape": "circular", + "diameter": 6.6, + "x": 113.38, + "y": 11.26, + "z": 12 + } + }, + "groups": [ + { + "metadata": { + "wellBottomShape": "flat" + }, + "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": "irregular", + "quirks": [], + "isTiprack": false, + "isMagneticModuleCompatible": false, + "loadName": "hellma_reference_plate" + }, + "namespace": "custom_beta", + "version": 1, + "schemaVersion": 2, + "cornerOffsetFromSlot": { + "x": 0, + "y": 0, + "z": 0 + } +} diff --git a/hardware-testing/hardware_testing/opentrons_api/helpers_ot3.py b/hardware-testing/hardware_testing/opentrons_api/helpers_ot3.py index 6c4e05f1ba8..cf49ae8feff 100644 --- a/hardware-testing/hardware_testing/opentrons_api/helpers_ot3.py +++ b/hardware-testing/hardware_testing/opentrons_api/helpers_ot3.py @@ -109,9 +109,17 @@ def _create_fake_pipette_id(mount: OT3Mount, model: Optional[str]) -> Optional[s return None items = model.split("_") assert len(items) == 3 - size = "P1K" if items[0] == "p1000" else "P50" + match items[0]: + case "p1000": + size = "P1K" + version = 35 + case "p50": + size = "P50" + version = 35 + case "p200": + size = "P2H" + version = 30 channels = "S" if items[1] == "single" else "M" - 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/ninety_six_assembly_qc_ot3/__main__.py b/hardware-testing/hardware_testing/production_qc/ninety_six_assembly_qc_ot3/__main__.py index 7495e9f5d2c..0fef18a5684 100644 --- a/hardware-testing/hardware_testing/production_qc/ninety_six_assembly_qc_ot3/__main__.py +++ b/hardware-testing/hardware_testing/production_qc/ninety_six_assembly_qc_ot3/__main__.py @@ -14,11 +14,11 @@ async def _main(cfg: TestConfig) -> None: # BUILD REPORT test_name = Path(__file__).parent.name ui.print_title(test_name.replace("_", " ").upper()) - + pipette_string = "p1000_96_v3.4" if cfg.pipette == 1000 else "p200_96_v3.0" # BUILD API api = await helpers_ot3.build_async_ot3_hardware_api( is_simulating=cfg.simulate, - pipette_left="p1000_96_v3.4", + pipette_left=pipette_string, ) # CSV REPORT @@ -49,7 +49,7 @@ async def _main(cfg: TestConfig) -> None: # RUN TESTS for section, test_run in cfg.tests.items(): ui.print_title(section.value) - await test_run(api, report, section.value) + await test_run(api, report, section.value, cfg.pipette) # RELOAD PIPETTE ui.print_title("DONE") @@ -71,6 +71,7 @@ async def _main(cfg: TestConfig) -> None: parser.add_argument( f"--only-{s.value.lower()}".replace("_", "-"), action="store_true" ) + parser.add_argument("--pipette", type=int, choices=[200, 1000], default=1000) args = parser.parse_args() _t_sections = { s: f @@ -87,5 +88,7 @@ async def _main(cfg: TestConfig) -> None: for s, f in TESTS if not getattr(args, f"skip_{s.value.lower().replace('-', '_')}") } - _config = TestConfig(simulate=args.simulate, tests=_t_sections) + _config = TestConfig( + simulate=args.simulate, tests=_t_sections, pipette=args.pipette + ) asyncio.run(_main(_config)) diff --git a/hardware-testing/hardware_testing/production_qc/ninety_six_assembly_qc_ot3/config.py b/hardware-testing/hardware_testing/production_qc/ninety_six_assembly_qc_ot3/config.py index 1666eb0990f..54f19f6a660 100644 --- a/hardware-testing/hardware_testing/production_qc/ninety_six_assembly_qc_ot3/config.py +++ b/hardware-testing/hardware_testing/production_qc/ninety_six_assembly_qc_ot3/config.py @@ -1,7 +1,7 @@ """Config.""" from dataclasses import dataclass import enum -from typing import Dict, Callable +from typing import Dict, Callable, Literal from hardware_testing.data.csv_report import CSVReport, CSVSection @@ -34,6 +34,7 @@ class TestConfig: simulate: bool tests: Dict[TestSection, Callable] + pipette: Literal[200, 1000] TESTS = [ diff --git a/hardware-testing/hardware_testing/production_qc/ninety_six_assembly_qc_ot3/test_capacitance.py b/hardware-testing/hardware_testing/production_qc/ninety_six_assembly_qc_ot3/test_capacitance.py index 795d78863be..2a431ecaf9c 100644 --- a/hardware-testing/hardware_testing/production_qc/ninety_six_assembly_qc_ot3/test_capacitance.py +++ b/hardware-testing/hardware_testing/production_qc/ninety_six_assembly_qc_ot3/test_capacitance.py @@ -1,6 +1,6 @@ """Test Capacitance.""" from asyncio import sleep -from typing import List, Union, Tuple, Optional, cast +from typing import List, Union, Tuple, Optional, cast, Literal from opentrons_hardware.hardware_control.tool_sensors import capacitive_probe from opentrons_hardware.firmware_bindings.constants import NodeId, SensorId @@ -104,7 +104,9 @@ def _get_hover_and_probe_pos( return hover_pos + probe_offset, probe_pos + probe_offset -async def run(api: OT3API, report: CSVReport, section: str) -> None: +async def run( + api: OT3API, report: CSVReport, section: str, pipette: Literal[200, 1000] +) -> None: """Run.""" z_ax = Axis.Z_L p_ax = Axis.P_L diff --git a/hardware-testing/hardware_testing/production_qc/ninety_six_assembly_qc_ot3/test_droplets.py b/hardware-testing/hardware_testing/production_qc/ninety_six_assembly_qc_ot3/test_droplets.py index dc81f62eeb9..2f12e425576 100644 --- a/hardware-testing/hardware_testing/production_qc/ninety_six_assembly_qc_ot3/test_droplets.py +++ b/hardware-testing/hardware_testing/production_qc/ninety_six_assembly_qc_ot3/test_droplets.py @@ -1,7 +1,7 @@ """Test Droplets.""" from asyncio import sleep from time import time -from typing import List, Union, Tuple, Optional, Dict +from typing import List, Union, Tuple, Optional, Dict, Literal from opentrons.hardware_control.ot3api import OT3API from opentrons.hardware_control.motion_utilities import target_position_from_relative @@ -16,14 +16,11 @@ from hardware_testing.opentrons_api import helpers_ot3 from hardware_testing.opentrons_api.types import OT3Mount, Point, Axis -TIP_VOLUME = 1000 -ASPIRATE_VOLUME = 1000 NUM_SECONDS_TO_WAIT = 30 HOVER_HEIGHT_MM = 50 DEPTH_INTO_RESERVOIR_FOR_ASPIRATE = -24 DEPTH_INTO_RESERVOIR_FOR_DISPENSE = DEPTH_INTO_RESERVOIR_FOR_ASPIRATE -TIP_RACK_LABWARE = f"opentrons_flex_96_tiprack_{TIP_VOLUME}ul" RESERVOIR_LABWARE = "nest_1_reservoir_195ml" TIP_RACK_96_SLOT = 4 @@ -86,31 +83,31 @@ def get_reservoir_nominal() -> Point: return reservoir_a1_nominal -def get_tiprack_96_nominal() -> Point: +def get_tiprack_96_nominal(pipette: Literal[200, 1000]) -> Point: """Get nominal tiprack position for 96-tip pick-up.""" tip_rack_a1_nominal = helpers_ot3.get_theoretical_a1_position( - TIP_RACK_96_SLOT, TIP_RACK_LABWARE + TIP_RACK_96_SLOT, f"opentrons_flex_96_tiprack_{pipette}ul" ) return tip_rack_a1_nominal + Point(z=TIP_RACK_96_ADAPTER_HEIGHT) -def get_tiprack_partial_nominal() -> Point: +def get_tiprack_partial_nominal(pipette: Literal[200, 1000]) -> Point: """Get nominal tiprack position for partial-tip pick-up.""" tip_rack_a1_nominal = helpers_ot3.get_theoretical_a1_position( - TIP_RACK_PARTIAL_SLOT, TIP_RACK_LABWARE + TIP_RACK_PARTIAL_SLOT, f"opentrons_flex_96_tiprack_{pipette}ul" ) return tip_rack_a1_nominal async def aspirate_and_wait( - api: OT3API, reservoir: Point, seconds: int = 30 + api: OT3API, reservoir: Point, pipette: Literal[200, 1000], seconds: int = 30 ) -> Tuple[bool, float]: """Aspirate and wait.""" await helpers_ot3.move_to_arched_ot3(api, OT3Mount.LEFT, reservoir) await api.move_to( OT3Mount.LEFT, reservoir + Point(z=DEPTH_INTO_RESERVOIR_FOR_ASPIRATE) ) - await api.aspirate(OT3Mount.LEFT, ASPIRATE_VOLUME) + await api.aspirate(OT3Mount.LEFT, pipette) await api.move_to(OT3Mount.LEFT, reservoir + Point(z=HOVER_HEIGHT_MM)) start_time = time() @@ -136,7 +133,7 @@ async def aspirate_and_wait( return result, duration_seconds -async def _drop_tip(api: OT3API, trash: Point) -> None: +async def _drop_tip(api: OT3API, trash: Point, pipette: Literal[200, 1000]) -> None: print("drop in trash") await helpers_ot3.move_to_arched_ot3(api, OT3Mount.LEFT, trash + Point(z=20)) await api.move_to(OT3Mount.LEFT, trash) @@ -144,7 +141,7 @@ async def _drop_tip(api: OT3API, trash: Point) -> None: # NOTE: a FW bug (as of v14) will sometimes not fully drop tips. # so here we ask if the operator needs to try again while not api.is_simulator and ui.get_user_answer("try dropping again"): - api.add_tip(OT3Mount.LEFT, helpers_ot3.get_default_tip_length(TIP_VOLUME)) + api.add_tip(OT3Mount.LEFT, helpers_ot3.get_default_tip_length(pipette)) await api.drop_tip(OT3Mount.LEFT) await api.home_z(OT3Mount.LEFT) @@ -164,7 +161,9 @@ async def _partial_pick_up_z_motion( await api._update_position_estimation([Axis.Z_L]) -async def _partial_pick_up(api: OT3API, position: Point, current: float) -> None: +async def _partial_pick_up( + api: OT3API, position: Point, current: float, pipette: Literal[200, 1000] +) -> None: await helpers_ot3.move_to_arched_ot3( api, OT3Mount.LEFT, @@ -172,16 +171,18 @@ async def _partial_pick_up(api: OT3API, position: Point, current: float) -> None safe_height=position.z + 10, ) await _partial_pick_up_z_motion(api, current=current, distance=13, speed=5) - api.add_tip(OT3Mount.LEFT, helpers_ot3.get_default_tip_length(TIP_VOLUME)) + api.add_tip(OT3Mount.LEFT, helpers_ot3.get_default_tip_length(pipette)) await api.prepare_for_aspirate(OT3Mount.LEFT) await api.home_z(OT3Mount.LEFT) -async def run(api: OT3API, report: CSVReport, section: str) -> None: +async def run( + api: OT3API, report: CSVReport, section: str, pipette: Literal[200, 1000] +) -> None: """Run.""" # GATHER NOMINAL POSITIONS trash_nominal = get_trash_nominal() - tip_rack_96_a1_nominal = get_tiprack_96_nominal() + tip_rack_96_a1_nominal = get_tiprack_96_nominal(pipette) # tip_rack_partial_a1_nominal = get_tiprack_partial_nominal() reservoir_a1_nominal = get_reservoir_nominal() reservoir_a1_actual: Optional[Point] = None @@ -208,7 +209,7 @@ async def _find_reservoir_pos() -> None: ) await helpers_ot3.jog_mount_ot3(api, OT3Mount.LEFT) print("picking up tips") - await api.pick_up_tip(OT3Mount.LEFT, helpers_ot3.get_default_tip_length(TIP_VOLUME)) + await api.pick_up_tip(OT3Mount.LEFT, helpers_ot3.get_default_tip_length(pipette)) await api.home_z(OT3Mount.LEFT) if not api.is_simulator: ui.get_user_ready("about to move to RESERVOIR") @@ -218,10 +219,13 @@ async def _find_reservoir_pos() -> None: await _find_reservoir_pos() assert reservoir_a1_actual result, duration = await aspirate_and_wait( - api, reservoir_a1_actual, seconds=NUM_SECONDS_TO_WAIT + api, + reservoir_a1_actual, + pipette=pipette, + seconds=NUM_SECONDS_TO_WAIT, ) report(section, "droplets-96-tips", [duration, CSVResult.from_bool(result)]) - await _drop_tip(api, trash_nominal) + await _drop_tip(api, trash_nominal, pipette) # if not api.is_simulator: # ui.get_user_ready(f"REMOVE 96 tip-rack from slot #{TIP_RACK_96_SLOT}") diff --git a/hardware-testing/hardware_testing/production_qc/ninety_six_assembly_qc_ot3/test_environmental_sensor.py b/hardware-testing/hardware_testing/production_qc/ninety_six_assembly_qc_ot3/test_environmental_sensor.py index 5892cfe276f..114b78bb815 100644 --- a/hardware-testing/hardware_testing/production_qc/ninety_six_assembly_qc_ot3/test_environmental_sensor.py +++ b/hardware-testing/hardware_testing/production_qc/ninety_six_assembly_qc_ot3/test_environmental_sensor.py @@ -1,6 +1,6 @@ """Test Environmental Sensor.""" from asyncio import sleep -from typing import List, Union +from typing import List, Union, Literal from opentrons.hardware_control.ot3api import OT3API @@ -33,7 +33,9 @@ def _remove_outliers_and_average(values: List[float]) -> float: return sum(no_outliers) / len(no_outliers) -async def run(api: OT3API, report: CSVReport, section: str) -> None: +async def run( + api: OT3API, report: CSVReport, section: str, pipette: Literal[200, 1000] +) -> None: """Run.""" await api.home_z(OT3Mount.LEFT) slot_5 = helpers_ot3.get_slot_calibration_square_position_ot3(5) diff --git a/hardware-testing/hardware_testing/production_qc/ninety_six_assembly_qc_ot3/test_jaws.py b/hardware-testing/hardware_testing/production_qc/ninety_six_assembly_qc_ot3/test_jaws.py index 0bb42a81c0f..c91ed0999b6 100644 --- a/hardware-testing/hardware_testing/production_qc/ninety_six_assembly_qc_ot3/test_jaws.py +++ b/hardware-testing/hardware_testing/production_qc/ninety_six_assembly_qc_ot3/test_jaws.py @@ -1,5 +1,5 @@ """Test Jaws.""" -from typing import List, Union, Tuple, Dict +from typing import List, Union, Tuple, Dict, Literal from opentrons.hardware_control.ot3api import OT3API @@ -98,7 +98,9 @@ async def jaw_precheck(api: OT3API, ax: Axis, speed: float) -> Tuple[bool, bool] return led_check, jaws_aligned -async def run(api: OT3API, report: CSVReport, section: str) -> None: +async def run( + api: OT3API, report: CSVReport, section: str, pipette: Literal[200, 1000] +) -> None: """Run.""" ax = Axis.Q settings = helpers_ot3.get_gantry_load_per_axis_motion_settings_ot3(api, ax) diff --git a/hardware-testing/hardware_testing/production_qc/ninety_six_assembly_qc_ot3/test_plunger.py b/hardware-testing/hardware_testing/production_qc/ninety_six_assembly_qc_ot3/test_plunger.py index 1f802e47599..50e79bc3946 100644 --- a/hardware-testing/hardware_testing/production_qc/ninety_six_assembly_qc_ot3/test_plunger.py +++ b/hardware-testing/hardware_testing/production_qc/ninety_six_assembly_qc_ot3/test_plunger.py @@ -1,5 +1,5 @@ """Test Plunger.""" -from typing import List, Union, Tuple, Dict +from typing import List, Union, Tuple, Dict, Literal from opentrons.hardware_control.ot3api import OT3API @@ -53,7 +53,9 @@ async def _is_plunger_still_aligned_with_encoder( return p_enc, p_est, is_aligned -async def run(api: OT3API, report: CSVReport, section: str) -> None: +async def run( + api: OT3API, report: CSVReport, section: str, pipette: Literal[200, 1000] +) -> None: """Run.""" ax = Axis.P_L mount = OT3Mount.LEFT diff --git a/hardware-testing/hardware_testing/production_qc/ninety_six_assembly_qc_ot3/test_pressure.py b/hardware-testing/hardware_testing/production_qc/ninety_six_assembly_qc_ot3/test_pressure.py index a73f64ef729..d3b04619aa7 100644 --- a/hardware-testing/hardware_testing/production_qc/ninety_six_assembly_qc_ot3/test_pressure.py +++ b/hardware-testing/hardware_testing/production_qc/ninety_six_assembly_qc_ot3/test_pressure.py @@ -1,6 +1,6 @@ """Test Pressure.""" from asyncio import sleep -from typing import List, Union +from typing import List, Union, Literal from opentrons_hardware.firmware_bindings.constants import SensorId @@ -94,7 +94,9 @@ def check_value(test_value: float, test_name: str) -> CSVResult: return CSVResult.FAIL -async def run(api: OT3API, report: CSVReport, section: str) -> None: +async def run( + api: OT3API, report: CSVReport, section: str, pipette: Literal[200, 1000] +) -> None: """Run.""" await api.home_z(OT3Mount.LEFT) slot_5 = helpers_ot3.get_slot_calibration_square_position_ot3(5) diff --git a/hardware-testing/hardware_testing/production_qc/ninety_six_assembly_qc_ot3/test_tip_sensor.py b/hardware-testing/hardware_testing/production_qc/ninety_six_assembly_qc_ot3/test_tip_sensor.py index e3e9d0571f3..8282cdab616 100644 --- a/hardware-testing/hardware_testing/production_qc/ninety_six_assembly_qc_ot3/test_tip_sensor.py +++ b/hardware-testing/hardware_testing/production_qc/ninety_six_assembly_qc_ot3/test_tip_sensor.py @@ -1,6 +1,6 @@ """Test Tip Sensor.""" import asyncio -from typing import List, Union, cast +from typing import List, Union, cast, Literal from opentrons_hardware.firmware_bindings import ArbitrationId from opentrons_hardware.firmware_bindings.constants import MessageId @@ -75,7 +75,9 @@ def _listener(message: MessageDefinition, arbitration_id: ArbitrationId) -> None return result -async def run(api: OT3API, report: CSVReport, section: str) -> None: +async def run( + api: OT3API, report: CSVReport, section: str, pipette: Literal[200, 1000] +) -> None: """Run.""" ax = Axis.Q await api.home_z(OT3Mount.LEFT) diff --git a/hardware-testing/hardware_testing/protocols/gravimetric_lpc/gravimetric/gravimetric_ot3_p200_96.py b/hardware-testing/hardware_testing/protocols/gravimetric_lpc/gravimetric/gravimetric_ot3_p200_96.py new file mode 100644 index 00000000000..726f5b72533 --- /dev/null +++ b/hardware-testing/hardware_testing/protocols/gravimetric_lpc/gravimetric/gravimetric_ot3_p200_96.py @@ -0,0 +1,41 @@ +"""Gravimetric OT3 P200.""" +from opentrons.protocol_api import ProtocolContext +from opentrons.protocol_api._types import OffDeckType + +metadata = {"protocolName": "gravimetric-ot3-p200-96"} +requirements = {"robotType": "Flex", "apiLevel": "2.21"} + +SLOT_SCALE = 4 +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], +} +LABWARE_ON_SCALE = "nest_1_reservoir_195ml" + + +def run(ctx: ProtocolContext) -> None: + """Run.""" + scale_labware = ctx.load_labware(LABWARE_ON_SCALE, SLOT_SCALE) + pipette = ctx.load_instrument("flex_96channel_200", "left") + 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_p200_96.py b/hardware-testing/hardware_testing/protocols/gravimetric_lpc/photometric/photometric_ot3_p200_96.py new file mode 100644 index 00000000000..092c00e5878 --- /dev/null +++ b/hardware-testing/hardware_testing/protocols/gravimetric_lpc/photometric/photometric_ot3_p200_96.py @@ -0,0 +1,44 @@ +"""Gravimetric OT3 P200.""" +from opentrons.protocol_api import ProtocolContext +from opentrons.protocol_api._types import OffDeckType + +metadata = {"protocolName": "photometric-ot3-p200-96"} +requirements = {"robotType": "Flex", "apiLevel": "2.15"} + +SLOTS_TIPRACK = { + 50: [5, 6, 8, 9, 11], + 200: [5, 6, 8, 9, 11], +} +SLOT_PLATE = 3 +SLOT_RESERVOIR = 2 + +RESERVOIR_LABWARE = "nest_1_reservoir_195ml" +PHOTOPLATE_LABWARE = "corning_96_wellplate_360ul_flat" + + +def run(ctx: ProtocolContext) -> None: + """Run.""" + reservoir = ctx.load_labware(RESERVOIR_LABWARE, SLOT_RESERVOIR) + plate = ctx.load_labware(PHOTOPLATE_LABWARE, SLOT_PLATE) + pipette = ctx.load_instrument("flex_96channel_200", "left") + 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/plate_reader_qc_protocol.py b/hardware-testing/hardware_testing/protocols/plate_reader_qc_protocol.py new file mode 100644 index 00000000000..c8ec7cc59af --- /dev/null +++ b/hardware-testing/hardware_testing/protocols/plate_reader_qc_protocol.py @@ -0,0 +1,138 @@ +from opentrons import protocol_api +import numpy as np +from typing import cast +from opentrons.protocol_api.module_contexts import AbsorbanceReaderContext + +# metadata +metadata = { + 'protocolName': 'Absorbance Plate Reader Reference Plate QA', + 'author': 'QA', +} + +requirements = { + "robotType": "Flex", + "apiLevel": "2.21", +} + + +def convert_read_dictionary_to_array(read_data): + """Convert a dictionary of read results to an array + + Converts a dictionary of OD values, as formatted by the Opentrons API's + plate reader read() function, to a 2D numpy.array of shape (8,12) for + further processing. + + read_data: dict + a dictonary of read values with celll numbers for keys, e.g. 'A1' + """ + data = np.empty((8,12)) + for key, value in read_data.items(): + row_index = ord(key[0]) - ord('A') + column_index = int(key[1:]) - 1 + data[row_index][column_index] = value + + return data + + +def check_plate_reader_accuracy(read_data, flipped=False): + """Check the accuracy of a measurement result returned by the read() method + + data: dictionary of plate reader absorbance valurs + as returned by the absorbanceReaderV1 read() method + flipped: bool + True if reference plate was rotated 180 degrees for measurment + """ + + # These are the hard-coded calibration values for Hellma 666-R013 with Serial + # Number 101934. If you're using a different reference plate you must update + # these values with the ones provided by Hellma with your reference plate + cal_values_450nm = np.array([ + [0. , 0. , 0.245 , 0.245 , 0.4973, 0.4973, 0.9897, 0.9897, 1.5258, 1.5258, 2.537 , 2.537 ], + [0. , 0. , 0.2451, 0.2451, 0.4972, 0.4972, 0.9877, 0.9877, 1.5253, 1.5253, 2.535 , 2.535 ], + [0. , 0. , 0.2451, 0.2451, 0.4973, 0.4973, 0.9871, 0.9871, 1.5246, 1.5246, 2.536 , 2.536 ], + [0. , 0. , 0.2452, 0.2452, 0.4974, 0.4974, 0.9872, 0.9872, 1.525 , 1.525 , 2.535 , 2.535 ], + [0. , 0. , 0.2452, 0.2452, 0.4976, 0.4976, 0.9872, 0.9872, 1.5248, 1.5248, 2.535 , 2.535 ], + [0. , 0. , 0.2454, 0.2454, 0.4977, 0.4977, 0.9874, 0.9874, 1.5245, 1.5245, 2.536 , 2.536 ], + [0. , 0. , 0.2453, 0.2453, 0.4977, 0.4977, 0.9876, 0.9876, 1.5245, 1.5245, 2.533 , 2.533 ], + [0. , 0. , 0.2456, 0.2456, 0.4977, 0.4977, 0.9891, 0.9891, 1.5243, 1.5243, 2.533 , 2.533 ] + ]) + cal_values_650nm = np.array([ + [0. , 0. , 0.2958, 0.2958, 0.5537, 0.5537, 0.9944, 0.9944, 1.4232, 1.4232, 2.372 , 2.372 ], + [0. , 0. , 0.296 , 0.296 , 0.5535, 0.5535, 0.9924, 0.9924, 1.4235, 1.4235, 2.37 , 2.37 ], + [0. , 0. , 0.296 , 0.296 , 0.5534, 0.5534, 0.9919, 0.9919, 1.4228, 1.4228, 2.37 , 2.37 ], + [0. , 0. , 0.2961, 0.2961, 0.5534, 0.5534, 0.9918, 0.9918, 1.423 , 1.423 , 2.369 , 2.369 ], + [0. , 0. , 0.2962, 0.2962, 0.5536, 0.5536, 0.9918, 0.9918, 1.4225, 1.4225, 2.369 , 2.369 ], + [0. , 0. , 0.2964, 0.2964, 0.5535, 0.5535, 0.992 , 0.992 , 1.4223, 1.4223, 2.369 , 2.369 ], + [0. , 0. , 0.2963, 0.2963, 0.5534, 0.5534, 0.9922, 0.9922, 1.4221, 1.4221, 2.368 , 2.368 ], + [0. , 0. , 0.2965, 0.2965, 0.5533, 0.5533, 0.9938, 0.9938, 1.4222, 1.4222, 2.367 , 2.367 ] + ]) + cal_tolerances = np.array( + [0. , 0. , 0.0024, 0.0024, 0.0034, 0.0034, 0.0034, 0.0034, 0.0068, 0.0068, 0.012 , 0.012 ] + ) + + # Calculate absolute accuracy tolerances for each cell + # The last two columns have a higher tolerance per the Byonoy datasheet + # because OD>2.0 and wavelength>=450nm on the Hellma plate + accuracy_tolerances_450nm = np.zeros((8,12)) + accuracy_tolerances_450nm[:,:10] = cal_values_450nm[:,:10]*0.010 + cal_tolerances[:10] + 0.01 + accuracy_tolerances_450nm[:,10:] = cal_values_450nm[:,10:]*0.015 + cal_tolerances[10:] + 0.01 + accuracy_tolerances_650nm = np.zeros((8,12)) + accuracy_tolerances_650nm[:,:10] = cal_values_650nm[:,:10]*0.010 + cal_tolerances[:10] + 0.01 + accuracy_tolerances_650nm[:,10:] = cal_values_650nm[:,10:]*0.015 + cal_tolerances[10:] + 0.01 + + # Convert read result dictionary to numpy array for comparison + data_450nm = convert_read_dictionary_to_array(read_data[450]) + data_650nm = convert_read_dictionary_to_array(read_data[650]) + + # Check accuracy + if (flipped): + within_tolerance_450nm = np.isclose(data_450nm, np.rot90(cal_values_450nm, 2), atol=np.rot90(accuracy_tolerances_450nm, 2)) + within_tolerance_650nm = np.isclose(data_650nm, np.rot90(cal_values_650nm, 2), atol=np.rot90(accuracy_tolerances_650nm, 2)) + else: + within_tolerance_450nm = np.isclose(data_450nm, cal_values_450nm, atol=accuracy_tolerances_450nm) + within_tolerance_650nm = np.isclose(data_650nm, cal_values_650nm, atol=accuracy_tolerances_650nm) + + errors_450nm = np.count_nonzero(np.where(within_tolerance_450nm==False)) + errors_650nm = np.count_nonzero(np.where(within_tolerance_650nm==False)) + msg = f"450nm Failures: {errors_450nm}, 650nm Failures: {errors_650nm}" + + return msg + +# protocol run function +def run(protocol: protocol_api.ProtocolContext): + HELLMA_PLATE_SLOT = "C2" + PLATE_READER_SLOT = "D3" + + plate_reader: AbsorbanceReaderContext = cast(AbsorbanceReaderContext, protocol.load_module("absorbanceReaderV1", PLATE_READER_SLOT)) + hellma_plate = protocol.load_labware("hellma_reference_plate", HELLMA_PLATE_SLOT) + tiprack_1000 = protocol.load_labware(load_name='opentrons_flex_96_tiprack_50ul', location="A2") + trash_labware = protocol.load_trash_bin("A3") + #instrument = protocol.load_instrument("flex_8channel_50", "left", tip_racks=[tiprack_1000]) + instrument = protocol.load_instrument("flex_96channel_1000", "left", tip_racks=[tiprack_1000]) + instrument.trash_container = trash_labware + + # Initialize to multiple wavelengths + plate_reader.initialize('multi', [450, 650]) + + plate_reader.open_lid() + protocol.move_labware(hellma_plate, plate_reader, use_gripper=True) + plate_reader.close_lid() + + # Take reading + result = plate_reader.read() + msg = f"multi: {result}" + protocol.comment(msg=msg) + #protocol.pause(msg=msg) + + # Place the Plate Reader lid back on using the Gripper. + plate_reader.open_lid() + protocol.move_labware(hellma_plate, HELLMA_PLATE_SLOT, use_gripper=True) + plate_reader.close_lid() + + # Check and display accuracy + if result is not None: + #msg = f"multi: {result}" + #msg = f"multi: {result[450]}" + msg = check_plate_reader_accuracy(result, flipped=False) + protocol.comment(msg=msg) + protocol.pause(msg=msg) diff --git a/hardware/opentrons_hardware/firmware_bindings/constants.py b/hardware/opentrons_hardware/firmware_bindings/constants.py index 923d8eb0725..d9dc98def39 100644 --- a/hardware/opentrons_hardware/firmware_bindings/constants.py +++ b/hardware/opentrons_hardware/firmware_bindings/constants.py @@ -358,6 +358,7 @@ class PipetteName(int, Enum): p50_multi = 0x03 p1000_96 = 0x04 p50_96 = 0x05 + p200_96 = 0x06 unknown = 0xFFFF diff --git a/hardware/opentrons_hardware/hardware_control/tool_sensors.py b/hardware/opentrons_hardware/hardware_control/tool_sensors.py index 95076f01c1c..f6c70f087bf 100644 --- a/hardware/opentrons_hardware/hardware_control/tool_sensors.py +++ b/hardware/opentrons_hardware/hardware_control/tool_sensors.py @@ -118,7 +118,7 @@ def _fix_pass_step_for_buffer( # will be the same duration=float64(abs(distance[movers[0]] / speed[movers[0]])), present_nodes=tool_nodes, - stop_condition=MoveStopCondition.sensor_report, + stop_condition=MoveStopCondition.sync_line, sensor_type_pass=sensor_type, sensor_id_pass=sensor_id, sensor_binding_flags=binding_flags, @@ -456,6 +456,14 @@ async def capacitive_probe( async with AsyncExitStack() as binding_stack: for listener in listeners.values(): await binding_stack.enter_async_context(listener) + for sensor in capacitive_sensors.values(): + await binding_stack.enter_async_context( + sensor_driver.bind_output( + can_messenger=messenger, + sensor=sensor, + binding=[SensorOutputBinding.sync], + ) + ) positions = await runner.run(can_messenger=messenger) await finalize_logs(messenger, tool, listeners, capacitive_sensors) diff --git a/hardware/opentrons_hardware/instruments/pipettes/serials.py b/hardware/opentrons_hardware/instruments/pipettes/serials.py index e697821373a..c4a8fc441d0 100644 --- a/hardware/opentrons_hardware/instruments/pipettes/serials.py +++ b/hardware/opentrons_hardware/instruments/pipettes/serials.py @@ -31,6 +31,7 @@ "P50M": PipetteName.p50_multi, "P1KH": PipetteName.p1000_96, "P50H": PipetteName.p50_96, + "P2HH": PipetteName.p200_96, } SERIAL_FORMAT_MSG = ( diff --git a/labware-library/src/components/App/styles.module.css b/labware-library/src/components/App/styles.module.css index b7f1b87eaa0..180b71e7c7b 100644 --- a/labware-library/src/components/App/styles.module.css +++ b/labware-library/src/components/App/styles.module.css @@ -33,6 +33,7 @@ } .content_container { + min-height: 87vh; width: 100%; padding: var(--spacing-5); margin: 0; diff --git a/opentrons-ai-client/src/OpentronsAIRoutes.tsx b/opentrons-ai-client/src/OpentronsAIRoutes.tsx index 32d09f351cf..1b435ac4138 100644 --- a/opentrons-ai-client/src/OpentronsAIRoutes.tsx +++ b/opentrons-ai-client/src/OpentronsAIRoutes.tsx @@ -1,6 +1,6 @@ import { Route, Navigate, Routes } from 'react-router-dom' import { Landing } from './pages/Landing' -import { UpdateProtocol } from './organisms/UpdateProtocol' +import { UpdateProtocol } from './pages/UpdateProtocol' import type { RouteProps } from './resources/types' import { Chat } from './pages/Chat' diff --git a/opentrons-ai-client/src/assets/localization/en/create_protocol.json b/opentrons-ai-client/src/assets/localization/en/create_protocol.json index 3d6e6735660..a91099593f4 100644 --- a/opentrons-ai-client/src/assets/localization/en/create_protocol.json +++ b/opentrons-ai-client/src/assets/localization/en/create_protocol.json @@ -17,12 +17,12 @@ "opentrons_ot2_label": "Opentrons OT-2", "opentrons_ot2": "Opentrons OT-2", "instruments_pipettes_title": "What pipettes would you like to use?", - "two_pipettes_label": "Two pipettes", + "two_pipettes_label": "1-Channel or 8-Channel pipettes", "right_pipette_label": "Right mount", "left_pipette_label": "Left mount", "choose_pipette_placeholder": "Choose pipette", - "96_channel_1000ul_pipette_label": "96-Channel 1000µL pipette", - "96_channel_1000ul_pipette": "96-Channel 1000µL pipette", + "96_channel_1000ul_pipette_label": "96-Channel 1000uL pipette", + "96_channel_1000ul_pipette": "96-Channel 1000uL pipette", "instruments_flex_gripper_title": "Do you want to use the Flex Gripper?", "flex_gripper_yes_label": "Yes, use the Flex Gripper", "flex_gripper": "Flex Gripper", @@ -46,8 +46,8 @@ "tubeRack": "Tube rack", "wellPlate": "Well plate", "no_labwares_added_yet": "No labware added yet", - "labwares_quantity_label": "quantity", - "labwares_remove_label": "remove", + "labwares_quantity_label": "Quantity", + "labwares_remove_label": "Remove", "labwares_save_label": "Save", "labwares_cancel_label": "Cancel", "search_for_labware_placeholder": "Search for labware...", @@ -59,5 +59,25 @@ "add_opentrons_liquid": "Add Liquid", "add_liquid_caption": "Example: \"Add 1.5mL of master mix to all the wells in the first column of the deep well plate.\"", "liquid": "Liquid", - "remove_liquid": "Remove" + "remove_liquid": "Remove", + "steps_section_title": "Steps", + "steps_section_textbody": "Give step-by-step instructions on how to handle liquids, with quantities in microliters (uL) and exact source and destination locations within labware. Always err on the side of providing extra information!", + "add_individual_step": "Add individual steps", + "paste_from_document": "Paste from document", + "paste_from_document_title": "Paste the steps from your document. Make sure your steps are clearly numbered.", + "paste_from_document_input_title": "Steps", + "paste_from_document_input_caption_1": "Example:", + "paste_from_document_input_caption_2": "Use right pipette to transfer 15 uL of mastermix from source well to destination well. Use the same pipette tip for all transfers.", + "paste_from_document_input_caption_3": "Use left pipette to transfer 10 ul of sample from the source to destination well. Mix the sample and mastermix of 25 ul total volume 9 times. Blow out to `destination well`. Use a new tip for each transfer.", + "add_step": "Add step", + "remove_step": "Remove", + "step": "Step", + "add_step_caption": "Example: \"Transfer 10 uL from each of the wells in the source labware to the same wells in the destination labware. Use a new tip for each transfer.", + "none": "None", + "create_protocol_prompt_robot": "Write a protocol using the Opentrons Python Protocol API v2 for {{robotType}} robot according to the following description:\n\n", + "description": "Description", + "pipette_mounts": "Pipette mount(s)", + "mounted_left": "is mounted on the left", + "mounted_right": "is mounted on the right", + "with_flex_gripper": "with the Flex Gripper" } diff --git a/opentrons-ai-client/src/assets/localization/en/protocol_generator.json b/opentrons-ai-client/src/assets/localization/en/protocol_generator.json index e321b939ade..a4d89e97303 100644 --- a/opentrons-ai-client/src/assets/localization/en/protocol_generator.json +++ b/opentrons-ai-client/src/assets/localization/en/protocol_generator.json @@ -30,10 +30,10 @@ "login": "Login", "logout": "Logout", "make_sure_your_prompt": "Write a prompt in a natural language for OpentronsAI to generate a protocol using the Opentrons Python Protocol API v2. The better the prompt, the better the quality of the protocol produced by OpentronsAI.", - "modify_intro": "Modify the following Python code using the Opentrons Python Protocol API v2. Ensure that the new labware and pipettes are compatible with the Flex robot. Ensure that you perform the correct Type of Update use the Details of Changes.\n\n", + "modify_intro": "Modify the following Python code using the Opentrons Python Protocol API v2. Ensure that the new labware and pipettes are compatible with the Flex robot.\n\n", "modify_python_code": "Original Python Code:\n", "modify_type_of_update": "Type of update:\n- ", - "modify_details_of_change": "Details of Changes:\n- ", + "modify_details_of_change": "Detail of changes:\n- ", "modules_and_adapters": "Modules and adapters: Specify the modules and labware adapters required by your protocol.", "notes": "A few important things to note:", "opentrons": "Opentrons", @@ -43,6 +43,10 @@ "pcr": "PCR", "pipettes": "Pipettes: Specify your pipettes, including the volume, number of channels, and whether they’re mounted on the left or right.", "privacy_policy": "By continuing, you agree to the Opentrons Privacy Policy and End user license agreement", + "progressFinalizing": "Finalizing...", + "progressGenerating": "Generating...", + "progressInitializing": "Initializing...", + "progressProcessing": "Processing...", "protocol_file": "Protocol file", "provide_details_of_changes": "Provide details of changes you want to make", "python_file_type_error": "Python file type required", diff --git a/opentrons-ai-client/src/atoms/ControlledTextAreaField/index.tsx b/opentrons-ai-client/src/atoms/ControlledTextAreaField/index.tsx new file mode 100644 index 00000000000..b7bc92d30a0 --- /dev/null +++ b/opentrons-ai-client/src/atoms/ControlledTextAreaField/index.tsx @@ -0,0 +1,39 @@ +import { Controller } from 'react-hook-form' +import { TextAreaField } from '../TextAreaField' + +interface ControlledTextAreaFieldProps { + id?: string + name: string + rules?: any + title?: string + caption?: string + height?: string +} + +export function ControlledTextAreaField({ + id, + name, + rules, + title, + caption, + height, +}: ControlledTextAreaFieldProps): JSX.Element { + return ( + ( + + )} + /> + ) +} diff --git a/opentrons-ai-client/src/atoms/ResizeBar/index.tsx b/opentrons-ai-client/src/atoms/ResizeBar/index.tsx new file mode 100644 index 00000000000..58ba202eee0 --- /dev/null +++ b/opentrons-ai-client/src/atoms/ResizeBar/index.tsx @@ -0,0 +1,59 @@ +import { + COLORS, + DISPLAY_FLEX, + POSITION_ABSOLUTE, + POSITION_RELATIVE, +} from '@opentrons/components' + +export function ResizeBar({ + handleMouseDown, +}: { + handleMouseDown: (e: React.MouseEvent) => void +}): JSX.Element { + return ( +
+
+
+
+
+
+ ) +} diff --git a/opentrons-ai-client/src/atoms/SendButton/index.tsx b/opentrons-ai-client/src/atoms/SendButton/index.tsx index 2a0079d21d6..eba4eeeae58 100644 --- a/opentrons-ai-client/src/atoms/SendButton/index.tsx +++ b/opentrons-ai-client/src/atoms/SendButton/index.tsx @@ -7,8 +7,11 @@ import { COLORS, DISPLAY_FLEX, Icon, - JUSTIFY_CENTER, + JUSTIFY_SPACE_AROUND, + StyledText, } from '@opentrons/components' +import { useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' interface SendButtonProps { handleClick: () => void @@ -21,6 +24,15 @@ export function SendButton({ disabled = false, isLoading = false, }: SendButtonProps): JSX.Element { + const { t } = useTranslation('protocol_generator') + + const progressTexts = [ + t('progressInitializing'), + t('progressProcessing'), + t('progressGenerating'), + t('progressFinalizing'), + ] + const playButtonStyle = css` -webkit-tap-highlight-color: transparent; &:focus { @@ -47,20 +59,50 @@ export function SendButton({ color: ${COLORS.grey50}; } ` + + const [buttonText, setButtonText] = useState(progressTexts[0]) + const [, setProgressIndex] = useState(0) + + useEffect(() => { + if (isLoading) { + const interval = setInterval(() => { + setProgressIndex(prevIndex => { + let newIndex = prevIndex + 1 + if (newIndex > progressTexts.length - 1) { + newIndex = progressTexts.length - 1 + } + return newIndex + }) + }, 10000) + + return () => { + setProgressIndex(0) + setButtonText(progressTexts[0]) + clearInterval(interval) + } + } + }, [isLoading]) + return ( + {isLoading ? ( + + {buttonText} + + ) : null} + /** name of field in form */ + name?: string + /** optional ID of