From 6dcafdced628832dbde5e3809fdd25c53dbc170a Mon Sep 17 00:00:00 2001 From: Brian Arthur Cooper Date: Mon, 24 Jul 2023 14:07:32 -0400 Subject: [PATCH 01/25] refactor(app): retract all axes in LPC instead of move to fixed trash (#13112) Closes RSS-254 Co-authored-by: Shlok Amin --- .../LabwarePositionCheck/CheckItem.tsx | 14 +++--------- .../LabwarePositionCheck/PickUpTip.tsx | 14 +++--------- .../__tests__/CheckItem.test.tsx | 22 +++++-------------- .../__tests__/PickUpTip.test.tsx | 9 +++----- 4 files changed, 15 insertions(+), 44 deletions(-) diff --git a/app/src/organisms/LabwarePositionCheck/CheckItem.tsx b/app/src/organisms/LabwarePositionCheck/CheckItem.tsx index b7b98dd8542a..438bb8b5dbab 100644 --- a/app/src/organisms/LabwarePositionCheck/CheckItem.tsx +++ b/app/src/organisms/LabwarePositionCheck/CheckItem.tsx @@ -8,7 +8,6 @@ import { PrepareSpace } from './PrepareSpace' import { JogToWell } from './JogToWell' import { CreateCommand, - FIXED_TRASH_ID, getIsTiprack, getLabwareDefURI, getLabwareDisplayName, @@ -217,19 +216,12 @@ export const CheckItem = (props: CheckItemProps): JSX.Element | null => { }, }, { - commandType: 'moveToWell' as const, - params: { - pipetteId: pipetteId, - labwareId: FIXED_TRASH_ID, - wellName: 'A1', - wellLocation: { origin: 'top' as const }, - }, + commandType: 'retractAxis' as const, + params: { axis: 'x' }, }, { commandType: 'retractAxis' as const, - params: { - axis: pipetteZMotorAxis, - }, + params: { axis: 'y' }, }, { commandType: 'moveLabware' as const, diff --git a/app/src/organisms/LabwarePositionCheck/PickUpTip.tsx b/app/src/organisms/LabwarePositionCheck/PickUpTip.tsx index cb78104417fd..3b357404cfab 100644 --- a/app/src/organisms/LabwarePositionCheck/PickUpTip.tsx +++ b/app/src/organisms/LabwarePositionCheck/PickUpTip.tsx @@ -9,7 +9,6 @@ import { JogToWell } from './JogToWell' import { CompletedProtocolAnalysis, CreateCommand, - FIXED_TRASH_ID, getLabwareDefURI, getLabwareDisplayName, getModuleType, @@ -215,19 +214,12 @@ export const PickUpTip = (props: PickUpTipProps): JSX.Element | null => { }, }, { - commandType: 'moveToWell' as const, - params: { - pipetteId: pipetteId, - labwareId: FIXED_TRASH_ID, - wellName: 'A1', - wellLocation: { origin: 'top' as const }, - }, + commandType: 'retractAxis' as const, + params: { axis: 'x' }, }, { commandType: 'retractAxis' as const, - params: { - axis: pipetteZMotorAxis, - }, + params: { axis: 'y' }, }, { commandType: 'moveLabware' as const, diff --git a/app/src/organisms/LabwarePositionCheck/__tests__/CheckItem.test.tsx b/app/src/organisms/LabwarePositionCheck/__tests__/CheckItem.test.tsx index 4d339d4c7546..46df9aae309f 100644 --- a/app/src/organisms/LabwarePositionCheck/__tests__/CheckItem.test.tsx +++ b/app/src/organisms/LabwarePositionCheck/__tests__/CheckItem.test.tsx @@ -218,18 +218,15 @@ describe('CheckItem', () => { }, }, { - commandType: 'moveToWell', + commandType: 'retractAxis' as const, params: { - pipetteId: 'pipetteId1', - labwareId: 'fixedTrash', - wellName: 'A1', - wellLocation: { origin: 'top', offset: undefined }, + axis: 'x', }, }, { commandType: 'retractAxis' as const, params: { - axis: 'leftZ', + axis: 'y', }, }, { @@ -283,19 +280,12 @@ describe('CheckItem', () => { }, }, { - commandType: 'moveToWell', - params: { - pipetteId: 'pipetteId1', - labwareId: 'fixedTrash', - wellName: 'A1', - wellLocation: { origin: 'top', offset: undefined }, - }, + commandType: 'retractAxis' as const, + params: { axis: 'x' }, }, { commandType: 'retractAxis' as const, - params: { - axis: 'leftZ', - }, + params: { axis: 'y' }, }, { commandType: 'moveLabware', diff --git a/app/src/organisms/LabwarePositionCheck/__tests__/PickUpTip.test.tsx b/app/src/organisms/LabwarePositionCheck/__tests__/PickUpTip.test.tsx index 67fba8aa08f3..44903812e0f8 100644 --- a/app/src/organisms/LabwarePositionCheck/__tests__/PickUpTip.test.tsx +++ b/app/src/organisms/LabwarePositionCheck/__tests__/PickUpTip.test.tsx @@ -389,18 +389,15 @@ describe('PickUpTip', () => { }, }, { - commandType: 'moveToWell', + commandType: 'retractAxis' as const, params: { - pipetteId: 'pipetteId1', - labwareId: 'fixedTrash', - wellName: 'A1', - wellLocation: { origin: 'top' }, + axis: 'x', }, }, { commandType: 'retractAxis' as const, params: { - axis: 'leftZ', + axis: 'y', }, }, { From 76eece676a404edd40f8ee1a01a3c1e37169f840 Mon Sep 17 00:00:00 2001 From: Andy Sigler Date: Mon, 24 Jul 2023 14:10:35 -0400 Subject: [PATCH 02/25] feat(hardware-testing): Belt calibration script defaults to just calibrate and not test (#13151) --- hardware-testing/Makefile | 1 + .../scripts/belt_calibration_ot3.py | 49 ++++++++++++------- 2 files changed, 31 insertions(+), 19 deletions(-) diff --git a/hardware-testing/Makefile b/hardware-testing/Makefile index 54cccbcdd24e..c06e857033c4 100755 --- a/hardware-testing/Makefile +++ b/hardware-testing/Makefile @@ -133,6 +133,7 @@ test-examples: .PHONY: test-scripts test-scripts: $(python) -m hardware_testing.scripts.bowtie_ot3 --simulate + $(python) -m hardware_testing.scripts.belt_calibration_ot3 --simulate .PHONY: test-integration test-integration: test-production-qc test-examples test-scripts test-gravimetric diff --git a/hardware-testing/hardware_testing/scripts/belt_calibration_ot3.py b/hardware-testing/hardware_testing/scripts/belt_calibration_ot3.py index 2ce58359d320..ba5fc0b04e8e 100644 --- a/hardware-testing/hardware_testing/scripts/belt_calibration_ot3.py +++ b/hardware-testing/hardware_testing/scripts/belt_calibration_ot3.py @@ -70,38 +70,49 @@ async def _calibrate_belts(api: OT3API, mount: types.OT3Mount) -> None: print(attitude) -async def _main(is_simulating: bool, mount: types.OT3Mount) -> None: +async def _main(is_simulating: bool, mount: types.OT3Mount, test: bool) -> None: ui.print_title("BELT CALIBRATION") api = await helpers_ot3.build_async_ot3_hardware_api( is_simulating=is_simulating, pipette_left="p1000_single_v3.4", pipette_right="p1000_single_v3.4", ) - print("homing") - await api.home() - print("resetting robot calibration") - await api.reset_instrument_offset(mount) - api.reset_robot_calibration() - - # SKIP calibrating the belts, then check accuracy - await _calibrate_pipette(api, mount) - await _check_belt_accuracy(api, mount) - - # DO calibrate the belts, then check accuracy - await _calibrate_belts(api, mount) # <-- !!! - await _calibrate_pipette(api, mount) - await _check_belt_accuracy(api, mount) - - print("done") + try: + print("homing") + await api.home() + attach_pos = helpers_ot3.get_slot_calibration_square_position_ot3(2) + current_pos = await api.gantry_position(mount) + await api.move_to(mount, attach_pos._replace(z=current_pos.z)) + if not api.is_simulator: + ui.get_user_ready("ATTACH a probe") + print("resetting robot calibration") + await api.reset_instrument_offset(mount) + api.reset_robot_calibration() + if test: + # check accuracy of gantry-to-deck + await _calibrate_pipette(api, mount) + await _check_belt_accuracy(api, mount) + # calibrate the belts + await _calibrate_belts(api, mount) # <-- !!! + if test: + # check accuracy of gantry-to-deck + await _calibrate_pipette(api, mount) + await _check_belt_accuracy(api, mount) + print("done") + finally: + if not api.is_simulator: + print("restarting opentrons-robot-server") + helpers_ot3.start_server_ot3() if __name__ == "__main__": parser = argparse.ArgumentParser() parser.add_argument("--simulate", action="store_true") - parser.add_argument("--mount", type=str, choices=["left", "right"], required=True) + parser.add_argument("--test", action="store_true") + parser.add_argument("--mount", type=str, choices=["left", "right"], default="left") args = parser.parse_args() if args.mount == "left": mnt = types.OT3Mount.LEFT else: mnt = types.OT3Mount.RIGHT - asyncio.run(_main(args.simulate, mnt)) + asyncio.run(_main(args.simulate, mnt, args.test)) From 32cab97d67ad7161ad911d2fdf83ec850f20dddd Mon Sep 17 00:00:00 2001 From: Ryan Howard Date: Tue, 25 Jul 2023 10:48:54 -0400 Subject: [PATCH 03/25] chore(refactor): Refactor and cleanup volumetric scripts (#13071) * Revert "Merge latest volumetric changes (#13100)" This reverts commit 773b8a4a59c43971ce0c9e3763971d0784566765. * Revert "fix(hardware-testing): Remove tips using them for finding liquid height (#13064)" This reverts commit cc0f509b7551b511b8bf608fa37def47bb3729f0. * Revert "support resupplying tipracks during a 96ch increment test run (#13065)" This reverts commit 965f02d1f1a06e36adb498119109e3f9b650910c. * Pull out all of the common functions between gravimetric and photometric * move trial generation out of excecute * combine the load pipette logic * pull gantry speed out of the photometric config * refactor out load tipracks * remove unused method * fix issues from rebasing * fixups from rebase and formating * refactor out trial building from the photometric script * add additional test targets * pull out the common elements of trials into a seperate dataclass * break out even more common code * break building pm report out of the run method * break the run trials loop out of the run method * format/lint * break building test report out of the run method in gravimetric run method * add the ui header to the build_report methods * move all of the dye prep into the dye prep method * improve LiquidTracker initialization * move the homing to execute_trials * refactor out common end script code * refactor out common get_tips * fixup things that broke during rebase * fix the photometric test target * remove evaporation measurement out of run for readablity * break out load scale for readablity * redo: Support resupplying tipracks during a 96ch increment tests * redo: Remove tips using them for finding liquid height * fix the tests * replace all print statements with ui * move the if-else out of the function to decrease the indent * make a base class config * fixup things that were lost in the rebase * construct the trial directory correctly * make sure 96 pipette doesn't collide during gravimetric tests * gracefully handle when the scale is beyond max * reimplement: Merge latest volumetric changes (#13100) * add stable flag to the blank gravimetric trials * Convert the default test volumes to the QC volumes * add default number of trials and correct volumes to match calculator sheet * make blank the default * update makefile to test the correct args for each tip * update increment test values * make single channle QC test work with all tips in one run * last few changes * fixup typing error * forgot a docstring for the linter * support the enviornmental sensor in the volumetric tests * make the asair prompt for port instead of auto finding * fix the tests --- hardware-testing/.flake8 | 4 +- hardware-testing/Makefile | 29 +- hardware-testing/hardware_testing/data/ui.py | 15 +- .../hardware_testing/drivers/__init__.py | 1 - .../hardware_testing/drivers/asair_sensor.py | 22 +- .../drivers/radwag/responses.py | 19 +- .../hardware_testing/gravimetric/__main__.py | 250 +++-- .../hardware_testing/gravimetric/config.py | 146 ++- .../hardware_testing/gravimetric/execute.py | 866 +++++++----------- .../gravimetric/execute_photometric.py | 660 +++++-------- .../hardware_testing/gravimetric/helpers.py | 305 +++++- .../gravimetric/increments.py | 191 ++-- .../gravimetric/liquid_class/defaults.py | 24 +- .../gravimetric/liquid_class/pipetting.py | 4 +- .../gravimetric/liquid_height/height.py | 13 +- .../gravimetric/measurement/__init__.py | 5 +- .../gravimetric/measurement/environment.py | 11 +- .../hardware_testing/gravimetric/tips.py | 12 +- .../hardware_testing/gravimetric/trial.py | 224 +++++ .../drivers/test_asair_sensor.py | 3 +- 20 files changed, 1522 insertions(+), 1282 deletions(-) create mode 100644 hardware-testing/hardware_testing/gravimetric/trial.py diff --git a/hardware-testing/.flake8 b/hardware-testing/.flake8 index a098f87d7e12..058358cad0a7 100644 --- a/hardware-testing/.flake8 +++ b/hardware-testing/.flake8 @@ -5,8 +5,8 @@ max-line-length = 100 # max cyclomatic complexity -# NOTE: (andy s) increasing this from 9 to 20 b/c test scripts often handle all logic in main -max-complexity = 20 +# NOTE: (andy s) increasing this from 9 to 15 b/c test scripts often handle all logic in main +max-complexity = 15 extend-ignore = # ignore E203 because black might reformat it diff --git a/hardware-testing/Makefile b/hardware-testing/Makefile index c06e857033c4..ab41d0b76f86 100755 --- a/hardware-testing/Makefile +++ b/hardware-testing/Makefile @@ -88,24 +88,29 @@ test-cov: .PHONY: test-photometric test-photometric: - $(python) -m hardware_testing.gravimetric --photometric --simulate --pipette 1000 --channels 96 --tip 50 --trials 5 - $(python) -m hardware_testing.gravimetric --photometric --simulate --pipette 1000 --channels 96 --tip 200 --trials 5 + -$(MAKE) apply-patches-gravimetric + $(python) -m hardware_testing.gravimetric --photometric --simulate --pipette 1000 --channels 96 --tip 50 --trials 1 + $(python) -m hardware_testing.gravimetric --photometric --simulate --pipette 1000 --channels 96 --tip 200 --trials 1 + -$(MAKE) remove-patches-gravimetric .PHONY: test-gravimetric-single test-gravimetric-single: - $(python) -m hardware_testing.gravimetric --simulate --pipette 1000 --channels 1 --tip 1000 --trials 1 - $(python) -m hardware_testing.gravimetric --simulate --pipette 1000 --channels 1 --tip 200 --trials 1 - $(python) -m hardware_testing.gravimetric --simulate --pipette 1000 --channels 1 --tip 50 --trials 1 - $(python) -m hardware_testing.gravimetric --simulate --pipette 50 --channels 1 --tip 50 --trials 1 - $(python) -m hardware_testing.gravimetric --simulate --pipette 1000 --channels 1 --tip 1000 --trials 1 --increment + $(python) -m hardware_testing.gravimetric --simulate --pipette 1000 --channels 1 --trials 1 + $(python) -m hardware_testing.gravimetric --simulate --pipette 1000 --channels 1 --extra --no-blank + $(python) -m hardware_testing.gravimetric --simulate --pipette 50 --channels 1 --no-blank + $(python) -m hardware_testing.gravimetric --simulate --pipette 1000 --channels 1 --trials 1 --increment --no-blank .PHONY: test-gravimetric-multi test-gravimetric-multi: - $(python) -m hardware_testing.gravimetric --simulate --pipette 50 --channels 8 --tip 50 --trials 1 - $(python) -m hardware_testing.gravimetric --simulate --pipette 50 --channels 8 --tip 50 --trials 1 --increment - $(python) -m hardware_testing.gravimetric --simulate --pipette 1000 --channels 8 --tip 1000 --trials 1 - $(python) -m hardware_testing.gravimetric --simulate --pipette 1000 --channels 8 --tip 200 --trials 1 - $(python) -m hardware_testing.gravimetric --simulate --pipette 1000 --channels 8 --tip 50 --trials 1 + $(python) -m hardware_testing.gravimetric --simulate --pipette 50 --channels 8 --tip 50 --trials 1 --no-blank + $(python) -m hardware_testing.gravimetric --simulate --pipette 50 --channels 8 --tip 50 --trials 1 --increment --no-blank + $(python) -m hardware_testing.gravimetric --simulate --pipette 1000 --channels 8 --tip 1000 --trials 1 --no-blank + $(python) -m hardware_testing.gravimetric --simulate --pipette 1000 --channels 8 --tip 200 --trials 1 --extra --no-blank + $(python) -m hardware_testing.gravimetric --simulate --pipette 1000 --channels 8 --tip 50 --trials 1 --no-blank + +.PHONY: test-gravimetric-96 +test-gravimetric-96: + $(python) -m hardware_testing.gravimetric --simulate --pipette 1000 --channels 96 --tip 1000 --trials 1 --no-blank .PHONY: test-gravimetric test-gravimetric: diff --git a/hardware-testing/hardware_testing/data/ui.py b/hardware-testing/hardware_testing/data/ui.py index f1e81708afee..3b33c18b52c1 100644 --- a/hardware-testing/hardware_testing/data/ui.py +++ b/hardware-testing/hardware_testing/data/ui.py @@ -1,5 +1,6 @@ """Production QC User Interface.""" - +from opentrons.hardware_control import SyncHardwareAPI +from opentrons.hardware_control.types import StatusBarState PRINT_HEADER_NUM_SPACES = 4 PRINT_HEADER_DASHES = "-" * PRINT_HEADER_NUM_SPACES @@ -25,6 +26,13 @@ def get_user_ready(message: str) -> None: input(f"WAIT: {message}, press ENTER when ready: ") +def alert_user_ready(message: str, hw: SyncHardwareAPI) -> None: + """Flash the ui lights on the ot3 and then use the get_user_ready.""" + hw.set_status_bar_state(StatusBarState.PAUSED) + get_user_ready(message) + hw.set_status_bar_state(StatusBarState.CONFIRMATION) + + def print_title(title: str) -> None: """Print title.""" """ @@ -54,3 +62,8 @@ def print_header(header: str) -> None: def print_error(message: str) -> None: """Print error.""" print(f"ERROR: {message}") + + +def print_info(message: str) -> None: + """Print information.""" + print(message) diff --git a/hardware-testing/hardware_testing/drivers/__init__.py b/hardware-testing/hardware_testing/drivers/__init__.py index a34e04ae8b23..080746c79529 100644 --- a/hardware-testing/hardware_testing/drivers/__init__.py +++ b/hardware-testing/hardware_testing/drivers/__init__.py @@ -2,7 +2,6 @@ from serial.tools.list_ports import comports # type: ignore[import] from .radwag import RadwagScaleBase, RadwagScale, SimRadwagScale -from .asair_sensor import AsairSensor, AsairSensorError def list_ports_and_select(device_name: str = "") -> str: diff --git a/hardware-testing/hardware_testing/drivers/asair_sensor.py b/hardware-testing/hardware_testing/drivers/asair_sensor.py index bf7a9aca4d48..be1d99376671 100644 --- a/hardware-testing/hardware_testing/drivers/asair_sensor.py +++ b/hardware-testing/hardware_testing/drivers/asair_sensor.py @@ -7,14 +7,14 @@ import abc import codecs import logging -import random import time from typing import Tuple from abc import ABC from dataclasses import dataclass - +from . import list_ports_and_select import serial # type: ignore[import] from serial.serialutil import SerialException # type: ignore[import] +from hardware_testing.data import ui log = logging.getLogger(__name__) @@ -66,6 +66,20 @@ def get_reading(self) -> Reading: ... +def BuildAsairSensor(simulate: bool) -> AsairSensorBase: + """Try to find and return an Asair sensor, if not found return a simulator.""" + ui.print_title("Connecting to Environmental sensor") + if not simulate: + port = list_ports_and_select(device_name="Asair environmental sensor") + try: + sensor = AsairSensor.connect(port) + ui.print_info(f"Found sensor on port {port}") + return sensor + except SerialException: + pass + return SimAsairSensor() + + class AsairSensor(AsairSensorBase): """Asair sensor driver.""" @@ -152,6 +166,6 @@ class SimAsairSensor(AsairSensorBase): def get_reading(self) -> Reading: """Get a reading.""" - temp = random.uniform(24.5, 25) - relative_hum = random.uniform(45, 40) + temp = 25.0 + relative_hum = 50.0 return Reading(temperature=temp, relative_humidity=relative_hum) diff --git a/hardware-testing/hardware_testing/drivers/radwag/responses.py b/hardware-testing/hardware_testing/drivers/radwag/responses.py index 5f5348977a58..50af16688ca1 100644 --- a/hardware-testing/hardware_testing/drivers/radwag/responses.py +++ b/hardware-testing/hardware_testing/drivers/radwag/responses.py @@ -74,13 +74,18 @@ def _on_unstable_measurement( data = RadwagResponse.build(command, response_list) # SI ? - 0.00020 g # TODO: we could accept more unit types if we wanted... - assert response_list[-1] == "g", ( - f'Expected units to be grams ("g"), ' f'instead got "{response_list[-1]}"' - ) - data.stable = "?" not in response_list - data.measurement = float(response_list[-2]) - if "-" in response_list: - data.measurement *= -1 + if RadwagResponseCodes.MAX_THRESHOLD_EXCEEDED in response_list: + print("Warning: Scale maximum exceeded returning infinity") + data.stable = False + data.measurement = float("inf") + else: + assert response_list[-1] == "g", ( + f'Expected units to be grams ("g"), ' f'instead got "{response_list[-1]}"' + ) + data.stable = "?" not in response_list + data.measurement = float(response_list[-2]) + if "-" in response_list: + data.measurement *= -1 return data diff --git a/hardware-testing/hardware_testing/gravimetric/__main__.py b/hardware-testing/hardware_testing/gravimetric/__main__.py index bed12dccd0d0..07609e72da47 100644 --- a/hardware-testing/hardware_testing/gravimetric/__main__.py +++ b/hardware-testing/hardware_testing/gravimetric/__main__.py @@ -2,11 +2,11 @@ from json import load as json_load from pathlib import Path import argparse -from typing import List +from typing import List, Union from opentrons.protocol_api import ProtocolContext -from hardware_testing.data import ui +from hardware_testing.data import create_run_id_and_start_time, ui, get_git_description from hardware_testing.protocols import ( gravimetric_ot3_p50_single, gravimetric_ot3_p50_multi_50ul_tip, @@ -26,8 +26,17 @@ ) from . import execute, helpers, workarounds, execute_photometric -from .config import GravimetricConfig, GANTRY_MAX_SPEED, PhotometricConfig +from .config import ( + GravimetricConfig, + GANTRY_MAX_SPEED, + PhotometricConfig, + ConfigType, + get_tip_volumes_for_qc, +) from .measurement import DELAY_FOR_MEASUREMENT +from .trial import TestResources +from .tips import get_tips +from hardware_testing.drivers import asair_sensor # FIXME: bump to v2.15 to utilize protocol engine API_LEVEL = "2.13" @@ -89,7 +98,7 @@ } -def run_gravimetric( +def build_gravimetric_cfg( protocol: ProtocolContext, pipette_volume: int, pipette_channels: int, @@ -104,7 +113,8 @@ def run_gravimetric( gantry_speed: int, scale_delay: int, isolate_channels: List[int], -) -> None: + extra: bool, +) -> GravimetricConfig: """Run.""" if increment: protocol_cfg = GRAVIMETRIC_CFG_INCREMENT[pipette_volume][pipette_channels][ @@ -112,33 +122,32 @@ def run_gravimetric( ] else: protocol_cfg = GRAVIMETRIC_CFG[pipette_volume][pipette_channels][tip_volume] - execute.run( - protocol, - GravimetricConfig( - name=protocol_cfg.metadata["protocolName"], # type: ignore[attr-defined] - pipette_mount="left", - pipette_volume=pipette_volume, - pipette_channels=pipette_channels, - tip_volume=tip_volume, - trials=trials, - labware_offsets=LABWARE_OFFSETS, - labware_on_scale=protocol_cfg.LABWARE_ON_SCALE, # type: ignore[attr-defined] - slot_scale=protocol_cfg.SLOT_SCALE, # type: ignore[attr-defined] - slots_tiprack=protocol_cfg.SLOTS_TIPRACK[tip_volume], # type: ignore[attr-defined] - increment=increment, - return_tip=return_tip, - blank=blank, - mix=mix, - inspect=inspect, - user_volumes=user_volumes, - gantry_speed=gantry_speed, - scale_delay=scale_delay, - isolate_channels=isolate_channels, - ), + return GravimetricConfig( + name=protocol_cfg.metadata["protocolName"], # type: ignore[attr-defined] + pipette_mount="left", + pipette_volume=pipette_volume, + pipette_channels=pipette_channels, + tip_volume=tip_volume, + trials=trials, + labware_offsets=LABWARE_OFFSETS, + labware_on_scale=protocol_cfg.LABWARE_ON_SCALE, # type: ignore[attr-defined] + slot_scale=protocol_cfg.SLOT_SCALE, # type: ignore[attr-defined] + slots_tiprack=protocol_cfg.SLOTS_TIPRACK[tip_volume], # type: ignore[attr-defined] + increment=increment, + return_tip=return_tip, + blank=blank, + mix=mix, + inspect=inspect, + user_volumes=user_volumes, + gantry_speed=gantry_speed, + scale_delay=scale_delay, + isolate_channels=isolate_channels, + kind=ConfigType.gravimetric, + extra=args.extra, ) -def run_photometric( +def build_photometric_cfg( protocol: ProtocolContext, pipette_volume: int, tip_volume: int, @@ -147,48 +156,122 @@ def run_photometric( mix: bool, inspect: bool, user_volumes: bool, - gantry_speed: int, touch_tip: bool, refill: bool, -) -> None: + extra: bool, +) -> PhotometricConfig: """Run.""" protocol_cfg = PHOTOMETRIC_CFG[tip_volume] - execute_photometric.run( - protocol, - PhotometricConfig( - name=protocol_cfg.metadata["protocolName"], # type: ignore[attr-defined] - pipette_mount="left", - pipette_volume=pipette_volume, - tip_volume=tip_volume, - trials=trials, - labware_offsets=LABWARE_OFFSETS, - photoplate=protocol_cfg.PHOTOPLATE_LABWARE, # type: ignore[attr-defined] - photoplate_slot=protocol_cfg.SLOT_PLATE, # type: ignore[attr-defined] - reservoir=protocol_cfg.RESERVOIR_LABWARE, # type: ignore[attr-defined] - reservoir_slot=protocol_cfg.SLOT_RESERVOIR, # type: ignore[attr-defined] - slots_tiprack=protocol_cfg.SLOTS_TIPRACK[tip_volume], # type: ignore[attr-defined] - return_tip=return_tip, - mix=mix, - inspect=inspect, - user_volumes=user_volumes, - gantry_speed=gantry_speed, - touch_tip=touch_tip, - refill=refill, + return PhotometricConfig( + name=protocol_cfg.metadata["protocolName"], # type: ignore[attr-defined] + pipette_mount="left", + pipette_volume=pipette_volume, + pipette_channels=96, + increment=False, + tip_volume=tip_volume, + trials=trials, + labware_offsets=LABWARE_OFFSETS, + photoplate=protocol_cfg.PHOTOPLATE_LABWARE, # type: ignore[attr-defined] + photoplate_slot=protocol_cfg.SLOT_PLATE, # type: ignore[attr-defined] + reservoir=protocol_cfg.RESERVOIR_LABWARE, # type: ignore[attr-defined] + reservoir_slot=protocol_cfg.SLOT_RESERVOIR, # type: ignore[attr-defined] + slots_tiprack=protocol_cfg.SLOTS_TIPRACK[tip_volume], # type: ignore[attr-defined] + return_tip=return_tip, + mix=mix, + inspect=inspect, + user_volumes=user_volumes, + touch_tip=touch_tip, + refill=refill, + kind=ConfigType.photometric, + extra=args.extra, + ) + + +def _main(args: argparse.Namespace, _ctx: ProtocolContext) -> None: + union_cfg: Union[PhotometricConfig, GravimetricConfig] + if args.photometric: + cfg_pm: PhotometricConfig = build_photometric_cfg( + _ctx, + args.pipette, + args.tip, + args.trials, + args.return_tip, + args.mix, + args.inspect, + args.user_volumes, + args.touch_tip, + args.refill, + args.extra, + ) + if args.trials == 0: + cfg_pm.trials = helpers.get_default_trials(cfg_pm) + union_cfg = cfg_pm + else: + cfg_gm: GravimetricConfig = build_gravimetric_cfg( + _ctx, + args.pipette, + args.channels, + args.tip, + args.trials, + args.increment, + args.return_tip, + False if args.no_blank else True, + args.mix, + args.inspect, + args.user_volumes, + args.gantry_speed, + args.scale_delay, + args.isolate_channels if args.isolate_channels else [], + args.extra, + ) + if args.trials == 0: + cfg_gm.trials = helpers.get_default_trials(cfg_gm) + union_cfg = cfg_gm + run_id, start_time = create_run_id_and_start_time() + ui.print_header("LOAD PIPETTE") + pipette = helpers._load_pipette(_ctx, union_cfg) + ui.print_header("GET PARAMETERS") + test_volumes = helpers._get_volumes(_ctx, union_cfg) + for v in test_volumes: + ui.print_info(f"\t{v} uL") + all_channels_same_time = ( + getattr(union_cfg, "increment", False) or union_cfg.pipette_channels == 96 + ) + run_args = TestResources( + ctx=_ctx, + pipette=pipette, + pipette_tag=helpers._get_tag_from_pipette(pipette, union_cfg), + tipracks=helpers._load_tipracks( + _ctx, union_cfg, use_adapters=args.channels == 96 ), + test_volumes=test_volumes, + run_id=run_id, + start_time=start_time, + operator_name=helpers._get_operator_name(_ctx.is_simulating()), + robot_serial=helpers._get_robot_serial(_ctx.is_simulating()), + tip_batch=helpers._get_tip_batch(_ctx.is_simulating()), + git_description=get_git_description(), + tips=get_tips(_ctx, pipette, args.tip, all_channels=all_channels_same_time), + env_sensor=asair_sensor.BuildAsairSensor(_ctx.is_simulating()), ) + if args.photometric: + execute_photometric.run(cfg_pm, run_args) + else: + execute.run(cfg_gm, run_args) + if __name__ == "__main__": parser = argparse.ArgumentParser("Pipette Testing") parser.add_argument("--simulate", action="store_true") parser.add_argument("--pipette", type=int, choices=[50, 1000], required=True) parser.add_argument("--channels", type=int, choices=[1, 8, 96], default=1) - parser.add_argument("--tip", type=int, choices=[50, 200, 1000], required=True) - parser.add_argument("--trials", type=int, required=True) + parser.add_argument("--tip", type=int, choices=[0, 50, 200, 1000], default=0) + parser.add_argument("--trials", type=int, default=0) parser.add_argument("--increment", action="store_true") parser.add_argument("--return-tip", action="store_true") parser.add_argument("--skip-labware-offsets", action="store_true") - parser.add_argument("--blank", action="store_true") + parser.add_argument("--no-blank", action="store_true") parser.add_argument("--mix", action="store_true") parser.add_argument("--inspect", action="store_true") parser.add_argument("--user-volumes", action="store_true") @@ -198,23 +281,22 @@ def run_photometric( parser.add_argument("--touch-tip", action="store_true") parser.add_argument("--refill", action="store_true") parser.add_argument("--isolate-channels", nargs="+", type=int, default=None) + parser.add_argument("--extra", action="store_true") args = parser.parse_args() if not args.simulate and not args.skip_labware_offsets: # getting labware offsets must be done before creating the protocol context # because it requires the robot-server to be running ui.print_title("SETUP") - print("Starting opentrons-robot-server, so we can http GET labware offsets") + ui.print_info( + "Starting opentrons-robot-server, so we can http GET labware offsets" + ) offsets = workarounds.http_get_all_labware_offsets() - print(f"found {len(offsets)} offsets:") + ui.print_info(f"found {len(offsets)} offsets:") for offset in offsets: - print(f"\t{offset['createdAt']}:") - print(f"\t\t{offset['definitionUri']}") - print(f"\t\t{offset['vector']}") + ui.print_info(f"\t{offset['createdAt']}:") + ui.print_info(f"\t\t{offset['definitionUri']}") + ui.print_info(f"\t\t{offset['vector']}") LABWARE_OFFSETS.append(offset) - if args.increment: - _protocol = GRAVIMETRIC_CFG_INCREMENT[args.pipette][args.channels][args.tip] - else: - _protocol = GRAVIMETRIC_CFG[args.pipette][args.channels][args.tip] # gather the custom labware (for simulation) custom_defs = {} if args.simulate: @@ -235,34 +317,14 @@ def run_photometric( deck_version="2", extra_labware=custom_defs, ) - if args.photometric: - run_photometric( - _ctx, - args.pipette, - args.tip, - args.trials, - args.return_tip, - args.mix, - args.inspect, - args.user_volumes, - args.gantry_speed, - args.touch_tip, - args.refill, - ) + if args.tip == 0: + for tip in get_tip_volumes_for_qc( + args.pipette, args.channels, args.extra, args.photometric + ): + hw = _ctx._core.get_hardware() + if not _ctx.is_simulating(): + ui.alert_user_ready(f"Ready to run with {tip}ul tip?", hw) + args.tip = tip + _main(args, _ctx) else: - run_gravimetric( - _ctx, - args.pipette, - args.channels, - args.tip, - args.trials, - args.increment, - args.return_tip, - args.blank, - args.mix, - args.inspect, - args.user_volumes, - args.gantry_speed, - args.scale_delay, - args.isolate_channels if args.isolate_channels else [], - ) + _main(args, _ctx) diff --git a/hardware-testing/hardware_testing/gravimetric/config.py b/hardware-testing/hardware_testing/gravimetric/config.py index 528ad58e5942..f5cbc3ba8e87 100644 --- a/hardware-testing/hardware_testing/gravimetric/config.py +++ b/hardware-testing/hardware_testing/gravimetric/config.py @@ -1,12 +1,20 @@ """Config.""" from dataclasses import dataclass -from typing import List +from typing import List, Dict from typing_extensions import Final +from enum import Enum + + +class ConfigType(Enum): + """Substitute for Literal which isn't available until 3.8.0.""" + + gravimetric = 1 + photometric = 2 @dataclass -class GravimetricConfig: - """Execute Gravimetric Setup Config.""" +class VolumetricConfig: + """Execute shared volumetric Setup Config.""" name: str pipette_volume: int @@ -15,40 +23,36 @@ class GravimetricConfig: tip_volume: int trials: int labware_offsets: List[dict] - labware_on_scale: str - slot_scale: int slots_tiprack: List[int] increment: bool return_tip: bool - blank: bool mix: bool inspect: bool user_volumes: bool + kind: ConfigType + extra: bool + + +@dataclass +class GravimetricConfig(VolumetricConfig): + """Execute Gravimetric Setup Config.""" + + labware_on_scale: str + slot_scale: int + blank: bool gantry_speed: int scale_delay: int isolate_channels: List[int] @dataclass -class PhotometricConfig: +class PhotometricConfig(VolumetricConfig): """Execute photometric Setup Config.""" - name: str - pipette_volume: int - pipette_mount: str - tip_volume: int - trials: int - labware_offsets: List[dict] photoplate: str photoplate_slot: int reservoir: str reservoir_slot: int - slots_tiprack: List[int] - return_tip: bool - mix: bool - inspect: bool - user_volumes: bool - gantry_speed: int touch_tip: bool refill: bool @@ -72,3 +76,107 @@ class PhotometricConfig: VIAL_SAFE_Z_OFFSET: Final = 25 LABWARE_BOTTOM_CLEARANCE = 1.5 + + +QC_VOLUMES_G: Dict[int, Dict[int, Dict[int, List[float]]]] = { + 1: { + 50: { # P50 + 50: [1.0, 50.0], # T50 + }, + 1000: { # P1000 + 50: [5.0], # T50 + 200: [], # T200 + 1000: [1000.0], # T1000 + }, + }, + 8: { + 50: { # P50 + 50: [1.0, 50.0], # T50 + }, + 1000: { # P1000 + 50: [5.0], # T50 + 200: [], # T200 + 1000: [1000.0], # T1000 + }, + }, + 96: { + 1000: { # P1000 + 50: [], # T50 + 200: [], # T200 + 1000: [1000.0], # T1000 + }, + }, +} + + +QC_VOLUMES_EXTRA_G: Dict[int, Dict[int, Dict[int, List[float]]]] = { + 1: { + 50: { # P50 + 50: [1.0, 10.0, 50.0], # T50 + }, + 1000: { # P1000 + 50: [5.0, 50], # T50 + 200: [200.0], # T200 + 1000: [1000.0], # T1000 + }, + }, + 8: { + 50: { # P50 + 50: [1.0, 10.0, 50.0], # T50 + }, + 1000: { # P1000 + 50: [5.0, 50], # T50 + 200: [200.0], # T200 + 1000: [1000.0], # T1000 + }, + }, + 96: { + 1000: { # P1000 + 50: [], # T50 + 200: [], # T200 + 1000: [1000.0], # T1000 + }, + }, +} + +QC_VOLUMES_P: Dict[int, Dict[int, Dict[int, List[float]]]] = { + 96: { + 1000: { # P1000 + 50: [5.0], # T50 + 200: [200.0], # T200 + 1000: [], # T1000 + }, + }, +} + +QC_DEFAULT_TRIALS: Dict[ConfigType, Dict[int, int]] = { + ConfigType.gravimetric: { + 1: 10, + 8: 8, + 96: 9, + }, + ConfigType.photometric: { + 96: 5, + }, +} + + +def get_tip_volumes_for_qc( + pipette_volume: int, pipette_channels: int, extra: bool, photometric: bool +) -> List[int]: + """Build the default testing volumes for qc.""" + config: Dict[int, Dict[int, Dict[int, List[float]]]] = {} + if photometric: + config = QC_VOLUMES_P + else: + if extra: + config = QC_VOLUMES_EXTRA_G + else: + config = QC_VOLUMES_G + tip_volumes = [ + t + for t in config[pipette_channels][pipette_volume].keys() + if len(config[pipette_channels][pipette_volume][t]) > 0 + ] + assert len(tip_volumes) > 0 + return tip_volumes diff --git a/hardware-testing/hardware_testing/gravimetric/execute.py b/hardware-testing/hardware_testing/gravimetric/execute.py index d752a440cf61..5302a477ac86 100644 --- a/hardware-testing/hardware_testing/gravimetric/execute.py +++ b/hardware-testing/hardware_testing/gravimetric/execute.py @@ -1,45 +1,49 @@ """Gravimetric.""" from time import sleep -from dataclasses import dataclass -from inspect import getsource -from statistics import stdev from typing import Optional, Tuple, List, Dict -from opentrons.hardware_control.instruments.ot3.pipette import Pipette -from opentrons.types import Location -from opentrons.protocol_api import ProtocolContext, InstrumentContext, Well, Labware +from opentrons.protocol_api import ProtocolContext, Well, Labware -from hardware_testing.data import create_run_id_and_start_time, ui, get_git_description +from hardware_testing.data import ui from hardware_testing.data.csv_report import CSVReport -from hardware_testing.opentrons_api.types import OT3Mount, Point -from hardware_testing.opentrons_api.helpers_ot3 import clear_pipette_ul_per_mm +from hardware_testing.opentrons_api.types import Point, OT3Mount from . import report from . import config -from .helpers import get_pipette_unique_name -from .workarounds import get_sync_hw_api, get_latest_offset_for_labware -from .increments import get_volume_increments -from .liquid_class.defaults import get_test_volumes, get_liquid_class +from .helpers import ( + _calculate_stats, + _get_channel_offset, + _calculate_average, + _jog_to_find_liquid_height, + _apply_labware_offsets, + _pick_up_tip, + _drop_tip, +) +from .trial import ( + build_gravimetric_trials, + GravimetricTrial, + TestResources, + _finish_test, +) from .liquid_class.pipetting import ( aspirate_with_liquid_class, dispense_with_liquid_class, PipettingCallbacks, ) -from .liquid_height.height import LiquidTracker, initialize_liquid_from_deck +from .liquid_height.height import LiquidTracker from .measurement import ( MeasurementData, MeasurementType, record_measurement_data, calculate_change_in_volume, create_measurement_tag, - DELAY_FOR_MEASUREMENT, ) from .measurement.environment import get_min_reading, get_max_reading from .measurement.record import ( GravimetricRecorder, GravimetricRecorderConfig, ) -from .tips import get_tips, MULTI_CHANNEL_TEST_ORDER +from .tips import MULTI_CHANNEL_TEST_ORDER _MEASUREMENTS: List[Tuple[str, MeasurementData]] = list() @@ -49,6 +53,13 @@ _tip_counter: Dict[int, int] = {} +def _minimum_z_height(cfg: config.GravimetricConfig) -> int: + if cfg.pipette_channels == 96: + return 133 + else: + return 0 + + def _generate_callbacks_for_trial( recorder: GravimetricRecorder, volume: Optional[float], @@ -99,190 +110,29 @@ def _update_environment_first_last_min_max(test_report: report.CSVReport) -> Non report.store_environment(test_report, report.EnvironmentReportState.MAX, max_data) -def _check_if_software_supports_high_volumes() -> bool: - src_a = getsource(Pipette.set_current_volume) - src_b = getsource(Pipette.ok_to_add_volume) - modified_a = "# assert new_volume <= self.working_volume" in src_a - modified_b = "return True" in src_b - return modified_a and modified_b - - -def _reduce_volumes_to_not_exceed_software_limit( - test_volumes: List[float], cfg: config.GravimetricConfig -) -> List[float]: - for i, v in enumerate(test_volumes): - liq_cls = get_liquid_class( - cfg.pipette_volume, cfg.pipette_channels, cfg.tip_volume, int(v) - ) - max_vol = cfg.tip_volume - liq_cls.aspirate.trailing_air_gap - test_volumes[i] = min(v, max_vol - 0.1) - return test_volumes - - -def _get_volumes(ctx: ProtocolContext, cfg: config.GravimetricConfig) -> List[float]: - if cfg.increment: - test_volumes = get_volume_increments(cfg.pipette_volume, cfg.tip_volume) - elif cfg.user_volumes and not ctx.is_simulating(): - _inp = input('Enter desired volumes, comma separated (eg: "10,100,1000") :') - test_volumes = [ - float(vol_str) for vol_str in _inp.strip().split(",") if vol_str - ] - else: - test_volumes = get_test_volumes( - cfg.pipette_volume, cfg.pipette_channels, cfg.tip_volume - ) - if not test_volumes: - raise ValueError("no volumes to test, check the configuration") - if not _check_if_software_supports_high_volumes(): - if ctx.is_simulating(): - test_volumes = _reduce_volumes_to_not_exceed_software_limit( - test_volumes, cfg - ) - else: - raise RuntimeError("you are not the correct branch") - return sorted(test_volumes, reverse=False) # lowest volumes first - - -def _get_channel_offset(cfg: config.GravimetricConfig, channel: int) -> Point: - assert ( - channel < cfg.pipette_channels - ), f"unexpected channel on {cfg.pipette_channels} channel pipette: {channel}" - if cfg.pipette_channels == 1: - return Point() - if cfg.pipette_channels == 8: - return Point(y=channel * 9.0) - if cfg.pipette_channels == 96: - row = channel % 8 # A-H - col = int(float(channel) / 8.0) # 1-12 - return Point(x=col * 9.0, y=row * 9.0) - raise ValueError(f"unexpected number of channels in config: {cfg.pipette_channels}") - - -def _load_pipette( - ctx: ProtocolContext, cfg: config.GravimetricConfig -) -> InstrumentContext: - load_str_channels = {1: "single", 8: "multi", 96: "96"} - if cfg.pipette_channels not in load_str_channels: - raise ValueError(f"unexpected number of channels: {cfg.pipette_channels}") - chnl_str = load_str_channels[cfg.pipette_channels] - if cfg.pipette_channels == 96: - pip_name = "p1000_96" - else: - pip_name = f"p{cfg.pipette_volume}_{chnl_str}_gen3" - print(f'pipette "{pip_name}" on mount "{cfg.pipette_mount}"') - pipette = ctx.load_instrument(pip_name, cfg.pipette_mount) - assert pipette.channels == cfg.pipette_channels, ( - f"expected {cfg.pipette_channels} channels, " - f"but got pipette with {pipette.channels} channels" - ) - assert pipette.max_volume == cfg.pipette_volume, ( - f"expected {cfg.pipette_volume} uL pipette, " - f"but got a {pipette.max_volume} uL pipette" - ) - pipette.default_speed = cfg.gantry_speed - # NOTE: 8ch QC testing means testing 1 channel at a time, - # so we need to decrease the pick-up current to work with 1 tip. - if pipette.channels == 8 and not cfg.increment: - hwapi = get_sync_hw_api(ctx) - mnt = OT3Mount.LEFT if cfg.pipette_mount == "left" else OT3Mount.RIGHT - hwpipette: Pipette = hwapi.hardware_pipettes[mnt.to_mount()] - hwpipette.pick_up_configurations.current = 0.2 - return pipette - - -def _apply_labware_offsets( - cfg: config.GravimetricConfig, tip_racks: List[Labware], labware_on_scale: Labware -) -> None: - def _apply(labware: Labware) -> None: - o = get_latest_offset_for_labware(cfg.labware_offsets, labware) - print( - f'Apply labware offset to "{labware.name}" (slot={labware.parent}): ' - f"x={round(o.x, 2)}, y={round(o.y, 2)}, z={round(o.z, 2)}" - ) - labware.set_calibration(o) - - _apply(labware_on_scale) - for rack in tip_racks: - _apply(rack) - - -def _load_labware( - ctx: ProtocolContext, cfg: config.GravimetricConfig -) -> Tuple[Labware, List[Labware]]: - print(f'Loading labware on scale: "{cfg.labware_on_scale}"') +def _load_labware(ctx: ProtocolContext, cfg: config.GravimetricConfig) -> Labware: + ui.print_info(f'Loading labware on scale: "{cfg.labware_on_scale}"') if cfg.labware_on_scale == "radwag_pipette_calibration_vial": namespace = "custom_beta" else: namespace = "opentrons" + # If running multiple tests in one run, the labware may already be loaded + loaded_labwares = ctx.loaded_labwares + if ( + cfg.slot_scale in loaded_labwares.keys() + and loaded_labwares[cfg.slot_scale].name == cfg.labware_on_scale + ): + return loaded_labwares[cfg.slot_scale] + labware_on_scale = ctx.load_labware( cfg.labware_on_scale, location=cfg.slot_scale, namespace=namespace ) - if cfg.pipette_channels == 96: - tiprack_namespace = "custom_beta" - tiprack_loadname = f"opentrons_flex_96_tiprack_{cfg.tip_volume}ul_adp" - else: - tiprack_namespace = "opentrons" - tiprack_loadname = f"opentrons_flex_96_tiprack_{cfg.tip_volume}ul" - tiprack_load_settings: List[Tuple[int, str, str]] = [ - ( - slot, - tiprack_loadname, - tiprack_namespace, - ) - for slot in cfg.slots_tiprack - ] - tipracks: List[Labware] = [] - for ls in tiprack_load_settings: - print(f'Loading tiprack "{ls[1]}" in slot #{ls[0]} with namespace "{ls[2]}"') - tipracks.append(ctx.load_labware(ls[1], location=ls[0], namespace=ls[2])) - _apply_labware_offsets(cfg, tipracks, labware_on_scale) - return labware_on_scale, tipracks - - -def _jog_to_find_liquid_height( - ctx: ProtocolContext, pipette: InstrumentContext, well: Well -) -> float: - _well_depth = well.depth - _liquid_height = _well_depth - _jog_size = -1.0 - if ctx.is_simulating(): - return _liquid_height - 1 - while True: - pipette.move_to(well.bottom(_liquid_height)) - inp = input( - f"height={_liquid_height}: ENTER to jog {_jog_size} mm, " - f'or enter new jog size, or "yes" to save: ' - ) - if inp: - if inp[0] == "y": - break - try: - _jog_size = min(max(float(inp), -1.0), 1.0) - except ValueError: - continue - _liquid_height = min(max(_liquid_height + _jog_size, 0), _well_depth) - return _liquid_height - - -def _calculate_average(volume_list: List[float]) -> float: - return sum(volume_list) / len(volume_list) - - -def _calculate_stats( - volume_list: List[float], total_volume: float -) -> Tuple[float, float, float]: - average = _calculate_average(volume_list) - if len(volume_list) <= 1: - print("skipping CV, only 1x trial per volume") - cv = -0.01 # negative number is impossible - else: - cv = stdev(volume_list) / average - d = (average - total_volume) / total_volume - return average, cv, d + _apply_labware_offsets(cfg, [labware_on_scale]) + return labware_on_scale def _print_stats(mode: str, average: float, cv: float, d: float) -> None: - print( + ui.print_info( f"{mode}:\n" f"\tavg: {round(average, 2)} uL\n" f"\tcv: {round(cv * 100.0, 2)}%\n" @@ -294,190 +144,148 @@ def _print_final_results( volumes: List[float], channel_count: int, test_report: CSVReport ) -> None: for vol in volumes: - print(f" * {vol}ul channel all:") + ui.print_info(f" * {vol}ul channel all:") for mode in ["aspirate", "dispense"]: avg, cv, d = report.get_volume_results_all(test_report, mode, vol) - print(f" - {mode}:") - print(f" avg: {avg}ul") - print(f" cv: {cv}%") - print(f" d: {d}%") + ui.print_info(f" - {mode}:") + ui.print_info(f" avg: {avg}ul") + ui.print_info(f" cv: {cv}%") + ui.print_info(f" d: {d}%") for channel in range(channel_count): - print(f" * vol {vol}ul channel {channel + 1}:") + ui.print_info(f" * vol {vol}ul channel {channel + 1}:") for mode in ["aspirate", "dispense"]: avg, cv, d = report.get_volume_results_per_channel( test_report, mode, vol, channel ) - print(f" - {mode}:") - print(f" avg: {avg}ul") - print(f" cv: {cv}%") - print(f" d: {d}%") + ui.print_info(f" - {mode}:") + ui.print_info(f" avg: {avg}ul") + ui.print_info(f" cv: {cv}%") + ui.print_info(f" d: {d}%") -def _run_trial( - ctx: ProtocolContext, - pipette: InstrumentContext, - well: Well, - channel_offset: Point, - tip_volume: int, - volume: float, +def _next_tip_for_channel( + cfg: config.GravimetricConfig, + resources: TestResources, channel: int, - channel_count: int, - trial: int, - recorder: GravimetricRecorder, - test_report: report.CSVReport, - liquid_tracker: LiquidTracker, - blank: bool, - inspect: bool, - mix: bool = False, - stable: bool = True, - scale_delay: int = DELAY_FOR_MEASUREMENT, + max_tips: int, +) -> Well: + _tips_used = sum([tc for tc in _tip_counter.values()]) + if _tips_used >= max_tips: + if cfg.pipette_channels != 96: + raise RuntimeError("ran out of tips") + if not resources.ctx.is_simulating(): + ui.print_title("Reset 96ch Tip Racks") + ui.get_user_ready(f"ADD {max_tips}x new tip-racks") + _tip_counter[channel] = 0 + _tip = resources.tips[channel][_tip_counter[channel]] + _tip_counter[channel] += 1 + return _tip + + +def _run_trial( + trial: GravimetricTrial, ) -> Tuple[float, MeasurementData, float, MeasurementData]: global _PREV_TRIAL_GRAMS pipetting_callbacks = _generate_callbacks_for_trial( - recorder, volume, channel, trial, blank + trial.recorder, trial.volume, trial.channel, trial.trial, trial.blank ) def _tag(m_type: MeasurementType) -> str: - return create_measurement_tag(m_type, None if blank else volume, channel, trial) + return create_measurement_tag( + m_type, None if trial.blank else trial.volume, trial.channel, trial.trial + ) def _record_measurement_and_store(m_type: MeasurementType) -> MeasurementData: m_tag = _tag(m_type) - if recorder.is_simulator and not blank: + if trial.recorder.is_simulator and not trial.blank: if m_type == MeasurementType.ASPIRATE: - recorder.add_simulation_mass(volume * -0.001) + trial.recorder.add_simulation_mass(trial.volume * -0.001) elif m_type == MeasurementType.DISPENSE: - recorder.add_simulation_mass(volume * 0.001) + trial.recorder.add_simulation_mass(trial.volume * 0.001) m_data = record_measurement_data( - ctx, + trial.ctx, m_tag, - recorder, - pipette.mount, - stable, - shorten=inspect, - delay_seconds=scale_delay, + trial.recorder, + trial.pipette.mount, + trial.stable, + trial.env_sensor, + shorten=trial.inspect, + delay_seconds=trial.scale_delay, ) - report.store_measurement(test_report, m_tag, m_data) + report.store_measurement(trial.test_report, m_tag, m_data) _MEASUREMENTS.append( ( m_tag, m_data, ) ) - _update_environment_first_last_min_max(test_report) + _update_environment_first_last_min_max(trial.test_report) return m_data - print("recorded weights:") + ui.print_info("recorded weights:") # RUN INIT - pipette.move_to(well.top(50).move(channel_offset)) # center channel over well - mnt = OT3Mount.RIGHT if pipette.mount == "right" else OT3Mount.LEFT - ctx._core.get_hardware().retract(mnt) # retract to top of gantry + trial.pipette.move_to( + trial.well.top(50).move(trial.channel_offset) + ) # center channel over well + mnt = OT3Mount.RIGHT if trial.pipette.mount == "right" else OT3Mount.LEFT + trial.ctx._core.get_hardware().retract(mnt) # retract to top of gantry m_data_init = _record_measurement_and_store(MeasurementType.INIT) - print(f"\tinitial grams: {m_data_init.grams_average} g") + ui.print_info(f"\tinitial grams: {m_data_init.grams_average} g") if _PREV_TRIAL_GRAMS is not None: _evaporation_loss_ul = abs( calculate_change_in_volume(_PREV_TRIAL_GRAMS, m_data_init) ) - print(f"{_evaporation_loss_ul} ul evaporated since last trial") - liquid_tracker.update_affected_wells( - well, aspirate=_evaporation_loss_ul, channels=1 + ui.print_info(f"{_evaporation_loss_ul} ul evaporated since last trial") + trial.liquid_tracker.update_affected_wells( + trial.well, aspirate=_evaporation_loss_ul, channels=1 ) _PREV_TRIAL_GRAMS = m_data_init # RUN ASPIRATE aspirate_with_liquid_class( - ctx, - pipette, - tip_volume, - volume, - well, - channel_offset, - channel_count, - liquid_tracker, + trial.ctx, + trial.pipette, + trial.tip_volume, + trial.volume, + trial.well, + trial.channel_offset, + trial.channel_count, + trial.liquid_tracker, callbacks=pipetting_callbacks, - blank=blank, - inspect=inspect, - mix=mix, + blank=trial.blank, + inspect=trial.inspect, + mix=trial.mix, ) - ctx._core.get_hardware().retract(mnt) + trial.ctx._core.get_hardware().retract(mnt) # retract to top of gantry m_data_aspirate = _record_measurement_and_store(MeasurementType.ASPIRATE) - print(f"\tgrams after aspirate: {m_data_aspirate.grams_average} g") - print(f"\tcelsius after aspirate: {m_data_aspirate.celsius_pipette} C") + ui.print_info(f"\tgrams after aspirate: {m_data_aspirate.grams_average} g") + ui.print_info(f"\tcelsius after aspirate: {m_data_aspirate.celsius_pipette} C") # RUN DISPENSE dispense_with_liquid_class( - ctx, - pipette, - tip_volume, - volume, - well, - channel_offset, - channel_count, - liquid_tracker, + trial.ctx, + trial.pipette, + trial.tip_volume, + trial.volume, + trial.well, + trial.channel_offset, + trial.channel_count, + trial.liquid_tracker, callbacks=pipetting_callbacks, - blank=blank, - inspect=inspect, - mix=mix, + blank=trial.blank, + inspect=trial.inspect, + mix=trial.mix, ) - ctx._core.get_hardware().retract(mnt) + trial.ctx._core.get_hardware().retract(mnt) # retract to top of gantry m_data_dispense = _record_measurement_and_store(MeasurementType.DISPENSE) - print(f"\tgrams after dispense: {m_data_dispense.grams_average} g") - + ui.print_info(f"\tgrams after dispense: {m_data_dispense.grams_average} g") # calculate volumes volume_aspirate = calculate_change_in_volume(m_data_init, m_data_aspirate) volume_dispense = calculate_change_in_volume(m_data_aspirate, m_data_dispense) return volume_aspirate, m_data_aspirate, volume_dispense, m_data_dispense -def _get_operator_name(is_simulating: bool) -> str: - if not is_simulating: - return input("OPERATOR name:").strip() - else: - return "simulation" - - -def _get_robot_serial(is_simulating: bool) -> str: - if not is_simulating: - return input("ROBOT SERIAL NUMBER:").strip() - else: - return "simulation-serial-number" - - -def _get_tip_batch(is_simulating: bool) -> str: - if not is_simulating: - return input("TIP BATCH:").strip() - else: - return "simulation-tip-batch" - - -def _pick_up_tip( - ctx: ProtocolContext, - pipette: InstrumentContext, - cfg: config.GravimetricConfig, - location: Location, -) -> None: - print( - f"picking tip {location.labware.as_well().well_name} " - f"from slot #{location.labware.parent.parent}" - ) - pipette.pick_up_tip(location) - # NOTE: the accuracy-adjust function gets set on the Pipette - # each time we pick-up a new tip. - if cfg.increment: - print("clearing pipette ul-per-mm table to be linear") - clear_pipette_ul_per_mm( - get_sync_hw_api(ctx)._obj_to_adapt, # type: ignore[arg-type] - OT3Mount.LEFT if cfg.pipette_mount == "left" else OT3Mount.RIGHT, - ) - - -def _drop_tip(pipette: InstrumentContext, return_tip: bool) -> None: - if return_tip: - pipette.return_tip(home_after=False) - else: - pipette.drop_tip(home_after=False) - - def _get_test_channels(cfg: config.GravimetricConfig) -> List[int]: if cfg.pipette_channels == 8 and not cfg.increment: # NOTE: only test channels separately when QC'ing a 8ch @@ -493,120 +301,36 @@ def _get_channel_divider(cfg: config.GravimetricConfig) -> float: return float(cfg.pipette_channels) -def _get_tag_from_pipette( - pipette: InstrumentContext, cfg: config.GravimetricConfig -) -> str: - pipette_tag = get_pipette_unique_name(pipette) - print(f'found pipette "{pipette_tag}"') - if cfg.increment: - pipette_tag += "-increment" - elif cfg.user_volumes: - pipette_tag += "-user-volume" - else: - pipette_tag += "-qc" - return pipette_tag - - -def _change_pipettes( - ctx: ProtocolContext, pipette: InstrumentContext, return_tip: bool -) -> None: - if pipette.has_tip: - if pipette.current_volume > 0: - print("dispensing liquid to trash") - trash = pipette.trash_container.wells()[0] - # FIXME: this should be a blow_out() at max volume, - # but that is not available through PyAPI yet - # so instead just dispensing. - pipette.dispense(pipette.current_volume, trash.top()) - pipette.aspirate(10) # to pull any droplets back up - print("dropping tip") - _drop_tip(pipette, return_tip) - print("moving to attach position") - pipette.move_to(ctx.deck.position_for(5).move(Point(x=0, y=9 * 7, z=150))) - - -def _next_tip_for_channel( - ctx: ProtocolContext, +def build_gm_report( cfg: config.GravimetricConfig, - tips: Dict[int, List[Well]], - channel: int, - max_tips: int, -) -> Well: - _tips_used = sum([tc for tc in _tip_counter.values()]) - if _tips_used >= max_tips: - if cfg.pipette_channels != 96: - raise RuntimeError("ran out of tips") - if not ctx.is_simulating(): - ui.print_title("Reset 96ch Tip Racks") - ui.get_user_ready(f"ADD {max_tips}x new tip-racks") - _tip_counter[channel] = 0 - _tip = tips[channel][_tip_counter[channel]] - _tip_counter[channel] += 1 - return _tip - - -@dataclass -class _RunParameters: - test_volumes: List[float] - tips: Dict[int, List[Well]] - num_channels_per_transfer: int - channels_to_test: List[int] - trial_total: int - total_tips: int - - -def _calculate_parameters( - ctx: ProtocolContext, cfg: config.GravimetricConfig, pipette: InstrumentContext -) -> _RunParameters: - test_volumes = _get_volumes(ctx, cfg) - for v in test_volumes: - print(f"\t{v} uL") - all_channels_same_time = cfg.increment or cfg.pipette_channels == 96 - tips = get_tips(ctx, pipette, all_channels=all_channels_same_time) - total_tips = len([tip for chnl_tips in tips.values() for tip in chnl_tips]) - channels_to_test = _get_test_channels(cfg) - for channel in channels_to_test: - # initialize the global tip counter, per each channel that will be tested - _tip_counter[channel] = 0 - if len(channels_to_test) > 1: - num_channels_per_transfer = 1 - else: - num_channels_per_transfer = cfg.pipette_channels - trial_total = len(test_volumes) * cfg.trials * len(channels_to_test) - support_tip_resupply = bool(cfg.pipette_channels == 96 and cfg.increment) - if trial_total > total_tips: - if not support_tip_resupply: - raise ValueError(f"more trials ({trial_total}) than tips ({total_tips})") - elif not ctx.is_simulating(): - ui.get_user_ready(f"prepare {trial_total - total_tips} extra tip-racks") - return _RunParameters( - test_volumes=test_volumes, - tips=tips, - num_channels_per_transfer=num_channels_per_transfer, - channels_to_test=channels_to_test, - trial_total=trial_total, - total_tips=total_tips, + resources: TestResources, + recorder: GravimetricRecorder, +) -> report.CSVReport: + """Build a CSVReport formated for gravimetric tests.""" + ui.print_header("CREATE TEST-REPORT") + test_report = report.create_csv_test_report( + resources.test_volumes, cfg, run_id=resources.run_id ) + test_report.set_tag(resources.pipette_tag) + test_report.set_operator(resources.operator_name) + test_report.set_version(resources.git_description) + report.store_serial_numbers( + test_report, + robot=resources.robot_serial, + pipette=resources.pipette_tag, + tips=resources.tip_batch, + scale=recorder.serial_number, + environment="None", + liquid="None", + ) + return test_report -def run(ctx: ProtocolContext, cfg: config.GravimetricConfig) -> None: - """Run.""" - run_id, start_time = create_run_id_and_start_time() - - ui.print_header("LOAD LABWARE") - labware_on_scale, tipracks = _load_labware(ctx, cfg) - liquid_tracker = LiquidTracker() - initialize_liquid_from_deck(ctx, liquid_tracker) - - ui.print_header("LOAD PIPETTE") - pipette = _load_pipette(ctx, cfg) - pipette_tag = _get_tag_from_pipette(pipette, cfg) - - ui.print_header("GET PARAMETERS") - parameters = _calculate_parameters(ctx, cfg, pipette) - +def _load_scale( + cfg: config.GravimetricConfig, resources: TestResources +) -> GravimetricRecorder: ui.print_header("LOAD SCALE") - print( + ui.print_info( "Some Radwag settings cannot be controlled remotely.\n" "Listed below are the things the must be done using the touchscreen:\n" " 1) Set profile to USER\n" @@ -615,130 +339,179 @@ def run(ctx: ProtocolContext, cfg: config.GravimetricConfig) -> None: recorder = GravimetricRecorder( GravimetricRecorderConfig( test_name=cfg.name, - run_id=run_id, - tag=pipette_tag, - start_time=start_time, + run_id=resources.run_id, + tag=resources.pipette_tag, + start_time=resources.start_time, duration=0, - frequency=1000 if ctx.is_simulating() else 5, + frequency=1000 if resources.ctx.is_simulating() else 5, stable=False, ), - simulate=ctx.is_simulating(), + simulate=resources.ctx.is_simulating(), ) - print(f'found scale "{recorder.serial_number}"') - if ctx.is_simulating(): + ui.print_info(f'found scale "{recorder.serial_number}"') + if resources.ctx.is_simulating(): start_sim_mass = {50: 15, 200: 200, 1000: 200} recorder.set_simulation_mass(start_sim_mass[cfg.tip_volume]) recorder.record(in_thread=True) - print(f'scale is recording to "{recorder.file_name}"') + ui.print_info(f'scale is recording to "{recorder.file_name}"') + return recorder - ui.print_header("CREATE TEST-REPORT") - test_report = report.create_csv_test_report( - parameters.test_volumes, cfg, run_id=run_id + +def _calculate_evaporation( + cfg: config.GravimetricConfig, + resources: TestResources, + recorder: GravimetricRecorder, + liquid_tracker: LiquidTracker, + test_report: report.CSVReport, + labware_on_scale: Labware, +) -> Tuple[float, float]: + ui.print_title("MEASURE EVAPORATION") + blank_trials = build_gravimetric_trials( + resources.ctx, + resources.pipette, + cfg, + labware_on_scale["A1"], + [resources.test_volumes[-1]], + [], + recorder, + test_report, + liquid_tracker, + True, + resources.env_sensor, ) - test_report.set_tag(pipette_tag) - test_report.set_operator(_get_operator_name(ctx.is_simulating())) - serial_number = _get_robot_serial(ctx.is_simulating()) - tip_batch = _get_tip_batch(ctx.is_simulating()) - test_report.set_version(get_git_description()) - report.store_serial_numbers( + ui.print_info(f"running {config.NUM_BLANK_TRIALS}x blank measurements") + mnt = OT3Mount.RIGHT if resources.pipette.mount == "right" else OT3Mount.LEFT + resources.ctx._core.get_hardware().retract(mnt) + for i in range(config.SCALE_SECONDS_TO_TRUE_STABILIZE): + ui.print_info( + f"wait for scale to stabilize " + f"({i + 1}/{config.SCALE_SECONDS_TO_TRUE_STABILIZE})" + ) + sleep(1) + actual_asp_list_evap: List[float] = [] + actual_disp_list_evap: List[float] = [] + for b_trial in blank_trials[resources.test_volumes[-1]][0]: + ui.print_header(f"BLANK {b_trial.trial + 1}/{config.NUM_BLANK_TRIALS}") + evap_aspirate, _, evap_dispense, _ = _run_trial(b_trial) + ui.print_info( + f"blank {b_trial.trial + 1}/{config.NUM_BLANK_TRIALS}:\n" + f"\taspirate: {evap_aspirate} uL\n" + f"\tdispense: {evap_dispense} uL" + ) + actual_asp_list_evap.append(evap_aspirate) + actual_disp_list_evap.append(evap_dispense) + ui.print_header("EVAPORATION AVERAGE") + average_aspirate_evaporation_ul = _calculate_average(actual_asp_list_evap) + average_dispense_evaporation_ul = _calculate_average(actual_disp_list_evap) + ui.print_info( + "average:\n" + f"\taspirate: {average_aspirate_evaporation_ul} uL\n" + f"\tdispense: {average_dispense_evaporation_ul} uL" + ) + report.store_average_evaporation( test_report, - robot=serial_number, - pipette=pipette_tag, - tips=tip_batch, - scale=recorder.serial_number, - environment="None", - liquid="None", + average_aspirate_evaporation_ul, + average_dispense_evaporation_ul, ) + return average_aspirate_evaporation_ul, average_dispense_evaporation_ul + + +def run(cfg: config.GravimetricConfig, resources: TestResources) -> None: + """Run.""" + global _PREV_TRIAL_GRAMS + global _MEASUREMENTS + ui.print_header("LOAD LABWARE") + labware_on_scale = _load_labware(resources.ctx, cfg) + liquid_tracker = LiquidTracker(resources.ctx) + + total_tips = len( + [tip for chnl_tips in resources.tips.values() for tip in chnl_tips] + ) + channels_to_test = _get_test_channels(cfg) + for channel in channels_to_test: + # initialize the global tip counter, per each channel that will be tested + _tip_counter[channel] = 0 + trial_total = len(resources.test_volumes) * cfg.trials * len(channels_to_test) + support_tip_resupply = bool(cfg.pipette_channels == 96 and cfg.increment) + if trial_total > total_tips: + if not support_tip_resupply: + raise ValueError(f"more trials ({trial_total}) than tips ({total_tips})") + elif not resources.ctx.is_simulating(): + ui.get_user_ready(f"prepare {trial_total - total_tips} extra tip-racks") + recorder = _load_scale(cfg, resources) + test_report = build_gm_report(cfg, resources, recorder) calibration_tip_in_use = True + + if resources.ctx.is_simulating(): + _PREV_TRIAL_GRAMS = None + _MEASUREMENTS = list() try: ui.print_title("FIND LIQUID HEIGHT") - print("homing...") - ctx.home() - pipette.home_plunger() - first_tip = parameters.tips[0][0] + ui.print_info("homing...") + resources.ctx.home() + resources.pipette.home_plunger() + first_tip = resources.tips[0][0] setup_channel_offset = _get_channel_offset(cfg, channel=0) first_tip_location = first_tip.top().move(setup_channel_offset) - _pick_up_tip(ctx, pipette, cfg, location=first_tip_location) + _pick_up_tip(resources.ctx, resources.pipette, cfg, location=first_tip_location) mnt = OT3Mount.LEFT if cfg.pipette_mount == "left" else OT3Mount.RIGHT - ctx._core.get_hardware().retract(mnt) - if not ctx.is_simulating(): + resources.ctx._core.get_hardware().retract(mnt) + if not resources.ctx.is_simulating(): ui.get_user_ready("REPLACE first tip with NEW TIP") ui.get_user_ready("CLOSE the door, and MOVE AWAY from machine") - print("moving to scale") + ui.print_info("moving to scale") well = labware_on_scale["A1"] - pipette.move_to(well.top(0)) - _liquid_height = _jog_to_find_liquid_height(ctx, pipette, well) + resources.pipette.move_to(well.top(0), minimum_z_height=_minimum_z_height(cfg)) + _liquid_height = _jog_to_find_liquid_height( + resources.ctx, resources.pipette, well + ) + resources.pipette.move_to(well.top().move(Point(0, 0, _minimum_z_height(cfg)))) height_below_top = well.depth - _liquid_height - print(f"liquid is {height_below_top} mm below top of vial") + ui.print_info(f"liquid is {height_below_top} mm below top of vial") liquid_tracker.set_start_volume_from_liquid_height( labware_on_scale["A1"], _liquid_height, name="Water" ) vial_volume = liquid_tracker.get_volume(well) - print(f"software thinks there is {vial_volume} uL of liquid in the vial") - + ui.print_info( + f"software thinks there is {vial_volume} uL of liquid in the vial" + ) if not cfg.blank or cfg.inspect: average_aspirate_evaporation_ul = 0.0 average_dispense_evaporation_ul = 0.0 else: - ui.print_title("MEASURE EVAPORATION") - print(f"running {config.NUM_BLANK_TRIALS}x blank measurements") - mnt = OT3Mount.RIGHT if pipette.mount == "right" else OT3Mount.LEFT - ctx._core.get_hardware().retract(mnt) - for i in range(config.SCALE_SECONDS_TO_TRUE_STABILIZE): - print( - f"wait for scale to stabilize " - f"({i + 1}/{config.SCALE_SECONDS_TO_TRUE_STABILIZE})" - ) - sleep(1) - actual_asp_list_evap: List[float] = [] - actual_disp_list_evap: List[float] = [] - for trial in range(config.NUM_BLANK_TRIALS): - ui.print_header(f"BLANK {trial + 1}/{config.NUM_BLANK_TRIALS}") - evap_aspirate, _, evap_dispense, _ = _run_trial( - ctx=ctx, - pipette=pipette, - well=labware_on_scale["A1"], - channel_offset=Point(), # first channel - tip_volume=cfg.tip_volume, - volume=parameters.test_volumes[-1], - channel=0, # first channel - channel_count=parameters.num_channels_per_transfer, - trial=trial, - recorder=recorder, - test_report=test_report, - liquid_tracker=liquid_tracker, - blank=True, # stay away from the liquid - inspect=cfg.inspect, - mix=cfg.mix, - stable=True, - scale_delay=cfg.scale_delay, - ) - print( - f"blank {trial + 1}/{config.NUM_BLANK_TRIALS}:\n" - f"\taspirate: {evap_aspirate} uL\n" - f"\tdispense: {evap_dispense} uL" - ) - actual_asp_list_evap.append(evap_aspirate) - actual_disp_list_evap.append(evap_dispense) - ui.print_header("EVAPORATION AVERAGE") - average_aspirate_evaporation_ul = _calculate_average(actual_asp_list_evap) - average_dispense_evaporation_ul = _calculate_average(actual_disp_list_evap) - print( - "average:\n" - f"\taspirate: {average_aspirate_evaporation_ul} uL\n" - f"\tdispense: {average_dispense_evaporation_ul} uL" - ) - report.store_average_evaporation( - test_report, + ( average_aspirate_evaporation_ul, average_dispense_evaporation_ul, + ) = _calculate_evaporation( + cfg, + resources, + recorder, + liquid_tracker, + test_report, + labware_on_scale, ) - print("dropping tip") - _drop_tip(pipette, return_tip=False) # always trash calibration tips + + ui.print_info("dropping tip") + _drop_tip( + resources.pipette, return_tip=False, minimum_z_height=_minimum_z_height(cfg) + ) # always trash calibration tips calibration_tip_in_use = False trial_count = 0 - for volume in parameters.test_volumes: + trials = build_gravimetric_trials( + resources.ctx, + resources.pipette, + cfg, + labware_on_scale["A1"], + resources.test_volumes, + channels_to_test, + recorder, + test_report, + liquid_tracker, + False, + resources.env_sensor, + ) + for volume in trials.keys(): actual_asp_list_all = [] actual_disp_list_all = [] ui.print_title(f"{volume} uL") @@ -749,53 +522,37 @@ def run(ctx: ProtocolContext, cfg: config.GravimetricConfig) -> None: trial_disp_dict: Dict[int, List[float]] = { trial: [] for trial in range(cfg.trials) } - for channel in parameters.channels_to_test: - if cfg.isolate_channels and (channel + 1) not in cfg.isolate_channels: - print(f"skipping channel {channel + 1}") - continue + for channel in trials[volume].keys(): channel_offset = _get_channel_offset(cfg, channel) actual_asp_list_channel = [] actual_disp_list_channel = [] aspirate_data_list = [] dispense_data_list = [] - for trial in range(cfg.trials): + for run_trial in trials[volume][channel]: trial_count += 1 ui.print_header( - f"{volume} uL channel {channel + 1} ({trial + 1}/{cfg.trials})" + f"{volume} uL channel {channel + 1} ({run_trial.trial + 1}/{cfg.trials})" ) - print(f"trial total {trial_count}/{parameters.trial_total}") + ui.print_info(f"trial total {trial_count}/{trial_total}") # NOTE: always pick-up new tip for each trial # b/c it seems tips heatup next_tip: Well = _next_tip_for_channel( - ctx, cfg, parameters.tips, channel, parameters.total_tips + cfg, resources, channel, total_tips ) next_tip_location = next_tip.top().move(channel_offset) - _pick_up_tip(ctx, pipette, cfg, location=next_tip_location) + _pick_up_tip( + resources.ctx, + resources.pipette, + cfg, + location=next_tip_location, + ) ( actual_aspirate, aspirate_data, actual_dispense, dispense_data, - ) = _run_trial( - ctx=ctx, - pipette=pipette, - well=labware_on_scale["A1"], - channel_offset=channel_offset, - tip_volume=cfg.tip_volume, - volume=volume, - channel=channel, - channel_count=parameters.num_channels_per_transfer, - trial=trial, - recorder=recorder, - test_report=test_report, - liquid_tracker=liquid_tracker, - blank=False, - inspect=cfg.inspect, - mix=cfg.mix, - stable=True, - scale_delay=cfg.scale_delay, - ) - print( + ) = _run_trial(run_trial) + ui.print_info( "measured volumes:\n" f"\taspirate: {round(actual_aspirate, 2)} uL\n" f"\tdispense: {round(actual_dispense, 2)} uL" @@ -805,7 +562,7 @@ def run(ctx: ProtocolContext, cfg: config.GravimetricConfig) -> None: chnl_div = _get_channel_divider(cfg) disp_with_evap /= chnl_div asp_with_evap /= chnl_div - print( + ui.print_info( "per-channel volume, with evaporation:\n" f"\taspirate: {round(asp_with_evap, 2)} uL\n" f"\tdispense: {round(disp_with_evap, 2)} uL" @@ -814,22 +571,22 @@ def run(ctx: ProtocolContext, cfg: config.GravimetricConfig) -> None: actual_asp_list_channel.append(asp_with_evap) actual_disp_list_channel.append(disp_with_evap) - trial_asp_dict[trial].append(asp_with_evap) - trial_disp_dict[trial].append(disp_with_evap) + trial_asp_dict[run_trial.trial].append(asp_with_evap) + trial_disp_dict[run_trial.trial].append(disp_with_evap) aspirate_data_list.append(aspirate_data) dispense_data_list.append(dispense_data) report.store_trial( test_report, - trial, - volume, - channel, + run_trial.trial, + run_trial.volume, + run_trial.channel, asp_with_evap, disp_with_evap, ) - print("dropping tip") - _drop_tip(pipette, cfg.return_tip) + ui.print_info("dropping tip") + _drop_tip(resources.pipette, cfg.return_tip, _minimum_z_height(cfg)) ui.print_header(f"{volume} uL channel {channel + 1} CALCULATIONS") aspirate_average, aspirate_cv, aspirate_d = _calculate_stats( @@ -940,15 +697,14 @@ def run(ctx: ProtocolContext, cfg: config.GravimetricConfig) -> None: d=dispense_d, ) finally: - print("ending recording") + ui.print_info("ending recording") recorder.stop() recorder.deactivate() - ui.print_title("CHANGE PIPETTES") _return_tip = False if calibration_tip_in_use else cfg.return_tip - _change_pipettes(ctx, pipette, _return_tip) + _finish_test(cfg, resources, _return_tip) ui.print_title("RESULTS") _print_final_results( - volumes=parameters.test_volumes, - channel_count=len(parameters.channels_to_test), + volumes=resources.test_volumes, + channel_count=len(channels_to_test), test_report=test_report, ) diff --git a/hardware-testing/hardware_testing/gravimetric/execute_photometric.py b/hardware-testing/hardware_testing/gravimetric/execute_photometric.py index 6a1c8374e0d2..8e68e36fea4c 100644 --- a/hardware-testing/hardware_testing/gravimetric/execute_photometric.py +++ b/hardware-testing/hardware_testing/gravimetric/execute_photometric.py @@ -1,15 +1,10 @@ """Gravimetric.""" -from inspect import getsource -from statistics import stdev from typing import Tuple, List, Dict from math import ceil -from opentrons.hardware_control.instruments.ot3.pipette import Pipette -from opentrons.types import Location -from opentrons.protocol_api import ProtocolContext, InstrumentContext, Well, Labware +from opentrons.protocol_api import ProtocolContext, Well, Labware -from hardware_testing.data.csv_report import CSVReport -from hardware_testing.data import create_run_id_and_start_time, ui, get_git_description +from hardware_testing.data import ui from hardware_testing.opentrons_api.types import Point, OT3Mount from .measurement import ( MeasurementType, @@ -19,15 +14,24 @@ from .measurement.environment import read_environment_data from . import report from . import config -from .helpers import get_pipette_unique_name -from .workarounds import get_latest_offset_for_labware -from .liquid_class.defaults import get_test_volumes, get_liquid_class +from .helpers import ( + _jog_to_find_liquid_height, + _apply_labware_offsets, + _pick_up_tip, + _drop_tip, +) +from .trial import ( + PhotometricTrial, + build_photometric_trials, + TestResources, + _finish_test, +) from .liquid_class.pipetting import ( aspirate_with_liquid_class, dispense_with_liquid_class, PipettingCallbacks, ) -from .liquid_height.height import LiquidTracker, initialize_liquid_from_deck +from .liquid_height.height import LiquidTracker from .tips import get_tips @@ -58,157 +62,31 @@ def _get_dye_type(volume: float) -> str: return dye_type -def _check_if_software_supports_high_volumes() -> bool: - src_a = getsource(Pipette.set_current_volume) - src_b = getsource(Pipette.ok_to_add_volume) - modified_a = "# assert new_volume <= self.working_volume" in src_a - modified_b = "return True" in src_b - return modified_a and modified_b - - -def _reduce_volumes_to_not_exceed_software_limit( - test_volumes: List[float], cfg: config.PhotometricConfig -) -> List[float]: - for i, v in enumerate(test_volumes): - liq_cls = get_liquid_class(cfg.pipette_volume, 96, cfg.tip_volume, int(v)) - max_vol = cfg.tip_volume - liq_cls.aspirate.trailing_air_gap - test_volumes[i] = min(v, max_vol - 0.1) - return test_volumes - - -def _get_volumes(ctx: ProtocolContext, cfg: config.PhotometricConfig) -> List[float]: - if cfg.user_volumes and not ctx.is_simulating(): - _inp = input('Enter desired volumes, comma separated (eg: "10,100,1000") :') - test_volumes = [ - float(vol_str) for vol_str in _inp.strip().split(",") if vol_str - ] - else: - test_volumes = get_test_volumes(cfg.pipette_volume, 96, cfg.tip_volume) - if not test_volumes: - raise ValueError("no volumes to test, check the configuration") - if not _check_if_software_supports_high_volumes(): - if ctx.is_simulating(): - test_volumes = _reduce_volumes_to_not_exceed_software_limit( - test_volumes, cfg - ) - else: - raise RuntimeError("you are not the correct branch") - return sorted(test_volumes, reverse=False) # lowest volumes first - - -def _get_channel_offset(cfg: config.PhotometricConfig, channel: int) -> Point: - row = channel % 8 # A-H - col = int(float(channel) / 8.0) # 1-12 - return Point(x=col * 9.0, y=row * 9.0) - - -def _load_pipette( - ctx: ProtocolContext, cfg: config.PhotometricConfig -) -> InstrumentContext: - pip_name = f"p{cfg.pipette_volume}_96" - print(f'pipette "{pip_name}" on mount "{cfg.pipette_mount}"') - pipette = ctx.load_instrument(pip_name, cfg.pipette_mount) - assert pipette.max_volume == cfg.pipette_volume, ( - f"expected {cfg.pipette_volume} uL pipette, " - f"but got a {pipette.max_volume} uL pipette" - ) - # pipette.default_speed = cfg.gantry_speed - return pipette - - -def _apply_labware_offsets( - cfg: config.PhotometricConfig, - tip_racks: List[Labware], - photoplate: Labware, - reservoir: Labware, -) -> None: - def _apply(labware: Labware) -> None: - o = get_latest_offset_for_labware(cfg.labware_offsets, labware) - print( - f'Apply labware offset to "{labware.name}" (slot={labware.parent}): ' - f"x={round(o.x, 2)}, y={round(o.y, 2)}, z={round(o.z, 2)}" - ) - labware.set_calibration(o) - - _apply(photoplate) - for rack in tip_racks: - _apply(rack) - _apply(reservoir) - - def _load_labware( ctx: ProtocolContext, cfg: config.PhotometricConfig -) -> Tuple[Labware, Labware, List[Labware]]: - print(f'Loading photoplate labware: "{cfg.photoplate}"') - photoplate = ctx.load_labware(cfg.photoplate, location=cfg.photoplate_slot) - tiprack_load_settings: List[Tuple[int, str]] = [ - ( - slot, - f"opentrons_flex_96_tiprack_{cfg.tip_volume}ul_adp", - ) - for slot in cfg.slots_tiprack - ] - for ls in tiprack_load_settings: - print(f'Loading tiprack "{ls[1]}" in slot #{ls[0]}') - reservoir = ctx.load_labware(cfg.reservoir, location=cfg.reservoir_slot) - tiprack_namespace = "custom_beta" - tipracks = [ - ctx.load_labware(ls[1], location=ls[0], namespace=tiprack_namespace) - for ls in tiprack_load_settings - ] - _apply_labware_offsets(cfg, tipracks, photoplate, reservoir) - return photoplate, reservoir, tipracks - - -def _jog_to_find_liquid_height( - ctx: ProtocolContext, pipette: InstrumentContext, well: Well -) -> float: - _well_depth = well.depth - _liquid_height = _well_depth - _jog_size = -1.0 - if ctx.is_simulating(): - return _liquid_height - 1 - while True: - pipette.move_to(well.bottom(_liquid_height)) - inp = input( - f"height={_liquid_height}: ENTER to jog {_jog_size} mm, " - f'or enter new jog size, or "yes" to save: ' - ) - if inp: - if inp[0] == "y": - break - try: - _jog_size = min(max(float(inp), -1.0), 1.0) - except ValueError: - continue - _liquid_height = min(max(_liquid_height + _jog_size, 0), _well_depth) - return _liquid_height - - -def _calculate_average(volume_list: List[float]) -> float: - return sum(volume_list) / len(volume_list) - - -def _calculate_stats( - volume_list: List[float], total_volume: float -) -> Tuple[float, float, float]: - average = _calculate_average(volume_list) - if len(volume_list) <= 1: - print("skipping CV, only 1x trial per volume") - cv = -0.01 # negative number is impossible +) -> Tuple[Labware, Labware]: + ui.print_info(f'Loading photoplate labware: "{cfg.photoplate}"') + # If running multiple tests in one run, the labware may already be loaded + loaded_labwares = ctx.loaded_labwares + + if ( + cfg.photoplate_slot in loaded_labwares.keys() + and loaded_labwares[cfg.photoplate_slot].name == cfg.photoplate + ): + photoplate = loaded_labwares[cfg.photoplate_slot] else: - cv = stdev(volume_list) / average - d = (average - total_volume) / total_volume - return average, cv, d - - -def _print_stats(mode: str, average: float, cv: float, d: float) -> None: - print( - f"{mode}:\n" - f"\tavg: {round(average, 2)} uL\n" - f"\tcv: {round(cv * 100.0, 2)}%\n" - f"\td: {round(d * 100.0, 2)}%" - ) + photoplate = ctx.load_labware(cfg.photoplate, location=cfg.photoplate_slot) + _apply_labware_offsets(cfg, [photoplate]) + + if ( + cfg.reservoir_slot in loaded_labwares.keys() + and loaded_labwares[cfg.reservoir_slot].name == cfg.reservoir + ): + reservoir = loaded_labwares[cfg.reservoir_slot] + else: + reservoir = ctx.load_labware(cfg.reservoir, location=cfg.reservoir_slot) + _apply_labware_offsets(cfg, [reservoir]) + return photoplate, reservoir def _dispense_volumes(volume: float) -> Tuple[float, float, int]: @@ -218,22 +96,7 @@ def _dispense_volumes(volume: float) -> Tuple[float, float, int]: return target_volume, volume_to_dispense, num_dispenses -def _run_trial( - ctx: ProtocolContext, - test_report: CSVReport, - pipette: InstrumentContext, - source: Well, - dest: Labware, - channel_offset: Point, - tip_volume: int, - volume: float, - trial: int, - liquid_tracker: LiquidTracker, - blank: bool, - inspect: bool, - cfg: config.PhotometricConfig, - mix: bool = False, -) -> None: +def _run_trial(trial: PhotometricTrial) -> None: """Aspirate dye and dispense into a photometric plate.""" def _no_op() -> None: @@ -241,12 +104,14 @@ def _no_op() -> None: return def _tag(m_type: MeasurementType) -> str: - return create_measurement_tag(m_type, volume, 0, trial) + return create_measurement_tag(m_type, trial.volume, 0, trial.trial) def _record_measurement_and_store(m_type: MeasurementType) -> EnvironmentData: m_tag = _tag(m_type) - m_data = read_environment_data(cfg.pipette_mount, ctx.is_simulating()) - report.store_measurements_pm(test_report, m_tag, m_data) + m_data = read_environment_data( + trial.cfg.pipette_mount, trial.ctx.is_simulating(), trial.env_sensor + ) + report.store_measurements_pm(trial.test_report, m_tag, m_data) _MEASUREMENTS.append( ( m_tag, @@ -267,118 +132,87 @@ def _record_measurement_and_store(m_type: MeasurementType) -> EnvironmentData: channel_count = 96 # RUN INIT - target_volume, volume_to_dispense, num_dispenses = _dispense_volumes(volume) + target_volume, volume_to_dispense, num_dispenses = _dispense_volumes(trial.volume) photoplate_preped_vol = max(target_volume - volume_to_dispense, 0) - if num_dispenses > 1 and not ctx.is_simulating(): + if num_dispenses > 1 and not trial.ctx.is_simulating(): # TODO: Likely will not test 1000 uL in the near-term, # but eventually we'll want to be more helpful here in prompting # what volumes need to be added between trials. ui.get_user_ready("check DYE is enough") _record_measurement_and_store(MeasurementType.INIT) - pipette.move_to(location=source.top().move(channel_offset), minimum_z_height=133) + trial.pipette.move_to(location=trial.source.top(), minimum_z_height=133) # RUN ASPIRATE aspirate_with_liquid_class( - ctx, - pipette, - tip_volume, - volume, - source, - channel_offset, + trial.ctx, + trial.pipette, + trial.tip_volume, + trial.volume, + trial.source, + Point(), channel_count, - liquid_tracker, + trial.liquid_tracker, callbacks=pipetting_callbacks, - blank=blank, - inspect=inspect, - mix=mix, + blank=False, + inspect=trial.inspect, + mix=trial.mix, touch_tip=False, ) _record_measurement_and_store(MeasurementType.ASPIRATE) for i in range(num_dispenses): - for w in dest.wells(): - liquid_tracker.set_start_volume(w, photoplate_preped_vol) - pipette.move_to(dest["A1"].top().move(channel_offset)) + for w in trial.dest.wells(): + trial.liquid_tracker.set_start_volume(w, photoplate_preped_vol) + trial.pipette.move_to(trial.dest["A1"].top()) # RUN DISPENSE dispense_with_liquid_class( - ctx, - pipette, - tip_volume, + trial.ctx, + trial.pipette, + trial.tip_volume, volume_to_dispense, - dest["A1"], - channel_offset, + trial.dest["A1"], + Point(), channel_count, - liquid_tracker, + trial.liquid_tracker, callbacks=pipetting_callbacks, - blank=blank, - inspect=inspect, - mix=mix, + blank=False, + inspect=trial.inspect, + mix=trial.mix, added_blow_out=(i + 1) == num_dispenses, - touch_tip=cfg.touch_tip, + touch_tip=trial.cfg.touch_tip, ) _record_measurement_and_store(MeasurementType.DISPENSE) - pipette.move_to(location=dest["A1"].top().move(Point(0, 0, 133))) + trial.pipette.move_to(location=trial.dest["A1"].top().move(Point(0, 0, 133))) if (i + 1) == num_dispenses: - _drop_tip(ctx, pipette, cfg) + _drop_tip(trial.pipette, trial.cfg.return_tip) else: - pipette.move_to(location=dest["A1"].top().move(Point(0, 107, 133))) - if not ctx.is_simulating(): + trial.pipette.move_to( + location=trial.dest["A1"].top().move(Point(0, 107, 133)) + ) + if not trial.ctx.is_simulating(): ui.get_user_ready("add SEAL to plate and remove from DECK") return -def _get_operator_name(is_simulating: bool) -> str: - if not is_simulating: - return input("OPERATOR name:").strip() - else: - return "simulation" - - -def _get_robot_serial(is_simulating: bool) -> str: - if not is_simulating: - return input("ROBOT SERIAL NUMBER:").strip() - else: - return "simulation-serial-number" - - -def _get_tip_batch(is_simulating: bool) -> str: - if not is_simulating: - return input("TIP BATCH:").strip() - else: - return "simulation-tip-batch" - - -def _pick_up_tip( - ctx: ProtocolContext, - pipette: InstrumentContext, - cfg: config.PhotometricConfig, - location: Location, -) -> None: - print( - f"picking tip {location.labware.as_well().well_name} " - f"from slot #{location.labware.parent.parent}" - ) - pipette.pick_up_tip(location) - - -def _drop_tip( - ctx: ProtocolContext, pipette: InstrumentContext, cfg: config.PhotometricConfig +def _display_dye_information( + cfg: config.PhotometricConfig, resources: TestResources ) -> None: - if cfg.return_tip: - pipette.return_tip(home_after=False) - else: - pipette.drop_tip(home_after=False) + ui.print_header("PREPARE") + dye_types_req: Dict[str, float] = {dye: 0 for dye in _DYE_MAP.keys()} + for vol in resources.test_volumes: + _, volume_to_dispense, num_dispenses = _dispense_volumes(vol) + dye_per_vol = vol * 96 * cfg.trials + dye_types_req[_get_dye_type(volume_to_dispense)] += dye_per_vol + include_hv = not [ + v + for v in resources.test_volumes + if _DYE_MAP["A"]["min"] <= v < _DYE_MAP["A"]["max"] + ] -def _display_dye_information( - ctx: ProtocolContext, - dye_types_req: Dict[str, float], - refill: bool, - include_hv: bool, -) -> None: for dye in dye_types_req.keys(): transfered_ul = dye_types_req[dye] reservoir_ul = max(_MIN_START_VOLUME_UL, transfered_ul + _MIN_END_VOLUME_UL) @@ -388,200 +222,184 @@ def _ul_to_ml(x: float) -> float: return round(x / 1000.0, 1) if dye_types_req[dye] > 0: - if refill: + if cfg.refill: # only add the minimum required volume - print(f' * {_ul_to_ml(leftover_ul)} mL "{dye}" LEFTOVER in reservoir') - if not ctx.is_simulating(): + ui.print_info( + f' * {_ul_to_ml(leftover_ul)} mL "{dye}" LEFTOVER in reservoir' + ) + if not resources.ctx.is_simulating(): ui.get_user_ready( f'[refill] ADD {_ul_to_ml(transfered_ul)} mL more DYE type "{dye}"' ) else: # add minimum required volume PLUS labware's dead-volume - if not ctx.is_simulating(): + if not resources.ctx.is_simulating(): dye_msg = 'A" or "HV' if include_hv and dye == "A" else dye ui.get_user_ready( f'add {_ul_to_ml(reservoir_ul)} mL of DYE type "{dye_msg}"' ) -def run(ctx: ProtocolContext, cfg: config.PhotometricConfig) -> None: - """Run.""" - run_id, start_time = create_run_id_and_start_time() - dye_types_req: Dict[str, float] = {dye: 0 for dye in _DYE_MAP.keys()} - test_volumes = _get_volumes(ctx, cfg) - total_photoplates = 0 - for vol in test_volumes: - target_volume, volume_to_dispense, num_dispenses = _dispense_volumes(vol) - total_photoplates += num_dispenses * cfg.trials - dye_per_vol = vol * 96 * cfg.trials - dye_types_req[_get_dye_type(volume_to_dispense)] += dye_per_vol +def build_pm_report( + cfg: config.PhotometricConfig, resources: TestResources +) -> report.CSVReport: + """Build a CSVReport formated for photometric tests.""" + ui.print_header("CREATE TEST-REPORT") + test_report = report.create_csv_test_report_photometric( + resources.test_volumes, cfg, run_id=resources.run_id + ) + test_report.set_tag(resources.pipette_tag) + test_report.set_operator(resources.operator_name) + test_report.set_version(resources.git_description) + report.store_serial_numbers_pm( + test_report, + robot=resources.robot_serial, + pipette=resources.pipette_tag, + tips=resources.tip_batch, + environment="None", + liquid="None", + ) + return test_report - trial_total = len(test_volumes) * cfg.trials - ui.print_header("LOAD LABWARE") - photoplate, reservoir, tipracks = _load_labware(ctx, cfg) - liquid_tracker = LiquidTracker() - initialize_liquid_from_deck(ctx, liquid_tracker) - - ui.print_header("LOAD PIPETTE") - pipette = _load_pipette(ctx, cfg) - pipette_tag = get_pipette_unique_name(pipette) - print(f"found pipette: {pipette_tag}") - if not ctx.is_simulating(): - ui.get_user_ready("create pipette QR code") - if cfg.user_volumes: - pipette_tag += "-user-volume" - else: - pipette_tag += "-qc" - - ui.print_header("GET PARAMETERS") - for v in test_volumes: - print(f"\t{v} uL") - tips = get_tips(ctx, pipette) - total_tips = len([tip for chnl_tips in tips.values() for tip in chnl_tips]) * len( - test_volumes - ) +def execute_trials( + cfg: config.PhotometricConfig, + resources: TestResources, + tips: Dict[int, List[Well]], + trials: Dict[float, List[PhotometricTrial]], +) -> None: + """Execute a batch of pre-constructed trials.""" + ui.print_info("homing...") + resources.ctx.home() + resources.pipette.home_plunger() def _next_tip() -> Well: + # get the first channel's first-used tip + # NOTE: note using list.pop(), b/c tip will be re-filled by operator, + # and so we can use pick-up-tip from there again nonlocal tips if not len(tips[0]): - if not ctx.is_simulating(): + if not resources.ctx.is_simulating(): ui.get_user_ready(f"replace TIPRACKS in slots {cfg.slots_tiprack}") - tips = get_tips(ctx, pipette) + tips = get_tips(resources.ctx, resources.pipette, cfg.tip_volume, True) return tips[0].pop(0) + trial_total = len(resources.test_volumes) * cfg.trials + trial_count = 0 + for volume in trials.keys(): + ui.print_title(f"{volume} uL") + for trial in trials[volume]: + trial_count += 1 + ui.print_header(f"{volume} uL ({trial.trial + 1}/{cfg.trials})") + ui.print_info(f"trial total {trial_count}/{trial_total}") + if not resources.ctx.is_simulating(): + ui.get_user_ready(f"put PLATE #{trial.trial + 1} and remove SEAL") + next_tip: Well = _next_tip() + next_tip_location = next_tip.top() + _pick_up_tip( + resources.ctx, resources.pipette, cfg, location=next_tip_location + ) + _run_trial(trial) + + +def _find_liquid_height( + cfg: config.PhotometricConfig, + resources: TestResources, + liquid_tracker: LiquidTracker, + reservoir: Well, +) -> None: + channel_count = 96 + setup_tip = resources.tips[0][0] + volume_for_setup = max(resources.test_volumes) + _pick_up_tip(resources.ctx, resources.pipette, cfg, location=setup_tip.top()) + mnt = OT3Mount.LEFT if cfg.pipette_mount == "left" else OT3Mount.RIGHT + resources.ctx._core.get_hardware().retract(mnt) + if not resources.ctx.is_simulating(): + ui.get_user_ready("REPLACE first tip with NEW TIP") + required_ul = max( + (volume_for_setup * channel_count * cfg.trials) + _MIN_END_VOLUME_UL, + _MIN_START_VOLUME_UL, + ) + if not resources.ctx.is_simulating(): + _liquid_height = _jog_to_find_liquid_height( + resources.ctx, resources.pipette, reservoir + ) + height_below_top = reservoir.depth - _liquid_height + ui.print_info(f"liquid is {height_below_top} mm below top of reservoir") + liquid_tracker.set_start_volume_from_liquid_height( + reservoir, _liquid_height, name="Dye" + ) + else: + liquid_tracker.set_start_volume(reservoir, required_ul) + reservoir_ul = liquid_tracker.get_volume(reservoir) + ui.print_info( + f"software thinks there is {round(reservoir_ul / 1000, 1)} mL " + f"of liquid in the reservoir (required = {round(required_ul / 1000, 1)} ml)" + ) + if required_ul <= reservoir_ul < _MAX_VOLUME_UL: + ui.print_info("valid liquid height") + elif required_ul > _MAX_VOLUME_UL: + raise NotImplementedError( + f"too many trials ({cfg.trials}) at {volume_for_setup} uL, " + f"refilling reservoir is currently not supported" + ) + elif reservoir_ul < required_ul: + error_msg = ( + f"not enough volume in reservoir to aspirate {volume_for_setup} uL " + f"across {channel_count}x channels for {cfg.trials}x trials" + ) + if resources.ctx.is_simulating(): + raise ValueError(error_msg) + ui.print_error(error_msg) + resources.pipette.move_to(location=reservoir.top(100)) + difference_ul = required_ul - reservoir_ul + ui.get_user_ready( + f"ADD {round(difference_ul / 1000.0, 1)} mL more liquid to RESERVOIR" + ) + resources.pipette.move_to(location=reservoir.top()) + else: + raise RuntimeError( + f"bad volume in reservoir: {round(reservoir_ul / 1000, 1)} ml" + ) + resources.pipette.drop_tip(home_after=False) # always trash setup tips + # NOTE: the first tip-rack should have already been replaced + # with new tips by the operator + + +def run(cfg: config.PhotometricConfig, resources: TestResources) -> None: + """Run.""" + trial_total = len(resources.test_volumes) * cfg.trials + + ui.print_header("LOAD LABWARE") + photoplate, reservoir = _load_labware(resources.ctx, cfg) + liquid_tracker = LiquidTracker(resources.ctx) + + total_tips = len( + [tip for chnl_tips in resources.tips.values() for tip in chnl_tips] + ) * len(resources.test_volumes) + assert ( trial_total <= total_tips ), f"more trials ({trial_total}) than tips ({total_tips})" - ui.print_header("CREATE TEST-REPORT") - test_report = report.create_csv_test_report_photometric( - test_volumes, cfg, run_id=run_id - ) - test_report.set_tag(pipette_tag) - test_report.set_operator(_get_operator_name(ctx.is_simulating())) - serial_number = _get_robot_serial(ctx.is_simulating()) - tip_batch = _get_tip_batch(ctx.is_simulating()) - test_report.set_version(get_git_description()) - report.store_serial_numbers_pm( + test_report = build_pm_report(cfg, resources) + + _display_dye_information(cfg, resources) + _find_liquid_height(cfg, resources, liquid_tracker, reservoir["A1"]) + + trials = build_photometric_trials( + resources.ctx, test_report, - robot=serial_number, - pipette=pipette_tag, - tips=tip_batch, - environment="None", - liquid="None", + resources.pipette, + reservoir["A1"], + photoplate, + resources.test_volumes, + liquid_tracker, + cfg, + resources.env_sensor, ) - ui.print_header("PREPARE") - can_swap_a_for_hv = not [ - v for v in test_volumes if _DYE_MAP["A"]["min"] <= v < _DYE_MAP["A"]["max"] - ] - _display_dye_information(ctx, dye_types_req, cfg.refill, can_swap_a_for_hv) - - print("homing...") - ctx.home() - pipette.home_plunger() - # get the first channel's first-used tip - # NOTE: note using list.pop(), b/c tip will be re-filled by operator, - # and so we can use pick-up-tip from there again try: - trial_count = 0 - source = reservoir["A1"] - channel_count = 96 - channel_offset = Point() - setup_tip = tips[0][0] - volume_for_setup = max(test_volumes) - _pick_up_tip(ctx, pipette, cfg, location=setup_tip.top()) - mnt = OT3Mount.LEFT if cfg.pipette_mount == "left" else OT3Mount.RIGHT - ctx._core.get_hardware().retract(mnt) - if not ctx.is_simulating(): - ui.get_user_ready("REPLACE first tip with NEW TIP") - required_ul = max( - (volume_for_setup * channel_count * cfg.trials) + _MIN_END_VOLUME_UL, - _MIN_START_VOLUME_UL, - ) - if not ctx.is_simulating(): - _liquid_height = _jog_to_find_liquid_height(ctx, pipette, source) - height_below_top = source.depth - _liquid_height - print(f"liquid is {height_below_top} mm below top of reservoir") - liquid_tracker.set_start_volume_from_liquid_height( - source, _liquid_height, name="Dye" - ) - else: - liquid_tracker.set_start_volume(source, required_ul) - reservoir_ul = liquid_tracker.get_volume(source) - print( - f"software thinks there is {round(reservoir_ul / 1000, 1)} mL " - f"of liquid in the reservoir (required = {round(required_ul / 1000, 1)} ml)" - ) - if required_ul <= reservoir_ul < _MAX_VOLUME_UL: - print("valid liquid height") - elif required_ul > _MAX_VOLUME_UL: - raise NotImplementedError( - f"too many trials ({cfg.trials}) at {volume_for_setup} uL, " - f"refilling reservoir is currently not supported" - ) - elif reservoir_ul < required_ul: - error_msg = ( - f"not enough volume in reservoir to aspirate {volume_for_setup} uL " - f"across {channel_count}x channels for {cfg.trials}x trials" - ) - if ctx.is_simulating(): - raise ValueError(error_msg) - ui.print_error(error_msg) - pipette.move_to(location=source.top(100).move(channel_offset)) - difference_ul = required_ul - reservoir_ul - ui.get_user_ready( - f"ADD {round(difference_ul / 1000.0, 1)} mL more liquid to RESERVOIR" - ) - pipette.move_to(location=source.top().move(channel_offset)) - else: - raise RuntimeError( - f"bad volume in reservoir: {round(reservoir_ul / 1000, 1)} ml" - ) - pipette.drop_tip(home_after=False) # always trash setup tips - # NOTE: the first tip-rack should have already been replaced - # with new tips by the operator - for volume in test_volumes: - ui.print_title(f"{volume} uL") - for trial in range(cfg.trials): - trial_count += 1 - ui.print_header(f"{volume} uL ({trial + 1}/{cfg.trials})") - print(f"trial total {trial_count}/{trial_total}") - if not ctx.is_simulating(): - ui.get_user_ready(f"put PLATE #{trial + 1} and remove SEAL") - next_tip: Well = _next_tip() - next_tip_location = next_tip.top() - _pick_up_tip(ctx, pipette, cfg, location=next_tip_location) - _run_trial( - ctx=ctx, - test_report=test_report, - pipette=pipette, - source=source, - dest=photoplate, - channel_offset=channel_offset, - tip_volume=cfg.tip_volume, - volume=volume, - trial=trial, - liquid_tracker=liquid_tracker, - blank=False, - inspect=cfg.inspect, - cfg=cfg, - mix=cfg.mix, - ) - + execute_trials(cfg, resources, resources.tips, trials) finally: - ui.print_title("CHANGE PIPETTES") - if pipette.has_tip: - if pipette.current_volume > 0: - print("dispensing liquid to trash") - trash = pipette.trash_container.wells()[0] - # FIXME: this should be a blow_out() at max volume, - # but that is not available through PyAPI yet - # so instead just dispensing. - pipette.dispense(pipette.current_volume, trash.top()) - pipette.aspirate(10) # to pull any droplets back up - print("dropping tip") - _drop_tip(ctx, pipette, cfg) - print("moving to attach position") - pipette.move_to(ctx.deck.position_for(5).move(Point(x=0, y=9 * 7, z=150))) + _finish_test(cfg, resources, cfg.return_tip) diff --git a/hardware-testing/hardware_testing/gravimetric/helpers.py b/hardware-testing/hardware_testing/gravimetric/helpers.py index eb563150baa9..03594dde337f 100644 --- a/hardware-testing/hardware_testing/gravimetric/helpers.py +++ b/hardware-testing/hardware_testing/gravimetric/helpers.py @@ -1,23 +1,33 @@ """Opentrons helper methods.""" import asyncio from types import MethodType -from typing import Any, List, Dict, Optional +from typing import Any, List, Dict, Optional, Tuple +from statistics import stdev +from . import config +from .liquid_class.defaults import get_liquid_class +from .increments import get_volume_increments +from inspect import getsource +from hardware_testing.data import ui from opentrons import protocol_api from opentrons.protocols.api_support.deck_type import ( guess_from_global_config as guess_deck_type_from_global_config, ) -from opentrons.protocol_api.labware import Well +from opentrons.protocol_api.labware import Well, Labware from opentrons.protocols.types import APIVersion from opentrons.hardware_control.thread_manager import ThreadManager -from opentrons.hardware_control.types import Axis +from opentrons.hardware_control.types import OT3Mount, Axis from opentrons.hardware_control.ot3api import OT3API +from opentrons.hardware_control.instruments.ot3.pipette import Pipette -from opentrons.types import Point +from opentrons.types import Point, Location from opentrons_shared_data.labware.dev_types import LabwareDefinition from hardware_testing.opentrons_api import helpers_ot3 +from opentrons.protocol_api import ProtocolContext, InstrumentContext +from .workarounds import get_sync_hw_api, get_latest_offset_for_labware +from hardware_testing.opentrons_api.helpers_ot3 import clear_pipette_ul_per_mm def _add_fake_simulate( @@ -34,10 +44,10 @@ def _add_fake_comment_pause( ctx: protocol_api.ProtocolContext, ) -> protocol_api.ProtocolContext: def _comment(_: protocol_api.ProtocolContext, a: Any) -> None: - print(a) + ui.print_info(a) def _pause(_: protocol_api.ProtocolContext, a: Any) -> None: - input(a) + ui.get_user_ready(a) setattr(ctx, "comment", MethodType(_comment, ctx)) setattr(ctx, "pause", MethodType(_pause, ctx)) @@ -104,3 +114,286 @@ def get_pipette_unique_name(pipette: protocol_api.InstrumentContext) -> str: def gantry_position_as_point(position: Dict[Axis, float]) -> Point: """Helper to convert Dict[Axis, float] to a Point().""" return Point(x=position[Axis.X], y=position[Axis.Y], z=position[Axis.Z]) + + +def _jog_to_find_liquid_height( + ctx: ProtocolContext, pipette: InstrumentContext, well: Well +) -> float: + _well_depth = well.depth + _liquid_height = _well_depth + _jog_size = -1.0 + if ctx.is_simulating(): + return _liquid_height - 1 + while True: + pipette.move_to(well.bottom(_liquid_height)) + inp = input( + f"height={_liquid_height}: ENTER to jog {_jog_size} mm, " + f'or enter new jog size, or "yes" to save: ' + ) + if inp: + if inp[0] == "y": + break + try: + _jog_size = min(max(float(inp), -1.0), 1.0) + except ValueError: + continue + _liquid_height = min(max(_liquid_height + _jog_size, 0), _well_depth) + return _liquid_height + + +def _calculate_average(volume_list: List[float]) -> float: + return sum(volume_list) / len(volume_list) + + +def _reduce_volumes_to_not_exceed_software_limit( + test_volumes: List[float], + cfg: config.VolumetricConfig, +) -> List[float]: + for i, v in enumerate(test_volumes): + liq_cls = get_liquid_class( + cfg.pipette_volume, cfg.pipette_channels, cfg.tip_volume, int(v) + ) + max_vol = cfg.tip_volume - liq_cls.aspirate.trailing_air_gap + test_volumes[i] = min(v, max_vol - 0.1) + return test_volumes + + +def _check_if_software_supports_high_volumes() -> bool: + src_a = getsource(Pipette.set_current_volume) + src_b = getsource(Pipette.ok_to_add_volume) + modified_a = "# assert new_volume <= self.working_volume" in src_a + modified_b = "return True" in src_b + return modified_a and modified_b + + +def _get_channel_offset(cfg: config.VolumetricConfig, channel: int) -> Point: + assert ( + channel < cfg.pipette_channels + ), f"unexpected channel on {cfg.pipette_channels} channel pipette: {channel}" + if cfg.pipette_channels == 1: + return Point() + if cfg.pipette_channels == 8: + return Point(y=channel * 9.0) + if cfg.pipette_channels == 96: + row = channel % 8 # A-H + col = int(float(channel) / 8.0) # 1-12 + return Point(x=col * 9.0, y=row * 9.0) + raise ValueError(f"unexpected number of channels in config: {cfg.pipette_channels}") + + +def _get_robot_serial(is_simulating: bool) -> str: + if not is_simulating: + return input("ROBOT SERIAL NUMBER:").strip() + else: + return "simulation-serial-number" + + +def _get_operator_name(is_simulating: bool) -> str: + if not is_simulating: + return input("OPERATOR name:").strip() + else: + return "simulation" + + +def _calculate_stats( + volume_list: List[float], total_volume: float +) -> Tuple[float, float, float]: + average = _calculate_average(volume_list) + if len(volume_list) <= 1: + ui.print_info("skipping CV, only 1x trial per volume") + cv = -0.01 # negative number is impossible + else: + cv = stdev(volume_list) / average + d = (average - total_volume) / total_volume + return average, cv, d + + +def _get_tip_batch(is_simulating: bool) -> str: + if not is_simulating: + return input("TIP BATCH:").strip() + else: + return "simulation-tip-batch" + + +def _apply(labware: Labware, cfg: config.VolumetricConfig) -> None: + o = get_latest_offset_for_labware(cfg.labware_offsets, labware) + ui.print_info( + f'Apply labware offset to "{labware.name}" (slot={labware.parent}): ' + f"x={round(o.x, 2)}, y={round(o.y, 2)}, z={round(o.z, 2)}" + ) + labware.set_calibration(o) + + +def _apply_labware_offsets( + cfg: config.VolumetricConfig, + labwares: List[Labware], +) -> None: + for lw in labwares: + _apply(lw, cfg) + + +def _pick_up_tip( + ctx: ProtocolContext, + pipette: InstrumentContext, + cfg: config.VolumetricConfig, + location: Location, +) -> None: + ui.print_info( + f"picking tip {location.labware.as_well().well_name} " + f"from slot #{location.labware.parent.parent}" + ) + pipette.pick_up_tip(location) + # NOTE: the accuracy-adjust function gets set on the Pipette + # each time we pick-up a new tip. + if cfg.increment: + ui.print_info("clearing pipette ul-per-mm table to be linear") + clear_pipette_ul_per_mm( + get_sync_hw_api(ctx)._obj_to_adapt, # type: ignore[arg-type] + OT3Mount.LEFT if cfg.pipette_mount == "left" else OT3Mount.RIGHT, + ) + + +def _drop_tip( + pipette: InstrumentContext, return_tip: bool, minimum_z_height: int = 0 +) -> None: + if return_tip: + pipette.return_tip(home_after=False) + else: + pipette.drop_tip(home_after=False) + if minimum_z_height > 0: + cur_location = pipette._get_last_location_by_api_version() + if cur_location is not None: + pipette.move_to(cur_location.move(Point(0, 0, minimum_z_height))) + + +def _get_volumes(ctx: ProtocolContext, cfg: config.VolumetricConfig) -> List[float]: + if cfg.increment: + test_volumes = get_volume_increments(cfg.pipette_volume, cfg.tip_volume) + elif cfg.user_volumes and not ctx.is_simulating(): + _inp = input('Enter desired volumes, comma separated (eg: "10,100,1000") :') + test_volumes = [ + float(vol_str) for vol_str in _inp.strip().split(",") if vol_str + ] + else: + test_volumes = get_test_volumes(cfg) + if not test_volumes: + raise ValueError("no volumes to test, check the configuration") + if not _check_if_software_supports_high_volumes(): + if ctx.is_simulating(): + test_volumes = _reduce_volumes_to_not_exceed_software_limit( + test_volumes, cfg + ) + else: + raise RuntimeError("you are not the correct branch") + return sorted(test_volumes, reverse=False) # lowest volumes first + + +def _load_pipette( + ctx: ProtocolContext, cfg: config.VolumetricConfig +) -> InstrumentContext: + load_str_channels = {1: "single_gen3", 8: "multi_gen3", 96: "96"} + pip_channels = cfg.pipette_channels + if pip_channels not in load_str_channels: + raise ValueError(f"unexpected number of channels: {pip_channels}") + chnl_str = load_str_channels[pip_channels] + pip_name = f"p{cfg.pipette_volume}_{chnl_str}" + ui.print_info(f'pipette "{pip_name}" on mount "{cfg.pipette_mount}"') + + # if we're doing multiple tests in one run, the pipette may already be loaded + loaded_pipettes = ctx.loaded_instruments + if cfg.pipette_mount in loaded_pipettes.keys(): + return loaded_pipettes[cfg.pipette_mount] + + pipette = ctx.load_instrument(pip_name, cfg.pipette_mount) + assert pipette.max_volume == cfg.pipette_volume, ( + f"expected {cfg.pipette_volume} uL pipette, " + f"but got a {pipette.max_volume} uL pipette" + ) + if hasattr(cfg, "gantry_speed"): + pipette.default_speed = getattr(cfg, "gantry_speed") + + # NOTE: 8ch QC testing means testing 1 channel at a time, + # so we need to decrease the pick-up current to work with 1 tip. + if pipette.channels == 8 and not cfg.increment: + hwapi = get_sync_hw_api(ctx) + mnt = OT3Mount.LEFT if cfg.pipette_mount == "left" else OT3Mount.RIGHT + hwpipette: Pipette = hwapi.hardware_pipettes[mnt.to_mount()] + hwpipette.pick_up_configurations.current = 0.2 + return pipette + + +def _get_tag_from_pipette( + pipette: InstrumentContext, + cfg: config.VolumetricConfig, +) -> str: + pipette_tag = get_pipette_unique_name(pipette) + ui.print_info(f'found pipette "{pipette_tag}"') + if cfg.increment: + pipette_tag += "-increment" + elif cfg.user_volumes: + pipette_tag += "-user-volume" + else: + pipette_tag += "-qc" + return pipette_tag + + +def _load_tipracks( + ctx: ProtocolContext, + cfg: config.VolumetricConfig, + use_adapters: bool = False, +) -> List[Labware]: + adp_str = "_adp" if use_adapters else "" + tiprack_load_settings: List[Tuple[int, str]] = [ + ( + slot, + f"opentrons_flex_96_tiprack_{cfg.tip_volume}ul{adp_str}", + ) + for slot in cfg.slots_tiprack + ] + for ls in tiprack_load_settings: + ui.print_info(f'Loading tiprack "{ls[1]}" in slot #{ls[0]}') + if use_adapters: + tiprack_namespace = "custom_beta" + else: + tiprack_namespace = "opentrons" + + # If running multiple tests in one run, the labware may already be loaded + loaded_labwares = ctx.loaded_labwares + pre_loaded_tips: List[Labware] = [] + for ls in tiprack_load_settings: + if ls[0] in loaded_labwares.keys() and loaded_labwares[ls[0]].name == ls[1]: + pre_loaded_tips.append(loaded_labwares[ls[0]]) + if len(pre_loaded_tips) == len(tiprack_load_settings): + return pre_loaded_tips + + tipracks = [ + ctx.load_labware(ls[1], location=ls[0], namespace=tiprack_namespace) + for ls in tiprack_load_settings + ] + _apply_labware_offsets(cfg, tipracks) + return tipracks + + +def get_test_volumes(cfg: config.VolumetricConfig) -> List[float]: + """Get test volumes.""" + if cfg.kind is config.ConfigType.photometric: + return config.QC_VOLUMES_P[cfg.pipette_channels][cfg.pipette_volume][ + cfg.tip_volume + ] + else: + if cfg.extra: + return config.QC_VOLUMES_EXTRA_G[cfg.pipette_channels][cfg.pipette_volume][ + cfg.tip_volume + ] + else: + return config.QC_VOLUMES_G[cfg.pipette_channels][cfg.pipette_volume][ + cfg.tip_volume + ] + + +def get_default_trials(cfg: config.VolumetricConfig) -> int: + """Return the default number of trials for QC tests.""" + if cfg.increment: + return 3 + else: + return config.QC_DEFAULT_TRIALS[cfg.kind][cfg.pipette_channels] diff --git a/hardware-testing/hardware_testing/gravimetric/increments.py b/hardware-testing/hardware_testing/gravimetric/increments.py index 385d2753d597..2b625e86ae8c 100644 --- a/hardware-testing/hardware_testing/gravimetric/increments.py +++ b/hardware-testing/hardware_testing/gravimetric/increments.py @@ -2,135 +2,86 @@ from typing import List P50_T50 = [ - 1.20, - 1.37, - 1.56, - 1.79, - 2.04, - 2.33, - 2.66, - 3.04, - 3.47, - 3.96, - 4.52, - 5.16, - 5.89, - 6.73, - 7.68, - 8.77, - 10.02, - 11.44, - 13.06, - 14.91, - 17.02, - 19.44, - 22.19, - 25.34, - 28.94, - 33.04, - 37.72, - 43.07, - 49.18, - 56.16, + 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, + 53.500, + 56.160, ] P1000_T50 = [ - 2.00, - 2.25, - 2.53, - 2.84, - 3.20, - 3.60, - 4.04, - 4.55, - 5.11, - 5.75, - 6.46, - 7.27, - 8.17, - 9.19, - 10.33, - 11.62, - 13.06, - 14.69, - 16.51, - 18.57, - 20.88, - 23.48, - 26.40, - 29.69, - 33.38, - 37.53, - 42.20, - 47.45, - 53.36, - 60.00, + 2.530, + 2.700, + 3.000, + 3.600, + 4.040, + 4.550, + 5.110, + 5.500, + 5.750, + 6.000, + 6.460, + 7.270, + 8.170, + 11.000, + 12.900, + 16.510, + 26.400, + 33.380, + 53.360, + 60.000, ] P1000_T200 = [ - 2.00, - 2.35, - 2.77, - 3.25, - 3.82, - 4.50, - 5.29, - 6.22, - 7.31, - 8.60, - 10.11, - 11.89, - 13.99, - 16.45, - 19.34, - 22.75, - 26.75, - 31.46, - 36.99, - 43.50, - 51.15, - 60.16, - 70.74, - 83.19, - 97.83, - 115.04, - 135.28, - 159.09, - 187.08, - 220.00, + 3.250, + 3.600, + 4.400, + 6.220, + 7.310, + 8.600, + 11.890, + 13.990, + 22.750, + 36.990, + 56.000, + 97.830, + 159.090, + 187.080, + 220.000, ] P1000_T1000 = [ - 5.00, - 6.03, - 7.27, - 8.77, - 10.57, - 12.74, - 15.37, - 18.53, - 22.34, - 26.94, - 32.48, - 39.17, - 47.23, - 56.95, - 68.67, - 82.80, - 99.84, - 120.38, - 145.16, - 175.03, - 211.05, - 254.48, - 306.85, - 369.99, - 446.13, - 537.94, - 648.65, - 782.13, - 943.08, - 1137.16, + 3.000, + 4.000, + 5.000, + 7.270, + 12.800, + 15.370, + 18.530, + 56.950, + 99.840, + 120.380, + 254.480, + 369.990, + 446.130, + 648.650, + 1030.000, + 1137.160, ] diff --git a/hardware-testing/hardware_testing/gravimetric/liquid_class/defaults.py b/hardware-testing/hardware_testing/gravimetric/liquid_class/defaults.py index bf31b7b4d14b..d8091d253b95 100644 --- a/hardware-testing/hardware_testing/gravimetric/liquid_class/defaults.py +++ b/hardware-testing/hardware_testing/gravimetric/liquid_class/defaults.py @@ -1,5 +1,5 @@ """Defaults.""" -from typing import List +from typing import Dict from .definition import ( LiquidClassSettings, @@ -20,7 +20,7 @@ _default_accel_96ch_ul_sec_sec = 16000 # dispense settings are constant across volumes -_dispense_defaults = { +_dispense_defaults: Dict[int, Dict[int, Dict[int, Dict[int, DispenseSettings]]]] = { 1: { 50: { # P50 50: { # T50 @@ -358,7 +358,7 @@ }, } -_aspirate_defaults = { +_aspirate_defaults: Dict[int, Dict[int, Dict[int, Dict[int, AspirateSettings]]]] = { 1: { 50: { # P50 50: { # T50 @@ -729,21 +729,3 @@ def _get_interp_liq_class(lower_ul: int, upper_ul: int) -> LiquidClassSettings: return _get_interp_liq_class(defined_volumes[1], defined_volumes[2]) else: return _build_liquid_class(defined_volumes[2]) - - -def get_test_volumes(pipette: int, channels: int, tip: int) -> List[float]: - """Get test volumes.""" - if channels == 96: - if tip == 50: - return [5.0] - elif tip == 200: - return [200.0] - elif tip == 1000: - return [1000.0] - else: - raise ValueError(f"no volumes to test for tip size: {tip} uL") - else: - # FIXME: also reduce total number of volumes we test for 1ch & 8ch pipettes - aspirate_cls_per_volume = _aspirate_defaults[channels][pipette][tip] - defined_volumes = list(aspirate_cls_per_volume.keys()) - return [float(v) for v in defined_volumes] diff --git a/hardware-testing/hardware_testing/gravimetric/liquid_class/pipetting.py b/hardware-testing/hardware_testing/gravimetric/liquid_class/pipetting.py index c98f8130cf69..2457042ad5af 100644 --- a/hardware-testing/hardware_testing/gravimetric/liquid_class/pipetting.py +++ b/hardware-testing/hardware_testing/gravimetric/liquid_class/pipetting.py @@ -144,7 +144,7 @@ def _retract( ] = z_discontinuity # NOTE: re-setting the gantry-load will reset the move-manager's per-axis constraints hw_api.set_gantry_load(hw_api.gantry_load) - # retract out of the liquid (not out of the well + # retract out of the liquid (not out of the well) pipette.move_to(well.top(mm_above_well_bottom).move(channel_offset), speed=speed) # reset discontinuity back to default if pipette.channels == 96: @@ -228,6 +228,8 @@ def _pipette_with_liquid_settings( def _dispense_with_added_blow_out() -> None: # dispense all liquid, plus some air by calling `pipette.blow_out(location, volume)` # FIXME: this is a hack, until there's an equivalent `pipette.blow_out(location, volume)` + hw_api = ctx._core.get_hardware() + hw_mount = OT3Mount.LEFT if pipette.mount == "left" else OT3Mount.RIGHT hw_api.blow_out(hw_mount, liquid_class.dispense.leading_air_gap) # ASPIRATE/DISPENSE SEQUENCE HAS THREE PHASES: diff --git a/hardware-testing/hardware_testing/gravimetric/liquid_height/height.py b/hardware-testing/hardware_testing/gravimetric/liquid_height/height.py index f33025883109..9d2cba5baf1d 100644 --- a/hardware-testing/hardware_testing/gravimetric/liquid_height/height.py +++ b/hardware-testing/hardware_testing/gravimetric/liquid_height/height.py @@ -7,10 +7,7 @@ from opentrons.protocol_api.labware import Well from opentrons.protocol_api import ProtocolContext -from hardware_testing.gravimetric.helpers import ( - well_is_reservoir, - get_list_of_wells_affected, -) +from hardware_testing.gravimetric import helpers class CalcType(ABC): @@ -200,7 +197,7 @@ def _get_actual_volume_change_in_well( """Get the actual volume change in well, depending on if the pipette is single or multi.""" if volume is None: return None - if well_is_reservoir(well): + if helpers.well_is_reservoir(well): volume *= float(channels) return volume @@ -208,9 +205,11 @@ def _get_actual_volume_change_in_well( class LiquidTracker: """Liquid Tracker.""" - def __init__(self) -> None: + def __init__(self, ctx: Optional[ProtocolContext] = None) -> None: """Liquid Tracker.""" self._items: dict = dict({}) + if ctx is not None: + initialize_liquid_from_deck(ctx, self) def reset(self) -> None: """Reset.""" @@ -346,7 +345,7 @@ def update_affected_wells( volume=dispense, channels=channels, ) - for w in get_list_of_wells_affected(well, channels): + for w in helpers.get_list_of_wells_affected(well, channels): self.update_well_volume( w, after_aspirate=actual_aspirate_amount, diff --git a/hardware-testing/hardware_testing/gravimetric/measurement/__init__.py b/hardware-testing/hardware_testing/gravimetric/measurement/__init__.py index f42922b40314..b3d09da44227 100644 --- a/hardware-testing/hardware_testing/gravimetric/measurement/__init__.py +++ b/hardware-testing/hardware_testing/gravimetric/measurement/__init__.py @@ -9,7 +9,7 @@ from .record import GravimetricRecorder, GravimetricRecording from .environment import read_environment_data, EnvironmentData, get_average_reading - +from hardware_testing.drivers import asair_sensor RELATIVE_DENSITY_WATER: Final = 1.0 @@ -139,11 +139,12 @@ def record_measurement_data( recorder: GravimetricRecorder, mount: str, stable: bool, + env_sensor: asair_sensor.AsairSensorBase, shorten: bool = False, delay_seconds: int = DELAY_FOR_MEASUREMENT, ) -> MeasurementData: """Record measurement data.""" - env_data = read_environment_data(mount, ctx.is_simulating()) + env_data = read_environment_data(mount, ctx.is_simulating(), env_sensor) # NOTE: we need to delay some amount, to give the scale time to accumulate samples with recorder.samples_of_tag(tag): if ctx.is_simulating(): diff --git a/hardware-testing/hardware_testing/gravimetric/measurement/environment.py b/hardware-testing/hardware_testing/gravimetric/measurement/environment.py index d780b14e9038..6eebd8187beb 100644 --- a/hardware-testing/hardware_testing/gravimetric/measurement/environment.py +++ b/hardware-testing/hardware_testing/gravimetric/measurement/environment.py @@ -7,6 +7,7 @@ SensorResponseBad, ) from hardware_testing.opentrons_api.types import OT3Mount +from hardware_testing.drivers import asair_sensor @dataclass @@ -21,7 +22,9 @@ class EnvironmentData: celsius_liquid: float -def read_environment_data(mount: str, is_simulating: bool) -> EnvironmentData: +def read_environment_data( + mount: str, is_simulating: bool, env_sensor: asair_sensor.AsairSensorBase +) -> EnvironmentData: """Read blank environment data.""" mnt = OT3Mount.LEFT if mount == "left" else OT3Mount.RIGHT try: @@ -31,12 +34,12 @@ def read_environment_data(mount: str, is_simulating: bool) -> EnvironmentData: print(e) celsius_pipette = 25.0 humidity_pipette = 50.0 - # TODO: implement USB environmental sensors + env_data = env_sensor.get_reading() d = EnvironmentData( celsius_pipette=celsius_pipette, humidity_pipette=humidity_pipette, - celsius_air=25.0, - humidity_air=50.0, + celsius_air=env_data.temperature, + humidity_air=env_data.relative_humidity, pascals_air=1000, celsius_liquid=25.0, ) diff --git a/hardware-testing/hardware_testing/gravimetric/tips.py b/hardware-testing/hardware_testing/gravimetric/tips.py index 363fdf281c93..34ca135774ad 100644 --- a/hardware-testing/hardware_testing/gravimetric/tips.py +++ b/hardware-testing/hardware_testing/gravimetric/tips.py @@ -64,10 +64,15 @@ def _get_racks(ctx: ProtocolContext) -> Dict[int, Labware]: } -def get_tips_for_single(ctx: ProtocolContext) -> List[Well]: +def get_tips_for_single(ctx: ProtocolContext, tip_volume: int) -> List[Well]: """Get tips for single channel.""" racks = _get_racks(ctx) - return [tip for rack in racks.values() for tip in rack.wells()] + return [ + tip + for rack in racks.values() + for tip in rack.wells() + if tip.max_volume == tip_volume + ] def get_tips_for_individual_channel_on_multi( @@ -103,11 +108,12 @@ def get_tips_for_96_channel(ctx: ProtocolContext) -> List[Well]: def get_tips( ctx: ProtocolContext, pipette: InstrumentContext, + tip_volume: int, all_channels: bool = True, ) -> Dict[int, List[Well]]: """Get tips.""" if pipette.channels == 1: - return {0: get_tips_for_single(ctx)} + return {0: get_tips_for_single(ctx, tip_volume)} elif pipette.channels == 8: if all_channels: return {0: get_tips_for_all_channels_on_multi(ctx)} diff --git a/hardware-testing/hardware_testing/gravimetric/trial.py b/hardware-testing/hardware_testing/gravimetric/trial.py new file mode 100644 index 000000000000..936b3c25b322 --- /dev/null +++ b/hardware-testing/hardware_testing/gravimetric/trial.py @@ -0,0 +1,224 @@ +"""Dataclass that describes the arguments for trials.""" +from dataclasses import dataclass +from typing import List, Optional, Union, Dict +from . import config +from opentrons.protocol_api import ProtocolContext, InstrumentContext, Well, Labware +from .measurement.record import GravimetricRecorder +from .measurement import DELAY_FOR_MEASUREMENT +from .liquid_height.height import LiquidTracker +from hardware_testing.data.csv_report import CSVReport +from hardware_testing.opentrons_api.types import Point +from . import helpers +from . import report +from hardware_testing.data import ui +from hardware_testing.drivers import asair_sensor + + +@dataclass +class VolumetricTrial: + """Common arguments for volumetric scripts.""" + + ctx: ProtocolContext + pipette: InstrumentContext + test_report: CSVReport + liquid_tracker: LiquidTracker + inspect: bool + trial: int + tip_volume: int + volume: float + mix: bool + acceptable_cv: Optional[float] + env_sensor: asair_sensor.AsairSensorBase + + +@dataclass +class GravimetricTrial(VolumetricTrial): + """All the arguments for a single gravimetric trial.""" + + well: Well + channel_offset: Point + channel: int + channel_count: int + recorder: GravimetricRecorder + blank: bool + stable: bool + cfg: config.GravimetricConfig + scale_delay: int = DELAY_FOR_MEASUREMENT + + +@dataclass +class PhotometricTrial(VolumetricTrial): + """All the arguments for a single photometric trial.""" + + source: Well + dest: Labware + cfg: config.PhotometricConfig + + +@dataclass +class TrialOrder: + """A list of all of the trials that a particular QC run needs to run.""" + + trials: List[Union[GravimetricTrial, PhotometricTrial]] + + +@dataclass +class TestResources: + """Common arguments to the run method of volumetric tests.""" + + ctx: ProtocolContext + pipette: InstrumentContext + pipette_tag: str + tipracks: List[Labware] + test_volumes: List[float] + run_id: str + start_time: float + operator_name: str + robot_serial: str + tip_batch: str + git_description: str + tips: Dict[int, List[Well]] + env_sensor: asair_sensor.AsairSensorBase + + +def build_gravimetric_trials( + ctx: ProtocolContext, + instr: InstrumentContext, + cfg: config.GravimetricConfig, + well: Well, + test_volumes: List[float], + channels_to_test: List[int], + recorder: GravimetricRecorder, + test_report: report.CSVReport, + liquid_tracker: LiquidTracker, + blank: bool, + env_sensor: asair_sensor.AsairSensorBase, +) -> Dict[float, Dict[int, List[GravimetricTrial]]]: + """Build a list of all the trials that will be run.""" + trial_list: Dict[float, Dict[int, List[GravimetricTrial]]] = {} + if len(channels_to_test) > 1: + num_channels_per_transfer = 1 + else: + num_channels_per_transfer = cfg.pipette_channels + if blank: + trial_list[test_volumes[-1]] = {0: []} + for trial in range(config.NUM_BLANK_TRIALS): + trial_list[test_volumes[-1]][0].append( + GravimetricTrial( + ctx=ctx, + pipette=instr, + well=well, + channel_offset=Point(), + tip_volume=cfg.tip_volume, + volume=test_volumes[-1], + channel=0, + channel_count=num_channels_per_transfer, + trial=trial, + recorder=recorder, + test_report=test_report, + liquid_tracker=liquid_tracker, + blank=blank, + inspect=cfg.inspect, + mix=cfg.mix, + stable=True, + scale_delay=cfg.scale_delay, + acceptable_cv=None, + cfg=cfg, + env_sensor=env_sensor, + ) + ) + else: + for volume in test_volumes: + trial_list[volume] = {} + for channel in channels_to_test: + if cfg.isolate_channels and (channel + 1) not in cfg.isolate_channels: + ui.print_info(f"skipping channel {channel + 1}") + continue + trial_list[volume][channel] = [] + channel_offset = helpers._get_channel_offset(cfg, channel) + for trial in range(cfg.trials): + trial_list[volume][channel].append( + GravimetricTrial( + ctx=ctx, + pipette=instr, + well=well, + channel_offset=channel_offset, + tip_volume=cfg.tip_volume, + volume=volume, + channel=channel, + channel_count=num_channels_per_transfer, + trial=trial, + recorder=recorder, + test_report=test_report, + liquid_tracker=liquid_tracker, + blank=blank, + inspect=cfg.inspect, + mix=cfg.mix, + stable=True, + scale_delay=cfg.scale_delay, + acceptable_cv=None, + cfg=cfg, + env_sensor=env_sensor, + ) + ) + return trial_list + + +def build_photometric_trials( + ctx: ProtocolContext, + test_report: CSVReport, + pipette: InstrumentContext, + source: Well, + dest: Labware, + test_volumes: List[float], + liquid_tracker: LiquidTracker, + cfg: config.PhotometricConfig, + env_sensor: asair_sensor.AsairSensorBase, +) -> Dict[float, List[PhotometricTrial]]: + """Build a list of all the trials that will be run.""" + trial_list: Dict[float, List[PhotometricTrial]] = {} + for volume in test_volumes: + trial_list[volume] = [] + for trial in range(cfg.trials): + trial_list[volume].append( + PhotometricTrial( + ctx=ctx, + test_report=test_report, + pipette=pipette, + source=source, + dest=dest, + tip_volume=cfg.tip_volume, + volume=volume, + trial=trial, + liquid_tracker=liquid_tracker, + inspect=cfg.inspect, + cfg=cfg, + mix=cfg.mix, + acceptable_cv=None, + env_sensor=env_sensor, + ) + ) + return trial_list + + +def _finish_test( + cfg: config.VolumetricConfig, + resources: TestResources, + return_tip: bool, +) -> None: + ui.print_title("CHANGE PIPETTES") + if resources.pipette.has_tip: + if resources.pipette.current_volume > 0: + ui.print_info("dispensing liquid to trash") + trash = resources.pipette.trash_container.wells()[0] + # FIXME: this should be a blow_out() at max volume, + # but that is not available through PyAPI yet + # so instead just dispensing. + resources.pipette.dispense(resources.pipette.current_volume, trash.top()) + resources.pipette.aspirate(10) # to pull any droplets back up + ui.print_info("dropping tip") + helpers._drop_tip(resources.pipette, return_tip) + ui.print_info("moving to attach position") + resources.pipette.move_to( + resources.ctx.deck.position_for(5).move(Point(x=0, y=9 * 7, z=150)) + ) diff --git a/hardware-testing/tests/hardware_testing/drivers/test_asair_sensor.py b/hardware-testing/tests/hardware_testing/drivers/test_asair_sensor.py index c1319d343283..7480b6665a6a 100644 --- a/hardware-testing/tests/hardware_testing/drivers/test_asair_sensor.py +++ b/hardware-testing/tests/hardware_testing/drivers/test_asair_sensor.py @@ -2,8 +2,7 @@ from unittest.mock import MagicMock import pytest -from hardware_testing.drivers import AsairSensor -from hardware_testing.drivers.asair_sensor import Reading +from hardware_testing.drivers.asair_sensor import Reading, AsairSensor @pytest.fixture From 47c1c4a2679cb6634f0031bbc33268044623f031 Mon Sep 17 00:00:00 2001 From: Laura Cox <31892318+Laura-Danielle@users.noreply.github.com> Date: Tue, 25 Jul 2023 17:53:53 +0300 Subject: [PATCH 04/25] feat(shared-data): validate all models in the v2 pipette folder (#13161) --- .../opentrons_shared_data/pipette/types.py | 4 +-- .../tests/pipette/test_validate_schema.py | 36 +++++++++++++++++++ 2 files changed, 38 insertions(+), 2 deletions(-) create mode 100644 shared-data/python/tests/pipette/test_validate_schema.py diff --git a/shared-data/python/opentrons_shared_data/pipette/types.py b/shared-data/python/opentrons_shared_data/pipette/types.py index 9e05981202ca..a4511fccfa85 100644 --- a/shared-data/python/opentrons_shared_data/pipette/types.py +++ b/shared-data/python/opentrons_shared_data/pipette/types.py @@ -8,14 +8,14 @@ # Needed for Int Comparison. Keeping it next to # the Literal type for ease of readability PipetteModelMajorVersion = [1, 2, 3] -PipetteModelMinorVersion = [0, 1, 2, 3, 4, 5] +PipetteModelMinorVersion = [0, 1, 2, 3, 4, 5, 6] # TODO Literals are only good for writing down # exact values. Is there a better typing mechanism # so we don't need to keep track of versions in two # different places? PipetteModelMajorVersionType = Literal[1, 2, 3] -PipetteModelMinorVersionType = Literal[0, 1, 2, 3, 4, 5] +PipetteModelMinorVersionType = Literal[0, 1, 2, 3, 4, 5, 6] class PipetteTipType(enum.Enum): diff --git a/shared-data/python/tests/pipette/test_validate_schema.py b/shared-data/python/tests/pipette/test_validate_schema.py new file mode 100644 index 000000000000..b9c6c0f34375 --- /dev/null +++ b/shared-data/python/tests/pipette/test_validate_schema.py @@ -0,0 +1,36 @@ +import os +from opentrons_shared_data import get_shared_data_root + +from opentrons_shared_data.pipette.pipette_definition import PipetteConfigurations +from opentrons_shared_data.pipette.load_data import load_definition +from opentrons_shared_data.pipette.pipette_load_name_conversions import ( + convert_pipette_model, +) +from opentrons_shared_data.pipette.dev_types import PipetteModel + + +def test_check_all_models_are_valid() -> None: + paths_to_validate = ( + get_shared_data_root() / "pipette" / "definitions" / "2" / "liquid" + ) + _channel_model_str = { + "single_channel": "single", + "ninety_six_channel": "96", + "eight_channel": "multi", + } + for channel_dir in os.listdir(paths_to_validate): + for model_dir in os.listdir(paths_to_validate / channel_dir): + for version_file in os.listdir(paths_to_validate / channel_dir / model_dir): + version_list = version_file.split(".json")[0].split("_") + built_model: PipetteModel = PipetteModel( + f"{model_dir}_{_channel_model_str[channel_dir]}_v{version_list[0]}.{version_list[1]}" + ) + + model_version = convert_pipette_model(built_model) + loaded_model = load_definition( + model_version.pipette_type, + model_version.pipette_channels, + model_version.pipette_version, + ) + + assert isinstance(loaded_model, PipetteConfigurations) From 63f94d99cdc274a657d996f207c338fa69ab0983 Mon Sep 17 00:00:00 2001 From: Max Marrone Date: Tue, 25 Jul 2023 11:33:17 -0400 Subject: [PATCH 05/25] =?UTF-8?q?feat(api):=20Limited=20support=20for=20pr?= =?UTF-8?q?ogrammatically=20running=20PAPIv=E2=89=A52.14=20(#12970)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/src/opentrons/execute.py | 457 +++++++++++++++--- api/src/opentrons/protocol_engine/__init__.py | 6 +- .../protocol_engine/create_protocol_engine.py | 61 ++- api/src/opentrons/util/async_helpers.py | 114 ++++- api/src/opentrons/util/entrypoint_util.py | 37 +- .../async_context_manager_in_thread.py | 98 ---- api/tests/opentrons/conftest.py | 24 +- .../opentrons/protocol_engine_in_thread.py | 68 --- .../test_async_context_manager_in_thread.py | 114 ----- api/tests/opentrons/test_execute.py | 45 +- .../opentrons/util/test_async_helpers.py | 126 +++++ ...point_utils.py => test_entrypoint_util.py} | 71 +++ 12 files changed, 842 insertions(+), 379 deletions(-) delete mode 100644 api/tests/opentrons/async_context_manager_in_thread.py delete mode 100644 api/tests/opentrons/protocol_engine_in_thread.py delete mode 100644 api/tests/opentrons/test_async_context_manager_in_thread.py create mode 100644 api/tests/opentrons/util/test_async_helpers.py rename api/tests/opentrons/util/{test_entrypoint_utils.py => test_entrypoint_util.py} (54%) diff --git a/api/src/opentrons/execute.py b/api/src/opentrons/execute.py index 63fe43cd2ca8..75add87a4d65 100644 --- a/api/src/opentrons/execute.py +++ b/api/src/opentrons/execute.py @@ -5,60 +5,106 @@ regular python shells. It also provides a console entrypoint for running a protocol from the command line. """ +import asyncio import atexit import argparse +import contextlib import logging import os +from pathlib import Path import sys +import tempfile from typing import ( TYPE_CHECKING, BinaryIO, Callable, Dict, + Generator, List, Optional, TextIO, Union, ) +from opentrons_shared_data.labware.labware_definition import LabwareDefinition +from opentrons_shared_data.robot.dev_types import RobotType + from opentrons import protocol_api, __version__, should_use_ot3 -from opentrons.config import IS_ROBOT, JUPYTER_NOTEBOOK_LABWARE_DIR -from opentrons.protocols.execution import execute as execute_apiv2 from opentrons.commands import types as command_types + +from opentrons.config import IS_ROBOT, JUPYTER_NOTEBOOK_LABWARE_DIR + +from opentrons.hardware_control import ( + API as OT2API, + HardwareControlAPI, + ThreadManagedHardware, + ThreadManager, +) + from opentrons.protocols import parse -from opentrons.protocols.types import ApiDeprecationError from opentrons.protocols.api_support.deck_type import ( guess_from_global_config as guess_deck_type_from_global_config, ) from opentrons.protocols.api_support.types import APIVersion -from opentrons.hardware_control import ( - API as OT2API, - ThreadManagedHardware, - ThreadManager, +from opentrons.protocols.execution import execute as execute_apiv2 +from opentrons.protocols.types import ( + ApiDeprecationError, + Protocol, + PythonProtocol, ) -from opentrons_shared_data.robot.dev_types import RobotType -from .util.entrypoint_util import labware_from_paths, datafiles_from_paths +from opentrons.protocol_api.core.engine import ENGINE_CORE_API_VERSION +from opentrons.protocol_api.protocol_context import ProtocolContext + +from opentrons.protocol_engine import ( + Config, + DeckType, + EngineStatus, + ErrorOccurrence as ProtocolEngineErrorOccurrence, + create_protocol_engine, + create_protocol_engine_in_thread, +) + +from opentrons.protocol_reader import ProtocolReader, ProtocolSource + +from opentrons.protocol_runner import create_protocol_runner + +from .util.entrypoint_util import ( + FoundLabware, + labware_from_paths, + datafiles_from_paths, + copy_file_like, +) if TYPE_CHECKING: - from opentrons_shared_data.labware.dev_types import LabwareDefinition + from opentrons_shared_data.labware.dev_types import ( + LabwareDefinition as LabwareDefinitionDict, + ) + _THREAD_MANAGED_HW: Optional[ThreadManagedHardware] = None #: The background global cache that all protocol contexts created by #: :py:meth:`get_protocol_api` will share +# When a ProtocolContext is using a ProtocolEngine to control the robot, it requires some +# additional long-lived resources besides _THREAD_MANAGED_HARDWARE. There's a background thread, +# an asyncio event loop in that thread, and some ProtocolEngine-controlled background tasks in that +# event loop. +# +# When we're executing a protocol file beginning-to-end, we can clean up those resources after it +# completes. However, when someone gets a live ProtocolContext through get_protocol_api(), we have +# no way of knowing when they're done with it. So, as a hack, we keep these resources open +# indefinitely, letting them leak. +# +# We keep this at module scope so that the contained context managers aren't garbage-collected. +# If they're garbage collected, they can close their resources prematurely. +# https://stackoverflow.com/a/69155026/497934 +_LIVE_PROTOCOL_ENGINE_CONTEXTS = contextlib.ExitStack() + + # See Jira RCORE-535. -_PYTHON_TOO_NEW_MESSAGE = ( - "Python protocols with apiLevels higher than 2.13" - " cannot currently be executed with" - " the opentrons_execute command-line tool," - " the opentrons.execute.execute() function," - " or the opentrons.execute.get_protocol_api() function." - " Use a lower apiLevel" - " or use the Opentrons App instead." -) _JSON_TOO_NEW_MESSAGE = ( "Protocols created by recent versions of Protocol Designer" " cannot currently be executed with" @@ -68,11 +114,14 @@ ) +_EmitRunlogCallable = Callable[[command_types.CommandMessage], None] + + def get_protocol_api( version: Union[str, APIVersion], - bundled_labware: Optional[Dict[str, "LabwareDefinition"]] = None, + bundled_labware: Optional[Dict[str, "LabwareDefinitionDict"]] = None, bundled_data: Optional[Dict[str, bytes]] = None, - extra_labware: Optional[Dict[str, "LabwareDefinition"]] = None, + extra_labware: Optional[Dict[str, "LabwareDefinitionDict"]] = None, ) -> protocol_api.ProtocolContext: """ Build and return a ``protocol_api.ProtocolContext`` @@ -117,16 +166,9 @@ def get_protocol_api( else: checked_version = version - if ( - extra_labware is None - and IS_ROBOT - and JUPYTER_NOTEBOOK_LABWARE_DIR.is_dir() # type: ignore[union-attr] - ): + if extra_labware is None: extra_labware = { - uri: details.definition - for uri, details in labware_from_paths( - [str(JUPYTER_NOTEBOOK_LABWARE_DIR)] - ).items() + uri: details.definition for uri, details in _get_jupyter_labware().items() } robot_type = _get_robot_type() @@ -134,8 +176,8 @@ def get_protocol_api( hardware_controller = _get_global_hardware_controller(robot_type) - try: - context = protocol_api.create_protocol_context( + if checked_version < ENGINE_CORE_API_VERSION: + context = _create_live_context_non_pe( api_version=checked_version, deck_type=deck_type, hardware_api=hardware_controller, @@ -143,8 +185,20 @@ def get_protocol_api( bundled_data=bundled_data, extra_labware=extra_labware, ) - except protocol_api.ProtocolEngineCoreRequiredError as e: - raise NotImplementedError(_PYTHON_TOO_NEW_MESSAGE) from e # See Jira RCORE-535. + else: + if bundled_labware is not None: + raise NotImplementedError( + f"The bundled_labware argument is not currently supported for Python protocols" + f" with apiLevel {ENGINE_CORE_API_VERSION} or newer." + ) + context = _create_live_context_pe( + api_version=checked_version, + robot_type=robot_type, + deck_type=guess_deck_type_from_global_config(), + hardware_api=_THREAD_MANAGED_HW, # type: ignore[arg-type] + bundled_data=bundled_data, + extra_labware=extra_labware, + ) hardware_controller.sync.cache_instruments() return context @@ -229,12 +283,12 @@ def get_arguments(parser: argparse.ArgumentParser) -> argparse.ArgumentParser: return parser -def execute( +def execute( # noqa: C901 protocol_file: Union[BinaryIO, TextIO], protocol_name: str, propagate_logs: bool = False, log_level: str = "warning", - emit_runlog: Optional[Callable[[command_types.CommandMessage], None]] = None, + emit_runlog: Optional[_EmitRunlogCallable] = None, custom_labware_paths: Optional[List[str]] = None, custom_data_paths: Optional[List[str]] = None, ) -> None: @@ -300,20 +354,18 @@ def execute( # will produce a string with information filled in } } - - """ stack_logger = logging.getLogger("opentrons") stack_logger.propagate = propagate_logs stack_logger.setLevel(getattr(logging, log_level.upper(), logging.WARNING)) + contents = protocol_file.read() + if custom_labware_paths: - extra_labware = { - uri: details.definition - for uri, details in labware_from_paths(custom_labware_paths).items() - } + extra_labware = labware_from_paths(custom_labware_paths) else: - extra_labware = {} + extra_labware = _get_jupyter_labware() + if custom_data_paths: extra_data = datafiles_from_paths(custom_data_paths) else: @@ -321,7 +373,12 @@ def execute( try: protocol = parse.parse( - contents, protocol_name, extra_labware=extra_labware, extra_data=extra_data + contents, + protocol_name, + extra_labware={ + uri: details.definition for uri, details in extra_labware.items() + }, + extra_data=extra_data, ) except parse.JSONSchemaVersionTooNewError as e: if e.attempted_schema_version == 6: @@ -332,24 +389,42 @@ def execute( if protocol.api_level < APIVersion(2, 0): raise ApiDeprecationError(version=protocol.api_level) - else: - bundled_data = getattr(protocol, "bundled_data", {}) - bundled_data.update(extra_data) - gpa_extras = getattr(protocol, "extra_labware", None) or None - context = get_protocol_api( - protocol.api_level, - bundled_labware=getattr(protocol, "bundled_labware", None), - bundled_data=bundled_data, - extra_labware=gpa_extras, + + # Guard against trying to run protocols for the wrong robot type. + # This matches what robot-server does. + if protocol.robot_type != _get_robot_type(): + raise RuntimeError( + f'This robot is of type "{_get_robot_type()}",' + f' so it can\'t execute protocols for robot type "{protocol.robot_type}"' ) + + if protocol.api_level < ENGINE_CORE_API_VERSION: + _run_file_non_pe( + protocol=protocol, + emit_runlog=emit_runlog, + ) + else: + # TODO(mm, 2023-07-06): Once these NotImplementedErrors are resolved, consider removing + # the enclosing if-else block and running everything through _run_file_pe() for simplicity. if emit_runlog: - broker = context.broker - broker.subscribe(command_types.COMMAND, emit_runlog) - context.home() - try: - execute_apiv2.run_protocol(protocol, context) - finally: - context.cleanup() + raise NotImplementedError( + f"Printing the run log is not currently supported for Python protocols" + f" with apiLevel {ENGINE_CORE_API_VERSION} or newer." + f" Pass --no-print-runlog to opentrons_execute" + f" or emit_runlog=None to opentrons.execute.execute()." + ) + if custom_data_paths: + raise NotImplementedError( + f"The custom_data_paths argument is not currently supported for Python protocols" + f" with apiLevel {ENGINE_CORE_API_VERSION} or newer." + ) + protocol_file.seek(0) + _run_file_pe( + protocol_file=protocol_file, + protocol_name=protocol_name, + extra_labware=extra_labware, + hardware_api=_get_global_hardware_controller(_get_robot_type()).wrapped(), + ) def make_runlog_cb() -> Callable[[command_types.CommandMessage], None]: @@ -401,17 +476,198 @@ def main() -> int: stack_logger.addHandler(logging.StreamHandler(sys.stdout)) log_level = args.log_level else: + # TODO(mm, 2023-07-13): This default logging prints error information redundantly + # when executing via Protocol Engine, because Protocol Engine logs when commands fail. log_level = "warning" - # Try to migrate containers from database to v2 format - execute( - protocol_file=args.protocol, - protocol_name=args.protocol.name, - custom_labware_paths=args.custom_labware_path, - custom_data_paths=(args.custom_data_path + args.custom_data_file), - log_level=log_level, - emit_runlog=printer, + + try: + execute( + protocol_file=args.protocol, + protocol_name=args.protocol.name, + custom_labware_paths=args.custom_labware_path, + custom_data_paths=(args.custom_data_path + args.custom_data_file), + log_level=log_level, + emit_runlog=printer, + ) + return 0 + except _ProtocolEngineExecuteError as error: + # _ProtocolEngineExecuteError is a wrapper that's meaningless to the CLI user. + # Take the actual protocol problem out of it and just print that. + print(error.to_stderr_string(), file=sys.stderr) + return 1 + # execute() might raise other exceptions, but we don't have a nice way to print those. + # Just let Python show a traceback. + + +class _ProtocolEngineExecuteError(Exception): + def __init__(self, errors: List[ProtocolEngineErrorOccurrence]) -> None: + """Raised when there was any fatal error running a protocol through Protocol Engine. + + Protocol Engine reports errors as data, not as exceptions. + But the only way for `execute()` to signal problems to its caller is to raise something. + So we need this class to wrap them. + + Params: + errors: The errors that Protocol Engine reported. + """ + # Show the full error details if this is part of a traceback. Don't try to summarize. + super().__init__(errors) + self._error_occurrences = errors + + def to_stderr_string(self) -> str: + """Return a string suitable as the stderr output of the `opentrons_execute` CLI. + + This summarizes from the full error details. + """ + # It's unclear what exactly we should extract here. + # + # First, do we print the first element, or the last, or all of them? + # + # Second, do we print the .detail? .errorCode? .errorInfo? .wrappedErrors? + # By contract, .detail seems like it would be insufficient, but experimentally, + # it includes a lot, like: + # + # ProtocolEngineError [line 3]: Error 4000 GENERAL_ERROR (ProtocolEngineError): + # UnexpectedProtocolError: Labware "fixture_12_trough" not found with version 1 + # in namespace "fixture". + return self._error_occurrences[0].detail + + +def _create_live_context_non_pe( + api_version: APIVersion, + hardware_api: ThreadManagedHardware, + deck_type: str, + extra_labware: Optional[Dict[str, "LabwareDefinitionDict"]], + bundled_labware: Optional[Dict[str, "LabwareDefinitionDict"]], + bundled_data: Optional[Dict[str, bytes]], +) -> ProtocolContext: + """Return a live ProtocolContext. + + This controls the robot through the older infrastructure, instead of through Protocol Engine. + """ + assert api_version < ENGINE_CORE_API_VERSION + return protocol_api.create_protocol_context( + api_version=api_version, + deck_type=deck_type, + hardware_api=hardware_api, + bundled_labware=bundled_labware, + bundled_data=bundled_data, + extra_labware=extra_labware, + ) + + +def _create_live_context_pe( + api_version: APIVersion, + hardware_api: ThreadManagedHardware, + robot_type: RobotType, + deck_type: str, + extra_labware: Dict[str, "LabwareDefinitionDict"], + bundled_data: Optional[Dict[str, bytes]], +) -> ProtocolContext: + """Return a live ProtocolContext that controls the robot through ProtocolEngine.""" + assert api_version >= ENGINE_CORE_API_VERSION + + global _LIVE_PROTOCOL_ENGINE_CONTEXTS + pe, loop = _LIVE_PROTOCOL_ENGINE_CONTEXTS.enter_context( + create_protocol_engine_in_thread( + hardware_api=hardware_api.wrapped(), + config=_get_protocol_engine_config(), + drop_tips_and_home_after=False, + ) ) - return 0 + + # `async def` so we can use loop.run_coroutine_threadsafe() to wait for its completion. + # Non-async would use call_soon_threadsafe(), which makes the waiting harder. + async def add_all_extra_labware() -> None: + for labware_definition_dict in extra_labware.values(): + labware_definition = LabwareDefinition.parse_obj(labware_definition_dict) + pe.add_labware_definition(labware_definition) + + # Add extra_labware to ProtocolEngine, being careful not to modify ProtocolEngine from this + # thread. See concurrency notes in ProtocolEngine docstring. + future = asyncio.run_coroutine_threadsafe(add_all_extra_labware(), loop) + future.result() + + return protocol_api.create_protocol_context( + api_version=api_version, + hardware_api=hardware_api, + deck_type=deck_type, + protocol_engine=pe, + protocol_engine_loop=loop, + bundled_data=bundled_data, + ) + + +def _run_file_non_pe( + protocol: Protocol, + emit_runlog: Optional[_EmitRunlogCallable], +) -> None: + """Run a protocol file without Protocol Engine, with the older infrastructure instead.""" + if isinstance(protocol, PythonProtocol): + extra_labware = protocol.extra_labware + bundled_labware = protocol.bundled_labware + bundled_data = protocol.bundled_data + else: + # JSON protocols do have "bundled labware" embedded in them, but those aren't represented in + # the parsed Protocol object and we don't need to create the ProtocolContext with them. + # execute_apiv2.run_protocol() will pull them out of the JSON and load them into the + # ProtocolContext. + extra_labware = None + bundled_labware = None + bundled_data = None + + context = _create_live_context_non_pe( + api_version=protocol.api_level, + hardware_api=_get_global_hardware_controller(_get_robot_type()), + deck_type=guess_deck_type_from_global_config(), + extra_labware=extra_labware, + bundled_labware=bundled_labware, + bundled_data=bundled_data, + ) + + if emit_runlog: + context.broker.subscribe(command_types.COMMAND, emit_runlog) + + context.home() + try: + execute_apiv2.run_protocol(protocol, context) + finally: + context.cleanup() + + +def _run_file_pe( + protocol_file: Union[BinaryIO, TextIO], + protocol_name: str, + extra_labware: Dict[str, FoundLabware], + hardware_api: HardwareControlAPI, +) -> None: + """Run a protocol file with Protocol Engine.""" + + async def run(protocol_source: ProtocolSource) -> None: + protocol_engine = await create_protocol_engine( + hardware_api=hardware_api, + config=_get_protocol_engine_config(), + ) + + protocol_runner = create_protocol_runner( + protocol_config=protocol_source.config, + protocol_engine=protocol_engine, + hardware_api=hardware_api, + ) + + # TODO(mm, 2023-06-30): This will home and drop tips at the end, which is not how + # things have historically behaved with PAPIv2.13 and older or JSONv5 and older. + result = await protocol_runner.run(protocol_source) + + if result.state_summary.status != EngineStatus.SUCCEEDED: + raise _ProtocolEngineExecuteError(result.state_summary.errors) + + with _adapt_protocol_source( + protocol_file=protocol_file, + protocol_name=protocol_name, + extra_labware=extra_labware, + ) as protocol_source: + asyncio.run(run(protocol_source)) def _get_robot_type() -> RobotType: @@ -419,6 +675,61 @@ def _get_robot_type() -> RobotType: return "OT-3 Standard" if should_use_ot3() else "OT-2 Standard" +def _get_protocol_engine_config() -> Config: + """Return a Protocol Engine config to execute protocols on this device.""" + return Config( + robot_type=_get_robot_type(), + deck_type=DeckType(guess_deck_type_from_global_config()), + # We deliberately omit ignore_pause=True because, in the current implementation of + # opentrons.protocol_api.core.engine, that would incorrectly make + # ProtocolContext.is_simulating() return True. + ) + + +def _get_jupyter_labware() -> Dict[str, FoundLabware]: + """Return labware files in this robot's Jupyter Notebook directory.""" + if IS_ROBOT: + # JUPYTER_NOTEBOOK_LABWARE_DIR should never be None when IS_ROBOT == True. + assert JUPYTER_NOTEBOOK_LABWARE_DIR is not None + if JUPYTER_NOTEBOOK_LABWARE_DIR.is_dir(): + return labware_from_paths([JUPYTER_NOTEBOOK_LABWARE_DIR]) + + return {} + + +@contextlib.contextmanager +def _adapt_protocol_source( + protocol_file: Union[BinaryIO, TextIO], + protocol_name: str, + extra_labware: Dict[str, FoundLabware], +) -> Generator[ProtocolSource, None, None]: + """Create a `ProtocolSource` representing input protocol files.""" + with tempfile.TemporaryDirectory() as temporary_directory: + # It's not well-defined in our customer-facing interfaces whether the supplied protocol_name + # should be just the filename part, or a path with separators. In case it contains stuff + # like "../", sanitize it to just the filename part so we don't save files somewhere bad. + safe_protocol_name = Path(protocol_name).name + + temp_protocol_file = Path(temporary_directory) / safe_protocol_name + + # FIXME(mm, 2023-06-26): Copying this file is pure overhead, and it introduces encoding + # hazards. Remove this when we can parse JSONv6+ and PAPIv2.14+ protocols without going + # through the filesystem. https://opentrons.atlassian.net/browse/RSS-281 + copy_file_like(source=protocol_file, destination=temp_protocol_file) + + custom_labware_files = [labware.path for labware in extra_labware.values()] + + protocol_source = asyncio.run( + ProtocolReader().read_saved( + files=[temp_protocol_file] + custom_labware_files, + directory=None, + files_are_prevalidated=False, + ) + ) + + yield protocol_source + + def _get_global_hardware_controller(robot_type: RobotType) -> ThreadManagedHardware: # Build a hardware controller in a worker thread, which is necessary # because ipython runs its notebook in asyncio but the notebook @@ -446,5 +757,13 @@ def _clear_cached_hardware_controller() -> None: _THREAD_MANAGED_HW = None +# This atexit registration must come after _clear_cached_hardware_controller() +# to ensure we tear things down in order from highest level to lowest level. +@atexit.register +def _clear_live_protocol_engine_contexts() -> None: + global _LIVE_PROTOCOL_ENGINE_CONTEXTS + _LIVE_PROTOCOL_ENGINE_CONTEXTS.close() + + if __name__ == "__main__": sys.exit(main()) diff --git a/api/src/opentrons/protocol_engine/__init__.py b/api/src/opentrons/protocol_engine/__init__.py index 1daaf846f127..a975b4973324 100644 --- a/api/src/opentrons/protocol_engine/__init__.py +++ b/api/src/opentrons/protocol_engine/__init__.py @@ -7,7 +7,10 @@ The main interface is the `ProtocolEngine` class. """ -from .create_protocol_engine import create_protocol_engine +from .create_protocol_engine import ( + create_protocol_engine, + create_protocol_engine_in_thread, +) from .protocol_engine import ProtocolEngine from .errors import ProtocolEngineError, ErrorOccurrence from .commands import ( @@ -55,6 +58,7 @@ __all__ = [ # main factory and interface exports "create_protocol_engine", + "create_protocol_engine_in_thread", "ProtocolEngine", "StateSummary", "Config", diff --git a/api/src/opentrons/protocol_engine/create_protocol_engine.py b/api/src/opentrons/protocol_engine/create_protocol_engine.py index cca1669355f6..f4e70afc4e74 100644 --- a/api/src/opentrons/protocol_engine/create_protocol_engine.py +++ b/api/src/opentrons/protocol_engine/create_protocol_engine.py @@ -1,13 +1,19 @@ """Main ProtocolEngine factory.""" +import asyncio +import contextlib +import typing + from opentrons.hardware_control import HardwareControlAPI from opentrons.hardware_control.types import DoorState -from opentrons.protocol_engine.resources.module_data_provider import ModuleDataProvider +from opentrons.util.async_helpers import async_context_manager_in_thread from .protocol_engine import ProtocolEngine -from .resources import DeckDataProvider +from .resources import DeckDataProvider, ModuleDataProvider from .state import Config, StateStore +# TODO(mm, 2023-06-16): Arguably, this not being a context manager makes us prone to forgetting to +# clean it up properly, especially in tests. See e.g. https://opentrons.atlassian.net/browse/RSS-222 async def create_protocol_engine( hardware_api: HardwareControlAPI, config: Config, @@ -32,3 +38,54 @@ async def create_protocol_engine( ) return ProtocolEngine(state_store=state_store, hardware_api=hardware_api) + + +@contextlib.contextmanager +def create_protocol_engine_in_thread( + hardware_api: HardwareControlAPI, + config: Config, + drop_tips_and_home_after: bool, +) -> typing.Generator[ + typing.Tuple[ProtocolEngine, asyncio.AbstractEventLoop], None, None +]: + """Run a `ProtocolEngine` in a worker thread. + + When this context manager is entered, it: + + 1. Starts a worker thread. + 2. Starts an asyncio event loop in that worker thread. + 3. Creates and `.play()`s a `ProtocolEngine` in that event loop. + 4. Returns the `ProtocolEngine` and the event loop. + Use functions like `asyncio.run_coroutine_threadsafe()` to safely interact with + the `ProtocolEngine` from your thread. + + When this context manager is exited, it: + + 1. Cleans up the `ProtocolEngine`. + 2. Stops and cleans up the event loop. + 3. Joins the thread. + """ + with async_context_manager_in_thread( + _protocol_engine(hardware_api, config, drop_tips_and_home_after) + ) as ( + protocol_engine, + loop, + ): + yield protocol_engine, loop + + +@contextlib.asynccontextmanager +async def _protocol_engine( + hardware_api: HardwareControlAPI, + config: Config, + drop_tips_and_home_after: bool, +) -> typing.AsyncGenerator[ProtocolEngine, None]: + protocol_engine = await create_protocol_engine( + hardware_api=hardware_api, + config=config, + ) + try: + protocol_engine.play() + yield protocol_engine + finally: + await protocol_engine.finish(drop_tips_and_home=drop_tips_and_home_after) diff --git a/api/src/opentrons/util/async_helpers.py b/api/src/opentrons/util/async_helpers.py index 56606dda468a..3e44c11153cf 100644 --- a/api/src/opentrons/util/async_helpers.py +++ b/api/src/opentrons/util/async_helpers.py @@ -3,9 +3,21 @@ """ from functools import wraps -from typing import TypeVar, Callable, Awaitable, cast, Any +from threading import Thread +from typing import ( + Any, + AsyncContextManager, + Awaitable, + Callable, + Generator, + Tuple, + TypeVar, + cast, +) import asyncio +import contextlib +import queue async def asyncio_yield() -> None: @@ -36,10 +48,10 @@ async def and await call() that still effectively "block" other concurrent tasks await asyncio.sleep(0) -Wrapped = TypeVar("Wrapped", bound=Callable[..., Awaitable[Any]]) +_Wrapped = TypeVar("_Wrapped", bound=Callable[..., Awaitable[Any]]) -def ensure_yield(async_def_func: Wrapped) -> Wrapped: +def ensure_yield(async_def_func: _Wrapped) -> _Wrapped: """ A decorator that makes sure that asyncio_yield() is called after the decorated async function finishes executing. @@ -57,4 +69,98 @@ async def _wrapper(*args: Any, **kwargs: Any) -> Any: await asyncio_yield() return ret - return cast(Wrapped, _wrapper) + return cast(_Wrapped, _wrapper) + + +_ContextManagerResult = TypeVar("_ContextManagerResult") + + +@contextlib.contextmanager +def async_context_manager_in_thread( + async_context_manager: AsyncContextManager[_ContextManagerResult], +) -> Generator[Tuple[_ContextManagerResult, asyncio.AbstractEventLoop], None, None]: + """Enter an async context manager in a worker thread. + + When you enter this context manager, it: + + 1. Spawns a worker thread. + 2. In that thread, starts an asyncio event loop. + 3. In that event loop, enters the context manager that you passed in. + 4. Returns: the result of entering that context manager, and the running event loop. + Use functions like `asyncio.run_coroutine_threadsafe()` to safely interact + with the returned object from your thread. + + When you exit this context manager, it: + + 1. In the worker thread's event loop, exits the context manager that you passed in. + 2. Stops and cleans up the worker thread's event loop. + 3. Joins the worker thread. + """ + with _run_loop_in_thread() as loop_in_thread: + async_object = asyncio.run_coroutine_threadsafe( + async_context_manager.__aenter__(), + loop=loop_in_thread, + ).result() + + try: + yield async_object, loop_in_thread + + finally: + exit = asyncio.run_coroutine_threadsafe( + async_context_manager.__aexit__(None, None, None), + loop=loop_in_thread, + ) + exit.result() + + +@contextlib.contextmanager +def _run_loop_in_thread() -> Generator[asyncio.AbstractEventLoop, None, None]: + """Run an event loop in a worker thread. + + Entering this context manager spawns a thread, starts an asyncio event loop in it, + and returns that loop. + + Exiting this context manager stops and cleans up the event loop, and then joins the thread. + """ + loop_mailbox: "queue.SimpleQueue[asyncio.AbstractEventLoop]" = queue.SimpleQueue() + + def _in_thread() -> None: + loop = asyncio.new_event_loop() + + # We assume that the lines above this will never fail, + # so we will always reach this point to unblock the parent thread. + loop_mailbox.put(loop) + + loop.run_forever() + + # If we've reached here, the loop has been stopped from outside this thread. Clean it up. + # + # This cleanup is naive because asyncio makes it difficult and confusing to get it right. + # Compare this with asyncio.run()'s cleanup, which: + # + # * Cancels and awaits any remaining tasks + # (according to the source code--this seems undocumented) + # * Shuts down asynchronous generators + # (see asyncio.shutdown_asyncgens()) + # * Shuts down the default thread pool executor + # (see https://bugs.python.org/issue34037 and asyncio.shutdown_default_executor()) + # + # In Python >=3.11, we should rewrite this to use asyncio.Runner, + # which can take care of these nuances for us. + loop.close() + + thread = Thread( + target=_in_thread, + name=f"{__name__} event loop thread", + # This is a load-bearing daemon=True. It avoids @atexit-related deadlocks when this is used + # by opentrons.execute and cleaned up by opentrons.execute's @atexit handler. + # https://github.com/Opentrons/opentrons/pull/12970#issuecomment-1648243785 + daemon=True, + ) + thread.start() + loop_in_thread = loop_mailbox.get() + try: + yield loop_in_thread + finally: + loop_in_thread.call_soon_threadsafe(loop_in_thread.stop) + thread.join() diff --git a/api/src/opentrons/util/entrypoint_util.py b/api/src/opentrons/util/entrypoint_util.py index 5625828f5d49..954d837c2f34 100644 --- a/api/src/opentrons/util/entrypoint_util.py +++ b/api/src/opentrons/util/entrypoint_util.py @@ -5,7 +5,8 @@ import logging from json import JSONDecodeError import pathlib -from typing import Dict, Sequence, Union, TYPE_CHECKING +import shutil +from typing import BinaryIO, Dict, Sequence, TextIO, Union, TYPE_CHECKING from jsonschema import ValidationError # type: ignore @@ -83,3 +84,37 @@ def datafiles_from_paths(paths: Sequence[Union[str, pathlib.Path]]) -> Dict[str, else: log.info(f"ignoring {child} in data path") return datafiles + + +# HACK(mm, 2023-06-29): This function is attempting to do something fundamentally wrong. +# Remove it when we fix https://opentrons.atlassian.net/browse/RSS-281. +def copy_file_like(source: Union[BinaryIO, TextIO], destination: pathlib.Path) -> None: + """Copy a file-like object to a path. + + Limitations: + If `source` is text, the new file's encoding may not correctly match its original encoding. + This can matter if it's a Python file and it has an encoding declaration + (https://docs.python.org/3.7/reference/lexical_analysis.html#encoding-declarations). + Also, its newlines may get translated. + """ + # When we read from the source stream, will it give us bytes, or text? + try: + # Experimentally, this is present (but possibly None) on text-mode streams, + # and not present on binary-mode streams. + getattr(source, "encoding") + except AttributeError: + source_is_text = False + else: + source_is_text = True + + if source_is_text: + destination_mode = "wt" + else: + destination_mode = "wb" + + with open( + destination, + mode=destination_mode, + ) as destination_file: + # Use copyfileobj() to limit memory usage. + shutil.copyfileobj(fsrc=source, fdst=destination_file) diff --git a/api/tests/opentrons/async_context_manager_in_thread.py b/api/tests/opentrons/async_context_manager_in_thread.py deleted file mode 100644 index 75f2d9820851..000000000000 --- a/api/tests/opentrons/async_context_manager_in_thread.py +++ /dev/null @@ -1,98 +0,0 @@ -"""A test helper to enter an async context manager in a worker thread.""" - -from __future__ import annotations - -import asyncio -import contextlib -import queue -import typing - -from concurrent.futures import ThreadPoolExecutor - - -_T = typing.TypeVar("_T") - - -@contextlib.contextmanager -def async_context_manager_in_thread( - async_context_manager: typing.AsyncContextManager[_T], -) -> typing.Generator[typing.Tuple[_T, asyncio.AbstractEventLoop], None, None]: - """Enter an async context manager in a worker thread. - - When you enter this context manager, it: - - 1. Spawns a worker thread. - 2. In that thread, starts an asyncio event loop. - 3. In that event loop, enters the context manager that you passed in. - 4. Returns: the result of entering that context manager, and the running event loop. - Use functions like `asyncio.run_coroutine_threadsafe()` to safely interact - with the returned object from your thread. - - When you exit this context manager, it: - - 1. In the worker thread's event loop, exits the context manager that you passed in. - 2. Stops and cleans up the worker thread's event loop. - 3. Joins the worker thread. - """ - with _run_loop_in_thread() as loop_in_thread: - async_object = asyncio.run_coroutine_threadsafe( - async_context_manager.__aenter__(), - loop=loop_in_thread, - ).result() - - try: - yield async_object, loop_in_thread - - finally: - exit = asyncio.run_coroutine_threadsafe( - async_context_manager.__aexit__(None, None, None), - loop=loop_in_thread, - ) - exit.result() - - -@contextlib.contextmanager -def _run_loop_in_thread() -> typing.Generator[asyncio.AbstractEventLoop, None, None]: - """Run an event loop in a worker thread. - - Entering this context manager spawns a thread, starts an asyncio event loop in it, - and returns that loop. - - Exiting this context manager stops and cleans up the event loop, and then joins the thread. - """ - loop_queue: "queue.SimpleQueue[asyncio.AbstractEventLoop]" = queue.SimpleQueue() - - def _in_thread() -> None: - loop = asyncio.new_event_loop() - - # We assume that the lines above this will never fail, - # so we will always reach this point to unblock the parent thread. - loop_queue.put(loop) - - loop.run_forever() - - # If we've reached here, the loop has been stopped from outside this thread. Clean it up. - # - # This cleanup is naive because asyncio makes it difficult and confusing to get it right. - # Compare this with asyncio.run()'s cleanup, which: - # - # * Cancels and awaits any remaining tasks - # (according to the source code--this seems undocumented) - # * Shuts down asynchronous generators - # (see asyncio.shutdown_asyncgens()) - # * Shuts down the default thread pool executor - # (see https://bugs.python.org/issue34037 and asyncio.shutdown_default_executor()) - # - # In Python >=3.11, we should rewrite this to use asyncio.Runner, - # which can take care of these nuances for us. - loop.close() - - with ThreadPoolExecutor(max_workers=1) as executor: - executor.submit(_in_thread) - - loop_in_thread = loop_queue.get() - - try: - yield loop_in_thread - finally: - loop_in_thread.call_soon_threadsafe(loop_in_thread.stop) diff --git a/api/tests/opentrons/conftest.py b/api/tests/opentrons/conftest.py index 2254792ff789..c2b520ad727c 100755 --- a/api/tests/opentrons/conftest.py +++ b/api/tests/opentrons/conftest.py @@ -50,13 +50,16 @@ ) from opentrons.protocol_api import ProtocolContext, Labware, create_protocol_context from opentrons.protocol_api.core.legacy.legacy_labware_core import LegacyLabwareCore +from opentrons.protocol_engine import ( + create_protocol_engine_in_thread, + Config as ProtocolEngineConfig, + DeckType, +) from opentrons.protocols.api_support import deck_type from opentrons.protocols.api_support.types import APIVersion from opentrons.protocols.api_support.definitions import MAX_SUPPORTED_VERSION from opentrons.types import Location, Point -from .protocol_engine_in_thread import protocol_engine_in_thread - if TYPE_CHECKING: from opentrons.drivers.smoothie_drivers import SmoothieDriver as SmoothieDriverType @@ -282,7 +285,22 @@ def _make_ot3_pe_ctx( deck_type: str, ) -> Generator[ProtocolContext, None, None]: """Return a ProtocolContext configured for an OT-3 and backed by Protocol Engine.""" - with protocol_engine_in_thread(hardware=hardware) as (engine, loop): + with create_protocol_engine_in_thread( + hardware_api=hardware.wrapped(), + config=ProtocolEngineConfig( + robot_type="OT-3 Standard", + deck_type=DeckType.OT3_STANDARD, + ignore_pause=True, + use_virtual_pipettes=True, + use_virtual_modules=True, + use_virtual_gripper=True, + block_on_door_open=False, + ), + drop_tips_and_home_after=False, + ) as ( + engine, + loop, + ): yield create_protocol_context( api_version=MAX_SUPPORTED_VERSION, hardware_api=hardware, diff --git a/api/tests/opentrons/protocol_engine_in_thread.py b/api/tests/opentrons/protocol_engine_in_thread.py deleted file mode 100644 index ec7ca1b21f69..000000000000 --- a/api/tests/opentrons/protocol_engine_in_thread.py +++ /dev/null @@ -1,68 +0,0 @@ -"""Run a `ProtocolEngine` in a worker thread.""" - -import asyncio -import contextlib -import typing - -from opentrons.hardware_control import ThreadManagedHardware -from opentrons.protocol_engine import ( - create_protocol_engine, - ProtocolEngine, - Config, - DeckType, -) - -from .async_context_manager_in_thread import async_context_manager_in_thread - - -@contextlib.contextmanager -def protocol_engine_in_thread( - hardware: ThreadManagedHardware, -) -> typing.Generator[ - typing.Tuple[ProtocolEngine, asyncio.AbstractEventLoop], None, None -]: - """Run a `ProtocolEngine` in a worker thread. - - When this context manager is entered, it: - - 1. Starts a worker thread. - 2. Starts an asyncio event loop in that worker thread. - 3. Creates and `.play()`s a `ProtocolEngine` in that event loop. - 4. Returns the `ProtocolEngine` and the event loop. - Use functions like `asyncio.run_coroutine_threadsafe()` to safely interact with - the `ProtocolEngine` from your thread. - - When this context manager is exited, it: - - 1. Cleans up the `ProtocolEngine`. - 2. Stops and cleans up the event loop. - 3. Joins the thread. - """ - with async_context_manager_in_thread(_protocol_engine(hardware)) as ( - protocol_engine, - loop, - ): - yield protocol_engine, loop - - -@contextlib.asynccontextmanager -async def _protocol_engine( - hardware: ThreadManagedHardware, -) -> typing.AsyncGenerator[ProtocolEngine, None]: - protocol_engine = await create_protocol_engine( - hardware_api=hardware.wrapped(), - config=Config( - robot_type="OT-3 Standard", - deck_type=DeckType.OT3_STANDARD, - ignore_pause=True, - use_virtual_pipettes=True, - use_virtual_modules=True, - use_virtual_gripper=True, - block_on_door_open=False, - ), - ) - try: - protocol_engine.play() - yield protocol_engine - finally: - await protocol_engine.finish() diff --git a/api/tests/opentrons/test_async_context_manager_in_thread.py b/api/tests/opentrons/test_async_context_manager_in_thread.py deleted file mode 100644 index 9eaf63c438cd..000000000000 --- a/api/tests/opentrons/test_async_context_manager_in_thread.py +++ /dev/null @@ -1,114 +0,0 @@ -"""Tests for the `async_context_manager_in_thread` helper.""" - - -import asyncio - -import pytest - -from .async_context_manager_in_thread import async_context_manager_in_thread - - -def test_enters_and_exits() -> None: - """It should enter and exit the given context manager appropriately, and return its result.""" - - class ContextManager: - def __init__(self) -> None: - self.entered = False - self.exited = False - - async def __aenter__(self) -> str: - self.entered = True - return "Yay!" - - async def __aexit__( - self, exc_type: object, exc_val: object, exc_tb: object - ) -> None: - self.exited = True - - context_manager = ContextManager() - - assert not context_manager.entered - assert not context_manager.exited - - with async_context_manager_in_thread(context_manager) as (result, _): - assert context_manager.entered - assert not context_manager.exited - assert result == "Yay!" - - assert context_manager.exited - - -def test_returns_matching_loop() -> None: - """It should return the event loop that the given context manager is running in.""" - - class ContextManager: - async def __aenter__(self) -> asyncio.AbstractEventLoop: - return asyncio.get_running_loop() - - async def __aexit__( - self, exc_type: object, exc_val: object, exc_tb: object - ) -> None: - pass - - context_manager = ContextManager() - with async_context_manager_in_thread(context_manager) as (result, loop_in_thread): - assert result is loop_in_thread - - -def test_loop_lifetime() -> None: - """Test the lifetime of the returned event loop. - - While the context manager is open, the event loop should be running and usable. - After the context manager closes, the event loop should be closed and unusable. - """ - - class NoOp: - async def __aenter__(self) -> None: - return None - - async def __aexit__( - self, exc_type: object, exc_val: object, exc_tb: object - ) -> None: - pass - - with async_context_manager_in_thread(NoOp()) as (_, loop_in_thread): - asyncio.run_coroutine_threadsafe(asyncio.sleep(0.000001), loop_in_thread) - - with pytest.raises(RuntimeError, match="Event loop is closed"): - loop_in_thread.call_soon_threadsafe(lambda: None) - - -def test_propagates_exception_from_enter() -> None: - """If the given context manager raises an exception when it's entered, it should propagate.""" - - class RaiseExceptionOnEnter: - async def __aenter__(self) -> None: - raise RuntimeError("Oh the humanity.") - - async def __aexit__( - self, exc_type: object, exc_val: object, exc_tb: object - ) -> None: - assert False, "We should not reach here." - - context_manager = RaiseExceptionOnEnter() - with pytest.raises(RuntimeError, match="Oh the humanity"): - with async_context_manager_in_thread(context_manager): - assert False, "We should not reach here." - - -def test_propagates_exception_from_exit() -> None: - """If the given context manager raises an exception when it's exited, it should propagate.""" - - class RaiseExceptionOnExit: - async def __aenter__(self) -> None: - return None - - async def __aexit__( - self, exc_type: object, exc_val: object, exc_tb: object - ) -> None: - raise RuntimeError("Oh the humanity.") - - context_manager = RaiseExceptionOnExit() - with pytest.raises(RuntimeError, match="Oh the humanity"): - with async_context_manager_in_thread(context_manager): - assert False, "We should not reach here." diff --git a/api/tests/opentrons/test_execute.py b/api/tests/opentrons/test_execute.py index e5d829b2ba61..e986fc1ed7cf 100644 --- a/api/tests/opentrons/test_execute.py +++ b/api/tests/opentrons/test_execute.py @@ -16,9 +16,11 @@ pipette_load_name_conversions as pipette_load_name, load_data as load_pipette_data, ) + from opentrons import execute, types -from opentrons.protocols.api_support.types import APIVersion from opentrons.hardware_control import Controller, api +from opentrons.protocol_api.core.engine import ENGINE_CORE_API_VERSION +from opentrons.protocols.api_support.types import APIVersion if TYPE_CHECKING: from tests.opentrons.conftest import Bundle, Protocol @@ -27,13 +29,7 @@ HERE = Path(__file__).parent -@pytest.fixture( - params=[ - APIVersion(2, 0), - # TODO(mm, 2023-07-14): Enable this for https://opentrons.atlassian.net/browse/RSS-268. - # ENGINE_CORE_API_VERSION, - ] -) +@pytest.fixture(params=[APIVersion(2, 0), ENGINE_CORE_API_VERSION]) def api_version(request: pytest.FixtureRequest) -> APIVersion: """Return an API version to test with. @@ -63,12 +59,15 @@ async def dummy_delay(self: Any, duration_s: float) -> None: @pytest.mark.parametrize( - "protocol_file", + ("protocol_file", "expect_run_log"), [ - "testosaur_v2.py", - # TODO(mm, 2023-07-14): Resolve this xfail. https://opentrons.atlassian.net/browse/RSS-268 + ("testosaur_v2.py", True), + ("testosaur_v2_14.py", False), + # FIXME(mm, 2023-07-20): Support printing the run log when executing new protocols. + # Then, remove this expect_run_log parametrization (it should always be True). pytest.param( "testosaur_v2_14.py", + True, marks=pytest.mark.xfail(strict=True, raises=NotImplementedError), ), ], @@ -76,7 +75,7 @@ async def dummy_delay(self: Any, duration_s: float) -> None: def test_execute_function_apiv2( protocol: Protocol, protocol_file: str, - monkeypatch: pytest.MonkeyPatch, + expect_run_log: bool, virtual_smoothie_env: None, mock_get_attached_instr: mock.AsyncMock, ) -> None: @@ -110,13 +109,21 @@ def emit_runlog(entry: Any) -> None: nonlocal entries entries.append(entry) - execute.execute(protocol.filelike, protocol.filename, emit_runlog=emit_runlog) - assert [item["payload"]["text"] for item in entries if item["$"] == "before"] == [ - "Picking up tip from A1 of Opentrons 96 Tip Rack 1000 µL on 1", - "Aspirating 100.0 uL from A1 of Corning 96 Well Plate 360 µL Flat on 2 at 500.0 uL/sec", - "Dispensing 100.0 uL into B1 of Corning 96 Well Plate 360 µL Flat on 2 at 1000.0 uL/sec", - "Dropping tip into H12 of Opentrons 96 Tip Rack 1000 µL on 1", - ] + execute.execute( + protocol.filelike, + protocol.filename, + emit_runlog=(emit_runlog if expect_run_log else None), + ) + + if expect_run_log: + assert [ + item["payload"]["text"] for item in entries if item["$"] == "before" + ] == [ + "Picking up tip from A1 of Opentrons 96 Tip Rack 1000 µL on 1", + "Aspirating 100.0 uL from A1 of Corning 96 Well Plate 360 µL Flat on 2 at 500.0 uL/sec", + "Dispensing 100.0 uL into B1 of Corning 96 Well Plate 360 µL Flat on 2 at 1000.0 uL/sec", + "Dropping tip into H12 of Opentrons 96 Tip Rack 1000 µL on 1", + ] def test_execute_function_json_v3( diff --git a/api/tests/opentrons/util/test_async_helpers.py b/api/tests/opentrons/util/test_async_helpers.py new file mode 100644 index 000000000000..14f9e1a04369 --- /dev/null +++ b/api/tests/opentrons/util/test_async_helpers.py @@ -0,0 +1,126 @@ +import asyncio + +import pytest + +from opentrons.util import async_helpers as subject + + +class TestAsyncContextManagerInThread: + """Tests for `async_context_manager_in_thread()`.""" + + @staticmethod + def test_enters_and_exits() -> None: + """It should enter and exit the given context manager appropriately, and return its result.""" + + class ContextManager: + def __init__(self) -> None: + self.entered = False + self.exited = False + + async def __aenter__(self) -> str: + self.entered = True + return "Yay!" + + async def __aexit__( + self, exc_type: object, exc_val: object, exc_tb: object + ) -> None: + self.exited = True + + context_manager = ContextManager() + + assert not context_manager.entered + assert not context_manager.exited + + with subject.async_context_manager_in_thread(context_manager) as (result, _): + assert context_manager.entered + assert not context_manager.exited + assert result == "Yay!" + + assert context_manager.exited + + @staticmethod + def test_returns_matching_loop() -> None: + """It should return the event loop that the given context manager is running in.""" + + class ContextManager: + async def __aenter__(self) -> asyncio.AbstractEventLoop: + return asyncio.get_running_loop() + + async def __aexit__( + self, exc_type: object, exc_val: object, exc_tb: object + ) -> None: + pass + + context_manager = ContextManager() + with subject.async_context_manager_in_thread(context_manager) as ( + result, + loop_in_thread, + ): + assert result is loop_in_thread + + @staticmethod + def test_loop_lifetime() -> None: + """Test the lifetime of the returned event loop. + + While the context manager is open, the event loop should be running and usable. + After the context manager closes, the event loop should be closed and unusable. + """ + + class NoOp: + async def __aenter__(self) -> None: + return None + + async def __aexit__( + self, exc_type: object, exc_val: object, exc_tb: object + ) -> None: + pass + + with subject.async_context_manager_in_thread(NoOp()) as (_, loop_in_thread): + # As a smoke test to see if the event loop is running and usable, + # run an arbitrary coroutine and wait for it to finish. + ( + asyncio.run_coroutine_threadsafe( + asyncio.sleep(0.000001), loop_in_thread + ) + ).result() + + # The loop should be closed and unusable now that the context manager has exited. + assert loop_in_thread.is_closed + with pytest.raises(RuntimeError, match="Event loop is closed"): + loop_in_thread.call_soon_threadsafe(lambda: None) + + @staticmethod + def test_propagates_exception_from_enter() -> None: + """If the given context manager raises an exception when it's entered, it should propagate.""" + + class RaiseExceptionOnEnter: + async def __aenter__(self) -> None: + raise RuntimeError("Oh the humanity.") + + async def __aexit__( + self, exc_type: object, exc_val: object, exc_tb: object + ) -> None: + assert False, "We should not reach here." + + context_manager = RaiseExceptionOnEnter() + with pytest.raises(RuntimeError, match="Oh the humanity"): + with subject.async_context_manager_in_thread(context_manager): + assert False, "We should not reach here." + + @staticmethod + def test_propagates_exception_from_exit() -> None: + """If the given context manager raises an exception when it's exited, it should propagate.""" + + class RaiseExceptionOnExit: + async def __aenter__(self) -> None: + return None + + async def __aexit__( + self, exc_type: object, exc_val: object, exc_tb: object + ) -> None: + raise RuntimeError("Oh the humanity.") + + context_manager = RaiseExceptionOnExit() + with pytest.raises(RuntimeError, match="Oh the humanity"): + with subject.async_context_manager_in_thread(context_manager): + pass diff --git a/api/tests/opentrons/util/test_entrypoint_utils.py b/api/tests/opentrons/util/test_entrypoint_util.py similarity index 54% rename from api/tests/opentrons/util/test_entrypoint_utils.py rename to api/tests/opentrons/util/test_entrypoint_util.py index c30351dec3b2..4e82ce0e80ff 100644 --- a/api/tests/opentrons/util/test_entrypoint_utils.py +++ b/api/tests/opentrons/util/test_entrypoint_util.py @@ -1,13 +1,17 @@ +import io import json import os from pathlib import Path from typing import Callable +import pytest + from opentrons_shared_data.labware.dev_types import LabwareDefinition as LabwareDefDict from opentrons.util.entrypoint_util import ( FoundLabware, labware_from_paths, datafiles_from_paths, + copy_file_like, ) @@ -75,3 +79,70 @@ def test_datafiles_from_paths(tmp_path: Path) -> None: "test1": "wait theres a second file???".encode(), "test-file": "this isnt even in a directory".encode(), } + + +class TestCopyFileLike: + """Tests for `copy_file_like()`.""" + + @pytest.fixture(params=["abc", "µ"]) + def source_text(self, request: pytest.FixtureRequest) -> str: + return request.param # type: ignore[attr-defined,no-any-return] + + @pytest.fixture + def source_bytes(self, source_text: str) -> bytes: + return b"\x00\x01\x02\x03\x04" + + @pytest.fixture + def source_path(self, tmp_path: Path) -> Path: + return tmp_path / "source" + + @pytest.fixture + def destination_path(self, tmp_path: Path) -> Path: + return tmp_path / "destination" + + def test_from_text_file( + self, + source_text: str, + source_path: Path, + destination_path: Path, + ) -> None: + """Test that it correctly copies from a text-mode `open()`.""" + source_path.write_text(source_text) + + with open( + source_path, + mode="rt", + ) as source_file: + copy_file_like(source=source_file, destination=destination_path) + + assert destination_path.read_text() == source_text + + def test_from_binary_file( + self, + source_bytes: bytes, + source_path: Path, + destination_path: Path, + ) -> None: + """Test that it correctly copies from a binary-mode `open()`.""" + source_path.write_bytes(source_bytes) + + with open(source_path, mode="rb") as source_file: + copy_file_like(source=source_file, destination=destination_path) + + assert destination_path.read_bytes() == source_bytes + + def test_from_stringio(self, source_text: str, destination_path: Path) -> None: + """Test that it correctly copies from an `io.StringIO`.""" + stringio = io.StringIO(source_text) + + copy_file_like(source=stringio, destination=destination_path) + + assert destination_path.read_text() == source_text + + def test_from_bytesio(self, source_bytes: bytes, destination_path: Path) -> None: + """Test that it correctly copies from an `io.BytesIO`.""" + bytesio = io.BytesIO(source_bytes) + + copy_file_like(source=bytesio, destination=destination_path) + + assert destination_path.read_bytes() == source_bytes From 75e12568178a154b99561f29c41f7cec8b2ed85d Mon Sep 17 00:00:00 2001 From: Joe Wojak Date: Tue, 25 Jul 2023 13:42:54 -0400 Subject: [PATCH 06/25] docs(api): new_labware.rst revisions for Flex (#13096) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Update new_labware.rst Starting changes to the tutorial docs (tutorial.rst). Incorporate Flex and Flex-related stuff into the docs. * Update new_labware.rst Starting save. * Update new_labware.rst Putting labware in a table. Similar to the tutorial.rst page. * Update new_labware.rst Intro section revisions. * Update new_labware.rst End of day commit. * Update new_labware.rst * Update new_labware.rst Latest revisions. Saving and may open draft PR. * Update new_labware.rst Fixing syntax errors with links. Added link to deck_slots.rst doc. * Update new_labware.rst Final final, maybe? * Update new_labware.rst Trying to get the section symbols and headers sorted out. Also, will add text for the load_labware `adapter` parameter. * Update new_labware.rst Adding "durable" to 1st sentence: "durable or consumable". Change in last sentence from "to create" to "when creating a protocol..." * Update api/docs/v2/new_labware.rst Removing extra space before period. Co-authored-by: Ed Cormany * Update new_labware.rst Change text from "tiprack" to "tip rack" except for code. * Update api/docs/v2/new_labware.rst Co-authored-by: Ed Cormany * Update new_labware.rst * Update new_labware.rst Making changes from reviewer feedback. Starting the `adapter` section. Committing to save changes. * Update new_labware.rst Pre-meeting push. Working out labware-adapter text and organization. * Update new_labware.rst Fooling around with `:attr:` too much. Saving recent changes. * Update new_labware.rst Adding loading labware on adapters. Examples not tested, need to test. Saving. * Update new_labware.rst Spinning out here. Going to move on to the next section. * Update new_labware.rst Got a context error with my code example. Checked with Jeremy and this only has to do with me not using the latest build. Code is correct. Pushing and moving PR out of draft status. * Update api/docs/v2/new_labware.rst Will commit and resolve. Co-authored-by: Ed Cormany * Apply suggestions from code review Batch commit of suggestions. Co-authored-by: Ed Cormany * Commit with review changes Made changes from reviewer feedback. * Remove commented text Had some commented text. Removing that. * Note text and table fix Adding version 2.14 or earlier text to the top of page note. Fixed/added ... to the labware types table. * proofread pass * rework adapter § * prose and code cleanup * less redundant redundancy * labware-on-adapters anchor --------- Co-authored-by: Ed Cormany Co-authored-by: Edward Cormany --- api/docs/v2/new_labware.rst | 329 ++++++++++++++++++++---------------- 1 file changed, 183 insertions(+), 146 deletions(-) diff --git a/api/docs/v2/new_labware.rst b/api/docs/v2/new_labware.rst index b27ef91548c3..a776dc943692 100644 --- a/api/docs/v2/new_labware.rst +++ b/api/docs/v2/new_labware.rst @@ -6,51 +6,123 @@ Labware ####### +Labware are the durable or consumable items that you work with, reuse, or discard while running a protocol on a Flex or OT-2. Items such as pipette tips, well plates, tubes, and reservoirs are all examples of labware. This section provides a brief overview of default labware, custom labware, and how to use basic labware API methods when creating a protocol for your robot. -When writing a protocol, you must inform the Protocol API about the labware you will be placing on the OT-2's deck. +.. note:: + + Code snippets use coordinate deck slot locations (e.g. ``'D1'``, ``'D2'``), like those found on Flex. If you have an OT-2 and are using API version 2.14 or earlier, replace the coordinate with its numeric OT-2 equivalent. For example, slot D1 on Flex corresponds to slot 1 on an OT-2. See :ref:`deck-slots` for more information. + +************* +Labware Types +************* + +Default Labware +=============== + +Default labware is everything listed in the `Opentrons Labware Library `_. When used in a protocol, your Flex or OT-2 knows how to work with default labware. However, you must first inform the API about the labware you will place on the robot’s deck. Search the library when you’re looking for the API load names of the labware you want to use. You can copy the load names from the library and pass them to the :py:meth:`~.ProtocolContext.load_labware` method in your protocol. + +Custom Labware +============== + +Custom labware is labware that is not listed the Labware Library. If your protocol needs something that's not in the library, you can create it with the `Opentrons Labware Creator `_. However, before using the Labware Creator, you should take a moment to review the support article `Creating Custom Labware Definitions `_. + +After you've created your labware, save it as a ``.json`` file and add it to the Opentrons App. See `Using Labware in Your Protocols `_ for instructions. + +If other people need to use your custom labware definition, they must also add it to their Opentrons App. + +*************** +Loading Labware +*************** + +Throughout this section, we'll use the labware listed in the following table. -When you load labware, you specify the name of the labware (e.g. ``corning_96_wellplate_360ul_flat``), and the slot on the OT-2's deck in which it will be placed (e.g. ``2``). The first place to look for the names of labware should always be the `Opentrons Labware Library `_, where Opentrons maintains a database of labware, their names in the API, what they look like, manufacturer part numbers, and more. In this example, we’ll use ``'corning_96_wellplate_360ul_flat'`` (`an ANSI standard 96-well plate `_) and ``'opentrons_96_tiprack_300ul'`` (`the Opentrons standard 300 µL tiprack `_). +.. list-table:: + :widths: 20 40 45 + :header-rows: 1 -In the example given in the :ref:`overview-section-v2` section, we loaded labware like this: + * - Labware type + - Labware name + - API load name + * - Well plate + - `Corning 96 Well Plate 360 µL Flat `_ + - ``corning_96_wellplate_360ul_flat`` + * - Flex tip rack + - `Opentrons Flex 96 Tips 200 µL `_ + - ``opentrons_flex_96_tiprack_200ul`` + * - OT-2 tip rack + - `Opentrons 96 Tip Rack 300 µL `_ + - ``opentrons_96_tiprack_300ul`` + +Similar to the code sample in :ref:`overview-section-v2`, here's how you use the :py:meth:`.ProtocolContext.load_labware` method to load labware on either Flex or OT-2. .. code-block:: python - plate = protocol.load_labware('corning_96_wellplate_360ul_flat', '2') + #Flex + tiprack = protocol.load_labware('opentrons_flex_96_tiprack_200ul', 'D1') + plate = protocol.load_labware('corning_96_wellplate_360ul_flat', 'D2') + +.. code-block:: python + + #OT-2 tiprack = protocol.load_labware('opentrons_96_tiprack_300ul', '1') + plate = protocol.load_labware('corning_96_wellplate_360ul_flat', '2') + +.. versionadded:: 2.0 +When the ``load_labware`` method loads labware into your protocol, it returns a :py:class:`~opentrons.protocol_api.labware.Labware` object. -which informed the protocol context that the deck contains a 300 µL tiprack in slot 1 and a 96 well plate in slot 2. +.. _labware-label: -A third optional argument can be used to give the labware a nickname to be displayed in the Opentrons App. +.. Tip:: + + The ``load_labware`` method includes an optional ``label`` argument. You can use it to identify labware with a descriptive name. If used, the label value is displayed in the Opentrons App. For example:: + + tiprack = protocol.load_labware( + load_name='corning_96_wellplate_360ul_flat', + location='D1', + label='any-name-you-want') -.. code-block:: python +.. _labware-on-adapters: - plate = protocol.load_labware('corning_96_wellplate_360ul_flat', - location='2', - label='any-name-you-want') +Loading Labware on Adapters +=========================== -Labware is loaded into a protocol using :py:meth:`.ProtocolContext.load_labware`, which returns -:py:class:`opentrons.protocol_api.labware.Labware` object. +The previous section demonstrates loading labware directly into a deck slot. But you can also load labware on top of an adapter that either fits on a module or goes directly on the deck. The ability to combine labware with adapters adds functionality and flexibility to your robot and protocols. -*************** -Finding Labware -*************** +You can either load the adapter first and the labware second, or load both the adapter and labware all at once. -Default Labware -^^^^^^^^^^^^^^^ +Loading Separately +------------------ -The OT-2 has a set of labware well-supported by Opentrons defined internally. This set of labware is always available to protocols. This labware can be found on the `Opentrons Labware Library `_. You can copy the load names that should be passed to ``protocol.load_labware`` statements to get the correct definitions. +The ``load_adapter()`` method is available on ``ProtocolContext`` and module contexts. It behaves similarly to ``load_labware()``, requiring the load name and location for the desired adapter. Load a module, adapter, and labware with separate calls to specify each layer of the physical stack of components individually:: + hs_mod = protocol.load_module('heaterShakerModuleV1', 'D1') + hs_adapter = hs_mod.load_adapter('opentrons_96_flat_bottom_adapter') + hs_plate = hs_mod.load_labware('nest_96_wellplate_200ul_flat') + +.. versionadded:: 2.15 + The ``load_adapter()`` method. -.. _v2-custom-labware: +Loading Together +---------------- -Custom Labware -^^^^^^^^^^^^^^ +Use the ``adapter`` argument of ``load_labware()`` to load an adapter at the same time as labware. For example, to load the same 96-well plate and adapter from the previous section at once:: + + hs_plate = hs_mod.load_labware( + load_name='nest_96_wellplate_200ul_flat', + location='D1', + adapter='opentrons_96_flat_bottom_adapter') + +.. versionadded:: 2.15 + The ``adapter`` parameter. -If you have a piece of labware that is not in the Labware Library, you can create your own definition using the `Opentrons Labware Creator `_. Before using the Labware Creator, you should read the introduction article `here `__. +The API also has some "combination" labware definitions, which treat the adapter and labware as a unit:: -Once you have created your labware and saved it as a ``.json`` file, you can add it to the Opentrons App by clicking "More" and then "Labware". Once you have added your labware to the Opentrons App, it will be available to all Python Protocol API version 2 protocols uploaded to your robot through that Opentrons App. If other people will be using this custom labware definition, they must also add it to their Opentrons App. You can find a support article about this custom labware process `here `__. + hs_combo = hs_mod.load_labware( + 'opentrons_96_flat_bottom_adapter_nest_wellplate_200ul_flat' + ) +Loading labware this way prevents you from :ref:`moving the labware ` onto or off of the adapter, so it's less flexible than loading the two separately. Avoid using combination definitions unless your protocol specifies an ``apiLevel`` of 2.14 or lower. .. _new-well-access: @@ -59,81 +131,76 @@ Accessing Wells in Labware ************************** Well Ordering -^^^^^^^^^^^^^ +============= -When writing a protocol, you will need to select which wells to -transfer liquids to and from. +You need to select which wells to transfer liquids to and from over the course of a protocol. -Rows of wells (see image below) on a labware are typically labeled with capital letters starting with ``'A'``; -for instance, an 8x12 96 well plate will have rows ``'A'`` through ``'H'``. +Rows of wells on a labware have labels that are capital letters starting with A. For instance, an 96-well plate has 8 rows, labeled ``'A'`` through ``'H'``. -Columns of wells (see image below) on a labware are typically labeled with numerical indices starting with ``'1'``; -for instance, an 8x12 96 well plate will have columns ``'1'`` through ``'12'``. +Columns of wells on a labware have labels that are numbers starting with 1. For instance, a 96-well plate has columns ``'1'`` through ``'12'``. -For all well accessing functions, the starting well will always be at the top left corner of the labware. -The ending well will be in the bottom right, see the diagram below for further explanation. +All well-accessing functions start with the well at the top left corner of the labware. The ending well is in the bottom right. The order of travel from top left to bottom right depends on which function you use. .. image:: ../img/well_iteration/Well_Iteration.png -.. code-block:: python - :substitutions: - - ''' - Examples in this section expect the following - ''' - metadata = {'apiLevel': '|apiLevel|'} - - def run(protocol): +The code in this section assumes that ``plate`` is a 24-well plate. For example: - plate = protocol.load_labware('corning_24_wellplate_3.4ml_flat', location='1') - - -.. versionadded:: 2.0 +.. code-block:: python + plate = protocol.load_labware('corning_24_wellplate_3.4ml_flat', location='D1') Accessor Methods -^^^^^^^^^^^^^^^^ - -There are many different ways to access wells inside labware. Different methods are useful in different contexts. The table below lists out the methods available to access wells and their differences. - -+-------------------------------------+-------------------------------------------------------------------------------------------------------------------+ -| Method Name | Returns | -+=====================================+===================================================================================================================+ -| :py:meth:`.Labware.wells` | List of all wells, i.e. ``[labware:A1, labware:B1, labware:C1...]`` | -+-------------------------------------+-------------------------------------------------------------------------------------------------------------------+ -| :py:meth:`.Labware.rows` | List of a list ordered by row, i.e ``[[labware:A1, labware:A2...], [labware:B1, labware:B2..]]`` | -+-------------------------------------+-------------------------------------------------------------------------------------------------------------------+ -| :py:meth:`.Labware.columns` | List of a list ordered by column, i.e. ``[[labware:A1, labware:B1..], [labware:A2, labware:B2..]]`` | -+-------------------------------------+-------------------------------------------------------------------------------------------------------------------+ -| :py:meth:`.Labware.wells_by_name` | Dictionary with well names as keys, i.e. ``{'A1': labware:A1, 'B1': labware:B1}`` | -+-------------------------------------+-------------------------------------------------------------------------------------------------------------------+ -| :py:meth:`.Labware.rows_by_name` | Dictionary with row names as keys, i.e. ``{'A': [labware:A1, labware:A2..], 'B': [labware:B1, labware:B2]}`` | -+-------------------------------------+-------------------------------------------------------------------------------------------------------------------+ -| :py:meth:`.Labware.columns_by_name` | Dictionary with column names as keys, i.e. ``{'1': [labware:A1, labware:B1..], '2': [labware:A2, labware:B2..]}`` | -+-------------------------------------+-------------------------------------------------------------------------------------------------------------------+ +================ + +The API provides many different ways to access wells inside labware. Different methods are useful in different contexts. The table below lists out the methods available to access wells and their differences. + +.. list-table:: + :widths: 20 30 50 + :header-rows: 1 + + * - Method + - Returns + - Example + * - :py:meth:`.Labware.wells` + - List of all wells. + - ``[labware:A1, labware:B1, labware:C1...]`` + * - :py:meth:`.Labware.rows` + - List of lists grouped by row. + - ``[[labware:A1, labware:A2...], [labware:B1, labware:B2...]]`` + * - :py:meth:`.Labware.columns` + - List of lists grouped by column. + - ``[[labware:A1, labware:B1...], [labware:A2, labware:B2...]]`` + * - :py:meth:`.Labware.wells_by_name` + - Dictionary with well names as keys. + - ``{'A1': labware:A1, 'B1': labware:B1}`` + * - :py:meth:`.Labware.rows_by_name` + - Dictionary with row names as keys. + - ``{'A': [labware:A1, labware:A2...], 'B': [labware:B1, labware:B2...]}`` + * - :py:meth:`.Labware.columns_by_name` + - Dictionary with column names as keys. + - ``{'1': [labware:A1, labware:B1...], '2': [labware:A2, labware:B2...]}`` Accessing Individual Wells -^^^^^^^^^^^^^^^^^^^^^^^^^^ +========================== Dictionary Access ----------------- -Once a labware is loaded into your protocol, you can easily access the many -wells within it by using dictionary indexing. If a well does not exist in this labware, -you will receive a ``KeyError``. This is equivalent to using the return value of -:py:meth:`.Labware.wells_by_name`: +The simplest way to refer to a single well is by its name, like A1 or D6. :py:meth:`.Labware.wells_by_name` accomplishes this. This is such a common task that the API also has an equivalent shortcut: dictionary indexing. .. code-block:: python - a1 = plate['A1'] - d6 = plate.wells_by_name()['D6'] + a1 = plate.wells_by_name()['A1'] + d6 = plate['D6'] # dictionary indexing + +If a well does not exist in the labware, such as ``plate['H12']`` on a 24-well plate, the API will raise a ``KeyError``. In contrast, it would be a valid reference on a standard 96-well plate. .. versionadded:: 2.0 List Access From ``wells`` -------------------------- -Wells can be referenced by their name, as demonstrated above. However, they can also be referenced with zero-indexing, with the first well in a labware being at position 0. +In addition to referencing wells by name, you can also reference them with zero-indexing. The first well in a labware is at position 0. .. code-block:: python @@ -142,59 +209,39 @@ Wells can be referenced by their name, as demonstrated above. However, they can .. tip:: - You may find well names (e.g. ``"B3"``) to be easier to reason with, - especially with irregular labware (e.g. - ``opentrons_10_tuberack_falcon_4x50ml_6x15ml_conical`` - (`Labware Library `_). - Whichever well access method you use, your protocol will be most maintainable if you use only one access method consistently. + You may find coordinate well names like ``"B3"`` easier to reason with, especially when working with irregular labware, e.g. + ``opentrons_10_tuberack_falcon_4x50ml_6x15ml_conical`` (see the `Opentrons 10 Tube Rack `_ in the Labware Library). Whichever well access method you use, your protocol will be most maintainable if you use only one access method consistently. .. versionadded:: 2.0 Accessing Groups of Wells -^^^^^^^^^^^^^^^^^^^^^^^^^ +========================= -When describing a liquid transfer, you can point to groups of wells for the -liquid's source and/or destination. Or, you can get a group of wells and loop -(or iterate) through them. +When handling liquid, you can provide a group of wells as the source or destination. Alternatively, you can take a group of wells and loop (or iterate) through them, with each liquid-handling command inside the loop accessing the loop index. -You can access a specific row or column of wells by using the -:py:meth:`.Labware.rows_by_name` and :py:meth:`.Labware.columns_by_name` methods -on a labware. These methods both return a dictionary with the row or column name as the keys: +Use :py:meth:`.Labware.rows_by_name` to access a specific row of wells or :py:meth:`.Labware.columns_by_name` to access a specific column of wells on a labware. These methods both return a dictionary with the row or column name as the keys: .. code-block:: python row_dict = plate.rows_by_name()['A'] - row_list = plate.rows()[0] # equivalent to the line above + row_list = plate.rows()[0] # equivalent to the line above column_dict = plate.columns_by_name()['1'] - column_list = plate.columns()[0] # equivalent to the line above + column_list = plate.columns()[0] # equivalent to the line above - print('Column "1" has', len(column_dict), 'wells') - print('Row "A" has', len(row_dict), 'wells') - -will print out... - -.. code-block:: python - - Column "1" has 4 wells - Row "A" has 6 wells + print('Column "1" has', len(column_dict), 'wells') # Column "1" has 4 wells + print('Row "A" has', len(row_dict), 'wells') # Row "A" has 6 wells Since these methods return either lists or dictionaries, you can iterate through them as you would regular Python data structures. -For example, to access the individual wells of row ``'A'`` in a well plate, you can do: - -.. code-block:: python +For example, to transfer 50 µL of liquid from the first well of a reservoir to each of the wells of row ``'A'`` on a plate:: for well in plate.rows()[0]: - print(well) + pipette.transfer(reservoir['A1'], well, 50) -or, +Equivalently, using ``rows_by_name``:: -.. code-block:: python - - for well_obj in plate.rows_by_name()['A'].values(): - print(well_obj) - -and it will return the individual well objects in row A. + for well in plate.rows_by_name()['A'].values(): + pipette.transfer(reservoir['A1'], well, 50) .. versionadded:: 2.0 @@ -202,14 +249,17 @@ and it will return the individual well objects in row A. Labeling Liquids in Wells ************************* -Optionally, you can specify the liquids that should be in various wells at the beginning of your protocol. Doing so helps you identify well contents by name and volume, and adds corresponding labels to a single well, or group of wells, in well plates and reservoirs. Viewing the initial liquid setup in a Python protocol is available in the Opentrons App v6.3.0 or higher. +Optionally, you can specify the liquids that should be in various wells at the beginning of your protocol. Doing so helps you identify well contents by name and volume, and adds corresponding labels to a single well, or group of wells, in well plates and reservoirs. You can view the initial liquid setup: -To use these optional methods, first create a liquid object with :py:meth:`.ProtocolContext.define_liquid` and then label individual wells by calling :py:meth:`.Well.load_liquid`, both within the ``run()`` function of your Python protocol. +- For Flex protocols, on the touchscreen. +- For Flex or OT-2 protocols, in the Opentrons App (v6.3.0 or higher). + +To use these optional methods, first create a liquid object with :py:meth:`.ProtocolContext.define_liquid` and then label individual wells by calling :py:meth:`.Well.load_liquid`. Let's examine how these two methods work. The following examples demonstrate how to define colored water samples for a well plate and reservoir. Defining Liquids -^^^^^^^^^^^^^^^^ +================ This example uses ``define_liquid`` to create two liquid objects and instantiates them with the variables ``greenWater`` and ``blueWater``, respectively. The arguments for ``define_liquid`` are all required, and let you name the liquid, describe it, and assign it a color: @@ -231,28 +281,28 @@ This example uses ``define_liquid`` to create two liquid objects and instantiate The ``display_color`` parameter accepts a hex color code, which adds a color to that liquid's label when you import your protocol into the Opentrons App. The ``define_liquid`` method accepts standard 3-, 4-, 6-, and 8-character hex color codes. Labeling Wells and Reservoirs -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +============================= This example uses ``load_liquid`` to label the initial well location, contents, and volume (in µL) for the liquid objects created by ``define_liquid``. Notice how values of the ``liquid`` argument use the variable names ``greenWater`` and ``blueWater`` (defined above) to associate each well with a particular liquid: .. code-block:: python well_plate["A1"].load_liquid(liquid=greenWater, volume=50) - well_plate["A2"].load_liquid(greenWater, volume=50) - well_plate["B1"].load_liquid(blueWater, volume=50) - well_plate["B2"].load_liquid(blueWater, volume=50) - reservoir["A1"].load_liquid(greenWater, volume=200) - reservoir["A2"].load_liquid(blueWater, volume=200) + well_plate["A2"].load_liquid(liquid=greenWater, volume=50) + well_plate["B1"].load_liquid(liquid=blueWater, volume=50) + well_plate["B2"].load_liquid(liquid=blueWater, volume=50) + reservoir["A1"].load_liquid(liquid=greenWater, volume=200) + reservoir["A2"].load_liquid(liquid=blueWater, volume=200) .. versionadded:: 2.14 -This information shows up in the Opentrons App (v6.3.0 or higher) after you import your protocol. A summary of liquids is available on the protocol detail page, and well-by-well detail is available in the Initial Liquid Setup section of the run setup page. +This information is available after you import your protocol to the app or send it to Flex. A summary of liquids appears on the protocol detail page, and well-by-well detail is available on the run setup page (under Initial Liquid Setup in the app, or under Liquids on Flex). .. note:: - ``load_liquid`` does not validate volume for your labware nor does it prevent you from adding multiple liquids to each well. For example, you could label a 40 µL well plate with ``greenWater``, ``volume=50``, and then also add blue water to the well. The API won't stop you. It's your responsibility to ensure the labels you use accurately reflect the amounts and types of liquid you plan to place into wells and reservoirs. + ``load_liquid`` does not validate volume for your labware nor does it prevent you from adding multiple liquids to each well. For example, you could label a 40 µL well with ``greenWater``, ``volume=50``, and then also add blue water to the well. The API won't stop you. It's your responsibility to ensure the labels you use accurately reflect the amounts and types of liquid you plan to place into wells and reservoirs. Labeling vs Handling Liquids -^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +============================ The ``load_liquid`` arguments include a volume amount (``volume=n`` in µL). This amount is just a label. It isn't a command or function that manipulates liquids. It only tells you how much liquid should be in a well at the start of the protocol. You need to use a method like :py:meth:`.transfer` to physically move liquids from a source to a destination. @@ -267,63 +317,50 @@ Well Dimensions The functions in the :ref:`new-well-access` section above return a single :py:class:`.Well` object or a larger object representing many wells. :py:class:`.Well` objects have attributes that provide information about their physical shape, such as the depth or diameter, as specified in their corresponding labware definition. These properties can be used for different applications, such as calculating the volume of a well or a :ref:`position-relative-labware`. Depth -^^^^^ +===== Use :py:attr:`.Well.depth` to get the distance in mm between the very top of the well and the very bottom. For example, a conical well's depth is measured from the top center to the bottom center of the well. .. code-block:: python :substitutions: - metadata = {'apiLevel': '|apiLevel|'} - - def run(protocol): - plate = protocol.load_labware('corning_96_wellplate_360ul_flat', '1') - depth = plate['A1'].depth # 10.67 + plate = protocol.load_labware('corning_96_wellplate_360ul_flat', 'D1') + depth = plate['A1'].depth # 10.67 Diameter -^^^^^^^^ +======== + Use :py:attr:`.Well.diameter` to get the diameter of a given well in mm. Since diameter is a circular measurement, this attribute is only present on labware with circular wells. If the well is not circular, the value will be ``None``. Use length and width (see below) for non-circular wells. .. code-block:: python :substitutions: - metadata = {'apiLevel': '|apiLevel|'} - - def run(protocol): - plate = protocol.load_labware('corning_96_wellplate_360ul_flat', '1') - diameter = plate['A1'].diameter # 6.86 - + plate = protocol.load_labware('corning_96_wellplate_360ul_flat', 'D1') + diameter = plate['A1'].diameter # 6.86 Length -^^^^^^ +====== + Use :py:attr:`.Well.length` to get the length of a given well in mm. Length is defined as the distance along the robot's x-axis (left to right). This attribute is only present on rectangular wells. If the well is not rectangular, the value will be ``None``. Use diameter (see above) for circular wells. .. code-block:: python :substitutions: - metadata = {'apiLevel': '|apiLevel|'} - - def run(protocol): - plate = protocol.load_labware('nest_12_reservoir_15ml', '1') - length = plate['A1'].length # 8.2 + plate = protocol.load_labware('nest_12_reservoir_15ml', 'D1') + length = plate['A1'].length # 8.2 Width -^^^^^ +===== + Use :py:attr:`.Well.width` to get the width of a given well in mm. Width is defined as the distance along the y-axis (front to back). This attribute is only present on rectangular wells. If the well is not rectangular, the value will be ``None``. Use diameter (see above) for circular wells. .. code-block:: python :substitutions: - metadata = {'apiLevel': '|apiLevel|'} - - def run(protocol): - plate = protocol.load_labware('nest_12_reservoir_15ml', '1') - width = plate['A1'].width # 71.2 - - + plate = protocol.load_labware('nest_12_reservoir_15ml', 'D1') + width = plate['A1'].width # 71.2 .. versionadded:: 2.9 - From 8d6e3911d8e30a13c0a3bb94c53ac508c05acd9c Mon Sep 17 00:00:00 2001 From: Seth Foster Date: Wed, 26 Jul 2023 09:08:35 -0400 Subject: [PATCH 07/25] chore: parse tags in utf8 explicitly in python (#13163) When we parse tags, we didn't explicitly set a codec for parsing the bytes. This is fine in most cases because the locale is set for utf-8, but in the docker container that builds buildroot the locale is C and that means ascii and that means that any tags that have utf-8 characters cause builds to fail. --- scripts/python_build_utils.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/scripts/python_build_utils.py b/scripts/python_build_utils.py index 752f0741e345..486c2c2360e3 100644 --- a/scripts/python_build_utils.py +++ b/scripts/python_build_utils.py @@ -80,9 +80,9 @@ def _latest_tag_for_prefix(prefix, git_dir): 'Could not find tag in {check_dir} matching {prefix} '.format( check_dir=check_dir, prefix=prefix) + '- build before release or no tags. Using 0.0.0-dev\n') - tags_result = prefix.encode() + b'0.0.0-dev' + tags_result = prefix.encode('utf-8') + b'0.0.0-dev' tags_matching = tags_result.strip().split(b'\n') - return tags_matching[-1].decode() + return tags_matching[-1].decode('utf-8') def _latest_version_for_project(project, git_dir): prefix = project_entries[project].tag_prefix @@ -95,11 +95,11 @@ def _ref_from_sha(sha): # it exists. Then all the tag and head refs branch_name = subprocess.check_output( ['git', 'rev-parse', '--symbolic-full-name', '--verify', '--quiet', 'HEAD'], - cwd=CWD).strip().decode().split('\n') + cwd=CWD).strip().decode('utf-8').split('\n') allrefs = subprocess.check_output( ['git', 'show-ref', '--tags', '--heads'], - cwd=CWD).strip().decode().split('\n') + cwd=CWD).strip().decode('utf-8').split('\n') # Keep... matching = [ this_ref for this_sha, this_ref in # the refs @@ -137,7 +137,7 @@ def dump_br_version(package, project, extra_tag='', git_dir=None): """ normalized = get_version(package, project, extra_tag, git_dir) sha = subprocess.check_output( - ['git', 'rev-parse', 'HEAD'], cwd=CWD).strip().decode() + ['git', 'rev-parse', 'HEAD'], cwd=CWD).strip().decode('utf-8') branch = _ref_from_sha(sha) pref = package_entries[package].br_version_prefix return json.dumps({pref+'_version': normalized, From 44f938bc710186b0b4e42a8dbd868bf5b6c0e583 Mon Sep 17 00:00:00 2001 From: Andy Sigler Date: Wed, 26 Jul 2023 20:38:42 -0400 Subject: [PATCH 08/25] fix(hardware-testing): Updates gripper assembly QC script to work for PVT build (#13170) --- .../hardware_control/ot3_calibration.py | 1 - .../gripper_assembly_qc_ot3/__main__.py | 1 + .../gripper_assembly_qc_ot3/test_force.py | 51 ++++--- .../gripper_assembly_qc_ot3/test_mount.py | 95 +++++++------ .../gripper_assembly_qc_ot3/test_probe.py | 127 +++++++++++------- .../gripper_assembly_qc_ot3/test_width.py | 74 ++++++---- 6 files changed, 209 insertions(+), 140 deletions(-) diff --git a/api/src/opentrons/hardware_control/ot3_calibration.py b/api/src/opentrons/hardware_control/ot3_calibration.py index cfd87f0b0329..9c7a140a304a 100644 --- a/api/src/opentrons/hardware_control/ot3_calibration.py +++ b/api/src/opentrons/hardware_control/ot3_calibration.py @@ -737,7 +737,6 @@ async def calibrate_gripper_jaw( return offset finally: hcapi.remove_gripper_probe() - await hcapi.ungrip() async def calibrate_gripper( diff --git a/hardware-testing/hardware_testing/production_qc/gripper_assembly_qc_ot3/__main__.py b/hardware-testing/hardware_testing/production_qc/gripper_assembly_qc_ot3/__main__.py index 6be123ca7772..7f4a571fea5e 100644 --- a/hardware-testing/hardware_testing/production_qc/gripper_assembly_qc_ot3/__main__.py +++ b/hardware-testing/hardware_testing/production_qc/gripper_assembly_qc_ot3/__main__.py @@ -19,6 +19,7 @@ async def _main(cfg: TestConfig) -> None: pipette_right="p1000_single_v3.3", gripper="GRPV1120230323A01", ) + await api.home_z(OT3Mount.GRIPPER) await api.home() home_pos = await api.gantry_position(OT3Mount.GRIPPER) if not api.has_gripper(): diff --git a/hardware-testing/hardware_testing/production_qc/gripper_assembly_qc_ot3/test_force.py b/hardware-testing/hardware_testing/production_qc/gripper_assembly_qc_ot3/test_force.py index 03d3c89c3499..b8f491ab8179 100644 --- a/hardware-testing/hardware_testing/production_qc/gripper_assembly_qc_ot3/test_force.py +++ b/hardware-testing/hardware_testing/production_qc/gripper_assembly_qc_ot3/test_force.py @@ -19,23 +19,21 @@ from hardware_testing.opentrons_api.types import Axis, OT3Mount, Point -SLOT_FORCE_GAUGE = 6 +SLOT_FORCE_GAUGE = 4 GRIP_DUTY_CYCLES: List[int] = [40, 30, 25, 20, 15, 10, 6] NUM_DUTY_CYCLE_TRIALS = 20 GRIP_FORCES_NEWTON: List[int] = [20, 15, 10, 5] -NUM_NEWTONS_TRIALS = 5 -FAILURE_THRESHOLD_PERCENTAGE = ( - 10 # TODO: wait for PVT to decide on if this should be tightened or loosened -) +NUM_NEWTONS_TRIALS = 1 +FAILURE_THRESHOLD_PERCENTAGES = [10, 10, 10, 20] WARMUP_SECONDS = 10 FORCE_GAUGE_TRIAL_SAMPLE_INTERVAL = 0.25 # seconds FORCE_GAUGE_TRIAL_SAMPLE_COUNT = 20 # 20 samples = 5 seconds @ 4Hz -GAUGE_OFFSET = Point(x=90, y=37.5, z=75) +GAUGE_OFFSET = Point(x=2, y=42, z=75) def _get_test_tag( @@ -79,14 +77,18 @@ def build_csv_lines() -> List[Union[CSVLine, CSVLineRepeating]]: for trial in range(NUM_NEWTONS_TRIALS): tag = _get_test_tag(trial, newtons=force) force_data_types = [float] * FORCE_GAUGE_TRIAL_SAMPLE_COUNT - data_row = [int] + force_data_types + [CSVResult] # type: ignore[operator,list-item] - lines.append(CSVLine(tag, data_row)) + lines.append(CSVLine(f"{tag}-data", force_data_types)) + lines.append(CSVLine(f"{tag}-average", [float])) + lines.append(CSVLine(f"{tag}-target", [float])) + lines.append(CSVLine(f"{tag}-pass-%", [float])) + lines.append(CSVLine(f"{tag}-result", [CSVResult])) for duty_cycle in GRIP_DUTY_CYCLES: for trial in range(NUM_DUTY_CYCLE_TRIALS): tag = _get_test_tag(trial, duty_cycle=duty_cycle) force_data_types = [float] * FORCE_GAUGE_TRIAL_SAMPLE_COUNT - data_row = [int] + force_data_types # type: ignore[operator] - lines.append(CSVLine(tag, data_row)) + lines.append(CSVLine(f"{tag}-data", force_data_types)) + lines.append(CSVLine(f"{tag}-average", [float])) + lines.append(CSVLine(f"{tag}-duty-cycle", [float])) return lines @@ -145,8 +147,8 @@ async def _setup(api: OT3API) -> Union[Mark10, SimMark10]: await api.home([z_ax, g_ax]) # MOVE TO GAUGE await api.ungrip() - hover_pos, target_pos = _get_force_gauge_hover_and_grip_positions(api) - await helpers_ot3.move_to_arched_ot3(api, mount, target_pos + Point(z=30)) + _, target_pos = _get_force_gauge_hover_and_grip_positions(api) + await helpers_ot3.move_to_arched_ot3(api, mount, target_pos + Point(z=15)) if not api.is_simulator: ui.get_user_ready("please make sure the gauge in the middle of the gripper") await api.move_to(mount, target_pos) @@ -175,9 +177,12 @@ async def run_increment(api: OT3API, report: CSVReport, section: str) -> None: ) actual_forces = await _grip_and_read_forces(api, gauge, duty=duty_cycle) print(actual_forces) + avg_force = sum(actual_forces) / len(actual_forces) + print(f"average = {round(avg_force, 2)} N") tag = _get_test_tag(trial, duty_cycle=duty_cycle) - report_data = [duty_cycle] + actual_forces # type: ignore[operator] - report(section, tag, report_data) + report(section, f"{tag}-data", actual_forces) + report(section, f"{tag}-average", [avg_force]) + report(section, f"{tag}-duty-cycle", [duty_cycle]) print("done") await api.retract(OT3Mount.GRIPPER) @@ -189,7 +194,9 @@ async def run(api: OT3API, report: CSVReport, section: str) -> None: # LOOP THROUGH FORCES ui.print_header("MEASURE NEWTONS") - for expected_force in GRIP_FORCES_NEWTON: + for expected_force, allowed_percent_error in zip( + GRIP_FORCES_NEWTON, FAILURE_THRESHOLD_PERCENTAGES + ): for trial in range(NUM_NEWTONS_TRIALS): print(f"{expected_force}N - trial {trial + 1}/{NUM_NEWTONS_TRIALS}") actual_forces = await _grip_and_read_forces( @@ -198,16 +205,16 @@ async def run(api: OT3API, report: CSVReport, section: str) -> None: print(actual_forces) # base PASS/FAIL on average avg_force = sum(actual_forces) / len(actual_forces) + print(f"average = {round(avg_force, 2)} N") error = (avg_force - expected_force) / expected_force - result = CSVResult.from_bool( - abs(error) * 100 < FAILURE_THRESHOLD_PERCENTAGE - ) + result = CSVResult.from_bool(abs(error) * 100 < allowed_percent_error) # store all data in CSV tag = _get_test_tag(trial, newtons=expected_force) - report_data = ( - [expected_force] + actual_forces + [result] # type: ignore[operator,list-item] - ) - report(section, tag, report_data) + report(section, f"{tag}-data", actual_forces) + report(section, f"{tag}-average", [avg_force]) + report(section, f"{tag}-target", [expected_force]) + report(section, f"{tag}-pass-%", [allowed_percent_error]) + report(section, f"{tag}-result", [result]) print("done") await api.retract(OT3Mount.GRIPPER) diff --git a/hardware-testing/hardware_testing/production_qc/gripper_assembly_qc_ot3/test_mount.py b/hardware-testing/hardware_testing/production_qc/gripper_assembly_qc_ot3/test_mount.py index 88c919eacae6..7d0855e54b45 100644 --- a/hardware-testing/hardware_testing/production_qc/gripper_assembly_qc_ot3/test_mount.py +++ b/hardware-testing/hardware_testing/production_qc/gripper_assembly_qc_ot3/test_mount.py @@ -2,6 +2,10 @@ from typing import List, Union, Tuple, Dict from opentrons.hardware_control.ot3api import OT3API +from opentrons.config.defaults_ot3 import ( + DEFAULT_MAX_SPEEDS, + DEFAULT_RUN_CURRENT, +) from hardware_testing.data import ui from hardware_testing.data.csv_report import ( @@ -11,22 +15,21 @@ CSVLineRepeating, ) from hardware_testing.opentrons_api import helpers_ot3 -from hardware_testing.opentrons_api.types import Axis, OT3Mount, Point +from hardware_testing.opentrons_api.types import Axis, OT3Mount, Point, OT3AxisKind SLOT_MOUNT_TEST = 5 -RETRACT_AFTER_HOME_MM = 0.5 Z_AXIS_TRAVEL_DISTANCE = 150.0 -Z_MAX_SKIP_MM = 0.25 +Z_MAX_SKIP_MM = 0.1 -SPEEDS_TO_TEST = [25.0, 50.0, 100.0, 200.0] -MIN_PASS_CURRENT = 0.5 +DEFAULT_SPEED = DEFAULT_MAX_SPEEDS.low_throughput[OT3AxisKind.Z_G] +DEFAULT_CURRENT = DEFAULT_RUN_CURRENT.low_throughput[OT3AxisKind.Z_G] +SPEEDS_TO_TEST = [DEFAULT_SPEED] +MIN_PASS_CURRENT = round(DEFAULT_CURRENT * 0.6, 1) # 0.67 * 0.6 = ~0.4 CURRENTS_SPEEDS: Dict[float, List[float]] = { - 0.2: SPEEDS_TO_TEST, - 0.3: SPEEDS_TO_TEST, - 0.4: SPEEDS_TO_TEST, - 0.5: SPEEDS_TO_TEST, - 0.6: SPEEDS_TO_TEST, - 0.67: SPEEDS_TO_TEST, + round(MIN_PASS_CURRENT - 0.2, 1): SPEEDS_TO_TEST, + round(MIN_PASS_CURRENT - 0.1, 1): SPEEDS_TO_TEST, + MIN_PASS_CURRENT: SPEEDS_TO_TEST, + DEFAULT_CURRENT: SPEEDS_TO_TEST, } @@ -54,14 +57,12 @@ def build_csv_lines() -> List[Union[CSVLine, CSVLineRepeating]]: async def _is_z_axis_still_aligned_with_encoder( - api: OT3API, -) -> Tuple[float, float, bool]: + api: OT3API, target_z: float +) -> Tuple[float, bool]: enc_pos = await api.encoder_current_position_ot3(OT3Mount.GRIPPER) - gantry_pos = await api.gantry_position(OT3Mount.GRIPPER) z_enc = enc_pos[Axis.Z_G] - z_est = gantry_pos.z - is_aligned = abs(z_est - z_enc) < Z_MAX_SKIP_MM - return z_enc, z_est, is_aligned + is_aligned = abs(target_z - z_enc) < Z_MAX_SKIP_MM + return z_enc, is_aligned async def run(api: OT3API, report: CSVReport, section: str) -> None: @@ -78,14 +79,14 @@ async def run(api: OT3API, report: CSVReport, section: str) -> None: target_pos = target_pos._replace(z=home_pos.z) await helpers_ot3.move_to_arched_ot3(api, OT3Mount.GRIPPER, target_pos) - async def _save_result(tag: str, include_pass_fail: bool) -> bool: - z_est, z_enc, z_aligned = await _is_z_axis_still_aligned_with_encoder(api) + async def _save_result(tag: str, target_z: float, include_pass_fail: bool) -> bool: + z_enc, z_aligned = await _is_z_axis_still_aligned_with_encoder(api, target_z) result = CSVResult.from_bool(z_aligned) if include_pass_fail: - report(section, tag, [z_est, z_enc, result]) + report(section, tag, [target_z, z_enc, result]) else: print(f"{tag}: {result}") - report(section, tag, [z_est, z_enc]) + report(section, tag, [target_z, z_enc]) return z_aligned # LOOP THROUGH CURRENTS + SPEEDS @@ -98,9 +99,8 @@ async def _save_result(tag: str, include_pass_fail: bool) -> bool: # HOME print("homing...") await api.home([z_ax]) - # RETRACT AND LOWER CURRENT - print("retracting 0.5 mm from endstop") - await api.move_rel(mount, Point(z=-RETRACT_AFTER_HOME_MM), speed=speed) + home_pos = await api.gantry_position(OT3Mount.GRIPPER) + # LOWER CURRENT print(f"lowering run-current to {current} amps") await helpers_ot3.set_gantry_load_per_axis_current_settings_ot3( api, z_ax, run_current=current @@ -108,29 +108,46 @@ async def _save_result(tag: str, include_pass_fail: bool) -> bool: await helpers_ot3.set_gantry_load_per_axis_motion_settings_ot3( api, z_ax, default_max_speed=speed ) - # await api._backend.set_active_current({z_ax: current}) + await api._backend.set_active_current({z_ax: current}) # MOVE DOWN print(f"moving down {Z_AXIS_TRAVEL_DISTANCE} mm at {speed} mm/sec") await _save_result( _get_test_tag(current, speed, "down", "start"), + target_z=home_pos.z, include_pass_fail=include_pass_fail, ) - await api.move_rel(mount, Point(z=-Z_AXIS_TRAVEL_DISTANCE), speed=speed) - down_passed = await _save_result( - _get_test_tag(current, speed, "down", "end"), - include_pass_fail=include_pass_fail, + await api.move_rel( + mount, + Point(z=-Z_AXIS_TRAVEL_DISTANCE), + speed=speed, + _expect_stalls=True, ) - # MOVE UP - print(f"moving up {Z_AXIS_TRAVEL_DISTANCE} mm at {speed} mm/sec") - await _save_result( - _get_test_tag(current, speed, "up", "start"), - include_pass_fail=include_pass_fail, - ) - await api.move_rel(mount, Point(z=Z_AXIS_TRAVEL_DISTANCE), speed=speed) - up_passed = await _save_result( - _get_test_tag(current, speed, "up", "end"), + down_end_passed = await _save_result( + _get_test_tag(current, speed, "down", "end"), + target_z=home_pos.z - Z_AXIS_TRAVEL_DISTANCE, include_pass_fail=include_pass_fail, ) + if down_end_passed: + # MOVE UP + print(f"moving up {Z_AXIS_TRAVEL_DISTANCE} mm at {speed} mm/sec") + await _save_result( + _get_test_tag(current, speed, "up", "start"), + target_z=home_pos.z - Z_AXIS_TRAVEL_DISTANCE, + include_pass_fail=include_pass_fail, + ) + await api.move_rel( + mount, + Point(z=Z_AXIS_TRAVEL_DISTANCE), + speed=speed, + _expect_stalls=True, + ) + up_end_passed = await _save_result( + _get_test_tag(current, speed, "up", "end"), + target_z=home_pos.z, + include_pass_fail=include_pass_fail, + ) + else: + up_end_passed = False # RESET CURRENTS AND HOME print("homing...") await helpers_ot3.set_gantry_load_per_axis_current_settings_ot3( @@ -140,7 +157,7 @@ async def _save_result(tag: str, include_pass_fail: bool) -> bool: api, z_ax, default_max_speed=default_z_speed ) await api.home([z_ax]) - if not down_passed or not up_passed and not api.is_simulator: + if not down_end_passed or not up_end_passed and not api.is_simulator: print(f"current {current} failed") print("skipping any remaining speeds at this current") break diff --git a/hardware-testing/hardware_testing/production_qc/gripper_assembly_qc_ot3/test_probe.py b/hardware-testing/hardware_testing/production_qc/gripper_assembly_qc_ot3/test_probe.py index 18fced5b55e7..cfc356831b5a 100644 --- a/hardware-testing/hardware_testing/production_qc/gripper_assembly_qc_ot3/test_probe.py +++ b/hardware-testing/hardware_testing/production_qc/gripper_assembly_qc_ot3/test_probe.py @@ -5,8 +5,11 @@ from opentrons_hardware.firmware_bindings.constants import NodeId from opentrons_hardware.sensors import sensor_driver, sensor_types -from opentrons.config.types import CapacitivePassSettings from opentrons.hardware_control.ot3api import OT3API +from opentrons.hardware_control.ot3_calibration import ( + calibrate_gripper, + calibrate_gripper_jaw, +) from opentrons.hardware_control.backends.ot3controller import OT3Controller from opentrons.hardware_control.backends.ot3utils import sensor_id_for_instrument @@ -26,6 +29,8 @@ TEST_SLOT = 5 PROBE_PREP_HEIGHT_MM = 5 PROBE_POS_OFFSET = Point(13, 13, 0) +JAW_ALIGNMENT_MM_X = 0.5 +JAW_ALIGNMENT_MM_Z = 0.5 def _get_test_tag(probe: GripperProbe) -> str: @@ -37,7 +42,16 @@ def build_csv_lines() -> List[Union[CSVLine, CSVLineRepeating]]: lines: List[Union[CSVLine, CSVLineRepeating]] = list() for p in GripperProbe: tag = _get_test_tag(p) - lines.append(CSVLine(tag, [float, float, float, float, float, CSVResult])) + lines.append(CSVLine(f"{tag}-open-air-pf", [float])) + lines.append(CSVLine(f"{tag}-probe-pf", [float])) + lines.append(CSVLine(f"{tag}-deck-pf", [float])) + lines.append(CSVLine(f"{tag}-result", [CSVResult])) + for p in GripperProbe: + lines.append(CSVLine(f"jaw-probe-{p.name.lower()}-xyz", [float, float, float])) + for axis in ["x", "z"]: + lines.append(CSVLine(f"jaw-alignment-{axis}-spec", [float])) + lines.append(CSVLine(f"jaw-alignment-{axis}-actual", [float])) + lines.append(CSVLine(f"jaw-alignment-{axis}-result", [CSVResult])) return lines @@ -92,21 +106,9 @@ async def run(api: OT3API, report: CSVReport, section: str) -> None: hover_pos, probe_pos = _get_hover_and_probe_pos(api) z_limit = probe_pos.z - pass_settings.max_overrun_distance_mm - async def _save_result( - tag: str, - no_probe: float, - probe: float, - found: float, - z_limit: float, - deck: float, - valid: bool, - ) -> None: - result = CSVResult.from_bool(valid) - report(section, tag, [no_probe, probe, found, z_limit, deck, result]) - for probe in GripperProbe: sensor_id = sensor_id_for_instrument(GripperProbe.to_type(probe)) - ui.print_header(f"Probe: {probe}") + ui.print_header(f"Capacitive: {probe.name}") cap_sensor = sensor_types.CapacitiveSensor.build(sensor_id, NodeId.gripper) print("homing and grip...") await api.home([z_ax, g_ax]) @@ -118,19 +120,19 @@ async def _save_result( await api.grip(15) # take reading for baseline (1) - no_probe_baseline = 0.0 + open_air_pf = 0.0 if not api.is_simulator: - no_probe_baseline = await _read_from_sensor(api, s_driver, cap_sensor, 10) - print(f"baseline without probe: {no_probe_baseline}") + open_air_pf = await _read_from_sensor(api, s_driver, cap_sensor, 10) + print(f"baseline without probe: {open_air_pf}") # take reading for baseline with pin attached (2) if not api.is_simulator: ui.get_user_ready(f"place calibration pin in the {probe.name}") # add pin to update critical point - probe_baseline = 0.0 + probe_pf = 0.0 if not api.is_simulator: - probe_baseline = await _read_from_sensor(api, s_driver, cap_sensor, 10) - print(f"baseline with probe: {probe_baseline}") + probe_pf = await _read_from_sensor(api, s_driver, cap_sensor, 10) + print(f"baseline with probe: {probe_pf}") # begins probing if not api.is_simulator: @@ -139,49 +141,72 @@ async def _save_result( # move to 5 mm above the deck await api.move_to(mount, probe_pos._replace(z=PROBE_PREP_HEIGHT_MM)) z_ax = Axis.by_mount(mount) - # NOTE: currently there's an issue where the 1st time an instrument - # probes, it won't trigger when contacting the deck. However all - # following probes work fine. So, here we do a "fake" probe - # in case this gripper was just turned on - await api.capacitive_probe( - mount, - z_ax, - PROBE_PREP_HEIGHT_MM, - CapacitivePassSettings( - prep_distance_mm=0.0, - max_overrun_distance_mm=1.0, - speed_mm_per_s=2.0, - sensor_threshold_pf=0.1, - ), - ) found_pos = await api.capacitive_probe(mount, z_ax, probe_pos.z, pass_settings) print(f"Found deck height: {found_pos}") # check against max overrun valid_height = found_pos >= z_limit - reading_on_deck = 0.0 + deck_pf = 0.0 if valid_height: if not api.is_simulator: ui.get_user_ready("about to press into the deck") await api.move_to(mount, probe_pos._replace(z=found_pos)) if not api.is_simulator: - reading_on_deck = await _read_from_sensor(api, s_driver, cap_sensor, 10) - print(f"Reading on deck: {reading_on_deck}") - - result = valid_height and reading_on_deck > probe_baseline - - await _save_result( - _get_test_tag(probe), - no_probe_baseline, - probe_baseline, - found_pos, - z_limit, - reading_on_deck, - result, - ) + deck_pf = await _read_from_sensor(api, s_driver, cap_sensor, 10) + print(f"Reading on deck: {deck_pf}") + + result = deck_pf > probe_pf + + _tag = _get_test_tag(probe) + report(section, f"{_tag}-open-air-pf", [open_air_pf]) + report(section, f"{_tag}-probe-pf", [probe_pf]) + report(section, f"{_tag}-deck-pf", [deck_pf]) + report(section, f"{_tag}-result", [CSVResult.from_bool(result)]) await api.home_z() await api.ungrip() if not api.is_simulator: ui.get_user_ready(f"remove calibration pin in the {probe.name}") api.remove_gripper_probe() + + async def _calibrate_jaw(_p: GripperProbe) -> Point: + ui.print_header(f"Probe Deck: {_p.name}") + await api.retract(OT3Mount.GRIPPER) + if not api.is_simulator: + ui.get_user_ready(f"attach probe to {_p.name}") + if api.is_simulator: + ret = Point(x=0, y=0, z=0) + else: + ret = await calibrate_gripper_jaw(api, _p) + await api.retract(OT3Mount.GRIPPER) + if not api.is_simulator: + ui.get_user_ready(f"remove probe from {_p.name}") + report(section, f"jaw-probe-{_p.name.lower()}-xyz", [ret.x, ret.y, ret.z]) + return ret + + _offsets = {probe: await _calibrate_jaw(probe) for probe in GripperProbe} + _diff_x = abs(_offsets[GripperProbe.FRONT].x - _offsets[GripperProbe.REAR].x) + _diff_z = abs(_offsets[GripperProbe.FRONT].z - _offsets[GripperProbe.REAR].z) + _offset = await calibrate_gripper( + api, + offset_front=_offsets[GripperProbe.FRONT], + offset_rear=_offsets[GripperProbe.REAR], + ) + print(f"front offset: {_offsets[GripperProbe.FRONT]}") + print(f"rear offset: {_offsets[GripperProbe.REAR]}") + print(f"average offset: {_offset}") + report(section, "jaw-alignment-x-spec", [JAW_ALIGNMENT_MM_X]) + report(section, "jaw-alignment-x-actual", [_diff_x]) + report( + section, + "jaw-alignment-x-result", + [CSVResult.from_bool(_diff_x <= JAW_ALIGNMENT_MM_X)], + ) + report(section, "jaw-alignment-z-spec", [JAW_ALIGNMENT_MM_Z]) + report(section, "jaw-alignment-z-actual", [_diff_z]) + report( + section, + "jaw-alignment-z-result", + [CSVResult.from_bool(_diff_z <= JAW_ALIGNMENT_MM_Z)], + ) + await api.retract(OT3Mount.GRIPPER) diff --git a/hardware-testing/hardware_testing/production_qc/gripper_assembly_qc_ot3/test_width.py b/hardware-testing/hardware_testing/production_qc/gripper_assembly_qc_ot3/test_width.py index 681a83d0d223..dfc3a9bb5fd7 100644 --- a/hardware-testing/hardware_testing/production_qc/gripper_assembly_qc_ot3/test_width.py +++ b/hardware-testing/hardware_testing/production_qc/gripper_assembly_qc_ot3/test_width.py @@ -1,5 +1,5 @@ """Test Width.""" -from typing import List, Union, Tuple +from typing import List, Union, Tuple, Optional from opentrons.hardware_control.ot3api import OT3API from opentrons_hardware.firmware_bindings.constants import NodeId @@ -15,12 +15,11 @@ from hardware_testing.opentrons_api import helpers_ot3 from hardware_testing.opentrons_api.types import Axis, OT3Mount, Point - FAILURE_THRESHOLD_MM = 3 GAUGE_HEIGHT_MM = 40 GRIP_HEIGHT_MM = 30 -TEST_WIDTHS_MM: List[float] = [85.75, 62] -SLOT_WIDTH_GAUGE: List[int] = [3, 9] +TEST_WIDTHS_MM: List[float] = [60, 85.75, 62] +SLOT_WIDTH_GAUGE: List[Optional[int]] = [None, 3, 9] GRIP_FORCES_NEWTON: List[float] = [5, 15, 20] @@ -41,7 +40,12 @@ def build_csv_lines() -> List[Union[CSVLine, CSVLineRepeating]]: for width in TEST_WIDTHS_MM: for force in GRIP_FORCES_NEWTON: tag = _get_test_tag(width, force) - lines.append(CSVLine(tag, [float, float, float, CSVResult])) + lines.append(CSVLine(f"{tag}-force", [float])) + lines.append(CSVLine(f"{tag}-width", [float])) + lines.append(CSVLine(f"{tag}-width-actual", [float])) + lines.append(CSVLine(f"{tag}-width-error", [float])) + lines.append(CSVLine(f"{tag}-width-error-adjusted", [float])) + lines.append(CSVLine(f"{tag}-result", [CSVResult])) return lines @@ -53,7 +57,10 @@ async def run(api: OT3API, report: CSVReport, section: str) -> None: gripper = api._gripper_handler.get_gripper() max_width = gripper.config.geometry.jaw_width["max"] - async def _save_result(_width: float, _force: float) -> None: + _error_when_gripping_itself = 0.0 + + async def _save_result(_width: float, _force: float, _cache_error: bool) -> float: + nonlocal _error_when_gripping_itself # fake the encoder to be in the right place, during simulation if api.is_simulator: sim_enc_pox = (max_width - width) / 2.0 @@ -61,40 +68,53 @@ async def _save_result(_width: float, _force: float) -> None: await api.refresh_positions() _width_actual = api._gripper_handler.get_gripper().jaw_width assert _width_actual is not None - print(f"actual width: {_width_actual}") - result = CSVResult.from_bool( - abs(_width - _width_actual) <= FAILURE_THRESHOLD_MM - ) + _width_error = _width_actual - _width + if _cache_error and not _error_when_gripping_itself: + _error_when_gripping_itself = _width_error + _width_error_adjusted = _width_error - _error_when_gripping_itself + # should always fail in the negative direction + result = CSVResult.from_bool(0 <= _width_error_adjusted <= FAILURE_THRESHOLD_MM) tag = _get_test_tag(_width, _force) - report(section, tag, [_force, _width, _width_actual, result]) + print(f"{tag}-width-error: {_width_error}") + print(f"{tag}-width-error-adjusted: {_width_error_adjusted}") + report(section, f"{tag}-force", [_force]) + report(section, f"{tag}-width", [_width]) + report(section, f"{tag}-width-actual", [_width_actual]) + report(section, f"{tag}-width-error", [_width_error]) + report(section, f"{tag}-width-error-adjusted", [_width_error_adjusted]) + report(section, f"{tag}-result", [result]) + return _width_error # HOME print("homing Z and G...") await api.home([z_ax, g_ax]) + # LOOP THROUGH WIDTHS for width, slot in zip(TEST_WIDTHS_MM, SLOT_WIDTH_GAUGE): - hover_pos, target_pos = _get_width_hover_and_grip_positions(api, slot) - # MOVE TO SLOT - await helpers_ot3.move_to_arched_ot3(api, mount, hover_pos) - # OPERATOR SETS UP GAUGE - ui.print_header(f"SETUP {width} MM GAUGE") - if not api.is_simulator: - ui.get_user_ready(f"add {width} mm wide gauge to slot {slot}") - # GRIPPER MOVES TO GAUGE - await api.ungrip() - await api.move_to(mount, target_pos) - if not api.is_simulator: - ui.get_user_ready(f"prepare to grip {width} mm") - # grip once to center the thing - await api.grip(20) + ui.print_header(f"TEST {width} MM") await api.ungrip() + if slot is not None: + hover_pos, target_pos = _get_width_hover_and_grip_positions(api, slot) + # MOVE TO SLOT + await helpers_ot3.move_to_arched_ot3(api, mount, hover_pos) + # OPERATOR SETS UP GAUGE + if not api.is_simulator: + ui.get_user_ready(f"add {width} mm wide gauge to slot {slot}") + # GRIPPER MOVES TO GAUGE + await api.move_to(mount, target_pos) + if not api.is_simulator: + ui.get_user_ready(f"prepare to grip {width} mm") + # grip once to center the thing + await api.grip(20) + await api.ungrip() # LOOP THROUGH FORCES + for force in GRIP_FORCES_NEWTON: # GRIP AND MEASURE WIDTH print(f"width(mm): {width}, force(N): {force}") await api.grip(force) - await _save_result(width, force) + await _save_result(width, force, _cache_error=(slot is None)) await api.ungrip() # RETRACT print("done") - await helpers_ot3.move_to_arched_ot3(api, mount, hover_pos) + await api.retract(OT3Mount.GRIPPER) From 42cb81e3a0075e573fb2f3641d4272b4b1702346 Mon Sep 17 00:00:00 2001 From: Jethary Rader <66035149+jerader@users.noreply.github.com> Date: Thu, 27 Jul 2023 10:30:48 -0400 Subject: [PATCH 09/25] refactor(app): calibration copy updates for 1, 8, 96 pipettes (#13141) closes RAUT-572 --- .../localization/en/pipette_wizard_flows.json | 6 ++--- .../PipetteWizardFlows/BeforeBeginning.tsx | 24 ++++++++++++------- .../__tests__/AttachProbe.test.tsx | 6 ++--- .../organisms/PipetteWizardFlows/utils.tsx | 2 +- 4 files changed, 23 insertions(+), 15 deletions(-) diff --git a/app/src/assets/localization/en/pipette_wizard_flows.json b/app/src/assets/localization/en/pipette_wizard_flows.json index fd3718e09c71..4935acd8126b 100644 --- a/app/src/assets/localization/en/pipette_wizard_flows.json +++ b/app/src/assets/localization/en/pipette_wizard_flows.json @@ -42,9 +42,9 @@ "hold_and_loosen": "Hold the pipette in place and loosen the pipette screws. (The screws are captive and will not come apart from the pipette.) Then carefully remove the pipette.", "hold_pipette_carefully": "Hold onto the pipette so it does not fall. Connect the pipette by aligning the two protruding rods on the mounting plate. Ensure a secure attachment by screwing in the four front screws with the provided screwdriver.", "how_to_reattach": "Push the right pipette mount up to the top of the z-axis. Then tighten the captive screw at the top right of the gantry carriage.When reattached, the right mount should no longer freely move up and down.", - "install_probe_8_channel": "Take the calibration probe from its storage location. Make sure its latch is in the unlocked (straight) position. Press the probe firmly onto the backmost pipette nozzle and then lock the latch. Then test that the probe is securely attached by gently pulling it back and forth.", - "install_probe_96_channel": "Take the calibration probe from its storage location. Make sure its latch is in the unlocked (straight) position. Press the probe firmly onto the A1 (back left corner) pipette nozzle and then lock the latch. Then test that the probe is securely attached by gently pulling it back and forth.", - "install_probe": "Take the calibration probe from its storage location. Make sure its latch is in the unlocked (straight) position. Press the probe firmly onto the pipette nozzle and then lock the latch. Then test that the probe is securely attached by gently pulling it back and forth.", + "install_probe_8_channel": "Take the calibration probe from its storage location. Ensure its collar is unlocked. Push the pipette ejector up and press the probe firmly onto the backmost pipette nozzle. Twist the collar to lock the probe. Test that the probe is secure by gently pulling it back and forth.", + "install_probe_96_channel": "Take the calibration probe from its storage location. Ensure its collar is unlocked. Push the pipette ejector up and press the probe firmly onto the A1 (back left corner) pipette nozzle. Twist the collar to lock the probe. Test that the probe is secure by gently pulling it back and forth.", + "install_probe": "Take the calibration probe from its storage location. Ensure its collar is unlocked. Push the pipette ejector up and press the probe firmly onto the pipette nozzle. Twist the collar to lock the probe. Test that the probe is secure by gently pulling it back and forth.", "loose_detach": "Loosen screws and detach ", "move_gantry_to_front": "Move gantry to front", "must_detach_mounting_plate": "You must detach the mounting plate before using other pipettes.", diff --git a/app/src/organisms/PipetteWizardFlows/BeforeBeginning.tsx b/app/src/organisms/PipetteWizardFlows/BeforeBeginning.tsx index e1faa510bae3..bf9173d6203e 100644 --- a/app/src/organisms/PipetteWizardFlows/BeforeBeginning.tsx +++ b/app/src/organisms/PipetteWizardFlows/BeforeBeginning.tsx @@ -1,6 +1,12 @@ import * as React from 'react' import { UseMutateFunction } from 'react-query' -import { COLORS, SIZE_1, SPACING } from '@opentrons/components' +import { + COLORS, + DIRECTION_COLUMN, + Flex, + SIZE_1, + SPACING, +} from '@opentrons/components' import { NINETY_SIX_CHANNEL, RIGHT, @@ -242,13 +248,15 @@ export const BeforeBeginning = ( {t('pipette_heavy', { weight: WEIGHT_OF_96_CHANNEL })} ) : null} - , - }} - /> + + , + }} + /> + } proceedButtonText={proceedButtonText} diff --git a/app/src/organisms/PipetteWizardFlows/__tests__/AttachProbe.test.tsx b/app/src/organisms/PipetteWizardFlows/__tests__/AttachProbe.test.tsx index ce21c612fc96..40ae1b620fcf 100644 --- a/app/src/organisms/PipetteWizardFlows/__tests__/AttachProbe.test.tsx +++ b/app/src/organisms/PipetteWizardFlows/__tests__/AttachProbe.test.tsx @@ -53,7 +53,7 @@ describe('AttachProbe', () => { const { getByText, getByTestId, getByRole, getByLabelText } = render(props) getByText('Attach calibration probe') getByText( - 'Take the calibration probe from its storage location. Make sure its latch is in the unlocked (straight) position. Press the probe firmly onto the pipette nozzle and then lock the latch. Then test that the probe is securely attached by gently pulling it back and forth.' + 'Take the calibration probe from its storage location. Ensure its collar is unlocked. Push the pipette ejector up and press the probe firmly onto the pipette nozzle. Twist the collar to lock the probe. Test that the probe is secure by gently pulling it back and forth.' ) getByTestId('Pipette_Attach_Probe_1.webm') const proceedBtn = getByRole('button', { name: 'Begin calibration' }) @@ -95,7 +95,7 @@ describe('AttachProbe', () => { const { getByText } = render(props) getByText( nestedTextMatcher( - 'backmost pipette nozzle and then lock the latch. Then test that the probe is securely attached by gently pulling it back and forth.' + 'Take the calibration probe from its storage location. Ensure its collar is unlocked. Push the pipette ejector up and press the probe firmly onto the backmost pipette nozzle. Twist the collar to lock the probe. Test that the probe is secure by gently pulling it back and forth.' ) ) }) @@ -162,7 +162,7 @@ describe('AttachProbe', () => { const { getByText, getByTestId, getByRole, getByLabelText } = render(props) getByText('Attach calibration probe') getByText( - 'Take the calibration probe from its storage location. Make sure its latch is in the unlocked (straight) position. Press the probe firmly onto the pipette nozzle and then lock the latch. Then test that the probe is securely attached by gently pulling it back and forth.' + 'Take the calibration probe from its storage location. Ensure its collar is unlocked. Push the pipette ejector up and press the probe firmly onto the pipette nozzle. Twist the collar to lock the probe. Test that the probe is secure by gently pulling it back and forth.' ) getByTestId('Pipette_Attach_Probe_1.webm') getByRole('button', { name: 'Begin calibration' }).click() diff --git a/app/src/organisms/PipetteWizardFlows/utils.tsx b/app/src/organisms/PipetteWizardFlows/utils.tsx index 28a20556652f..8baded2da4dc 100644 --- a/app/src/organisms/PipetteWizardFlows/utils.tsx +++ b/app/src/organisms/PipetteWizardFlows/utils.tsx @@ -78,7 +78,7 @@ export function getPipetteAnimations( width: 100%; min-height: ${section === SECTIONS.ATTACH_PROBE || section === SECTIONS.DETACH_PROBE - ? `20rem` + ? `18rem` : `12rem`}; `} autoPlay={true} From a536c53d892460b71ff363f402ce175d6c9de48a Mon Sep 17 00:00:00 2001 From: Brent Hagen Date: Thu, 27 Jul 2023 10:46:18 -0400 Subject: [PATCH 10/25] feat(app,components): add intervention modal to ODD (#13135) makes necessary changes to the intervention modal and base modal components to display different layout/styling on ODD and desktop. adds the intervention modal to the RunningProtocol ODD page. re RLAB-323, closes RLAB-321, RLAB-324, RLAB-325 --- .../en/protocol_command_text.json | 6 +- app/src/molecules/Modal/Modal.tsx | 20 ++- app/src/molecules/Modal/ModalHeader.tsx | 10 +- app/src/molecules/Modal/types.ts | 4 +- .../InterventionCommandMessage.tsx | 51 +++++- .../InterventionModal.stories.tsx | 18 +- .../MoveLabwareInterventionContent.tsx | 156 +++++++++++------- .../PauseInterventionContent.tsx | 63 +++++-- .../InterventionModal/__fixtures__/index.ts | 2 + .../__tests__/InterventionModal.test.tsx | 28 ++-- app/src/organisms/InterventionModal/index.tsx | 103 +++++++++--- app/src/organisms/RunProgressMeter/index.tsx | 40 ++--- .../pages/OnDeviceDisplay/RunningProtocol.tsx | 39 +++++ .../hardware-sim/Deck/MoveLabwareOnDeck.tsx | 23 ++- 14 files changed, 406 insertions(+), 157 deletions(-) diff --git a/app/src/assets/localization/en/protocol_command_text.json b/app/src/assets/localization/en/protocol_command_text.json index ee1c027f1657..f7eaf48e493a 100644 --- a/app/src/assets/localization/en/protocol_command_text.json +++ b/app/src/assets/localization/en/protocol_command_text.json @@ -19,15 +19,19 @@ "module_in_slot_plural": "{{module}}", "module_in_slot": "{{module}} in Slot {{slot_name}}", "move_labware_manually": "Manually move {{labware}} from {{old_location}} to {{new_location}}", + "move_labware_on": "Move labware on {{robot_name}}", "move_labware_using_gripper": "Moving {{labware}} using gripper from {{old_location}} to {{new_location}}", + "move_labware": "Move Labware", "move_relative": "Moving {{distance}} mm along {{axis}} axis", "move_to_coordinates": "Moving to (X: {{x}}, Y: {{y}}, Z: {{z}})", "move_to_slot": "Moving to Slot {{slot_name}}", "move_to_well": "Moving to well {{well_name}} of {{labware}} in {{labware_location}}", "notes": "notes", "off_deck": "off deck", + "offdeck": "offdeck", "opening_tc_lid": "Opening Thermocycler lid", - "perform_manual_step": "Perform manual step on {{robot_name}}", + "pause_on": "Pause on {{robot_name}}", + "pause": "Pause", "pickup_tip": "Picking up tip from {{well_name}} of {{labware}} in {{labware_location}}", "save_position": "Saving position", "set_and_await_hs_shake": "Setting Heater-Shaker to shake at {{rpm}} rpm and waiting until reached", diff --git a/app/src/molecules/Modal/Modal.tsx b/app/src/molecules/Modal/Modal.tsx index 95a32d9e3801..60f1ca8f79a8 100644 --- a/app/src/molecules/Modal/Modal.tsx +++ b/app/src/molecules/Modal/Modal.tsx @@ -11,9 +11,10 @@ import { import { BackgroundOverlay } from '../BackgroundOverlay' import { ModalHeader } from './ModalHeader' +import type { StyleProps } from '@opentrons/components' import type { ModalHeaderBaseProps, ModalSize } from '../Modal/types' -interface ModalProps { +interface ModalProps extends StyleProps { /** clicking anywhere outside of the modal closes it */ onOutsideClick?: React.MouseEventHandler /** modal content */ @@ -24,7 +25,13 @@ interface ModalProps { header?: ModalHeaderBaseProps } export function Modal(props: ModalProps): JSX.Element { - const { modalSize = 'medium', onOutsideClick, children, header } = props + const { + modalSize = 'medium', + onOutsideClick, + children, + header, + ...styleProps + } = props let modalWidth: string = '45.625rem' switch (modalSize) { @@ -61,13 +68,7 @@ export function Modal(props: ModalProps): JSX.Element { }} > {header != null ? ( - + ) : null} {children} diff --git a/app/src/molecules/Modal/ModalHeader.tsx b/app/src/molecules/Modal/ModalHeader.tsx index 540528d36a51..7f1414b741a9 100644 --- a/app/src/molecules/Modal/ModalHeader.tsx +++ b/app/src/molecules/Modal/ModalHeader.tsx @@ -14,7 +14,14 @@ import { StyledText } from '../../atoms/text' import type { ModalHeaderBaseProps } from '../Modal/types' export function ModalHeader(props: ModalHeaderBaseProps): JSX.Element { - const { title, hasExitIcon, iconName, iconColor, onClick } = props + const { + title, + hasExitIcon, + iconName, + iconColor, + onClick, + ...styleProps + } = props return ( {iconName != null && iconColor != null ? ( diff --git a/app/src/molecules/Modal/types.ts b/app/src/molecules/Modal/types.ts index 68f047067543..a9ddd05ffede 100644 --- a/app/src/molecules/Modal/types.ts +++ b/app/src/molecules/Modal/types.ts @@ -1,8 +1,8 @@ -import type { IconName } from '@opentrons/components' +import type { IconName, StyleProps } from '@opentrons/components' export type ModalSize = 'small' | 'medium' | 'large' -export interface ModalHeaderBaseProps { +export interface ModalHeaderBaseProps extends StyleProps { title: string onClick?: React.MouseEventHandler hasExitIcon?: boolean diff --git a/app/src/organisms/InterventionModal/InterventionCommandMessage.tsx b/app/src/organisms/InterventionModal/InterventionCommandMessage.tsx index db74ed06be93..36582eb2524d 100644 --- a/app/src/organisms/InterventionModal/InterventionCommandMessage.tsx +++ b/app/src/organisms/InterventionModal/InterventionCommandMessage.tsx @@ -1,8 +1,45 @@ import * as React from 'react' import { useTranslation } from 'react-i18next' -import { COLORS } from '@opentrons/components' +import { css } from 'styled-components' + +import { + COLORS, + DIRECTION_COLUMN, + Flex, + RESPONSIVENESS, + SPACING, + TEXT_TRANSFORM_CAPITALIZE, + TEXT_TRANSFORM_UPPERCASE, + TYPOGRAPHY, +} from '@opentrons/components' import { StyledText } from '../../atoms/text' +const INTERVENTION_COMMAND_STYLE = css` + flex-direction: ${DIRECTION_COLUMN}; + grid-gap: ${SPACING.spacing4}; + @media ${RESPONSIVENESS.touchscreenMediaQuerySpecs} { + grid-gap: 0; + } +` + +const INTERVENTION_COMMAND_NOTES_STYLE = css` + ${TYPOGRAPHY.h6Default} + color: ${COLORS.errorDisabled}; + text-transform: ${TEXT_TRANSFORM_UPPERCASE}; + @media ${RESPONSIVENESS.touchscreenMediaQuerySpecs} { + ${TYPOGRAPHY.smallBodyTextBold} + color: ${COLORS.darkBlack100}; + text-transform: ${TEXT_TRANSFORM_CAPITALIZE}; + } +` + +const INTERVENTION_COMMAND_MESSAGE_STYLE = css` + ${TYPOGRAPHY.pRegular} + @media ${RESPONSIVENESS.touchscreenMediaQuerySpecs} { + ${TYPOGRAPHY.smallBodyTextRegular} + } +` + export interface InterventionCommandMessageProps { commandMessage: string | null } @@ -10,20 +47,20 @@ export interface InterventionCommandMessageProps { export function InterventionCommandMessage({ commandMessage, }: InterventionCommandMessageProps): JSX.Element { - const { t, i18n } = useTranslation('protocol_command_text') + const { t } = useTranslation('protocol_command_text') return ( - <> - - {i18n.format(t('notes'), 'upperCase')}: + + + {t('notes')} - + {commandMessage != null && commandMessage !== '' ? commandMessage.length > 220 ? `${commandMessage.substring(0, 217)}...` : commandMessage : t('wait_for_resume')} - + ) } diff --git a/app/src/organisms/InterventionModal/InterventionModal.stories.tsx b/app/src/organisms/InterventionModal/InterventionModal.stories.tsx index cb935a21a358..b181cfc5b4d2 100644 --- a/app/src/organisms/InterventionModal/InterventionModal.stories.tsx +++ b/app/src/organisms/InterventionModal/InterventionModal.stories.tsx @@ -1,9 +1,21 @@ import * as React from 'react' +import { Provider } from 'react-redux' +import { createStore } from 'redux' +import { configReducer } from '../../redux/config/reducer' import { mockRunData } from './__fixtures__' import { InterventionModal as InterventionModalComponent } from './' +import type { Store } from 'redux' import type { Story, Meta } from '@storybook/react' +const dummyConfig = { + config: { + isOnDevice: false, + }, +} as any + +const store: Store = createStore(configReducer, dummyConfig) + const now = new Date() const pauseCommand = { @@ -22,7 +34,11 @@ export default { const Template: Story< React.ComponentProps -> = args => +> = args => ( + + + +) export const PauseIntervention = Template.bind({}) PauseIntervention.args = { diff --git a/app/src/organisms/InterventionModal/MoveLabwareInterventionContent.tsx b/app/src/organisms/InterventionModal/MoveLabwareInterventionContent.tsx index 0b2fee8409bd..2ad8cadc8a07 100644 --- a/app/src/organisms/InterventionModal/MoveLabwareInterventionContent.tsx +++ b/app/src/organisms/InterventionModal/MoveLabwareInterventionContent.tsx @@ -1,5 +1,6 @@ import * as React from 'react' import { useTranslation } from 'react-i18next' +import { css } from 'styled-components' import { Flex, @@ -14,6 +15,10 @@ import { MoveLabwareOnDeck, Module, LabwareRender, + LocationIcon, + DISPLAY_NONE, + RESPONSIVENESS, + TEXT_TRANSFORM_UPPERCASE, } from '@opentrons/components' import { @@ -41,16 +46,67 @@ import { import type { RunData } from '@opentrons/api-client' import { getLoadedLabware } from '../CommandText/utils/accessors' +const LABWARE_DESCRIPTION_STYLE = css` + flex-direction: ${DIRECTION_COLUMN}; + grid-gap: ${SPACING.spacing8}; + padding: ${SPACING.spacing16}; + background-color: ${COLORS.fundamentalsBackground}; + border-radius: ${BORDERS.radiusSoftCorners}; + @media ${RESPONSIVENESS.touchscreenMediaQuerySpecs} { + background-color: ${COLORS.light1}; + border-radius: ${BORDERS.borderRadiusSize3}; + } +` + +const LABWARE_NAME_TITLE_STYLE = css` + font-weight: ${TYPOGRAPHY.fontWeightSemiBold}; + @media ${RESPONSIVENESS.touchscreenMediaQuerySpecs} { + display: ${DISPLAY_NONE}; + } +` + +const LABWARE_NAME_STYLE = css` + color: ${COLORS.errorDisabled}; + @media ${RESPONSIVENESS.touchscreenMediaQuerySpecs} { + ${TYPOGRAPHY.bodyTextBold} + color: ${COLORS.darkBlack100}; + } +` + +const DIVIDER_STYLE = css` + @media ${RESPONSIVENESS.touchscreenMediaQuerySpecs} { + display: ${DISPLAY_NONE}; + } +` + +const LABWARE_DIRECTION_STYLE = css` + align-items: ${ALIGN_CENTER}; + grid-gap: ${SPACING.spacing4}; + text-transform: ${TEXT_TRANSFORM_UPPERCASE}; + @media ${RESPONSIVENESS.touchscreenMediaQuerySpecs} { + grid-gap: ${SPACING.spacing8}; + } +` + +const ICON_STYLE = css` + height: 1.5rem; + @media ${RESPONSIVENESS.touchscreenMediaQuerySpecs} { + height: 2.5rem; + } +` + export interface MoveLabwareInterventionProps { command: MoveLabwareRunTimeCommand analysis: CompletedProtocolAnalysis | null run: RunData + isOnDevice: boolean } export function MoveLabwareInterventionContent({ command, analysis, run, + isOnDevice, }: MoveLabwareInterventionProps): JSX.Element | null { const { t } = useTranslation(['protocol_setup', 'protocol_command_text']) @@ -87,47 +143,41 @@ export function MoveLabwareInterventionContent({ if (oldLabwareLocation == null || movedLabwareDef == null) return null return ( - - + - - - - {t('labware_name')} - - {labwareName} - - - {t('labware_location')} - - - - - → - - - + + + + + {t('labware_name')} + + + {labwareName} + + + + + + + + + @@ -135,10 +185,13 @@ export function MoveLabwareInterventionContent({ {moduleRenderInfo.map( @@ -167,21 +220,6 @@ export function MoveLabwareInterventionContent({ ) } -function MoveLabwareHeader(): JSX.Element { - const { t } = useTranslation('run_details') - return ( - - - {t('move_labware')} - - ) -} - interface LabwareDisplayLocationProps { protocolData: RunData location: LabwareLocation @@ -192,11 +230,13 @@ function LabwareDisplayLocation( ): JSX.Element { const { t } = useTranslation('protocol_command_text') const { protocolData, location, robotType } = props - let displayLocation = '' + let displayLocation: React.ReactNode = '' if (location === 'offDeck') { - displayLocation = t('off_deck') + // typecheck thinks t() can return undefined + // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion + displayLocation = } else if ('slotName' in location) { - displayLocation = t('slot', { slot_name: location.slotName }) + displayLocation = } else if ('moduleId' in location) { const moduleModel = getModuleModelFromRunData( protocolData, diff --git a/app/src/organisms/InterventionModal/PauseInterventionContent.tsx b/app/src/organisms/InterventionModal/PauseInterventionContent.tsx index df1505c4cb87..58eaeea3c1a9 100644 --- a/app/src/organisms/InterventionModal/PauseInterventionContent.tsx +++ b/app/src/organisms/InterventionModal/PauseInterventionContent.tsx @@ -1,13 +1,16 @@ import * as React from 'react' import { useTranslation } from 'react-i18next' +import { css } from 'styled-components' import { ALIGN_CENTER, + BORDERS, COLORS, DIRECTION_COLUMN, Flex, - Icon, + RESPONSIVENESS, SPACING, + TYPOGRAPHY, useInterval, } from '@opentrons/components' @@ -16,6 +19,15 @@ import { EMPTY_TIMESTAMP } from '../Devices/constants' import { formatInterval } from '../RunTimeControl/utils' import { InterventionCommandMessage } from './InterventionCommandMessage' +const PAUSE_INTERVENTION_CONTENT_STYLE = css` + flex-direction: ${DIRECTION_COLUMN}; + grid-gap: ${SPACING.spacing12}; + width: 100%; + @media ${RESPONSIVENESS.touchscreenMediaQuerySpecs} { + grid-gap: ${SPACING.spacing20}; + } +` + export interface PauseContentProps { startedAt: string | null message: string | null @@ -26,15 +38,43 @@ export function PauseInterventionContent({ message, }: PauseContentProps): JSX.Element { return ( - + - - - + ) } +const PAUSE_HEADER_STYLE = css` + align-items: ${ALIGN_CENTER}; + background-color: ${COLORS.fundamentalsBackground}; + border-radius: ${BORDERS.radiusSoftCorners}; + grid-gap: ${SPACING.spacing6}; + padding: ${SPACING.spacing16}; + @media ${RESPONSIVENESS.touchscreenMediaQuerySpecs} { + align-self: ${ALIGN_CENTER}; + background-color: ${COLORS.light1}; + border-radius: ${BORDERS.borderRadiusSize3}; + grid-gap: ${SPACING.spacing32}; + padding: ${SPACING.spacing24}; + min-width: 36.5rem; + } +` + +const PAUSE_TEXT_STYLE = css` + ${TYPOGRAPHY.h1Default} + @media ${RESPONSIVENESS.touchscreenMediaQuerySpecs} { + ${TYPOGRAPHY.level4HeaderSemiBold} + } +` + +const PAUSE_TIME_STYLE = css` + ${TYPOGRAPHY.h1Default} + @media ${RESPONSIVENESS.touchscreenMediaQuerySpecs} { + ${TYPOGRAPHY.level1Header} + } +` + interface PauseHeaderProps { startedAt: string | null } @@ -48,16 +88,11 @@ function PauseHeader({ startedAt }: PauseHeaderProps): JSX.Element { startedAt != null ? formatInterval(startedAt, now) : EMPTY_TIMESTAMP return ( - - - - {i18n.format(t('paused_for'), 'capitalize')} {runTime} + + + {i18n.format(t('paused_for'), 'capitalize')} + {runTime} ) } diff --git a/app/src/organisms/InterventionModal/__fixtures__/index.ts b/app/src/organisms/InterventionModal/__fixtures__/index.ts index c94ce5f424cb..b3b1ddd3c9ff 100644 --- a/app/src/organisms/InterventionModal/__fixtures__/index.ts +++ b/app/src/organisms/InterventionModal/__fixtures__/index.ts @@ -25,6 +25,7 @@ export const MOCK_LABWARE_ID = '71e1664f-3e69-400a-931b-1ddfa3bff5c8' export const MOCK_MODULE_ID = 'f806ff9f-3b17-4692-aa63-f77c57fe18d1' export const mockPauseCommandWithStartTime = { + key: 'mockPauseCommandKey', commandType: 'waitForResume', startedAt: new Date(), params: { @@ -57,6 +58,7 @@ export const mockPauseCommandWithNoMessage = { } as any export const mockMoveLabwareCommandFromSlot = { + key: 'mockMoveLabwareCommandKey', commandType: 'moveLabware', params: { labwareId: 'mockLabwareID2', diff --git a/app/src/organisms/InterventionModal/__tests__/InterventionModal.test.tsx b/app/src/organisms/InterventionModal/__tests__/InterventionModal.test.tsx index 6ca47051deb1..2f1317fca63d 100644 --- a/app/src/organisms/InterventionModal/__tests__/InterventionModal.test.tsx +++ b/app/src/organisms/InterventionModal/__tests__/InterventionModal.test.tsx @@ -47,21 +47,23 @@ describe('InterventionModal', () => { it('renders an InterventionModal with the robot name in the header, learn more link, and confirm button', () => { const { getByText, getByRole } = render(props) - expect(getByText('Perform manual step on Otie')).toBeTruthy() - expect(getByText('Learn more about manual steps')).toBeTruthy() - expect(getByRole('button', { name: 'Confirm and resume' })).toBeTruthy() + getByText('Pause on Otie') + getByText('Learn more about manual steps') + getByRole('button', { name: 'Confirm and resume' }) }) it('renders a pause intervention modal given a pause-type command', () => { const { getByText } = render(props) - expect(getByText(truncatedCommandMessage)).toBeTruthy() - expect(getByText(/Paused for [0-9]{2}:[0-9]{2}:[0-9]{2}/)).toBeTruthy() + getByText(truncatedCommandMessage) + getByText('Paused for') + getByText(/[0-9]{2}:[0-9]{2}:[0-9]{2}/) }) it('renders a pause intervention modal with an empty timestamp when no start time given', () => { props = { ...props, command: mockPauseCommandWithoutStartTime } const { getByText } = render(props) - expect(getByText('Paused for --:--:--')).toBeTruthy() + getByText('Paused for') + getByText('--:--:--') }) it('clicking "Confirm and resume" triggers the resume handler', () => { @@ -92,12 +94,11 @@ describe('InterventionModal', () => { } as any, } const { getByText, queryAllByText } = render(props) - getByText('Move Labware') + getByText('Move labware on Otie') getByText('Labware Name') getByText('mockLabware') - getByText('Labware Location') - queryAllByText('Slot A1') - queryAllByText('Slot D3') + queryAllByText('A1') + queryAllByText('D3') }) it('renders a move labware intervention modal given a move labware command - module starting point', () => { @@ -128,11 +129,10 @@ describe('InterventionModal', () => { } as any, } const { getByText, queryAllByText } = render(props) - getByText('Move Labware') + getByText('Move labware on Otie') getByText('Labware Name') getByText('mockLabware') - getByText('Labware Location') - queryAllByText('Slot A1') - queryAllByText('Slot C1') + queryAllByText('A1') + queryAllByText('C1') }) }) diff --git a/app/src/organisms/InterventionModal/index.tsx b/app/src/organisms/InterventionModal/index.tsx index 44ca054f1534..a51223f3aee4 100644 --- a/app/src/organisms/InterventionModal/index.tsx +++ b/app/src/organisms/InterventionModal/index.tsx @@ -1,5 +1,6 @@ import * as React from 'react' import { useTranslation } from 'react-i18next' +import { useSelector } from 'react-redux' import { Box, @@ -24,12 +25,16 @@ import { PrimaryButton, } from '@opentrons/components' +import { SmallButton } from '../../atoms/buttons' import { StyledText } from '../../atoms/text' +import { Modal } from '../../molecules/Modal' +import { getIsOnDevice } from '../../redux/config' import { PauseInterventionContent } from './PauseInterventionContent' import { MoveLabwareInterventionContent } from './MoveLabwareInterventionContent' import type { RunCommandSummary, RunData } from '@opentrons/api-client' -import { CompletedProtocolAnalysis } from '@opentrons/shared-data' +import type { IconName } from '@opentrons/components' +import type { CompletedProtocolAnalysis } from '@opentrons/shared-data' const BASE_STYLE = { position: POSITION_ABSOLUTE, @@ -49,23 +54,19 @@ const MODAL_STYLE = { overflowY: OVERFLOW_AUTO, maxHeight: '100%', width: '47rem', - margin: SPACING.spacing24, - border: `6px ${String(BORDERS.styleSolid)} ${String(COLORS.blueEnabled)}`, + border: `6px ${BORDERS.styleSolid} ${COLORS.blueEnabled}`, borderRadius: BORDERS.radiusSoftCorners, boxShadow: BORDERS.smallDropShadow, } as const const HEADER_STYLE = { - display: DISPLAY_FLEX, - flexDirection: DIRECTION_COLUMN, - alignItems: ALIGN_FLEX_START, - justifyContent: JUSTIFY_CENTER, - padding: `0px ${SPACING.spacing32}`, + alignItems: ALIGN_CENTER, + gridGap: SPACING.spacing12, + padding: `${SPACING.spacing20} ${SPACING.spacing32}`, color: COLORS.white, backgroundColor: COLORS.blueEnabled, position: POSITION_STICKY, top: 0, - height: '3.25rem', } as const const CONTENT_STYLE = { @@ -82,6 +83,7 @@ const CONTENT_STYLE = { const FOOTER_STYLE = { display: DISPLAY_FLEX, width: '100%', + alignItems: ALIGN_CENTER, justifyContent: JUSTIFY_SPACE_BETWEEN, } as const @@ -101,6 +103,7 @@ export function InterventionModal({ analysis, }: InterventionModalProps): JSX.Element { const { t } = useTranslation(['protocol_command_text', 'protocol_info']) + const isOnDevice = useSelector(getIsOnDevice) const childContent = React.useMemo(() => { if ( @@ -114,7 +117,12 @@ export function InterventionModal({ /> ) } else if (command.commandType === 'moveLabware') { - return + return ( + + ) } else { return null } @@ -125,7 +133,51 @@ export function InterventionModal({ run.modules.map(m => m.id).join(), ]) - return ( + let iconName: IconName | null = null + let headerTitle = '' + let headerTitleOnDevice = '' + if ( + command.commandType === 'waitForResume' || + command.commandType === 'pause' // legacy pause command + ) { + iconName = 'pause-circle' + headerTitle = t('pause_on', { robot_name: robotName }) + headerTitleOnDevice = t('pause') + } else if (command.commandType === 'moveLabware') { + iconName = 'move-xy-circle' + headerTitle = t('move_labware_on', { robot_name: robotName }) + headerTitleOnDevice = t('move_labware') + } + + // TODO(bh, 2023-7-18): this is a one-off modal implementation for desktop + // reimplement when design system shares a modal component between desktop/ODD + return isOnDevice ? ( + + + {childContent} + + + + ) : ( - - - {t('perform_manual_step', { robot_name: robotName })} - - + + {iconName != null ? ( + + ) : null} + {headerTitle} + {childContent} - - - {t('protocol_info:manual_steps_learn_more')} - - - + + {t('protocol_info:manual_steps_learn_more')} + + {t('confirm_and_resume')} diff --git a/app/src/organisms/RunProgressMeter/index.tsx b/app/src/organisms/RunProgressMeter/index.tsx index 9ca521373726..84b7cb33370c 100644 --- a/app/src/organisms/RunProgressMeter/index.tsx +++ b/app/src/organisms/RunProgressMeter/index.tsx @@ -61,9 +61,9 @@ interface RunProgressMeterProps { export function RunProgressMeter(props: RunProgressMeterProps): JSX.Element { const { runId, robotName, makeHandleJumpToStep, resumeRunHandler } = props const [ - showInterventionModal, - setShowInterventionModal, - ] = React.useState(false) + interventionModalCommandKey, + setInterventionModalCommandKey, + ] = React.useState(null) const { t } = useTranslation('run_details') const runStatus = useRunStatus(runId) const [targetProps, tooltipProps] = useHoverTooltip({ @@ -157,17 +157,22 @@ export function RunProgressMeter(props: RunProgressMeterProps): JSX.Element { ) } - if ( - lastRunCommand != null && - isInterventionCommand(lastRunCommand) && - !showInterventionModal - ) { - // this setTimeout is a hacky way to make sure the modal closes when we tell it to - // we can run into issues when there are 2 back to back move labware commands - // the modal never really un-renders and so the animations break after the first modal - // not really a fan of this, but haven't been able to fix the problem any other way - setTimeout(() => setShowInterventionModal(true), 0) - } + React.useEffect(() => { + if ( + lastRunCommand != null && + interventionModalCommandKey != null && + lastRunCommand.key !== interventionModalCommandKey + ) { + // set intervention modal command key to null if different from current command key + setInterventionModalCommandKey(null) + } else if ( + lastRunCommand?.key != null && + isInterventionCommand(lastRunCommand) && + interventionModalCommandKey === null + ) { + setInterventionModalCommandKey(lastRunCommand.key) + } + }, [lastRunCommand, interventionModalCommandKey]) const onDownloadClick: React.MouseEventHandler = e => { if (downloadIsDisabled) return false @@ -178,7 +183,7 @@ export function RunProgressMeter(props: RunProgressMeterProps): JSX.Element { return ( <> - {showInterventionModal && + {interventionModalCommandKey != null && lastRunCommand != null && isInterventionCommand(lastRunCommand) && analysisCommands != null && @@ -189,10 +194,7 @@ export function RunProgressMeter(props: RunProgressMeterProps): JSX.Element { { - setShowInterventionModal(false) - resumeRunHandler() - }} + onResume={resumeRunHandler} run={runData} analysis={analysis} /> diff --git a/app/src/pages/OnDeviceDisplay/RunningProtocol.tsx b/app/src/pages/OnDeviceDisplay/RunningProtocol.tsx index eb9da0d24cf1..80ddb9c82848 100644 --- a/app/src/pages/OnDeviceDisplay/RunningProtocol.tsx +++ b/app/src/pages/OnDeviceDisplay/RunningProtocol.tsx @@ -26,6 +26,8 @@ import { TertiaryButton } from '../../atoms/buttons' import { StepMeter } from '../../atoms/StepMeter' import { useMostRecentCompletedAnalysis } from '../../organisms/LabwarePositionCheck/useMostRecentCompletedAnalysis' import { useLastRunCommandKey } from '../../organisms/Devices/hooks/useLastRunCommandKey' +import { InterventionModal } from '../../organisms/InterventionModal' +import { isInterventionCommand } from '../../organisms/InterventionModal/utils' import { useRunStatus, useRunTimestamps, @@ -43,6 +45,7 @@ import { CancelingRunModal } from '../../organisms/OnDeviceDisplay/RunningProtoc import { ConfirmCancelRunModal } from '../../organisms/OnDeviceDisplay/RunningProtocol/ConfirmCancelRunModal' import { getLocalRobot } from '../../redux/discovery' +import type { RunTimeCommand } from '@opentrons/shared-data' import type { OnDeviceRouteParams } from '../../App/types' const RUN_STATUS_REFETCH_INTERVAL = 5000 @@ -73,6 +76,10 @@ export function RunningProtocol(): JSX.Element { showConfirmCancelRunModal, setShowConfirmCancelRunModal, ] = React.useState(false) + const [ + interventionModalCommandKey, + setInterventionModalCommandKey, + ] = React.useState(null) const lastAnimatedCommand = React.useRef(null) const swipe = useSwipe() const robotSideAnalysis = useMostRecentCompletedAnalysis(runId) @@ -116,6 +123,27 @@ export function RunningProtocol(): JSX.Element { } }, [currentOption, swipe, swipe.setSwipeType]) + const currentCommand = robotSideAnalysis?.commands.find( + (c: RunTimeCommand, index: number) => index === currentRunCommandIndex + ) + + React.useEffect(() => { + if ( + currentCommand != null && + interventionModalCommandKey != null && + currentCommand.key !== interventionModalCommandKey + ) { + // set intervention modal command key to null if different from current command key + setInterventionModalCommandKey(null) + } else if ( + currentCommand?.key != null && + isInterventionCommand(currentCommand) && + interventionModalCommandKey === null + ) { + setInterventionModalCommandKey(currentCommand.key) + } + }, [currentCommand, interventionModalCommandKey]) + return ( <> {runStatus === RUN_STATUS_STOP_REQUESTED ? : null} @@ -142,6 +170,17 @@ export function RunningProtocol(): JSX.Element { isActiveRun={true} /> ) : null} + {interventionModalCommandKey != null && + runRecord?.data != null && + currentCommand != null ? ( + + ) : null} getDeckDefFromRobotType(robotType), [ @@ -146,7 +152,14 @@ export function MoveLabwareOnDeck( transform="scale(1, -1)" // reflect horizontally about the center {...styleProps} > - {deckDef != null && } + {deckDef != null && ( + + )} {backgroundItems} Date: Thu, 27 Jul 2023 11:09:54 -0400 Subject: [PATCH 11/25] feat(app): ODD add error codes to protocol failed modal (#13158) --- api-client/src/runs/types.ts | 3 + .../assets/localization/en/run_details.json | 1 + .../RunningProtocol/RunFailedModal.tsx | 95 +++++++++++++++---- .../__tests__/RunFailedModal.test.tsx | 57 +++++++++-- .../RunTimeControl/__fixtures__/index.ts | 3 + 5 files changed, 130 insertions(+), 29 deletions(-) diff --git a/api-client/src/runs/types.ts b/api-client/src/runs/types.ts index defed3d1e192..77549b05da5f 100644 --- a/api-client/src/runs/types.ts +++ b/api-client/src/runs/types.ts @@ -113,6 +113,9 @@ export interface CommandData { export interface RunError { id: string errorType: string + errorInfo: { [key: string]: string } + wrappedErrors: RunError[] + errorCode: string createdAt: string detail: string } diff --git a/app/src/assets/localization/en/run_details.json b/app/src/assets/localization/en/run_details.json index ac6eb0e8df31..22b159815555 100644 --- a/app/src/assets/localization/en/run_details.json +++ b/app/src/assets/localization/en/run_details.json @@ -35,6 +35,7 @@ "end_step_time": "End", "end": "End", "error_type": "Error: {{errorType}}", + "error_info": "Error {{errorCode}}: {{errorType}}", "failed_step": "Failed step", "ignore_stored_data": "Ignore stored data", "labware_offset_data": "labware offset data", diff --git a/app/src/organisms/OnDeviceDisplay/RunningProtocol/RunFailedModal.tsx b/app/src/organisms/OnDeviceDisplay/RunningProtocol/RunFailedModal.tsx index 37a30cb32a0f..637bf5e04333 100644 --- a/app/src/organisms/OnDeviceDisplay/RunningProtocol/RunFailedModal.tsx +++ b/app/src/organisms/OnDeviceDisplay/RunningProtocol/RunFailedModal.tsx @@ -1,6 +1,7 @@ import * as React from 'react' import { useTranslation } from 'react-i18next' import { useHistory } from 'react-router-dom' +import isEmpty from 'lodash/isEmpty' import { css } from 'styled-components' import { @@ -20,13 +21,7 @@ import { SmallButton } from '../../../atoms/buttons' import { Modal } from '../../../molecules/Modal' import type { ModalHeaderBaseProps } from '../../../molecules/Modal/types' - -interface RunError { - id: string - errorType: string - createdAt: string - detail: string -} +import type { RunError } from '@opentrons/api-client' interface RunFailedModalProps { runId: string @@ -44,11 +39,13 @@ export function RunFailedModal({ const { stopRun } = useStopRunMutation() const [isCanceling, setIsCanceling] = React.useState(false) - if (errors == null) return null + if (errors == null || errors.length === 0) return null const modalHeader: ModalHeaderBaseProps = { title: t('run_failed_modal_title'), } + const highestPriorityError = getHighestPriorityError(errors) + const handleClose = (): void => { setIsCanceling(true) setShowRunFailedModal(false) @@ -76,8 +73,9 @@ export function RunFailedModal({ alignItems={ALIGN_FLEX_START} > - {t('error_type', { - errorType: errors[0].errorType, + {t('error_info', { + errorType: highestPriorityError.errorType, + errorCode: highestPriorityError.errorCode, })} - - {errors?.map(error => ( - - {error.detail} + + + {highestPriorityError.detail} + + {!isEmpty(highestPriorityError.errorInfo) && ( + + {JSON.stringify(highestPriorityError.errorInfo)} - ))} + )} @@ -135,3 +132,63 @@ const SCROLL_BAR_STYLE = css` border-radius: 11px; } ` + +const _getHighestPriorityError = (error: RunError): RunError => { + if (error.wrappedErrors.length === 0) { + return error + } + + let highestPriorityError = error + + error.wrappedErrors.forEach(wrappedError => { + const e = _getHighestPriorityError(wrappedError) + const isHigherPriority = _getIsHigherPriority( + e.errorCode, + highestPriorityError.errorCode + ) + if (isHigherPriority) { + highestPriorityError = e + } + }) + return highestPriorityError +} + +/** + * returns true if the first error code is higher priority than the second, false otherwise + */ +const _getIsHigherPriority = ( + errorCode1: string, + errorCode2: string +): boolean => { + const errorNumber1 = Number(errorCode1) + const errorNumber2 = Number(errorCode2) + + const isSameCategory = + Math.floor(errorNumber1 / 1000) === Math.floor(errorNumber2 / 1000) + const isCode1GenericError = errorNumber1 % 1000 === 0 + + let isHigherPriority = null + + if ( + (isSameCategory && !isCode1GenericError) || + (!isSameCategory && errorNumber1 < errorNumber2) + ) { + isHigherPriority = true + } else { + isHigherPriority = false + } + + return isHigherPriority +} + +export const getHighestPriorityError = (errors: RunError[]): RunError => { + const highestFirstWrappedError = _getHighestPriorityError(errors[0]) + return [highestFirstWrappedError, ...errors.slice(1)].reduce((acc, val) => { + const e = _getHighestPriorityError(val) + const isHigherPriority = _getIsHigherPriority(e.errorCode, acc.errorCode) + if (isHigherPriority) { + return e + } + return acc + }) +} diff --git a/app/src/organisms/OnDeviceDisplay/RunningProtocol/__tests__/RunFailedModal.test.tsx b/app/src/organisms/OnDeviceDisplay/RunningProtocol/__tests__/RunFailedModal.test.tsx index bb0f98546e4a..b8bf79aad9e5 100644 --- a/app/src/organisms/OnDeviceDisplay/RunningProtocol/__tests__/RunFailedModal.test.tsx +++ b/app/src/organisms/OnDeviceDisplay/RunningProtocol/__tests__/RunFailedModal.test.tsx @@ -16,12 +16,53 @@ const mockPush = jest.fn() const mockErrors = [ { id: 'd0245210-dfb9-4f1c-8ad0-3416b603a7ba', - errorType: 'ExceptionInProtocolError', + errorType: 'generalError', createdAt: '2023-04-09T21:41:51.333171+00:00', - detail: - 'ProtocolEngineError [line 16]: ModuleNotAttachedError: No available', + detail: 'Error with code 4000 (lowest priority)', + errorInfo: {}, + errorCode: '4000', + wrappedErrors: [ + { + id: 'd0245210-dfb9-4f1c-8ad0-3416b603a7ba', + errorType: 'roboticsInteractionError', + createdAt: '2023-04-09T21:41:51.333171+00:00', + detail: 'Error with code 3000 (second lowest priortiy)', + errorInfo: {}, + errorCode: '3000', + wrappedErrors: [], + }, + { + id: 'd0245210-dfb9-4f1c-8ad0-3416b603a7ba', + errorType: 'roboticsControlError', + createdAt: '2023-04-09T21:41:51.333171+00:00', + detail: 'Error with code 2000 (second highest priority)', + errorInfo: {}, + errorCode: '2000', + wrappedErrors: [ + { + id: 'd0245210-dfb9-4f1c-8ad0-3416b603a7ba', + errorType: 'hardwareCommunicationError', + createdAt: '2023-04-09T21:41:51.333171+00:00', + detail: 'Error with code 1000 (highest priority)', + errorInfo: {}, + errorCode: '1000', + wrappedErrors: [], + }, + ], + }, + ], + }, + { + id: 'd0245210-dfb9-4f1c-8ad0-3416b603a7ba', + errorType: 'roboticsInteractionError', + createdAt: '2023-04-09T21:41:51.333171+00:00', + detail: 'Error with code 2001 (second highest priortiy)', + errorInfo: {}, + errorCode: '2001', + wrappedErrors: [], }, ] + let mockStopRun: jest.Mock jest.mock('react-router-dom', () => { @@ -60,12 +101,11 @@ describe('RunFailedModal', () => { mockUseStopRunMutation.mockReturnValue({ stopRun: mockStopRun } as any) }) - it('should render text and button', () => { + it('should render the highest priority error', () => { const [{ getByText }] = render(props) getByText('Run failed') - getByText( - 'ProtocolEngineError [line 16]: ModuleNotAttachedError: No available' - ) + getByText('Error 1000: hardwareCommunicationError') + getByText('Error with code 1000 (highest priority)') getByText( 'Download the run logs from the Opentrons App and send it to support@opentrons.com for assistance.' ) @@ -79,7 +119,4 @@ describe('RunFailedModal', () => { expect(mockStopRun).toHaveBeenCalled() expect(mockPush).toHaveBeenCalledWith('/dashboard') }) - // ToDo (kj:04/12/2023) I made this test todo since we need the system update to align with the design. - // This test will be added when we can get error code and other information - it.todo('should render error code and message') }) diff --git a/app/src/organisms/RunTimeControl/__fixtures__/index.ts b/app/src/organisms/RunTimeControl/__fixtures__/index.ts index a748a6c985d7..1a18a9a6bcf7 100644 --- a/app/src/organisms/RunTimeControl/__fixtures__/index.ts +++ b/app/src/organisms/RunTimeControl/__fixtures__/index.ts @@ -125,6 +125,9 @@ export const mockFailedRun: RunData = { errorType: 'RuntimeError', createdAt: 'noon forty-five', detail: 'this run failed', + errorInfo: {}, + wrappedErrors: [], + errorCode: '4000', }, ], pipettes: [], From 68a054601925d83b70afa63a98b7a95bc5a5edd4 Mon Sep 17 00:00:00 2001 From: Frank Sinapi Date: Thu, 27 Jul 2023 11:51:47 -0400 Subject: [PATCH 12/25] feat(robot-server): red light when estop is engaged (#13173) * add hardware api function to get the estop state * set up light control task to accept no engine store & base status on both estop and engine store * Add ability to define callbacks after hardware initialization & hook that up to start the light control task to avoid circular dependency issues * Fixed tests and added some more tests --- api/src/opentrons/hardware_control/api.py | 4 + api/src/opentrons/hardware_control/ot3api.py | 4 + .../protocols/chassis_accessory_manager.py | 10 ++- robot-server/robot_server/app_setup.py | 7 +- robot-server/robot_server/hardware.py | 22 ++++- .../robot_server/runs/dependencies.py | 67 ++++++++++----- .../robot_server/runs/light_control_task.py | 44 ++++++++-- .../tests/runs/test_light_control_task.py | 85 +++++++++++++++++-- 8 files changed, 198 insertions(+), 45 deletions(-) diff --git a/api/src/opentrons/hardware_control/api.py b/api/src/opentrons/hardware_control/api.py index 9c5bd691ac37..0537d2a7dbed 100644 --- a/api/src/opentrons/hardware_control/api.py +++ b/api/src/opentrons/hardware_control/api.py @@ -50,6 +50,7 @@ MotionChecks, PauseType, StatusBarState, + EstopState, ) from .errors import ( MustHomeError, @@ -1158,3 +1159,6 @@ def _string_map_from_axis_map( self, input_map: Dict[Axis, "API.MapPayload"] ) -> Dict[str, "API.MapPayload"]: return {ot2_axis_to_string(k): v for k, v in input_map.items()} + + def get_estop_state(self) -> EstopState: + return EstopState.DISENGAGED diff --git a/api/src/opentrons/hardware_control/ot3api.py b/api/src/opentrons/hardware_control/ot3api.py index 48b4ff4b9155..a248a624305e 100644 --- a/api/src/opentrons/hardware_control/ot3api.py +++ b/api/src/opentrons/hardware_control/ot3api.py @@ -99,6 +99,7 @@ TipStateType, EstopOverallStatus, EstopAttachLocation, + EstopState, ) from .errors import ( MustHomeError, @@ -2262,3 +2263,6 @@ def estop_acknowledge_and_clear(self) -> EstopOverallStatus: Returns the estop status after clearing the status.""" self._backend.estop_state_machine.acknowledge_and_clear() return self.estop_status + + def get_estop_state(self) -> EstopState: + return self._backend.estop_state_machine.state diff --git a/api/src/opentrons/hardware_control/protocols/chassis_accessory_manager.py b/api/src/opentrons/hardware_control/protocols/chassis_accessory_manager.py index 081343fac8e9..8b9d3948508e 100644 --- a/api/src/opentrons/hardware_control/protocols/chassis_accessory_manager.py +++ b/api/src/opentrons/hardware_control/protocols/chassis_accessory_manager.py @@ -1,6 +1,6 @@ from typing import Dict, Optional from typing_extensions import Protocol -from ..types import DoorState, StatusBarState +from ..types import DoorState, StatusBarState, EstopState from .event_sourcer import EventSourcer @@ -60,3 +60,11 @@ def get_status_bar_state(self) -> StatusBarState: :returns: The current status bar state enumeration.""" ... + + def get_estop_state(self) -> EstopState: + """Get the current Estop state. + + If the Estop is not supported on this robot, this will always return Disengaged. + + :returns: The current Estop state.""" + ... diff --git a/robot-server/robot_server/app_setup.py b/robot-server/robot_server/app_setup.py index 27469e7f9e82..88c40a8fdc07 100644 --- a/robot-server/robot_server/app_setup.py +++ b/robot-server/robot_server/app_setup.py @@ -16,6 +16,7 @@ clean_up_task_runner, ) from .settings import get_settings +from .runs.dependencies import start_light_control_task log = logging.getLogger(__name__) @@ -52,7 +53,10 @@ async def on_startup() -> None: settings = get_settings() initialize_logging() - start_initializing_hardware(app_state=app.state) + initialize_task_runner(app_state=app.state) + start_initializing_hardware( + app_state=app.state, callbacks=[start_light_control_task] + ) start_initializing_persistence( app_state=app.state, persistence_directory=( @@ -61,7 +65,6 @@ async def on_startup() -> None: else settings.persistence_directory ), ) - initialize_task_runner(app_state=app.state) @app.on_event("shutdown") diff --git a/robot-server/robot_server/hardware.py b/robot-server/robot_server/hardware.py index ed9bca3c5ee6..3420a1d88463 100644 --- a/robot-server/robot_server/hardware.py +++ b/robot-server/robot_server/hardware.py @@ -3,7 +3,7 @@ import logging from pathlib import Path from fastapi import Depends, status -from typing import Callable, Union, TYPE_CHECKING, cast, Awaitable, Iterator +from typing import Callable, Union, TYPE_CHECKING, cast, Awaitable, Iterator, Iterable from uuid import uuid4 # direct to avoid import cycles in service.dependencies from traceback import format_exception_only, TracebackException from contextlib import contextmanager @@ -57,6 +57,8 @@ if TYPE_CHECKING: from opentrons.hardware_control.ot3api import OT3API +# Function that can be called when hardware initialization completes +PostInitCallback = Callable[[AppState, HardwareControlAPI], Awaitable[None]] log = logging.getLogger(__name__) @@ -79,15 +81,22 @@ def __init__(self, payload: TracebackException) -> None: self.payload = payload -def start_initializing_hardware(app_state: AppState) -> None: +def start_initializing_hardware( + app_state: AppState, callbacks: Iterable[PostInitCallback] +) -> None: """Initialize the hardware API singleton, attaching it to global state. Returns immediately while the hardware API initializes in the background. + + Any defined callbacks will be called after the hardware API is initialized, but + before the post-init tasks are executed. """ initialize_task = _init_task_accessor.get_from(app_state) if initialize_task is None: - initialize_task = asyncio.create_task(_initialize_hardware_api(app_state)) + initialize_task = asyncio.create_task( + _initialize_hardware_api(app_state, callbacks) + ) _init_task_accessor.set_on(app_state, initialize_task) @@ -441,7 +450,9 @@ def _format_exc(log_prefix: str) -> Iterator[None]: log.error(f"{log_prefix}: {format_exception_only(type(be), be)}") -async def _initialize_hardware_api(app_state: AppState) -> None: +async def _initialize_hardware_api( + app_state: AppState, callbacks: Iterable[PostInitCallback] +) -> None: """Initialize the HardwareAPI and attach it to global state.""" app_settings = get_settings() systemd_available = IS_ROBOT and ARCHITECTURE != SystemArchitecture.HOST @@ -458,6 +469,9 @@ async def _initialize_hardware_api(app_state: AppState) -> None: _initialize_event_watchers(app_state, hardware) _hw_api_accessor.set_on(app_state, hardware) + for callback in callbacks: + await callback(app_state, hardware.wrapped()) + _systemd_notify(systemd_available) if should_use_ot3(): diff --git a/robot-server/robot_server/runs/dependencies.py b/robot-server/robot_server/runs/dependencies.py index b54cff5a0c18..faebfb2e6181 100644 --- a/robot-server/robot_server/runs/dependencies.py +++ b/robot-server/robot_server/runs/dependencies.py @@ -1,5 +1,5 @@ """Run router dependency-injection wire-up.""" -from fastapi import Depends +from fastapi import Depends, status from sqlalchemy.engine import Engine as SQLEngine from opentrons_shared_data.robot.dev_types import RobotType @@ -12,7 +12,11 @@ AppStateAccessor, get_app_state, ) -from robot_server.hardware import get_hardware, get_deck_type, get_robot_type +from robot_server.hardware import ( + get_hardware, + get_deck_type, + get_robot_type, +) from robot_server.persistence import get_sql_engine from robot_server.service.task_runner import get_task_runner, TaskRunner from robot_server.settings import get_settings @@ -22,6 +26,9 @@ from .engine_store import EngineStore from .run_store import RunStore from .run_data_manager import RunDataManager +from robot_server.errors.robot_errors import ( + HardwareNotYetInitialized, +) from .light_control_task import LightController, run_light_task _run_store_accessor = AppStateAccessor[RunStore]("run_store") @@ -43,11 +50,47 @@ async def get_run_store( return run_store +async def start_light_control_task( + app_state: AppState, + hardware_api: HardwareControlAPI, +) -> None: + """Should be called once to start the light control task during server initialization. + + Note that this function lives in robot_server.runs instead of the robot_server.hardware + module (where it would more logically fit) due to circular dependencies; the hardware + module depends on multiple routers that depend on the hardware module. + """ + light_controller = _light_control_accessor.get_from(app_state) + + if light_controller is None: + light_controller = LightController(api=hardware_api, engine_store=None) + get_task_runner(app_state=app_state).run( + run_light_task, driver=light_controller + ) + _light_control_accessor.set_on(app_state, light_controller) + + return None + + +async def get_light_controller( + app_state: AppState = Depends(get_app_state), +) -> LightController: + """Get the light controller as a dependency. + + Raises a `HardwareNotYetInitialized` if the light controller hasn't been started yet. + """ + controller = _light_control_accessor.get_from(app_state=app_state) + if controller is None: + raise HardwareNotYetInitialized().as_error(status.HTTP_503_SERVICE_UNAVAILABLE) + return controller + + async def get_engine_store( app_state: AppState = Depends(get_app_state), hardware_api: HardwareControlAPI = Depends(get_hardware), robot_type: RobotType = Depends(get_robot_type), deck_type: DeckType = Depends(get_deck_type), + light_controller: LightController = Depends(get_light_controller), ) -> EngineStore: """Get a singleton EngineStore to keep track of created engines / runners.""" engine_store = _engine_store_accessor.get_from(app_state) @@ -57,6 +100,8 @@ async def get_engine_store( hardware_api=hardware_api, robot_type=robot_type, deck_type=deck_type ) _engine_store_accessor.set_on(app_state, engine_store) + # Provide the engine store to the light controller + light_controller.update_engine_store(engine_store=engine_store) return engine_store @@ -72,28 +117,10 @@ async def get_protocol_run_has_been_played( return protocol_run_state.commands.has_been_played() -async def ensure_light_control_task( - app_state: AppState = Depends(get_app_state), - engine_store: EngineStore = Depends(get_engine_store), - task_runner: TaskRunner = Depends(get_task_runner), - api: HardwareControlAPI = Depends(get_hardware), -) -> None: - """Ensure the light control task is running.""" - light_controller = _light_control_accessor.get_from(app_state) - - if light_controller is None: - light_controller = LightController(api=api, engine_store=engine_store) - task_runner.run(run_light_task, driver=light_controller) - _light_control_accessor.set_on(app_state, light_controller) - - return None - - async def get_run_data_manager( task_runner: TaskRunner = Depends(get_task_runner), engine_store: EngineStore = Depends(get_engine_store), run_store: RunStore = Depends(get_run_store), - light_control: None = Depends(ensure_light_control_task), ) -> RunDataManager: """Get a run data manager to keep track of current/historical run data.""" return RunDataManager( diff --git a/robot-server/robot_server/runs/light_control_task.py b/robot-server/robot_server/runs/light_control_task.py index 3148f5de55d2..0fb438107686 100644 --- a/robot-server/robot_server/runs/light_control_task.py +++ b/robot-server/robot_server/runs/light_control_task.py @@ -2,15 +2,24 @@ from typing import Optional from logging import getLogger import asyncio +from dataclasses import dataclass from .engine_store import EngineStore from opentrons.hardware_control import HardwareControlAPI from opentrons.protocol_engine.types import EngineStatus -from opentrons.hardware_control.types import StatusBarState +from opentrons.hardware_control.types import StatusBarState, EstopState log = getLogger(__name__) +@dataclass +class Status: + """Class to encapsulate overall status of the system as it pertains to the light control task.""" + + estop_status: EstopState + engine_status: Optional[EngineStatus] + + def _engine_status_to_status_bar(status: Optional[EngineStatus]) -> StatusBarState: """Convert an engine status into a status bar status.""" if status is None: @@ -32,31 +41,48 @@ def _engine_status_to_status_bar(status: Optional[EngineStatus]) -> StatusBarSta class LightController: """LightController sets the status bar to match the protocol status.""" - def __init__(self, api: HardwareControlAPI, engine_store: EngineStore) -> None: + def __init__( + self, api: HardwareControlAPI, engine_store: Optional[EngineStore] + ) -> None: """Create a new LightController.""" self._api = api self._engine_store = engine_store - async def update( - self, prev_status: Optional[EngineStatus], new_status: Optional[EngineStatus] - ) -> None: + def update_engine_store(self, engine_store: EngineStore) -> None: + """Provide a handle to an EngineStore for the light control task.""" + self._engine_store = engine_store + + async def update(self, prev_status: Status, new_status: Status) -> None: """Update the status bar if the current run status has changed.""" if prev_status == new_status: # No change, don't try to set anything. return - await self._api.set_status_bar_state( - state=_engine_status_to_status_bar(status=new_status) - ) + if new_status.estop_status == EstopState.PHYSICALLY_ENGAGED: + # Estop takes precedence + await self._api.set_status_bar_state(state=StatusBarState.SOFTWARE_ERROR) + else: + await self._api.set_status_bar_state( + state=_engine_status_to_status_bar(status=new_status.engine_status) + ) - def get_current_status(self) -> Optional[EngineStatus]: + def _get_current_engine_status(self) -> Optional[EngineStatus]: """Get the `status` value from the engine's active run engine.""" + if self._engine_store is None: + return None current_id = self._engine_store.current_run_id if current_id is not None: return self._engine_store.engine.state_view.commands.get_status() return None + def get_current_status(self) -> Status: + """Get the overall status of the system for light purposes.""" + return Status( + estop_status=self._api.get_estop_state(), + engine_status=self._get_current_engine_status(), + ) + async def run_light_task(driver: LightController) -> None: """Run the light control task. diff --git a/robot-server/tests/runs/test_light_control_task.py b/robot-server/tests/runs/test_light_control_task.py index af382bdbacac..5b464e721284 100644 --- a/robot-server/tests/runs/test_light_control_task.py +++ b/robot-server/tests/runs/test_light_control_task.py @@ -5,10 +5,10 @@ from decoy import Decoy from opentrons.hardware_control import HardwareControlAPI -from opentrons.hardware_control.types import StatusBarState +from opentrons.hardware_control.types import StatusBarState, EstopState from opentrons.protocol_engine.types import EngineStatus from robot_server.runs.engine_store import EngineStore -from robot_server.runs.light_control_task import LightController +from robot_server.runs.light_control_task import LightController, Status @pytest.fixture @@ -26,12 +26,16 @@ def subject( @pytest.mark.parametrize( - ["active", "status"], [ - [False, EngineStatus.IDLE], - [True, EngineStatus.IDLE], - [True, EngineStatus.RUNNING], - [False, EngineStatus.FAILED], + "active", + "status", + "estop", + ], + [ + [False, EngineStatus.IDLE, EstopState.DISENGAGED], + [True, EngineStatus.IDLE, EstopState.PHYSICALLY_ENGAGED], + [True, EngineStatus.RUNNING, EstopState.LOGICALLY_ENGAGED], + [False, EngineStatus.FAILED, EstopState.NOT_PRESENT], ], ) async def test_get_current_status( @@ -40,12 +44,18 @@ async def test_get_current_status( subject: LightController, active: bool, status: EngineStatus, + estop: EstopState, + hardware_api: HardwareControlAPI, ) -> None: """Test LightController.get_current_status.""" decoy.when(engine_store.current_run_id).then_return("fake_id" if active else None) decoy.when(engine_store.engine.state_view.commands.get_status()).then_return(status) + decoy.when(hardware_api.get_estop_state()).then_return(estop) - expected = status if active else None + expected = Status( + estop_status=estop, + engine_status=status if active else None, + ) assert subject.get_current_status() == expected @@ -83,10 +93,67 @@ async def test_light_controller_update( Verifies that the status bar is NOT updated if the state is the same, and checks that state mapping is correct. """ - await subject.update(prev_status=prev_state, new_status=new_state) + await subject.update( + prev_status=Status( + estop_status=EstopState.DISENGAGED, engine_status=prev_state + ), + new_status=Status(estop_status=EstopState.DISENGAGED, engine_status=new_state), + ) call_count = 0 if prev_state == new_state else 1 decoy.verify( await hardware_api.set_status_bar_state(state=expected), times=call_count ) + + +async def test_provide_engine_store( + decoy: Decoy, hardware_api: HardwareControlAPI, engine_store: EngineStore +) -> None: + """Test providing an engine store after initialization.""" + subject = LightController(api=hardware_api, engine_store=None) + decoy.when(hardware_api.get_estop_state()).then_return(EstopState.DISENGAGED) + assert subject.get_current_status() == Status( + estop_status=EstopState.DISENGAGED, + engine_status=None, + ) + + decoy.when(engine_store.current_run_id).then_return("fake_id") + decoy.when(engine_store.engine.state_view.commands.get_status()).then_return( + EngineStatus.RUNNING + ) + + subject.update_engine_store(engine_store=engine_store) + assert subject.get_current_status() == Status( + estop_status=EstopState.DISENGAGED, + engine_status=EngineStatus.RUNNING, + ) + + +async def test_estop_precedence( + decoy: Decoy, + hardware_api: HardwareControlAPI, + subject: LightController, +) -> None: + """Test that the estop is prioritized.""" + # Software error + await subject.update( + prev_status=Status(EstopState.PHYSICALLY_ENGAGED, None), + new_status=Status(EstopState.PHYSICALLY_ENGAGED, EngineStatus.RUNNING), + ) + # Running + await subject.update( + prev_status=Status(EstopState.PHYSICALLY_ENGAGED, EngineStatus.RUNNING), + new_status=Status(EstopState.LOGICALLY_ENGAGED, EngineStatus.RUNNING), + ) + # Software error + await subject.update( + prev_status=Status(EstopState.LOGICALLY_ENGAGED, EngineStatus.RUNNING), + new_status=Status(EstopState.PHYSICALLY_ENGAGED, EngineStatus.IDLE), + ) + + decoy.verify( + await hardware_api.set_status_bar_state(state=StatusBarState.SOFTWARE_ERROR), + await hardware_api.set_status_bar_state(state=StatusBarState.RUNNING), + await hardware_api.set_status_bar_state(state=StatusBarState.SOFTWARE_ERROR), + ) From 95952f0ca532ee8e216b2e96b5dd6571af254b2b Mon Sep 17 00:00:00 2001 From: Andy Sigler Date: Thu, 27 Jul 2023 14:19:06 -0400 Subject: [PATCH 13/25] decrease 96ch Z holding current from 0,8 to 0.5 amps (#13180) --- api/src/opentrons/config/defaults_ot3.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/src/opentrons/config/defaults_ot3.py b/api/src/opentrons/config/defaults_ot3.py index ddf65b5331f9..2918e4d54be3 100644 --- a/api/src/opentrons/config/defaults_ot3.py +++ b/api/src/opentrons/config/defaults_ot3.py @@ -159,7 +159,7 @@ high_throughput={ OT3AxisKind.X: 0.5, OT3AxisKind.Y: 0.5, - OT3AxisKind.Z: 0.8, + OT3AxisKind.Z: 0.5, OT3AxisKind.P: 0.3, OT3AxisKind.Z_G: 0.2, OT3AxisKind.Q: 0.3, From 88b66dfd68fc369818748e56309724b616073ea2 Mon Sep 17 00:00:00 2001 From: Andy Sigler Date: Thu, 27 Jul 2023 14:51:04 -0400 Subject: [PATCH 14/25] feat(api, hardware-testing): Support liquid-probe in pipette QC for PVT build (#13138) --- .../hardware_control/backends/ot3simulator.py | 5 +- api/src/opentrons/hardware_control/ot3api.py | 57 +- .../hardware_control/test_ot3_api.py | 61 +- hardware-testing/Makefile | 1 + .../pressure_fixture_ot3.ino | 2 +- .../hardware_testing/data/csv_report.py | 6 + .../hardware_testing/drivers/__init__.py | 1 + .../drivers/pressure_fixture.py | 8 +- .../hardware_testing/measure/__init__.py | 1 - .../measure/pressure/__init__.py | 1 - .../opentrons_api/helpers_ot3.py | 48 +- .../production_qc/firmware_check.py | 64 ++ .../pipette_assembly_qc_ot3/__init__.py | 1 + .../__main__.py} | 755 ++++++++++++++---- .../pipette_assembly_qc_ot3/pressure.py} | 30 +- .../pipette_current_speed_qc_ot3.py | 132 +-- .../scripts/provision_pipette.py | 21 +- .../2/liquid/eight_channel/p1000/3_5.json | 1 - 18 files changed, 880 insertions(+), 315 deletions(-) delete mode 100644 hardware-testing/hardware_testing/measure/__init__.py delete mode 100644 hardware-testing/hardware_testing/measure/pressure/__init__.py create mode 100644 hardware-testing/hardware_testing/production_qc/firmware_check.py create mode 100644 hardware-testing/hardware_testing/production_qc/pipette_assembly_qc_ot3/__init__.py rename hardware-testing/hardware_testing/production_qc/{pipette_assembly_qc_ot3.py => pipette_assembly_qc_ot3/__main__.py} (60%) rename hardware-testing/hardware_testing/{measure/pressure/config.py => production_qc/pipette_assembly_qc_ot3/pressure.py} (81%) diff --git a/api/src/opentrons/hardware_control/backends/ot3simulator.py b/api/src/opentrons/hardware_control/backends/ot3simulator.py index 6316af70ce50..a79e636e25e6 100644 --- a/api/src/opentrons/hardware_control/backends/ot3simulator.py +++ b/api/src/opentrons/hardware_control/backends/ot3simulator.py @@ -294,13 +294,14 @@ async def liquid_probe( auto_zero_sensor: bool = True, num_baseline_reads: int = 10, sensor_id: SensorId = SensorId.S0, - ) -> None: + ) -> Dict[NodeId, float]: head_node = axis_to_node(Axis.by_mount(mount)) pos = self._position - pos[head_node] = max_z_distance - 2 + pos[head_node] += max_z_distance self._position.update(pos) self._encoder_position.update(pos) + return self._position @ensure_yield async def move( diff --git a/api/src/opentrons/hardware_control/ot3api.py b/api/src/opentrons/hardware_control/ot3api.py index a248a624305e..affb79c368c2 100644 --- a/api/src/opentrons/hardware_control/ot3api.py +++ b/api/src/opentrons/hardware_control/ot3api.py @@ -68,7 +68,6 @@ from .backends.ot3utils import ( get_system_constraints, get_system_constraints_for_calibration, - axis_convert, ) from .backends.errors import SubsystemUpdating from .execution_manager import ExecutionManagerProvider @@ -91,8 +90,6 @@ GripperJawState, InstrumentProbeType, GripperProbe, - EarlyLiquidSenseTrigger, - LiquidNotFound, UpdateStatus, StatusBarState, SubSystemState, @@ -2058,25 +2055,27 @@ async def liquid_probe( if not probe_settings: probe_settings = self.config.liquid_sense - mount_axis = Axis.by_mount(mount) - gantry_position = await self.gantry_position(mount, refresh=True) - - await self.move_to( - mount, - top_types.Point( - x=gantry_position.x, - y=gantry_position.y, - z=probe_settings.starting_mount_height, - ), - ) + pos = await self.gantry_position(mount, refresh=True) + probe_start_pos = pos._replace(z=probe_settings.starting_mount_height) + await self.move_to(mount, probe_start_pos) if probe_settings.aspirate_while_sensing: - await self.home_plunger(mount) + await self._move_to_plunger_bottom(mount, rate=1.0) + else: + # TODO: shorten this distance by only moving just far enough + # to account for the specified "max-z-distance" + target_pos = target_position_from_plunger( + checked_mount, instrument.plunger_positions.top, self._current_position + ) + # FIXME: this should really be the slower "aspirate" speed, + # but this is still in testing phase so let's bias towards speed + max_speeds = self.config.motion_settings.default_max_speed + speed = max_speeds[self.gantry_load][OT3AxisKind.P] + await self._move(target_pos, speed=speed, acquire_lock=True) plunger_direction = -1 if probe_settings.aspirate_while_sensing else 1 - - machine_pos_node_id = await self._backend.liquid_probe( + await self._backend.liquid_probe( mount, probe_settings.max_z_distance, probe_settings.mount_speed, @@ -2086,27 +2085,9 @@ async def liquid_probe( probe_settings.auto_zero_sensor, probe_settings.num_baseline_reads, ) - machine_pos = axis_convert(machine_pos_node_id, 0.0) - position = self._deck_from_machine(machine_pos) - z_distance_traveled = ( - position[mount_axis] - probe_settings.starting_mount_height - ) - if z_distance_traveled < probe_settings.min_z_distance: - min_z_travel_pos = position - min_z_travel_pos[mount_axis] = probe_settings.min_z_distance - raise EarlyLiquidSenseTrigger( - triggered_at=position, - min_z_pos=min_z_travel_pos, - ) - elif z_distance_traveled > probe_settings.max_z_distance: - max_z_travel_pos = position - max_z_travel_pos[mount_axis] = probe_settings.max_z_distance - raise LiquidNotFound( - position=position, - max_z_pos=max_z_travel_pos, - ) - - return position[mount_axis] + end_pos = await self.gantry_position(mount, refresh=True) + await self.move_to(mount, probe_start_pos) + return end_pos.z async def capacitive_probe( self, diff --git a/api/tests/opentrons/hardware_control/test_ot3_api.py b/api/tests/opentrons/hardware_control/test_ot3_api.py index 4f4b7d361e34..b8af4396ce2d 100644 --- a/api/tests/opentrons/hardware_control/test_ot3_api.py +++ b/api/tests/opentrons/hardware_control/test_ot3_api.py @@ -4,7 +4,7 @@ from typing_extensions import Literal from math import copysign import pytest -from mock import AsyncMock, patch, Mock, call, PropertyMock +from mock import AsyncMock, patch, Mock, call, PropertyMock, MagicMock from hypothesis import given, strategies, settings, HealthCheck, assume, example from opentrons.calibration_storage.types import CalibrationStatus, SourceType @@ -41,8 +41,6 @@ CriticalPoint, GripperProbe, InstrumentProbeType, - LiquidNotFound, - EarlyLiquidSenseTrigger, SubSystem, GripperJawState, StatusBarState, @@ -263,13 +261,13 @@ async def mock_refresh(ot3_hardware: ThreadManager[OT3API]) -> Iterator[AsyncMoc @pytest.fixture async def mock_instrument_handlers( ot3_hardware: ThreadManager[OT3API], -) -> Iterator[Tuple[Mock]]: +) -> Iterator[Tuple[MagicMock]]: with patch.object( ot3_hardware.managed_obj, "_gripper_handler", - Mock(spec=GripperHandler), + MagicMock(spec=GripperHandler), ) as mock_gripper_handler, patch.object( - ot3_hardware.managed_obj, "_pipette_handler", Mock(spec=OT3PipetteHandler) + ot3_hardware.managed_obj, "_pipette_handler", MagicMock(spec=OT3PipetteHandler) ) as mock_pipette_handler: yield mock_gripper_handler, mock_pipette_handler @@ -579,10 +577,10 @@ async def test_liquid_probe( pipette_node: Axis, mount: OT3Mount, fake_liquid_settings: LiquidProbeSettings, - mock_instrument_handlers: Tuple[Mock], + mock_instrument_handlers: Tuple[MagicMock], mock_current_position_ot3: AsyncMock, mock_ungrip: AsyncMock, - mock_home_plunger: AsyncMock, + mock_move_to_plunger_bottom: AsyncMock, ) -> None: mock_ungrip.return_value = None backend = ot3_hardware.managed_obj._backend @@ -616,7 +614,7 @@ async def test_liquid_probe( data_file="fake_file_name", ) await ot3_hardware.liquid_probe(mount, fake_settings_aspirate) - mock_home_plunger.assert_called_once() + mock_move_to_plunger_bottom.assert_called_once() backend.liquid_probe.assert_called_once_with( mount, fake_settings_aspirate.max_z_distance, @@ -635,51 +633,6 @@ async def test_liquid_probe( ) # should raise no exceptions -@pytest.mark.parametrize( - "mount, head_node, pipette_node", - [ - (OT3Mount.LEFT, NodeId.head_l, NodeId.pipette_left), - (OT3Mount.RIGHT, NodeId.head_r, NodeId.pipette_right), - ], -) -async def test_liquid_sensing_errors( - mock_move_to: AsyncMock, - ot3_hardware: ThreadManager[OT3API], - head_node: NodeId, - pipette_node: NodeId, - mount: OT3Mount, - fake_liquid_settings: LiquidProbeSettings, - mock_instrument_handlers: Tuple[Mock], - mock_current_position_ot3: AsyncMock, - mock_home_plunger: AsyncMock, - mock_ungrip: AsyncMock, -) -> None: - backend = ot3_hardware.managed_obj._backend - mock_ungrip.return_value = None - await ot3_hardware.home() - mock_move_to.return_value = None - - with patch.object( - backend, "liquid_probe", AsyncMock(spec=backend.liquid_probe) - ) as mock_position: - return_dict = { - head_node: 103, - NodeId.gantry_x: 0, - NodeId.gantry_y: 0, - pipette_node: 200, - } - # should raise LiquidNotFound - mock_position.return_value = return_dict - with pytest.raises(LiquidNotFound): - await ot3_hardware.liquid_probe(mount, fake_liquid_settings) - - # should raise EarlyLiquidSenseTrigger - return_dict[head_node], return_dict[pipette_node] = 150, 150 - mock_position.return_value = return_dict - with pytest.raises(EarlyLiquidSenseTrigger): - await ot3_hardware.liquid_probe(mount, fake_liquid_settings) - - @pytest.mark.parametrize( "mount,moving", [ diff --git a/hardware-testing/Makefile b/hardware-testing/Makefile index ab41d0b76f86..60e31c7f5864 100755 --- a/hardware-testing/Makefile +++ b/hardware-testing/Makefile @@ -126,6 +126,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.firmware_check --simulate .PHONY: test-examples test-examples: diff --git a/hardware-testing/firmware/pressure_fixture_ot3/pressure_fixture_ot3.ino b/hardware-testing/firmware/pressure_fixture_ot3/pressure_fixture_ot3.ino index a2154f4a1994..414c54b9b8cd 100644 --- a/hardware-testing/firmware/pressure_fixture_ot3/pressure_fixture_ot3.ino +++ b/hardware-testing/firmware/pressure_fixture_ot3/pressure_fixture_ot3.ino @@ -3,7 +3,7 @@ #include"MMR920C04.h" #include "TCA9548A.h" -#define VERSION ("0.0.0") +#define VERSION ("1.0.0") String inputString = ""; // a String to hold incoming data bool stringComplete = false; // whether the string is complete diff --git a/hardware-testing/hardware_testing/data/csv_report.py b/hardware-testing/hardware_testing/data/csv_report.py index c18dd430b237..bc4032a95f78 100644 --- a/hardware-testing/hardware_testing/data/csv_report.py +++ b/hardware-testing/hardware_testing/data/csv_report.py @@ -44,6 +44,7 @@ def print_csv_result(test: str, result: CSVResult) -> None: META_DATA_TEST_TIME_UTC = "test_time_utc" META_DATA_TEST_OPERATOR = "test_operator" META_DATA_TEST_VERSION = "test_version" +META_DATA_TEST_FIRMWARE = "firmware" RESULTS_OVERVIEW_TITLE = "RESULTS_OVERVIEW" @@ -262,6 +263,7 @@ def _generate_meta_data_section() -> CSVSection: CSVLine(tag=META_DATA_TEST_TIME_UTC, data=[str]), CSVLine(tag=META_DATA_TEST_OPERATOR, data=[str]), CSVLine(tag=META_DATA_TEST_VERSION, data=[str]), + CSVLine(tag=META_DATA_TEST_FIRMWARE, data=[str]), ], ) @@ -388,6 +390,10 @@ def set_version(self, version: str) -> None: """Set version.""" self(META_DATA_TITLE, META_DATA_TEST_VERSION, [version]) + def set_firmware(self, firmware: str) -> None: + """Set firmware.""" + self(META_DATA_TITLE, META_DATA_TEST_FIRMWARE, [firmware]) + def save_to_disk(self) -> Path: """CSV Report save to disk.""" if not self._file_name: diff --git a/hardware-testing/hardware_testing/drivers/__init__.py b/hardware-testing/hardware_testing/drivers/__init__.py index 080746c79529..549384a69396 100644 --- a/hardware-testing/hardware_testing/drivers/__init__.py +++ b/hardware-testing/hardware_testing/drivers/__init__.py @@ -8,6 +8,7 @@ def list_ports_and_select(device_name: str = "") -> str: """List serial ports and display list for user to select from.""" ports = comports() assert ports, "no serial ports found" + ports.sort(key=lambda p: p.device) print("found ports:") for i, p in enumerate(ports): print(f"\t{i + 1}) {p.device}") diff --git a/hardware-testing/hardware_testing/drivers/pressure_fixture.py b/hardware-testing/hardware_testing/drivers/pressure_fixture.py index 873ff399702e..82b5ccbb2d33 100644 --- a/hardware-testing/hardware_testing/drivers/pressure_fixture.py +++ b/hardware-testing/hardware_testing/drivers/pressure_fixture.py @@ -11,6 +11,7 @@ FIXTURE_REBOOT_TIME = 2 FIXTURE_NUM_CHANNELS: Final[int] = 8 FIXTURE_BAUD_RATE: Final[int] = 115200 +FIXTURE_VERSION_REQUIRED = "1.0.0" FIXTURE_CMD_TERMINATOR = "\r\n" FIXTURE_CMD_GET_VERSION = "VERSION" @@ -89,7 +90,7 @@ def disconnect(self) -> None: def firmware_version(self) -> str: """Firmware version.""" - return "0.0.0" + return FIXTURE_VERSION_REQUIRED def read_all_pressure_channel(self) -> List[float]: """Read Pressure for all channels.""" @@ -121,7 +122,10 @@ def connect(self) -> None: self._port.flushInput() # NOTE: device might take a few seconds to boot up sleep(FIXTURE_REBOOT_TIME) - assert self.firmware_version(), "unable to communicate with pressure fixture" + fw_version = self.firmware_version() + assert ( + fw_version == FIXTURE_VERSION_REQUIRED + ), f"unexpected pressure-fixture version: {fw_version}" def disconnect(self) -> None: """Disconnect.""" diff --git a/hardware-testing/hardware_testing/measure/__init__.py b/hardware-testing/hardware_testing/measure/__init__.py deleted file mode 100644 index c8874ad7bd36..000000000000 --- a/hardware-testing/hardware_testing/measure/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Measure.""" diff --git a/hardware-testing/hardware_testing/measure/pressure/__init__.py b/hardware-testing/hardware_testing/measure/pressure/__init__.py deleted file mode 100644 index 2c93ae2f8ff8..000000000000 --- a/hardware-testing/hardware_testing/measure/pressure/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Measure pressure.""" diff --git a/hardware-testing/hardware_testing/opentrons_api/helpers_ot3.py b/hardware-testing/hardware_testing/opentrons_api/helpers_ot3.py index b1de2957112a..8250e8bb5d0e 100644 --- a/hardware-testing/hardware_testing/opentrons_api/helpers_ot3.py +++ b/hardware-testing/hardware_testing/opentrons_api/helpers_ot3.py @@ -18,6 +18,7 @@ from opentrons.config.robot_configs import build_config_ot3, load_ot3 as load_ot3_config from opentrons.config.advanced_settings import set_adv_setting from opentrons.hardware_control.backends.ot3utils import sensor_node_for_mount +from opentrons.hardware_control.types import SubSystem # TODO (lc 10-27-2022) This should be changed to an ot3 pipette object once we # have that well defined. @@ -38,6 +39,8 @@ TIP_LENGTH_OVERLAP = 10.5 TIP_LENGTH_LOOKUP = {50: 57.9, 200: 58.35, 1000: 95.6} +RESET_DELAY_SECONDS = 2 + @dataclass class CalibrationSquare: @@ -121,6 +124,42 @@ def _create_attached_instruments_dict( } +async def update_firmware( + api: OT3API, force: bool = False, subsystems: Optional[List[SubSystem]] = None +) -> None: + """Update firmware of OT3.""" + if not api.is_simulator: + await asyncio.sleep(RESET_DELAY_SECONDS) + subsystems_on_boot = api.attached_subsystems + progress_tracker: Dict[SubSystem, List[int]] = {} + + def _print_update_progress() -> None: + msg = "" + for _sub_sys, (_ver, _prog) in progress_tracker.items(): + if msg: + msg += ", " + msg += f"{_sub_sys.name}: v{_ver} ({_prog}%)" + print(msg) + + if not subsystems: + subsystems = [] + async for update in api.update_firmware(set(subsystems), force=force): + fw_version = subsystems_on_boot[update.subsystem].next_fw_version + if update.subsystem not in progress_tracker: + progress_tracker[update.subsystem] = [fw_version, 0] + if update.progress != progress_tracker[update.subsystem][1]: + progress_tracker[update.subsystem][1] = update.progress + _print_update_progress() + + +async def reset_api(api: OT3API) -> None: + """Reset OT3API.""" + if not api.is_simulator: + await asyncio.sleep(RESET_DELAY_SECONDS) + await api.cache_instruments() + await api.refresh_positions() + + async def build_async_ot3_hardware_api( is_simulating: Optional[bool] = False, use_defaults: Optional[bool] = True, @@ -164,10 +203,9 @@ async def build_async_ot3_hardware_api( kwargs["use_usb_bus"] = False # type: ignore[assignment] api = await builder(loop=loop, **kwargs) # type: ignore[arg-type] if not is_simulating: - await asyncio.sleep(0.5) - await api.cache_instruments() - async for update in api.update_firmware(): - print(f"Update: {update.subsystem.name}: {update.progress}%") + await update_firmware(api) + print(f"Firmware: v{api.fw_version}") + await reset_api(api) return api @@ -417,6 +455,7 @@ async def move_plunger_absolute_ot3( position: float, motor_current: Optional[float] = None, speed: Optional[float] = None, + expect_stalls: bool = False, ) -> None: """Move OT3 plunger position to an absolute position.""" if not api.hardware_pipettes[mount.to_mount()]: @@ -425,6 +464,7 @@ async def move_plunger_absolute_ot3( _move_coro = api._move( target_position={plunger_axis: position}, # type: ignore[arg-type] speed=speed, + expect_stalls=expect_stalls, ) if motor_current is None: await _move_coro diff --git a/hardware-testing/hardware_testing/production_qc/firmware_check.py b/hardware-testing/hardware_testing/production_qc/firmware_check.py new file mode 100644 index 000000000000..f84f5eb386cb --- /dev/null +++ b/hardware-testing/hardware_testing/production_qc/firmware_check.py @@ -0,0 +1,64 @@ +"""Firmware Check.""" +from asyncio import run +from typing import List + +from opentrons.hardware_control.ot3api import OT3API + +from hardware_testing.opentrons_api import helpers_ot3 +from hardware_testing.opentrons_api.types import OT3Mount + +from opentrons.hardware_control.types import SubSystem + + +def _get_instrument_serial_number(api: OT3API, subsystem: SubSystem) -> str: + if subsystem == SubSystem.pipette_right: + _pip = api.hardware_pipettes[OT3Mount.RIGHT.to_mount()] + assert _pip + _pip_id = helpers_ot3.get_pipette_serial_ot3(_pip) + _id = f" ({_pip_id})" + elif subsystem == SubSystem.pipette_left: + _pip = api.hardware_pipettes[OT3Mount.LEFT.to_mount()] + assert _pip + _pip_id = helpers_ot3.get_pipette_serial_ot3(_pip) + _id = f" ({_pip_id})" + elif subsystem == SubSystem.gripper: + gripper = api.attached_gripper + assert gripper + gripper_id = str(gripper["gripper_id"]) + _id = f" ({gripper_id})" + else: + _id = "" + return _id + + +async def _main(simulate: bool, subsystems: List[SubSystem]) -> None: + api = await helpers_ot3.build_async_ot3_hardware_api(is_simulating=simulate) + while True: + for subsys, state in api.attached_subsystems.items(): + _id = _get_instrument_serial_number(api, subsys) + print(f" - v{state.current_fw_version}: {subsys.name}{_id}") + if not api.is_simulator: + input("\n\npress ENTER to check/update firmware:") + await helpers_ot3.update_firmware(api, subsystems=subsystems) + print("done") + if api.is_simulator: + break + await helpers_ot3.reset_api(api) + + +if __name__ == "__main__": + import argparse + + parser = argparse.ArgumentParser() + parser.add_argument("--simulate", action="store_true") + ot3_subsystems = [s for s in SubSystem if s != SubSystem.motor_controller_board] + for s in ot3_subsystems: + parser.add_argument(f"--{s.name.replace('_', '-')}", action="store_true") + args = parser.parse_args() + _subsystems = [] + for s in ot3_subsystems: + if getattr(args, f"{s.name}"): + _subsystems.append(s) + if not _subsystems: + _subsystems = [] + run(_main(args.simulate, _subsystems)) diff --git a/hardware-testing/hardware_testing/production_qc/pipette_assembly_qc_ot3/__init__.py b/hardware-testing/hardware_testing/production_qc/pipette_assembly_qc_ot3/__init__.py new file mode 100644 index 000000000000..6ed41791ea3a --- /dev/null +++ b/hardware-testing/hardware_testing/production_qc/pipette_assembly_qc_ot3/__init__.py @@ -0,0 +1 @@ +"""Pipette assembly qc.""" diff --git a/hardware-testing/hardware_testing/production_qc/pipette_assembly_qc_ot3.py b/hardware-testing/hardware_testing/production_qc/pipette_assembly_qc_ot3/__main__.py similarity index 60% rename from hardware-testing/hardware_testing/production_qc/pipette_assembly_qc_ot3.py rename to hardware-testing/hardware_testing/production_qc/pipette_assembly_qc_ot3/__main__.py index d31dadb89f48..3758e68f9230 100644 --- a/hardware-testing/hardware_testing/production_qc/pipette_assembly_qc_ot3.py +++ b/hardware-testing/hardware_testing/production_qc/pipette_assembly_qc_ot3/__main__.py @@ -1,16 +1,32 @@ """Pipette Assembly QC Test.""" +# FIXME: (andy s) Sorry but this script should be re-written completely. +# It works, but it was written in a hurry and is just terrible to edit. + import argparse import asyncio from dataclasses import dataclass, fields import os +from pathlib import Path from time import time -from typing import Optional, Callable, List, Any, Tuple +from typing import Optional, Callable, List, Any, Tuple, Dict from typing_extensions import Final +from opentrons_hardware.firmware_bindings.arbitration_id import ArbitrationId +from opentrons_hardware.firmware_bindings.messages.message_definitions import ( + PushTipPresenceNotification, +) +from opentrons_hardware.firmware_bindings.messages.messages import MessageDefinition from opentrons_hardware.firmware_bindings.constants import SensorType -from opentrons.config.types import CapacitivePassSettings +from opentrons.config.types import LiquidProbeSettings +from opentrons.hardware_control.types import TipStateType, FailedTipStateCheck from opentrons.hardware_control.ot3api import OT3API +from opentrons.hardware_control.ot3_calibration import ( + calibrate_pipette, + EdgeNotFoundError, + EarlyCapacitiveSenseTrigger, + CalibrationStructureNotFoundError, +) from hardware_testing import data from hardware_testing.drivers import list_ports_and_select @@ -18,13 +34,14 @@ PressureFixture, SimPressureFixture, ) -from hardware_testing.measure.pressure.config import ( # type: ignore[import] +from .pressure import ( # type: ignore[import] PRESSURE_FIXTURE_TIP_VOLUME, PRESSURE_FIXTURE_ASPIRATE_VOLUME, PRESSURE_FIXTURE_EVENT_CONFIGS as PRESSURE_CFG, pressure_fixture_a1_location, PressureEvent, PressureEventConfig, + PRESSURE_FIXTURE_INSERT_DEPTH, ) from hardware_testing.opentrons_api import helpers_ot3 from hardware_testing.opentrons_api.types import ( @@ -33,17 +50,33 @@ Axis, ) +DEFAULT_SLOT_TIP_RACK_1000 = 7 +DEFAULT_SLOT_TIP_RACK_200 = 4 +DEFAULT_SLOT_TIP_RACK_50 = 1 + +DEFAULT_SLOT_FIXTURE = 3 +DEFAULT_SLOT_RESERVOIR = 8 +DEFAULT_SLOT_PLATE = 2 +DEFAULT_SLOT_TRASH = 12 + +PROBING_DECK_PRECISION_MM = 0.1 + TRASH_HEIGHT_MM: Final = 45 LEAK_HOVER_ABOVE_LIQUID_MM: Final = 50 +ASPIRATE_SUBMERGE_MM: Final = 3 + +# FIXME: reduce this spec after dial indicator is implemented +LIQUID_PROBE_ERROR_THRESHOLD_PRECISION_MM = 0.4 +LIQUID_PROBE_ERROR_THRESHOLD_ACCURACY_MM = 1.5 SAFE_HEIGHT_TRAVEL = 10 -SAFE_HEIGHT_CALIBRATE = 10 +SAFE_HEIGHT_CALIBRATE = 0 + +ENCODER_ALIGNMENT_THRESHOLD_MM = 0.1 COLUMNS = "ABCDEFGH" PRESSURE_DATA_HEADER = ["PHASE", "CH1", "CH2", "CH3", "CH4", "CH5", "CH6", "CH7", "CH8"] -SPEED_REDUCTION_PERCENTAGE = 0.3 - MULTI_CHANNEL_1_OFFSET = Point(y=9 * 7 * 0.5) # NOTE: there is a ton of pressure data, so we want it on the bottom of the CSV @@ -52,6 +85,8 @@ # save final test results, to be saved and displayed at the end FINAL_TEST_RESULTS = [] +_available_tips: Dict[int, List[str]] = {} + @dataclass class TestConfig: @@ -62,13 +97,16 @@ class TestConfig: skip_fixture: bool skip_diagnostics: bool skip_plunger: bool + skip_tip_presence: bool + skip_liquid_probe: bool fixture_port: str - fixture_depth: int fixture_side: str fixture_aspirate_sample_count: int - slot_tip_rack_liquid: int - slot_tip_rack_fixture: int + slot_tip_rack_1000: int + slot_tip_rack_200: int + slot_tip_rack_50: int slot_reservoir: int + slot_plate: int slot_fixture: int slot_trash: int num_trials: int @@ -81,9 +119,11 @@ class LabwareLocations: """Test Labware Locations.""" trash: Optional[Point] - tip_rack_liquid: Optional[Point] - tip_rack_fixture: Optional[Point] + tip_rack_1000: Optional[Point] + tip_rack_200: Optional[Point] + tip_rack_50: Optional[Point] reservoir: Optional[Point] + plate: Optional[Point] fixture: Optional[Point] @@ -91,16 +131,20 @@ class LabwareLocations: # we start with actual values here to pass linting IDEAL_LABWARE_LOCATIONS: LabwareLocations = LabwareLocations( trash=None, - tip_rack_liquid=None, - tip_rack_fixture=None, + tip_rack_1000=None, + tip_rack_200=None, + tip_rack_50=None, reservoir=None, + plate=None, fixture=None, ) CALIBRATED_LABWARE_LOCATIONS: LabwareLocations = LabwareLocations( trash=None, - tip_rack_liquid=None, - tip_rack_fixture=None, + tip_rack_1000=None, + tip_rack_200=None, + tip_rack_50=None, reservoir=None, + plate=None, fixture=None, ) @@ -110,35 +154,23 @@ class LabwareLocations: # THRESHOLDS: capacitive sensor CAP_THRESH_OPEN_AIR = { - 1: [1.0, 8.0], - 8: [5.0, 20.0], - 96: [0.0, 10.0], + 1: [3.0, 7.0], + 8: [3.0, 7.0], # TODO: update for PVT multi build } CAP_THRESH_PROBE = { - 1: [1.0, 10.0], - 8: [5.0, 20.0], - 96: [0.0, 20.0], + 1: [4.0, 8.0], + 8: [5.0, 20.0], # TODO: update for PVT multi build } CAP_THRESH_SQUARE = { - 1: [0.0, 1000.0], - 8: [0.0, 1000.0], - 96: [0.0, 1000.0], + 1: [8.0, 15.0], + 8: [0.0, 1000.0], # TODO: update for PVT multi build } -CAP_PROBE_DISTANCE = 50.0 -CAP_PROBE_SECONDS = 5.0 -CAP_PROBE_SETTINGS = CapacitivePassSettings( - prep_distance_mm=CAP_PROBE_DISTANCE, - max_overrun_distance_mm=0.0, - speed_mm_per_s=CAP_PROBE_DISTANCE / CAP_PROBE_SECONDS, - sensor_threshold_pf=1.0, -) # THRESHOLDS: air-pressure sensor -PRESSURE_ASPIRATE_VOL = {50: 10.0, 1000: 100.0} -PRESSURE_MAX_VALUE_ABS = 7500 -PRESSURE_THRESH_OPEN_AIR = [-300, 300] -PRESSURE_THRESH_SEALED = [-1000, 1000] -PRESSURE_THRESH_COMPRESS = [-PRESSURE_MAX_VALUE_ABS, PRESSURE_MAX_VALUE_ABS] +PRESSURE_ASPIRATE_VOL = {50: 10.0, 1000: 20.0} +PRESSURE_THRESH_OPEN_AIR = [-15, 15] +PRESSURE_THRESH_SEALED = [-50, 50] +PRESSURE_THRESH_COMPRESS = [-2600, 1600] def _bool_to_pass_fail(result: bool) -> str: @@ -156,30 +188,39 @@ def _get_operator_answer_to_question(question: str) -> bool: def _get_tips_used_for_droplet_test( - pipette_channels: int, num_trials: int -) -> List[str]: + pipette_channels: int, pipette_volume: int, num_trials: int +) -> Tuple[int, List[str]]: if pipette_channels == 1: tip_columns = COLUMNS[:num_trials] - return [f"{c}1" for c in tip_columns] + return pipette_volume, [f"{c}1" for c in tip_columns] elif pipette_channels == 8: - return [f"A{r + 1}" for r in range(num_trials)] + return pipette_volume, [f"A{r + 1}" for r in range(num_trials)] raise RuntimeError(f"unexpected number of channels: {pipette_channels}") def _get_ideal_labware_locations( - test_config: TestConfig, pipette_volume: int, pipette_channels: int + test_config: TestConfig, pipette_channels: int ) -> LabwareLocations: - tip_rack_liquid_loc_ideal = helpers_ot3.get_theoretical_a1_position( - test_config.slot_tip_rack_liquid, - f"opentrons_flex_96_tiprack_{pipette_volume}ul", + tip_rack_1000_loc_ideal = helpers_ot3.get_theoretical_a1_position( + test_config.slot_tip_rack_1000, + "opentrons_flex_96_tiprack_1000ul", + ) + tip_rack_200_loc_ideal = helpers_ot3.get_theoretical_a1_position( + test_config.slot_tip_rack_200, + "opentrons_flex_96_tiprack_200ul", ) - tip_rack_fixture_loc_ideal = helpers_ot3.get_theoretical_a1_position( - test_config.slot_tip_rack_fixture, - f"opentrons_flex_96_tiprack_{PRESSURE_FIXTURE_TIP_VOLUME}ul", + tip_rack_50_loc_ideal = helpers_ot3.get_theoretical_a1_position( + test_config.slot_tip_rack_50, + "opentrons_flex_96_tiprack_50ul", ) reservoir_loc_ideal = helpers_ot3.get_theoretical_a1_position( test_config.slot_reservoir, "nest_1_reservoir_195ml" ) + plate_loc_ideal = helpers_ot3.get_theoretical_a1_position( + test_config.slot_plate, "corning_96_wellplate_360ul_flat" + ) + # NOTE: we are using well H6 (not A1) + plate_loc_ideal += Point(x=9 * 5, y=9 * -7) # trash trash_loc_ideal = helpers_ot3.get_slot_calibration_square_position_ot3( test_config.slot_trash @@ -196,9 +237,11 @@ def _get_ideal_labware_locations( reservoir_loc_ideal += MULTI_CHANNEL_1_OFFSET trash_loc_ideal += MULTI_CHANNEL_1_OFFSET return LabwareLocations( - tip_rack_liquid=tip_rack_liquid_loc_ideal, - tip_rack_fixture=tip_rack_fixture_loc_ideal, + tip_rack_1000=tip_rack_1000_loc_ideal, + tip_rack_200=tip_rack_200_loc_ideal, + tip_rack_50=tip_rack_50_loc_ideal, reservoir=reservoir_loc_ideal, + plate=plate_loc_ideal + Point(z=5), # give a few extra mm to help alignment trash=trash_loc_ideal, fixture=fixture_loc_ideal, ) @@ -256,37 +299,79 @@ async def _pick_up_tip( return actual -async def _pick_up_tip_for_liquid(api: OT3API, mount: OT3Mount, tip: str) -> None: - CALIBRATED_LABWARE_LOCATIONS.tip_rack_liquid = await _pick_up_tip( +async def _pick_up_tip_for_tip_volume( + api: OT3API, mount: OT3Mount, tip_volume: int +) -> None: + pip = api.hardware_pipettes[mount.to_mount()] + assert pip + pip_channels = pip.channels.value + tip = _available_tips[tip_volume][0] + _available_tips[tip_volume] = _available_tips[tip_volume][pip_channels:] + if tip_volume == 1000: + CALIBRATED_LABWARE_LOCATIONS.tip_rack_1000 = await _pick_up_tip( + api, + mount, + tip, + IDEAL_LABWARE_LOCATIONS.tip_rack_1000, + CALIBRATED_LABWARE_LOCATIONS.tip_rack_1000, + tip_volume=tip_volume, + ) + elif tip_volume == 200: + CALIBRATED_LABWARE_LOCATIONS.tip_rack_200 = await _pick_up_tip( + api, + mount, + tip, + IDEAL_LABWARE_LOCATIONS.tip_rack_200, + CALIBRATED_LABWARE_LOCATIONS.tip_rack_200, + tip_volume=tip_volume, + ) + elif tip_volume == 50: + CALIBRATED_LABWARE_LOCATIONS.tip_rack_50 = await _pick_up_tip( + api, + mount, + tip, + IDEAL_LABWARE_LOCATIONS.tip_rack_50, + CALIBRATED_LABWARE_LOCATIONS.tip_rack_50, + tip_volume=tip_volume, + ) + else: + raise ValueError(f"unexpected tip volume: {tip_volume}") + + +async def _move_to_reservoir_liquid(api: OT3API, mount: OT3Mount) -> None: + CALIBRATED_LABWARE_LOCATIONS.reservoir = await _move_to_or_calibrate( api, mount, - tip, - IDEAL_LABWARE_LOCATIONS.tip_rack_liquid, - CALIBRATED_LABWARE_LOCATIONS.tip_rack_liquid, + IDEAL_LABWARE_LOCATIONS.reservoir, + CALIBRATED_LABWARE_LOCATIONS.reservoir, ) -async def _pick_up_tip_for_fixture(api: OT3API, mount: OT3Mount, tip: str) -> None: - CALIBRATED_LABWARE_LOCATIONS.tip_rack_fixture = await _pick_up_tip( +async def _move_to_plate_liquid(api: OT3API, mount: OT3Mount) -> None: + CALIBRATED_LABWARE_LOCATIONS.plate = await _move_to_or_calibrate( api, mount, - tip, - IDEAL_LABWARE_LOCATIONS.tip_rack_fixture, - CALIBRATED_LABWARE_LOCATIONS.tip_rack_fixture, - tip_volume=PRESSURE_FIXTURE_TIP_VOLUME, + IDEAL_LABWARE_LOCATIONS.plate, + CALIBRATED_LABWARE_LOCATIONS.plate, ) -async def _move_to_liquid(api: OT3API, mount: OT3Mount) -> None: - CALIBRATED_LABWARE_LOCATIONS.reservoir = await _move_to_or_calibrate( +async def _move_to_above_plate_liquid( + api: OT3API, mount: OT3Mount, height_mm: float +) -> None: + assert ( + CALIBRATED_LABWARE_LOCATIONS.plate + ), "you must calibrate the liquid before hovering" + await _move_to_or_calibrate( api, mount, - IDEAL_LABWARE_LOCATIONS.reservoir, - CALIBRATED_LABWARE_LOCATIONS.reservoir, + IDEAL_LABWARE_LOCATIONS.plate, + CALIBRATED_LABWARE_LOCATIONS.plate + Point(z=height_mm), ) async def _move_to_fixture(api: OT3API, mount: OT3Mount) -> None: + CALIBRATED_LABWARE_LOCATIONS.fixture = await _move_to_or_calibrate( api, mount, @@ -312,6 +397,7 @@ async def _aspirate_and_look_for_droplets( assert pip pipette_volume = pip.working_volume print(f"aspirating {pipette_volume} microliters") + await api.move_rel(mount, Point(z=-ASPIRATE_SUBMERGE_MM)) await api.aspirate(mount, pipette_volume) await api.move_rel(mount, Point(z=LEAK_HOVER_ABOVE_LIQUID_MM)) for t in range(wait_time): @@ -326,6 +412,7 @@ async def _aspirate_and_look_for_droplets( await api.move_rel(mount, Point(z=-LEAK_HOVER_ABOVE_LIQUID_MM)) await api.dispense(mount, pipette_volume) await api.blow_out(mount) + await api.move_rel(mount, Point(z=ASPIRATE_SUBMERGE_MM)) return leak_test_passed @@ -378,7 +465,7 @@ async def _read_pressure_and_check_results( else: test_pass_stability = True csv_data_stability = [ - tag.value, + f"pressure-{tag.value}", "stability", _bool_to_pass_fail(test_pass_stability), ] @@ -392,7 +479,7 @@ async def _read_pressure_and_check_results( else: test_pass_accuracy = True csv_data_accuracy = [ - tag.value, + f"pressure-{tag.value}", "accuracy", _bool_to_pass_fail(test_pass_accuracy), ] @@ -420,7 +507,9 @@ async def _fixture_check_pressure( ) results.append(r) # insert into the fixture - await api.move_rel(mount, Point(z=-test_config.fixture_depth)) + # NOTE: unknown amount of pressure here (depends on where Z was calibrated) + fixture_depth = PRESSURE_FIXTURE_INSERT_DEPTH[pip_vol] + await api.move_rel(mount, Point(z=-fixture_depth)) r = await _read_pressure_and_check_results( api, fixture, @@ -452,7 +541,7 @@ async def _fixture_check_pressure( ) results.append(r) # retract out of fixture - await api.move_rel(mount, Point(z=test_config.fixture_depth)) + await api.move_rel(mount, Point(z=fixture_depth)) r = await _read_pressure_and_check_results( api, fixture, PressureEvent.POST, write_cb, accumulate_raw_data_cb, pip_channels ) @@ -460,18 +549,25 @@ async def _fixture_check_pressure( return False not in results +def _reset_available_tip() -> None: + for tip_size in [50, 200, 1000]: + _available_tips[tip_size] = [ + f"{row}{col + 1}" for col in range(12) for row in "ABCDEFGH" + ] + + async def _test_for_leak( api: OT3API, mount: OT3Mount, test_config: TestConfig, - tip: str, + tip_volume: int, fixture: Optional[PressureFixture], write_cb: Optional[Callable], accumulate_raw_data_cb: Optional[Callable], - droplet_wait_seconds: Optional[int] = None, + droplet_wait_seconds: int = 30, ) -> bool: if fixture: - await _pick_up_tip_for_fixture(api, mount, tip) + await _pick_up_tip_for_tip_volume(api, mount, tip_volume=tip_volume) assert write_cb, "pressure fixture requires recording data to disk" assert ( accumulate_raw_data_cb @@ -481,15 +577,12 @@ async def _test_for_leak( api, mount, test_config, fixture, write_cb, accumulate_raw_data_cb ) else: - assert droplet_wait_seconds is not None - await _pick_up_tip_for_liquid(api, mount, tip) - await _move_to_liquid(api, mount) + await _pick_up_tip_for_tip_volume(api, mount, tip_volume=tip_volume) + await _move_to_reservoir_liquid(api, mount) test_passed = await _aspirate_and_look_for_droplets( api, mount, droplet_wait_seconds ) await _drop_tip_in_trash(api, mount) - pass_msg = _bool_to_pass_fail(test_passed) - print(f"tip {tip}: {pass_msg}") return test_passed @@ -497,11 +590,11 @@ async def _test_for_leak_by_eye( api: OT3API, mount: OT3Mount, test_config: TestConfig, - tip: str, + tip_volume: int, droplet_wait_time: int, ) -> bool: return await _test_for_leak( - api, mount, test_config, tip, None, None, None, droplet_wait_time + api, mount, test_config, tip_volume, None, None, None, droplet_wait_time ) @@ -616,7 +709,7 @@ async def _get_plunger_pos_and_encoder() -> Tuple[float, float]: print("moving plunger") await helpers_ot3.move_plunger_absolute_ot3(api, mount, drop_tip) pip_pos, pip_enc = await _get_plunger_pos_and_encoder() - if abs(pip_pos - pip_enc) > 0.1: + if abs(pip_pos - pip_enc) > ENCODER_ALIGNMENT_THRESHOLD_MM: print(f"FAIL: plunger ({pip_pos}) and encoder ({pip_enc}) are too different") encoder_move_pass = False write_cb(["encoder-move", pip_pos, pip_enc, _bool_to_pass_fail(encoder_move_pass)]) @@ -676,10 +769,59 @@ async def _read_cap() -> float: ] ) - if not api.is_simulator: - _get_operator_answer_to_question( - 'touch a SQUARE to the probe, enter "y" when touching' + offsets: List[Point] = [] + for trial in range(2): + print("probing deck slot #5") + if trial > 0 and not api.is_simulator: + input("`REINSTALL` the probe, press ENTER when ready: ") + await api.home() + if api.is_simulator: + pass + try: + await calibrate_pipette(api, mount, slot=5) # type: ignore[arg-type] + except ( + EdgeNotFoundError, + EarlyCapacitiveSenseTrigger, + CalibrationStructureNotFoundError, + ) as e: + print(f"ERROR: {e}") + write_cb([f"probe-slot-{trial}", None, None, None]) + else: + pip = api.hardware_pipettes[mount.to_mount()] + assert pip + o = pip.pipette_offset.offset + print(f"found offset: {o}") + write_cb( + [f"probe-slot-{trial}", round(o.x, 2), round(o.y, 2), round(o.z, 2)] + ) + offsets.append(o) + await api.retract(mount) + if ( + not api.is_simulator + and len(offsets) > 1 + and ( + abs(offsets[0].x - offsets[1].x) < PROBING_DECK_PRECISION_MM + and abs(offsets[0].x - offsets[1].x) < PROBING_DECK_PRECISION_MM + and abs(offsets[0].x - offsets[1].x) < PROBING_DECK_PRECISION_MM ) + ): + probe_slot_result = _bool_to_pass_fail(True) + else: + probe_slot_result = _bool_to_pass_fail(False) + print(f"probe-slot-result: {probe_slot_result}") + + probe_pos = helpers_ot3.get_slot_calibration_square_position_ot3(5) + probe_pos += Point(13, 13, 0) + await api.add_tip(mount, api.config.calibration.probe_length) + print(f"Moving to: {probe_pos}") + # start probe 5mm above deck + _probe_start_mm = probe_pos.z + 5 + current_pos = await api.gantry_position(mount) + if current_pos.z < _probe_start_mm: + await api.move_to(mount, current_pos._replace(z=_probe_start_mm)) + current_pos = await api.gantry_position(mount) + await api.move_to(mount, probe_pos._replace(z=current_pos.z)) + await api.move_to(mount, probe_pos) capacitance_with_square = await _read_cap() print(f"square capacitance: {capacitance_with_square}") if ( @@ -695,22 +837,8 @@ async def _read_cap() -> float: _bool_to_pass_fail(capacitive_square_pass), ] ) - - print("probing downwards by 50 mm") - if not api.is_simulator: - _get_operator_answer_to_question("ready to touch the probe when it moves down?") - current_pos = await api.gantry_position(mount) - probe_target = current_pos.z - CAP_PROBE_SETTINGS.prep_distance_mm - probe_axis = Axis.by_mount(mount) - trigger_pos = await api.capacitive_probe( - mount, probe_axis, probe_target, CAP_PROBE_SETTINGS - ) - if trigger_pos <= probe_target + 1: - capacitive_probing_pass = False - print("FAIL: probe was not triggered while moving downwards") - write_cb( - ["capacitive-probing", trigger_pos, _bool_to_pass_fail(capacitive_probing_pass)] - ) + await api.home_z(mount) + await api.remove_tip(mount) if not api.is_simulator: _get_operator_answer_to_question('REMOVE the probe, enter "y" when removed') @@ -726,6 +854,7 @@ async def _test_diagnostics_pressure( ) -> bool: await api.add_tip(mount, 0.1) await api.prepare_for_aspirate(mount) + await api.remove_tip(mount) async def _read_pressure() -> float: return await _read_pipette_sensor_repeatedly_and_average( @@ -753,8 +882,8 @@ async def _read_pressure() -> float: _, bottom, _, _ = helpers_ot3.get_plunger_positions_ot3(api, mount) print("moving plunger to bottom") await helpers_ot3.move_plunger_absolute_ot3(api, mount, bottom) - if not api.is_simulator: - _get_operator_answer_to_question('ATTACH tip to nozzle, enter "y" when ready') + await _pick_up_tip_for_tip_volume(api, mount, tip_volume=50) + await api.retract(mount) if not api.is_simulator: _get_operator_answer_to_question('COVER tip with finger, enter "y" when ready') pressure_sealed = await _read_pressure() @@ -793,10 +922,10 @@ async def _read_pressure() -> float: ) if not api.is_simulator: - _get_operator_answer_to_question('REMOVE tip to nozzle, enter "y" when ready') + _get_operator_answer_to_question('REMOVE your finger, enter "y" when ready') print("moving plunger back down to BOTTOM position") await api.dispense(mount) - await api.remove_tip(mount) + await _drop_tip_in_trash(api, mount) return pressure_open_air_pass and pressure_sealed_pass and pressure_compress_pass @@ -805,10 +934,6 @@ async def _test_diagnostics(api: OT3API, mount: OT3Mount, write_cb: Callable) -> environment_pass = await _test_diagnostics_environment(api, mount, write_cb) print(f"environment: {_bool_to_pass_fail(environment_pass)}") write_cb(["diagnostics-environment", _bool_to_pass_fail(environment_pass)]) - # PRESSURE - pressure_pass = await _test_diagnostics_pressure(api, mount, write_cb) - print(f"pressure: {_bool_to_pass_fail(pressure_pass)}") - write_cb(["diagnostics-pressure", _bool_to_pass_fail(pressure_pass)]) # ENCODER encoder_pass = await _test_diagnostics_encoder(api, mount, write_cb) print(f"encoder: {_bool_to_pass_fail(encoder_pass)}") @@ -817,6 +942,10 @@ async def _test_diagnostics(api: OT3API, mount: OT3Mount, write_cb: Callable) -> capacitance_pass = await _test_diagnostics_capacitive(api, mount, write_cb) print(f"capacitance: {_bool_to_pass_fail(capacitance_pass)}") write_cb(["diagnostics-capacitance", _bool_to_pass_fail(capacitance_pass)]) + # PRESSURE + pressure_pass = await _test_diagnostics_pressure(api, mount, write_cb) + print(f"pressure: {_bool_to_pass_fail(pressure_pass)}") + write_cb(["diagnostics-pressure", _bool_to_pass_fail(pressure_pass)]) return environment_pass and pressure_pass and encoder_pass and capacitance_pass @@ -841,12 +970,219 @@ async def _test_plunger_positions( drop_tip_passed = True else: drop_tip_passed = _get_operator_answer_to_question("is DROP-TIP correct?") - write_cb(["plunger-blow-out", _bool_to_pass_fail(blow_out_passed)]) + write_cb(["plunger-drop-tip", _bool_to_pass_fail(drop_tip_passed)]) print("homing the plunger") await api.home([Axis.of_main_tool_actuator(mount)]) return blow_out_passed and drop_tip_passed +async def _jog_for_tip_state( + api: OT3API, + mount: OT3Mount, + current_z: float, + max_z: float, + step_mm: float, + criteria: Tuple[float, float], + tip_state: TipStateType, +) -> bool: + async def _jog(_step: float) -> None: + nonlocal current_z + await api.move_rel(mount, Point(z=_step)) + current_z = round(current_z + _step, 2) + + async def _matches_state(_state: TipStateType) -> bool: + try: + await asyncio.sleep(0.2) + await api._backend.get_tip_present(mount, _state) + return True + except FailedTipStateCheck: + return False + + while (step_mm > 0 and current_z < max_z) or (step_mm < 0 and current_z > max_z): + await _jog(step_mm) + if await _matches_state(tip_state): + passed = min(criteria) <= current_z <= max(criteria) + print(f"found {tip_state.name} displacement: {current_z} ({passed})") + return passed + print(f"ERROR: did not find {tip_state.name} displacement: {current_z}") + return False + + +async def _test_tip_presence_flag( + api: OT3API, mount: OT3Mount, write_cb: Callable +) -> bool: + await api.retract(mount) + slot_5_pos = helpers_ot3.get_slot_calibration_square_position_ot3(5) + current_pos = await api.gantry_position(mount) + await api.move_to(mount, slot_5_pos._replace(z=current_pos.z)) + await api.move_rel(mount, Point(z=-20)) + wiggle_passed = await _wait_for_tip_presence_state_change(api, seconds_to_wait=5) + if not api.is_simulator: + input("press ENTER to continue") + + offset_from_a1 = Point(x=9 * 11, y=9 * -7, z=-5) + nominal_test_pos = ( + IDEAL_LABWARE_LOCATIONS.tip_rack_50 + offset_from_a1 # type: ignore[operator] + ) + await api.retract(mount) + await helpers_ot3.move_to_arched_ot3(api, mount, nominal_test_pos) + print("align NOZZLE with tip-rack HOLE:") + await helpers_ot3.jog_mount_ot3(api, mount) + nozzle_pos = await api.gantry_position(mount) + print(f"nozzle: {nozzle_pos.z}") + await api.move_rel(mount, Point(z=-6)) + print("align EJECTOR with tip-rack HOLE:") + await helpers_ot3.jog_mount_ot3(api, mount) + ejector_pos = await api.gantry_position(mount) + ejector_rel_pos = round(ejector_pos.z - nozzle_pos.z, 2) + + pip = api.hardware_pipettes[mount.to_mount()] + assert pip + pip_channels = pip.channels.value + pick_up_criteria = { + 1: ( + ejector_rel_pos + -1.3, + ejector_rel_pos + -2.5, + ), + 8: ( + ejector_rel_pos + -1.9, + ejector_rel_pos + -3.2, + ), + }[pip_channels] + + pick_up_result = await _jog_for_tip_state( + api, + mount, + current_z=ejector_rel_pos, + max_z=-10.5, + criteria=pick_up_criteria, + step_mm=-0.1, + tip_state=TipStateType.PRESENT, + ) + pick_up_pos = await api.gantry_position(mount) + pick_up_pos_rel = round(pick_up_pos.z - nozzle_pos.z, 2) + + await api.move_to(mount, nozzle_pos + Point(z=-10.5)) # nominal tip depth + drop_criteria = { + 1: ( + -10.5 + 1.2, + -10.5 + 2.3, + ), + 8: ( + -10.5 + 1.9, + -10.5 + 3.5, + ), + }[pip_channels] + drop_result = await _jog_for_tip_state( + api, + mount, + current_z=-10.5, + max_z=0.0, + criteria=drop_criteria, + step_mm=0.1, + tip_state=TipStateType.ABSENT, + ) + drop_pos = await api.gantry_position(mount) + drop_pos_rel = round(drop_pos.z - nozzle_pos.z, 2) + + pick_up_disp = round(ejector_rel_pos - pick_up_pos_rel, 2) + drop_disp = round(10.5 + drop_pos_rel, 2) + write_cb(["tip-presence-ejector-height-above-nozzle", ejector_rel_pos]) + write_cb( + [ + "tip-presence-pick-up-displacement", + pick_up_disp, + _bool_to_pass_fail(pick_up_result), + ] + ) + write_cb(["tip-presence-pick-up-height-above-nozzle", pick_up_pos_rel]) + write_cb( + ["tip-presence-drop-displacement", drop_disp, _bool_to_pass_fail(drop_result)] + ) + write_cb(["tip-presence-drop-height-above-nozzle", drop_pos_rel]) + write_cb(["tip-presence-wiggle", _bool_to_pass_fail(wiggle_passed)]) + return pick_up_result and drop_result and wiggle_passed + + +@dataclass +class _LiqProbeCfg: + mount_speed: float + plunger_speed: float + sensor_threshold_pascals: float + + +PROBE_SETTINGS: Dict[int, Dict[int, _LiqProbeCfg]] = { + 50: { + 50: _LiqProbeCfg( + mount_speed=11, + plunger_speed=21, + sensor_threshold_pascals=150, + ), + }, + 1000: { + 50: _LiqProbeCfg( + mount_speed=5, + plunger_speed=10, + sensor_threshold_pascals=200, + ), + 200: _LiqProbeCfg( + mount_speed=5, + plunger_speed=10, + sensor_threshold_pascals=200, + ), + 1000: _LiqProbeCfg( + mount_speed=5, + plunger_speed=11, + sensor_threshold_pascals=150, + ), + }, +} + + +async def _test_liquid_probe( + api: OT3API, mount: OT3Mount, tip_volume: int, trials: int +) -> List[float]: + pip = api.hardware_pipettes[mount.to_mount()] + assert pip + pip_vol = int(pip.working_volume) + # force the operator to re-calibrate the liquid every time + CALIBRATED_LABWARE_LOCATIONS.plate = None + await _pick_up_tip_for_tip_volume(api, mount, tip_volume) + await _move_to_plate_liquid(api, mount) + await _drop_tip_in_trash(api, mount) + trial_results: List[float] = [] + hover_mm = 3 + max_submerge_mm = -3 + max_z_distance_machine_coords = hover_mm - max_submerge_mm + assert CALIBRATED_LABWARE_LOCATIONS.plate is not None + target_z = CALIBRATED_LABWARE_LOCATIONS.plate.z + for trial in range(trials): + await _pick_up_tip_for_tip_volume(api, mount, tip_volume) + await _move_to_above_plate_liquid(api, mount, height_mm=hover_mm) + start_pos = await api.gantry_position(mount) + probe_cfg = PROBE_SETTINGS[pip_vol][tip_volume] + probe_settings = LiquidProbeSettings( + starting_mount_height=start_pos.z, + max_z_distance=max_z_distance_machine_coords, # FIXME: deck coords + min_z_distance=0, # FIXME: remove + mount_speed=probe_cfg.mount_speed, + plunger_speed=probe_cfg.plunger_speed, + sensor_threshold_pascals=probe_cfg.sensor_threshold_pascals, + expected_liquid_height=0, # FIXME: remove + log_pressure=False, # FIXME: remove + aspirate_while_sensing=False, # FIXME: I heard this doesn't work + auto_zero_sensor=True, # TODO: when would we want to adjust this? + num_baseline_reads=10, # TODO: when would we want to adjust this? + data_file="", # FIXME: remove + ) + end_z = await api.liquid_probe(mount, probe_settings) + error_mm = end_z - target_z + print(f"liquid-probe error: {error_mm}") + trial_results.append(end_z - target_z) # store the mm error from target + await _drop_tip_in_trash(api, mount) + return trial_results + + @dataclass class CSVCallbacks: """CSV callback functions.""" @@ -869,7 +1205,7 @@ def _create_csv_and_get_callbacks( pipette_sn: str, ) -> Tuple[CSVProperties, CSVCallbacks]: run_id = data.create_run_id() - test_name = data.create_test_name_from_file(__file__) + test_name = Path(__file__).parent.name.replace("_", "-") folder_path = data.create_folder_for_test_data(test_name) file_name = data.create_file_name(test_name, run_id, pipette_sn) csv_display_name = os.path.join(folder_path, file_name) @@ -917,7 +1253,56 @@ def _handle_final_test_results(t: str, r: bool) -> None: ) -async def _main(test_config: TestConfig) -> None: +async def _wait_for_tip_presence_state_change( + api: OT3API, seconds_to_wait: int +) -> bool: + if not api.is_simulator: + input("wiggle test, press ENTER when ready: ") + print("prepare to wiggle the ejector, in 3 seconds...") + for i in range(3): + print(f"{i + 1}..") + if not api.is_simulator: + await asyncio.sleep(1) + print("WIGGLE!") + + event = asyncio.Event() + test_pass = True + if not api.is_simulator: + + def _listener(message: MessageDefinition, arb_id: ArbitrationId) -> None: + if isinstance(message, PushTipPresenceNotification): + event.set() + + messenger = api._backend._messenger # type: ignore[union-attr] + messenger.add_listener(_listener) + try: + for i in range(seconds_to_wait): + print(f"wiggle the ejector ({i + 1}/{seconds_to_wait} seconds)") + try: + await asyncio.wait_for(event.wait(), 1.0) + test_pass = False # event was set, so we failed the test + messenger.remove_listener(_listener) + break + except asyncio.TimeoutError: + continue # timed out, so keep waiting + finally: + messenger.remove_listener(_listener) + if test_pass: + print("PASS: no unexpected tip-presence") + else: + print("FAIL: tip-presence state changed unexpectedly") + return test_pass + + +def _test_barcode(api: OT3API, pipette_sn: str) -> Tuple[str, bool]: + if not api.is_simulator: + barcode_sn = input("scan pipette barcode: ").strip() + else: + barcode_sn = str(pipette_sn) + return barcode_sn, barcode_sn == pipette_sn + + +async def _main(test_config: TestConfig) -> None: # noqa: C901 global IDEAL_LABWARE_LOCATIONS global CALIBRATED_LABWARE_LOCATIONS global FINAL_TEST_RESULTS @@ -932,6 +1317,13 @@ async def _main(test_config: TestConfig) -> None: pipette_left="p1000_single_v3.4", pipette_right="p1000_multi_v3.4", ) + + # home and move to attach position + await api.home([Axis.X, Axis.Y, Axis.Z_L, Axis.Z_R]) + attach_pos = helpers_ot3.get_slot_calibration_square_position_ot3(5) + current_pos = await api.gantry_position(OT3Mount.RIGHT) + await api.move_to(OT3Mount.RIGHT, attach_pos._replace(z=current_pos.z)) + pips = {OT3Mount.from_mount(m): p for m, p in api.hardware_pipettes.items() if p} assert pips, "no pipettes attached" for mount, pipette in pips.items(): @@ -941,18 +1333,21 @@ async def _main(test_config: TestConfig) -> None: "qc this pipette?" ): continue + _reset_available_tip() # setup our labware locations pipette_volume = int(pipette.working_volume) pipette_channels = int(pipette.channels) IDEAL_LABWARE_LOCATIONS = _get_ideal_labware_locations( - test_config, pipette_volume, pipette_channels + test_config, pipette_channels ) CALIBRATED_LABWARE_LOCATIONS = LabwareLocations( trash=None, - tip_rack_liquid=None, - tip_rack_fixture=None, + tip_rack_1000=None, + tip_rack_200=None, + tip_rack_50=None, reservoir=None, + plate=None, fixture=None, ) @@ -975,6 +1370,8 @@ async def _main(test_config: TestConfig) -> None: csv_cb.write(["date", csv_props.id]) # run-id includes a date/time string csv_cb.write(["pipette", pipette_sn]) csv_cb.write(["simulating" if test_config.simulate else "live"]) + csv_cb.write(["version", data.get_git_description()]) + csv_cb.write(["firmware", api.fw_version]) # add test configurations to CSV csv_cb.write(["-------------------"]) csv_cb.write(["TEST-CONFIGURATIONS"]) @@ -1003,6 +1400,13 @@ async def _main(test_config: TestConfig) -> None: csv_cb.write( ["pressure-compressed"] + [str(t) for t in PRESSURE_THRESH_COMPRESS] ) + csv_cb.write(["probe-deck", PROBING_DECK_PRECISION_MM]) + csv_cb.write( + ["liquid-probe-precision", LIQUID_PROBE_ERROR_THRESHOLD_PRECISION_MM] + ) + csv_cb.write( + ["liquid-probe-accuracy", LIQUID_PROBE_ERROR_THRESHOLD_ACCURACY_MM] + ) # add pressure thresholds to CSV csv_cb.write(["-----------------------"]) csv_cb.write(["PRESSURE-CONFIGURATIONS"]) @@ -1010,22 +1414,29 @@ async def _main(test_config: TestConfig) -> None: for f in fields(config): csv_cb.write([t.value, f.name, getattr(config, f.name)]) - tips_used = _get_tips_used_for_droplet_test( - pipette_channels, test_config.num_trials - ) - # run the test csv_cb.write(["----"]) csv_cb.write(["TEST"]) + print("homing") - await api.home() + await api.home([Axis.of_main_tool_actuator(mount)]) + barcode_sn, barcode_passed = _test_barcode(api, pipette_sn) + csv_cb.write( + [ + "pipette-barcode", + pipette_sn, + barcode_sn, + _bool_to_pass_fail(barcode_passed), + ] + ) if not test_config.skip_plunger or not test_config.skip_diagnostics: print("moving over slot 3") - pos_slot_2 = helpers_ot3.get_slot_calibration_square_position_ot3(3) + pos_slot_3 = helpers_ot3.get_slot_calibration_square_position_ot3(3) current_pos = await api.gantry_position(mount) - hover_over_slot_2 = pos_slot_2._replace(z=current_pos.z) - await api.move_to(mount, hover_over_slot_2) + hover_over_slot_3 = pos_slot_3._replace(z=current_pos.z) + await api.move_to(mount, hover_over_slot_3) + await api.move_rel(mount, Point(z=-20)) if not test_config.skip_diagnostics: test_passed = await _test_diagnostics(api, mount, csv_cb.write) csv_cb.results("diagnostics", test_passed) @@ -1033,26 +1444,69 @@ async def _main(test_config: TestConfig) -> None: test_passed = await _test_plunger_positions(api, mount, csv_cb.write) csv_cb.results("plunger", test_passed) + if not test_config.skip_liquid_probe: + tip_vols = [50] if pipette_volume == 50 else [50, 200, 1000] + test_passed = True + for tip_vol in tip_vols: + probe_data = await _test_liquid_probe( + api, mount, tip_volume=tip_vol, trials=3 + ) + for trial, found_height in enumerate(probe_data): + csv_label = f"liquid-probe-{tip_vol}-tip-trial-{trial}" + csv_cb.write([csv_label, round(found_height, 2)]) + precision = abs(max(probe_data) - min(probe_data)) * 0.5 + accuracy = sum(probe_data) / len(probe_data) + prec_tag = f"liquid-probe-{tip_vol}-tip-precision" + acc_tag = f"liquid-probe-{tip_vol}-tip-accuracy" + tip_tag = f"liquid-probe-{tip_vol}-tip" + precision_passed = bool( + precision < LIQUID_PROBE_ERROR_THRESHOLD_PRECISION_MM + ) + accuracy_passed = bool( + abs(accuracy) < LIQUID_PROBE_ERROR_THRESHOLD_ACCURACY_MM + ) + tip_passed = precision_passed and accuracy_passed + print(prec_tag, precision, _bool_to_pass_fail(precision_passed)) + print(acc_tag, accuracy, _bool_to_pass_fail(accuracy_passed)) + print(tip_tag, _bool_to_pass_fail(tip_passed)) + csv_cb.write( + [prec_tag, precision, _bool_to_pass_fail(precision_passed)] + ) + csv_cb.write([acc_tag, accuracy, _bool_to_pass_fail(accuracy_passed)]) + csv_cb.write([tip_tag, _bool_to_pass_fail(tip_passed)]) + if not tip_passed: + test_passed = False + csv_cb.results("liquid-probe", test_passed) + if not test_config.skip_liquid: - for i, tip in enumerate(tips_used): + for i in range(test_config.num_trials): droplet_wait_seconds = test_config.droplet_wait_seconds * (i + 1) test_passed = await _test_for_leak_by_eye( - api, mount, test_config, tip, droplet_wait_seconds + api, + mount, + test_config, + tip_volume=pipette_volume, + droplet_wait_time=droplet_wait_seconds, ) - csv_cb.results("droplets", test_passed) + csv_cb.results(f"droplets-{droplet_wait_seconds}", test_passed) if not test_config.skip_fixture: test_passed = await _test_for_leak( api, mount, test_config, - "A1", + tip_volume=PRESSURE_FIXTURE_TIP_VOLUME, fixture=fixture, write_cb=csv_cb.write, accumulate_raw_data_cb=csv_cb.pressure, ) csv_cb.results("pressure", test_passed) + if not test_config.skip_tip_presence: + test_passed = await _test_tip_presence_flag(api, mount, csv_cb.write) + print("tip-presence: ", _bool_to_pass_fail(test_passed)) + csv_cb.results("tip-presence", test_passed) + print("test complete") csv_cb.write(["-------------"]) csv_cb.write(["PRESSURE-DATA"]) @@ -1079,20 +1533,23 @@ async def _main(test_config: TestConfig) -> None: # print the filepath again, to help debugging print(f"CSV: {csv_props.name}") - print("homing") - await api.home() - # disengage x,y for replace the new pipette - await api.disengage_axes([Axis.X, Axis.Y]) + + # move to attach position + await api.retract(mount) + current_pos = await api.gantry_position(OT3Mount.RIGHT) + await api.move_to(OT3Mount.RIGHT, attach_pos._replace(z=current_pos.z)) print("done") if __name__ == "__main__": arg_parser = argparse.ArgumentParser(description="OT-3 Pipette Assembly QC Test") - arg_parser.add_argument("--operator", type=str, required=True) + arg_parser.add_argument("--operator", type=str, default=None) arg_parser.add_argument("--skip-liquid", action="store_true") arg_parser.add_argument("--skip-fixture", action="store_true") arg_parser.add_argument("--skip-diagnostics", action="store_true") arg_parser.add_argument("--skip-plunger", action="store_true") + arg_parser.add_argument("--skip-tip-presence", action="store_true") + arg_parser.add_argument("--skip-liquid-probe", action="store_true") arg_parser.add_argument("--fixture-side", choices=["left", "right"], default="left") arg_parser.add_argument("--port", type=str, default="") arg_parser.add_argument("--num-trials", type=int, default=2) @@ -1102,27 +1559,45 @@ async def _main(test_config: TestConfig) -> None: default=PRESSURE_CFG[PressureEvent.ASPIRATE_P50].sample_count, ) arg_parser.add_argument("--wait", type=int, default=30) - arg_parser.add_argument("--slot-tip-rack-liquid", type=int, default=7) - arg_parser.add_argument("--slot-tip-rack-fixture", type=int, default=1) - arg_parser.add_argument("--slot-reservoir", type=int, default=8) - arg_parser.add_argument("--slot-fixture", type=int, default=2) - arg_parser.add_argument("--slot-trash", type=int, default=12) - arg_parser.add_argument("--insert-depth", type=int, default=14) + arg_parser.add_argument( + "--slot-tip-rack-1000", type=int, default=DEFAULT_SLOT_TIP_RACK_1000 + ) + arg_parser.add_argument( + "--slot-tip-rack-200", type=int, default=DEFAULT_SLOT_TIP_RACK_200 + ) + arg_parser.add_argument( + "--slot-tip-rack-50", type=int, default=DEFAULT_SLOT_TIP_RACK_50 + ) + arg_parser.add_argument( + "--slot-reservoir", type=int, default=DEFAULT_SLOT_RESERVOIR + ) + arg_parser.add_argument("--slot-plate", type=int, default=DEFAULT_SLOT_PLATE) + arg_parser.add_argument("--slot-fixture", type=int, default=DEFAULT_SLOT_FIXTURE) + arg_parser.add_argument("--slot-trash", type=int, default=DEFAULT_SLOT_TRASH) arg_parser.add_argument("--simulate", action="store_true") args = arg_parser.parse_args() + if args.operator: + operator = args.operator + elif not args.simulate: + operator = input("OPERATOR name:").strip() + else: + operator = "simulation" _cfg = TestConfig( - operator_name=args.operator, + operator_name=operator, skip_liquid=args.skip_liquid, skip_fixture=args.skip_fixture, skip_diagnostics=args.skip_diagnostics, skip_plunger=args.skip_plunger, + skip_tip_presence=args.skip_tip_presence, + skip_liquid_probe=args.skip_liquid_probe, fixture_port=args.port, - fixture_depth=args.insert_depth, fixture_side=args.fixture_side, fixture_aspirate_sample_count=args.aspirate_sample_count, - slot_tip_rack_liquid=args.slot_tip_rack_liquid, - slot_tip_rack_fixture=args.slot_tip_rack_fixture, + slot_tip_rack_1000=args.slot_tip_rack_1000, + slot_tip_rack_200=args.slot_tip_rack_200, + slot_tip_rack_50=args.slot_tip_rack_50, slot_reservoir=args.slot_reservoir, + slot_plate=args.slot_plate, slot_fixture=args.slot_fixture, slot_trash=args.slot_trash, num_trials=args.num_trials, diff --git a/hardware-testing/hardware_testing/measure/pressure/config.py b/hardware-testing/hardware_testing/production_qc/pipette_assembly_qc_ot3/pressure.py similarity index 81% rename from hardware-testing/hardware_testing/measure/pressure/config.py rename to hardware-testing/hardware_testing/production_qc/pipette_assembly_qc_ot3/pressure.py index 4b3bb3175e05..d279a50d2793 100644 --- a/hardware-testing/hardware_testing/measure/pressure/config.py +++ b/hardware-testing/hardware_testing/production_qc/pipette_assembly_qc_ot3/pressure.py @@ -6,8 +6,8 @@ from hardware_testing.opentrons_api.types import Point -LOCATION_A1_LEFT = Point(x=14.4, y=74.5, z=71.2) -LOCATION_A1_RIGHT = LOCATION_A1_LEFT._replace(x=128 - 14.4) +LOCATION_A1_LEFT = Point(x=14.4, y=74.5, z=96) +LOCATION_A1_RIGHT = LOCATION_A1_LEFT._replace(x=128 - LOCATION_A1_LEFT.x) PRESSURE_FIXTURE_TIP_VOLUME = 50 # always 50ul @@ -35,9 +35,9 @@ class PressureEventConfig: sample_delay: float -PRESSURE_FIXTURE_ASPIRATE_VOLUME = {50: 20.0, 1000: 50.0} +PRESSURE_FIXTURE_ASPIRATE_VOLUME = {50: 11.0, 1000: 12.0} +PRESSURE_FIXTURE_INSERT_DEPTH = {50: 28.5, 1000: 33.0} -FIXTURE_EVENT_STABILITY_THRESHOLD = 3000.0 DEFAULT_PRESSURE_SAMPLE_DELAY = 0.25 DEFAULT_PRESSURE_SAMPLE_COUNT = 10 # FIXME: reduce once firmware latency is reduced @@ -46,37 +46,37 @@ class PressureEventConfig: # but we want to keep the number of samples constant between test runs, # so that is why we don't specify a sample duration (b/c frequency is unpredictable) DEFAULT_PRESSURE_SAMPLE_COUNT_DURING_ASPIRATE = int( - (2 * 60) / DEFAULT_PRESSURE_SAMPLE_DELAY + (1 * 60) / DEFAULT_PRESSURE_SAMPLE_DELAY ) PRESSURE_NONE = PressureEventConfig( min=-10.0, max=10.0, stability_delay=DEFAULT_STABILIZE_SECONDS, - stability_threshold=FIXTURE_EVENT_STABILITY_THRESHOLD, + stability_threshold=2.0, sample_count=DEFAULT_PRESSURE_SAMPLE_COUNT, sample_delay=DEFAULT_PRESSURE_SAMPLE_DELAY, ) PRESSURE_INSERTED = PressureEventConfig( - min=0.0, - max=700.0, + min=3000.0, + max=8000.0, stability_delay=DEFAULT_STABILIZE_SECONDS, - stability_threshold=FIXTURE_EVENT_STABILITY_THRESHOLD, + stability_threshold=50.0, sample_count=DEFAULT_PRESSURE_SAMPLE_COUNT, sample_delay=DEFAULT_PRESSURE_SAMPLE_DELAY, ) PRESSURE_ASPIRATED_P50 = PressureEventConfig( - min=-5000.0, - max=0.0, + min=2000.0, + max=7000.0, stability_delay=DEFAULT_STABILIZE_SECONDS, - stability_threshold=FIXTURE_EVENT_STABILITY_THRESHOLD, + stability_threshold=200.0, sample_count=DEFAULT_PRESSURE_SAMPLE_COUNT_DURING_ASPIRATE, sample_delay=DEFAULT_PRESSURE_SAMPLE_DELAY, ) PRESSURE_ASPIRATED_P1000 = PressureEventConfig( - min=-5000.0, - max=0.0, + min=2000.0, + max=7000.0, stability_delay=DEFAULT_STABILIZE_SECONDS, - stability_threshold=FIXTURE_EVENT_STABILITY_THRESHOLD, + stability_threshold=200.0, sample_count=DEFAULT_PRESSURE_SAMPLE_COUNT_DURING_ASPIRATE, sample_delay=DEFAULT_PRESSURE_SAMPLE_DELAY, ) diff --git a/hardware-testing/hardware_testing/production_qc/pipette_current_speed_qc_ot3.py b/hardware-testing/hardware_testing/production_qc/pipette_current_speed_qc_ot3.py index 699b1e7faeaa..ab5e77d0eb1d 100644 --- a/hardware-testing/hardware_testing/production_qc/pipette_current_speed_qc_ot3.py +++ b/hardware-testing/hardware_testing/production_qc/pipette_current_speed_qc_ot3.py @@ -3,7 +3,12 @@ import asyncio from opentrons.hardware_control.ot3api import OT3API -from opentrons.config.defaults_ot3 import DEFAULT_RUN_CURRENT, DEFAULT_MAX_SPEEDS +from opentrons.config.defaults_ot3 import ( + DEFAULT_RUN_CURRENT, + DEFAULT_MAX_SPEEDS, + DEFAULT_ACCELERATIONS, +) +from opentrons_shared_data.errors.exceptions import StallOrCollisionDetectedError from hardware_testing.data.csv_report import ( CSVReport, @@ -17,17 +22,22 @@ TEST_TAG = "CURRENTS-SPEEDS" -STALL_THRESHOLD_MM = 0.25 -TEST_SPEEDS = [40, 60, 70, 80] +DEFAULT_ACCELERATION = DEFAULT_ACCELERATIONS.low_throughput[types.OT3AxisKind.P] +DEFAULT_CURRENT = DEFAULT_RUN_CURRENT.low_throughput[types.OT3AxisKind.P] +DEFAULT_SPEED = DEFAULT_MAX_SPEEDS.low_throughput[types.OT3AxisKind.P] + +MUST_PASS_CURRENT = DEFAULT_CURRENT * 0.6 # the target spec (must pass here) +STALL_THRESHOLD_MM = 0.1 +TEST_SPEEDS = [DEFAULT_MAX_SPEEDS.low_throughput[types.OT3AxisKind.P]] PLUNGER_CURRENTS_SPEED = { - 0.2: TEST_SPEEDS, - 0.3: TEST_SPEEDS, - 0.4: TEST_SPEEDS, - 0.6: TEST_SPEEDS, + round(MUST_PASS_CURRENT - 0.3, 1): TEST_SPEEDS, + round(MUST_PASS_CURRENT - 0.2, 1): TEST_SPEEDS, + round(MUST_PASS_CURRENT - 0.1, 1): TEST_SPEEDS, + round(MUST_PASS_CURRENT, 1): TEST_SPEEDS, + DEFAULT_CURRENT: TEST_SPEEDS, } +TEST_ACCELERATION = 1500 # used during gravimetric tests -DEFAULT_CURRENT = DEFAULT_RUN_CURRENT.low_throughput[types.OT3AxisKind.P] -DEFAULT_SPEED = DEFAULT_MAX_SPEEDS.low_throughput[types.OT3AxisKind.P] MAX_CURRENT = max(max(list(PLUNGER_CURRENTS_SPEED.keys())), 1.0) MAX_SPEED = max(TEST_SPEEDS) @@ -46,6 +56,9 @@ def _build_csv_report(operator: str, pipette_sn: str) -> CSVReport: _report = CSVReport( test_name="pipette-current-speed-qc-ot3", sections=[ + CSVSection( + title="OVERALL", lines=[CSVLine("failing-current", [float, CSVResult])] + ), CSVSection( title=TEST_TAG, lines=[ @@ -67,7 +80,7 @@ def _build_csv_report(operator: str, pipette_sn: str) -> CSVReport: return _report -async def _home(api: OT3API, mount: types.OT3Mount) -> None: +async def _home_plunger(api: OT3API, mount: types.OT3Mount) -> None: # restore default current/speed before homing pipette_ax = types.Axis.of_main_tool_actuator(mount) await helpers_ot3.set_gantry_load_per_axis_current_settings_ot3( @@ -80,7 +93,12 @@ async def _home(api: OT3API, mount: types.OT3Mount) -> None: async def _move_plunger( - api: OT3API, mount: types.OT3Mount, p: float, s: float, c: float + api: OT3API, + mount: types.OT3Mount, + p: float, + s: float, + c: float, + a: float, ) -> None: # set max currents/speeds, to make sure we're not accidentally limiting ourselves pipette_ax = types.Axis.of_main_tool_actuator(mount) @@ -88,10 +106,15 @@ async def _move_plunger( api, pipette_ax, run_current=MAX_CURRENT ) await helpers_ot3.set_gantry_load_per_axis_motion_settings_ot3( - api, pipette_ax, default_max_speed=MAX_SPEED + api, + pipette_ax, + default_max_speed=MAX_SPEED, + acceleration=a, ) # move - await helpers_ot3.move_plunger_absolute_ot3(api, mount, p, speed=s, motor_current=c) + await helpers_ot3.move_plunger_absolute_ot3( + api, mount, p, speed=s, motor_current=c, expect_stalls=True + ) async def _record_plunger_alignment( @@ -129,6 +152,7 @@ async def _test_direction( report: CSVReport, current: float, speed: float, + acceleration: float, direction: str, ) -> bool: plunger_poses = helpers_ot3.get_plunger_positions_ot3(api, mount) @@ -141,31 +165,29 @@ async def _test_direction( return False # move the plunger _plunger_target = {"down": blowout, "up": top}[direction] - await _move_plunger(api, mount, _plunger_target, speed, current) - # check that encoder/motor still align - aligned = await _record_plunger_alignment( - api, mount, report, current, speed, direction, "end" - ) - if not aligned: - # move to target position using max current - plunger_axis = types.Axis.of_main_tool_actuator(mount) - await api._update_position_estimation([plunger_axis]) - await _move_plunger(api, mount, _plunger_target, DEFAULT_SPEED, MAX_CURRENT) - return False - return True + try: + await _move_plunger(api, mount, _plunger_target, speed, current, acceleration) + # check that encoder/motor still align + aligned = await _record_plunger_alignment( + api, mount, report, current, speed, direction, "end" + ) + except StallOrCollisionDetectedError as e: + print(e) + aligned = False + await _home_plunger(api, mount) + return aligned async def _unstick_plunger(api: OT3API, mount: types.OT3Mount) -> None: plunger_poses = helpers_ot3.get_plunger_positions_ot3(api, mount) top, bottom, blowout, drop_tip = plunger_poses - await _move_plunger(api, mount, bottom, 10, 1.0) - await _home(api, mount) + await _move_plunger(api, mount, bottom, 10, 1.0, DEFAULT_ACCELERATION) + await _home_plunger(api, mount) -async def _test_plunger(api: OT3API, mount: types.OT3Mount, report: CSVReport) -> None: +async def _test_plunger(api: OT3API, mount: types.OT3Mount, report: CSVReport) -> float: ui.print_header("UNSTICK PLUNGER") await _unstick_plunger(api, mount) - failures = [] # start at HIGHEST (easiest) current currents = sorted(list(PLUNGER_CURRENTS_SPEED.keys()), reverse=True) for current in currents: @@ -173,39 +195,44 @@ async def _test_plunger(api: OT3API, mount: types.OT3Mount, report: CSVReport) - speeds = sorted(PLUNGER_CURRENTS_SPEED[current], reverse=False) for speed in speeds: ui.print_header(f"CURRENT = {current}; SPEED = {speed}") - await _home(api, mount) + await _home_plunger(api, mount) for direction in ["down", "up"]: _pass = await _test_direction( - api, mount, report, current, speed, direction + api, mount, report, current, speed, TEST_ACCELERATION, direction ) if not _pass: ui.print_error( f"failed moving {direction} at {current} amps and {speed} mm/sec" ) - failures.append( - ( - current, - speed, - direction, - ) - ) - if failures: - print("current\tspeed\tdirection") - for failure in failures: - print(f"{failure[0]}\t{failure[1]}\t{failure[2]}") + return current + return 0.0 -def _get_next_pipette_mount(api: OT3API) -> types.OT3Mount: +async def _get_next_pipette_mount(api: OT3API) -> types.OT3Mount: if not api.is_simulator: ui.get_user_ready("attach a pipette") + await helpers_ot3.update_firmware(api) + await api.cache_instruments() found = [ types.OT3Mount.from_mount(m) for m, p in api.hardware_pipettes.items() if p ] if not found: - return _get_next_pipette_mount(api) + return await _get_next_pipette_mount(api) return found[0] +async def _reset_gantry(api: OT3API) -> None: + await api.home() + home_pos = await api.gantry_position( + types.OT3Mount.RIGHT, types.CriticalPoint.MOUNT + ) + test_pos = helpers_ot3.get_slot_calibration_square_position_ot3(5) + test_pos = test_pos._replace(z=home_pos.z) + await api.move_to( + types.OT3Mount.RIGHT, test_pos, critical_point=types.CriticalPoint.MOUNT + ) + + async def _main(is_simulating: bool) -> None: api = await helpers_ot3.build_async_ot3_hardware_api( is_simulating=is_simulating, @@ -214,17 +241,11 @@ async def _main(is_simulating: bool) -> None: ) _operator = _get_operator(api.is_simulator) # home and move to a safe position - await api.home() - home_pos = await api.gantry_position(types.OT3Mount.LEFT, types.CriticalPoint.MOUNT) - test_pos = helpers_ot3.get_slot_calibration_square_position_ot3(1) - test_pos = test_pos._replace(z=home_pos.z) - await api.move_to( - types.OT3Mount.LEFT, test_pos, critical_point=types.CriticalPoint.MOUNT - ) + await _reset_gantry(api) # test each attached pipette while True: - mount = _get_next_pipette_mount(api) + mount = await _get_next_pipette_mount(api) pipette = api.hardware_pipettes[mount.to_mount()] assert pipette pipette_sn = helpers_ot3.get_pipette_serial_ot3(pipette) @@ -232,7 +253,12 @@ async def _main(is_simulating: bool) -> None: if not api.is_simulator and not ui.get_user_answer("QC this pipette"): continue report = _build_csv_report(_operator, pipette_sn) - await _test_plunger(api, mount, report) + failing_current = await _test_plunger(api, mount, report) + report( + "OVERALL", + "failing-current", + [failing_current, CSVResult.from_bool(failing_current < MUST_PASS_CURRENT)], + ) if api.is_simulator: break diff --git a/hardware/opentrons_hardware/scripts/provision_pipette.py b/hardware/opentrons_hardware/scripts/provision_pipette.py index 9a8f2ed26fb2..3f390a5b153b 100644 --- a/hardware/opentrons_hardware/scripts/provision_pipette.py +++ b/hardware/opentrons_hardware/scripts/provision_pipette.py @@ -54,18 +54,28 @@ async def flash_serials( return +def _read_input_and_confirm(prompt: str) -> str: + inp = input(prompt).strip() + if "y" in input(f"read serial '{inp}', write to pipette? (y/n): "): + return inp + else: + return _read_input_and_confirm(prompt) + + async def get_serial( prompt: str, base_log: logging.Logger ) -> Tuple[PipetteName, int, bytes]: """Get a serial number that is correct and parseable.""" loop = asyncio.get_running_loop() while True: - serial = await loop.run_in_executor(None, lambda: input(prompt)) + serial = await loop.run_in_executor( + None, lambda: _read_input_and_confirm(prompt) + ) try: name, model, data = serials.info_from_serial_string(serial) except Exception as e: base_log.exception("invalid serial") - if isinstance(Exception, KeyboardInterrupt): + if isinstance(e, KeyboardInterrupt): raise print(str(e)) else: @@ -120,7 +130,12 @@ async def update_serial_and_confirm( base_log.info(f"serial confirmed on attempt {attempt}") return else: - base_log.debug("message relevant serial NOT confirmed") + raise RuntimeError( + f"serial does not match expected " + f"(name={message.payload.name}, " + f"model={message.payload.model}, " + f"serial={message.payload.serial})" + ) base_log.debug(f"message {type(message)} is not relevant") base_log.debug( f"{(target-datetime.datetime.now()).total_seconds()} remaining in attempt {attempt}" diff --git a/shared-data/pipette/definitions/2/liquid/eight_channel/p1000/3_5.json b/shared-data/pipette/definitions/2/liquid/eight_channel/p1000/3_5.json index 675472c7799d..239c0be58828 100644 --- a/shared-data/pipette/definitions/2/liquid/eight_channel/p1000/3_5.json +++ b/shared-data/pipette/definitions/2/liquid/eight_channel/p1000/3_5.json @@ -361,7 +361,6 @@ "opentrons/opentrons_flex_96_tiprack_50ul/1": 10.5, "opentrons/opentrons_flex_96_tiprack_1000ul/1": 10.5 }, - "maxVolume": 1000, "minVolume": 5, "defaultTipracks": [ From 54a12756841fe2aeee8fb81a6528bffe3cf35f12 Mon Sep 17 00:00:00 2001 From: Laura Cox <31892318+Laura-Danielle@users.noreply.github.com> Date: Thu, 27 Jul 2023 22:01:06 +0300 Subject: [PATCH 15/25] feat(robot-server): Expose tip status in instruments (#13160) --- .../backends/ot3controller.py | 12 +++-- .../hardware_control/backends/ot3simulator.py | 4 ++ .../opentrons/hardware_control/dev_types.py | 4 ++ api/src/opentrons/hardware_control/ot3api.py | 11 +++++ .../instruments/instrument_models.py | 11 +++++ .../robot_server/instruments/router.py | 48 +++++++++++++------ robot-server/tests/instruments/test_router.py | 13 ++++- 7 files changed, 83 insertions(+), 20 deletions(-) diff --git a/api/src/opentrons/hardware_control/backends/ot3controller.py b/api/src/opentrons/hardware_control/backends/ot3controller.py index e1f0ebbe65ee..3458db66d00e 100644 --- a/api/src/opentrons/hardware_control/backends/ot3controller.py +++ b/api/src/opentrons/hardware_control/backends/ot3controller.py @@ -759,15 +759,17 @@ async def get_limit_switches(self) -> OT3AxisMap[bool]: return {node_to_axis(node): bool(val) for node, val in res.items()} async def get_tip_present(self, mount: OT3Mount, tip_state: TipStateType) -> None: + """Raise an error if the expected tip state does not match the current state.""" + res = await self.get_tip_present_state(mount) + if res != tip_state.value: + raise FailedTipStateCheck(tip_state, res) + + async def get_tip_present_state(self, mount: OT3Mount) -> int: """Get the state of the tip ejector flag for a given mount.""" - # TODO (lc 06/09/2023) We should create a separate type for - # pipette specific sensors. This work is done in the overpressure - # PR. res = await get_tip_ejector_state( self._messenger, sensor_node_for_mount(OT3Mount(mount.value)) # type: ignore ) - if res != tip_state.value: - raise FailedTipStateCheck(tip_state, res) + return res @staticmethod def _tip_motor_nodes(axis_current_keys: KeysView[Axis]) -> List[NodeId]: diff --git a/api/src/opentrons/hardware_control/backends/ot3simulator.py b/api/src/opentrons/hardware_control/backends/ot3simulator.py index a79e636e25e6..a3e1ae1aebac 100644 --- a/api/src/opentrons/hardware_control/backends/ot3simulator.py +++ b/api/src/opentrons/hardware_control/backends/ot3simulator.py @@ -370,6 +370,10 @@ async def gripper_hold_jaw( self._encoder_position[NodeId.gripper_g] = encoder_position_um / 1000.0 async def get_tip_present(self, mount: OT3Mount, tip_state: TipStateType) -> None: + """Raise an error if the given state doesn't match the physical state.""" + pass + + async def get_tip_present_state(self, mount: OT3Mount) -> int: """Get the state of the tip ejector flag for a given mount.""" pass diff --git a/api/src/opentrons/hardware_control/dev_types.py b/api/src/opentrons/hardware_control/dev_types.py index 5a4a991c5e84..25f86c0f7b44 100644 --- a/api/src/opentrons/hardware_control/dev_types.py +++ b/api/src/opentrons/hardware_control/dev_types.py @@ -91,6 +91,10 @@ class PipetteDict(InstrumentDict): default_blow_out_volume: float +class PipetteStateDict(TypedDict): + tip_detected: bool + + class GripperDict(InstrumentDict): model: GripperModel gripper_id: str diff --git a/api/src/opentrons/hardware_control/ot3api.py b/api/src/opentrons/hardware_control/ot3api.py index affb79c368c2..c5b23ff4c313 100644 --- a/api/src/opentrons/hardware_control/ot3api.py +++ b/api/src/opentrons/hardware_control/ot3api.py @@ -138,6 +138,7 @@ AttachedGripper, AttachedPipette, PipetteDict, + PipetteStateDict, InstrumentDict, GripperDict, ) @@ -1848,6 +1849,16 @@ def get_attached_instruments(self) -> Dict[top_types.Mount, PipetteDict]: # Warning: don't use this in new code, used `get_attached_pipettes` instead return self.get_attached_pipettes() + async def get_instrument_state( + self, mount: Union[top_types.Mount, OT3Mount] + ) -> PipetteStateDict: + # TODO we should have a PipetteState that can be returned from + # this function with additional state (such as critical points) + realmount = OT3Mount.from_mount(mount) + res = await self._backend.get_tip_present_state(realmount) + pipette_state_for_mount: PipetteStateDict = {"tip_detected": bool(res)} + return pipette_state_for_mount + def reset_instrument( self, mount: Union[top_types.Mount, OT3Mount, None] = None ) -> None: diff --git a/robot-server/robot_server/instruments/instrument_models.py b/robot-server/robot_server/instruments/instrument_models.py index 6c49a9d7fdda..80ace11cdf3e 100644 --- a/robot-server/robot_server/instruments/instrument_models.py +++ b/robot-server/robot_server/instruments/instrument_models.py @@ -81,6 +81,16 @@ class PipetteData(BaseModel): # add calibration data as decided by https://opentrons.atlassian.net/browse/RSS-167 +class PipetteState(BaseModel): + """State from an attached pipette.""" + + tipDetected: bool = Field( + None, + description="Physical state of the tip photointerrupter on the Flex. Null for OT-2", + alias="tip_detected", + ) + + class Pipette(_GenericInstrument[PipetteModel, PipetteData]): """Attached pipette info & configuration.""" @@ -88,6 +98,7 @@ class Pipette(_GenericInstrument[PipetteModel, PipetteData]): instrumentName: PipetteName instrumentModel: PipetteModel data: PipetteData + state: Optional[PipetteState] class Gripper(_GenericInstrument[GripperModelStr, GripperData]): diff --git a/robot-server/robot_server/instruments/router.py b/robot-server/robot_server/instruments/router.py index 94cfa429d0c7..f926cbb2c241 100644 --- a/robot-server/robot_server/instruments/router.py +++ b/robot-server/robot_server/instruments/router.py @@ -1,5 +1,5 @@ """Instruments routes.""" -from typing import Optional, Dict, Iterator, TYPE_CHECKING, cast +from typing import Optional, Dict, List, TYPE_CHECKING, cast from fastapi import APIRouter, status, Depends @@ -25,7 +25,11 @@ OT3Mount, SubSystem as HWSubSystem, ) -from opentrons.hardware_control.dev_types import PipetteDict, GripperDict +from opentrons.hardware_control.dev_types import ( + PipetteDict, + PipetteStateDict, + GripperDict, +) from opentrons_shared_data.gripper.gripper_definition import GripperModelStr from .instrument_models import ( @@ -37,6 +41,7 @@ AttachedItem, BadGripper, BadPipette, + PipetteState, ) from robot_server.subsystems.models import SubSystem @@ -53,6 +58,7 @@ def _pipette_dict_to_pipette_res( pipette_offset: Optional[PipetteOffsetByPipetteMount], mount: Mount, fw_version: Optional[int], + pipette_state: Optional[PipetteStateDict], ) -> Pipette: """Convert PipetteDict to Pipette response model.""" if pipette_dict: @@ -81,6 +87,7 @@ def _pipette_dict_to_pipette_res( if calibration_data else None, ), + state=PipetteState.parse_obj(pipette_state) if pipette_state else None, ) @@ -131,7 +138,7 @@ def _bad_pipette_response(subsystem: SubSystem) -> BadPipette: ) -def _get_gripper_instrument_data( +async def _get_gripper_instrument_data( hardware: "OT3API", attached_gripper: Optional[GripperDict], ) -> Optional[AttachedItem]: @@ -147,7 +154,7 @@ def _get_gripper_instrument_data( return None -def _get_pipette_instrument_data( +async def _get_pipette_instrument_data( hardware: "OT3API", attached_pipettes: Dict[Mount, PipetteDict], mount: Mount, @@ -162,27 +169,36 @@ def _get_pipette_instrument_data( Optional[PipetteOffsetByPipetteMount], hardware.get_instrument_offset(OT3Mount.from_mount(mount)), ) + pipette_state = await hardware.get_instrument_state(mount) return _pipette_dict_to_pipette_res( pipette_dict=pipette_dict, mount=mount, pipette_offset=offset, fw_version=status.current_fw_version if status else None, + pipette_state=pipette_state, ) return None -def _get_instrument_data( +async def _get_instrument_data( hardware: "OT3API", -) -> Iterator[AttachedItem]: +) -> List[AttachedItem]: attached_pipettes = hardware.attached_pipettes attached_gripper = hardware.attached_gripper - for info in ( - _get_pipette_instrument_data(hardware, attached_pipettes, Mount.LEFT), - _get_pipette_instrument_data(hardware, attached_pipettes, Mount.RIGHT), - _get_gripper_instrument_data(hardware, attached_gripper), - ): + + pipette_left = await _get_pipette_instrument_data( + hardware, attached_pipettes, Mount.LEFT + ) + pipette_right = await _get_pipette_instrument_data( + hardware, attached_pipettes, Mount.RIGHT + ) + gripper = await _get_gripper_instrument_data(hardware, attached_gripper) + + info_list = [] + for info in (pipette_left, pipette_right, gripper): if info: - yield info + info_list.append(info) + return info_list async def _get_attached_instruments_ot3( @@ -190,7 +206,7 @@ async def _get_attached_instruments_ot3( ) -> PydanticResponse[SimpleMultiBody[AttachedItem]]: # OT3 await hardware.cache_instruments() - response_data = list(_get_instrument_data(hardware)) + response_data = await _get_instrument_data(hardware) return await PydanticResponse.create( content=SimpleMultiBody.construct( data=response_data, @@ -206,7 +222,11 @@ async def _get_attached_instruments_ot2( pipettes = hardware.attached_instruments response_data = [ _pipette_dict_to_pipette_res( - pipette_dict=pipette_dict, mount=mount, pipette_offset=None, fw_version=None + pipette_dict=pipette_dict, + mount=mount, + pipette_offset=None, + fw_version=None, + pipette_state=None, ) for mount, pipette_dict in pipettes.items() if pipette_dict diff --git a/robot-server/tests/instruments/test_router.py b/robot-server/tests/instruments/test_router.py index ee692d2e93ea..63347465b4bb 100644 --- a/robot-server/tests/instruments/test_router.py +++ b/robot-server/tests/instruments/test_router.py @@ -35,6 +35,7 @@ InstrumentCalibrationData, BadPipette, BadGripper, + PipetteState, ) from robot_server.instruments.router import ( get_attached_instruments, @@ -115,7 +116,7 @@ async def test_get_all_attached_instruments( subsystem=SubSystem.pipette_right, ) - def rehearse_instrument_retrievals() -> None: + async def rehearse_instrument_retrievals() -> None: decoy.when(ot3_hardware_api.attached_gripper).then_return( cast( GripperDict, @@ -140,6 +141,14 @@ def rehearse_instrument_retrievals() -> None: Mount.RIGHT: right_pipette_dict, } ) + + decoy.when(await ot3_hardware_api.get_instrument_state(Mount.LEFT)).then_return( + {"tip_detected": True} + ) + + decoy.when( + await ot3_hardware_api.get_instrument_state(Mount.RIGHT) + ).then_return({"tip_detected": False}) decoy.when(ot3_hardware_api.attached_subsystems).then_return( { HWSubSystem.pipette_left: SubSystemState( @@ -217,6 +226,7 @@ def rehearse_instrument_retrievals() -> None: last_modified=None, ), ), + state=PipetteState(tip_detected=True), ), Pipette.construct( ok=True, @@ -237,6 +247,7 @@ def rehearse_instrument_retrievals() -> None: last_modified=None, ), ), + state=PipetteState(tip_detected=False), ), Gripper.construct( ok=True, From 2ac1ab6dd831116754c246cc4165399747b98b9f Mon Sep 17 00:00:00 2001 From: Seth Foster Date: Thu, 27 Jul 2023 15:44:13 -0400 Subject: [PATCH 16/25] feat(api): reduce unused mount currents with 96 (#13181) This will make the hold and run currents for the unused right mount be much lower when the 96 is attached. --- .../hardware_control/backends/ot3utils.py | 15 +++++++++++++-- api/src/opentrons/hardware_control/types.py | 3 ++- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/api/src/opentrons/hardware_control/backends/ot3utils.py b/api/src/opentrons/hardware_control/backends/ot3utils.py index dadf8953ba64..098c4177bcfd 100644 --- a/api/src/opentrons/hardware_control/backends/ot3utils.py +++ b/api/src/opentrons/hardware_control/backends/ot3utils.py @@ -216,9 +216,20 @@ def get_current_settings( for axis_kind in conf_by_pip["hold_current"].keys(): for axis in Axis.of_kind(axis_kind): currents[axis] = CurrentConfig( - conf_by_pip["hold_current"][axis_kind], - conf_by_pip["run_current"][axis_kind], + hold_current=conf_by_pip["hold_current"][axis_kind], + run_current=conf_by_pip["run_current"][axis_kind], ) + if gantry_load == GantryLoad.HIGH_THROUGHPUT: + # In high-throughput configuration, the right mount doesn't do anything: the + # lead screw nut is disconnected from the carriage, and it just hangs out + # up at the top of the axis. We should therefore not give it a lot of current. + # TODO: think of a better way to do this + lt_config = config.by_gantry_load(GantryLoad.LOW_THROUGHPUT) + currents[Axis.Z_R] = CurrentConfig( + hold_current=lt_config["hold_current"][OT3AxisKind.Z], + # not a typo: keep that current low + run_current=lt_config["hold_current"][OT3AxisKind.Z], + ) return currents diff --git a/api/src/opentrons/hardware_control/types.py b/api/src/opentrons/hardware_control/types.py index 831293b0eca1..20f50606f251 100644 --- a/api/src/opentrons/hardware_control/types.py +++ b/api/src/opentrons/hardware_control/types.py @@ -59,7 +59,7 @@ class OT3AxisKind(enum.Enum): #: Gripper Z axis Q = 5 #: High-throughput tip grabbing axis - OTHER = 5 + OTHER = 6 #: The internal axes of high throughput pipettes, for instance def __str__(self) -> str: @@ -165,6 +165,7 @@ def of_kind(cls, kind: OT3AxisKind) -> List["Axis"]: OT3AxisKind.Y: [cls.Y], OT3AxisKind.Z: [cls.Z_L, cls.Z_R], OT3AxisKind.Z_G: [cls.Z_G], + OT3AxisKind.Q: [cls.Q], OT3AxisKind.OTHER: [cls.Q, cls.G], } return kind_map[kind] From 11915b711c7520d0fd6adbe539fb570751b214c9 Mon Sep 17 00:00:00 2001 From: Ed Cormany Date: Thu, 27 Jul 2023 16:26:48 -0400 Subject: [PATCH 17/25] api(docs): refactor of Hardware Modules page (#13183) --------- Co-authored-by: Joe Wojak --- api/docs/img/modules/flex-usb-order.png | Bin 0 -> 69709 bytes api/docs/v2/modules/heater_shaker.rst | 198 +++++++ api/docs/v2/modules/magnetic_block.rst | 34 ++ api/docs/v2/modules/magnetic_module.rst | 105 ++++ api/docs/v2/modules/multiple_same_type.rst | 70 +++ api/docs/v2/modules/setup.rst | 131 +++++ api/docs/v2/modules/temperature_module.rst | 59 ++ api/docs/v2/modules/thermocycler.rst | 145 +++++ api/docs/v2/new_modules.rst | 615 +-------------------- 9 files changed, 764 insertions(+), 593 deletions(-) create mode 100644 api/docs/img/modules/flex-usb-order.png create mode 100644 api/docs/v2/modules/heater_shaker.rst create mode 100644 api/docs/v2/modules/magnetic_block.rst create mode 100644 api/docs/v2/modules/magnetic_module.rst create mode 100644 api/docs/v2/modules/multiple_same_type.rst create mode 100644 api/docs/v2/modules/setup.rst create mode 100644 api/docs/v2/modules/temperature_module.rst create mode 100644 api/docs/v2/modules/thermocycler.rst diff --git a/api/docs/img/modules/flex-usb-order.png b/api/docs/img/modules/flex-usb-order.png new file mode 100644 index 0000000000000000000000000000000000000000..322fd4ffcda92324b914f64a77b159cd3081df0f GIT binary patch literal 69709 zcmeFZWmH_x(lv1x_4K1RagJ2HxUYQk|>Dp5di=IiWFE(2><~4LoQDEcMu8^@Xsj# z03~55DykqQDhg6?v@^4`F$Dl<5{wNDFr^r1Mhp!N3`Ty?(;_;$DTRkeD;f9=^$dV| zdZzMw@{`habT;wPH=$Mf07Z(u&DO}#pvUh_Bb8|TF;mBuE!Xr%0ewS8HSuNXe-r`g z(pC;^@vqPWmZPxZl*q`@v_ege9#T-@YfvUp*ib~UCR0##bEL;8AXm&_ADBuiiV=d5 zE+`Wu3UdnJ4+C!~lOqb!h*I*pg4;kqTvSC|oQQuyM1ds;YCjQGT8Um-sKLos*i_NX zQz9aeoF!Qahwm!!#u+;rIXW62U&s(u@#Wzi7X!f{7rIeM4l**XUqCbmeBJ#GGO`yL z0sj3Jx>5O+e{XAMz*eoju#qC!Dpd4_M&w*{49Mp61R1$K?(yxd zyZP08sA% zu>Z;f0Mbw&{+m~VqWO0jAOH|%34r-`87;{D?*|OIAl(1H1M`9ZDFO7)hyHJ#^KVh& zus3PQ4Z$9)=>!>LtiKl&ATt{uQiG z`wKHG6ASZy>xQuM{iX8AJ6f7TTK-$V04v|WO8&oS|89qm`ETR@>tX(aZNUKmLI5c-VHJ0%;~Y3|ou4^pQ#k}T>kMw=ur~HltY7b@t!YR}i=i#E zY`!y-wr8z1laek|+9|tUDY;5*Q3e2H5YRysLM#R>oh>UZBcGchzi8?zZ@jrlNVc)R zmm;?u%gNYyuy8g%CJbkb3bT)ig+fbLww}#wpw{|Ro?YosY#)~vRd($(Pe6+FbVmp-K!9gtqy+m zOm^f9<6C%KAV~Qz|HuB-YM-%n0!JxIWrPkPv~qaGwZ?0FK#jxh!bX0?r6ib1EfweE z(;nZRSfv1WPHMhnmEiSy=nv81cio!GznNNll&V$hrfA`De=xRxTII(N)CYq;!&sR! z>mE94QjhZMesZq~6RD0bDPGlpQ>CfxY;@S+t^2ONQF}bT%}g@P=i%eV@nvPf>?&+u z{+sQ^wvt<(xhe>sPtRAbo`GH-HCSaj(TIc(U8%ae;Y6$XC$5~$aH9P91+7t1hg#30 z=+&u9`@}_Ps1xFo$_lOUIFB;otYNcbr?35Dd(Dq*532;unk$|X9p!}T3%iX--E$4n ziqSJQKmHD%HGwDAr@O~TQ_Av>_KqwDjCBm^U8W&cqQWj3V*)lRUR_g9_pdM7sHCz# zJ5q2w0$#6@$F<0sY~z-a@!nmvyF2*mrkU3{!Z8t>arQ3lS-FIWd^zptX#Bjg-cUcc zT$a;l)HaUg?Kgrr8Sib~UXF(RQZFKWl(Dd;dK9xp#4}*mDhEK*AF;Pm@;|Mo-~I&|!@ z@VidhNIzwE*9CJgqoO60{zl$j&rWat5hkmDGh9$WfcN_qe(dh?6GefacOCBGv;aH? z4Tb6T1;4Jn?_Es3a?b6SkttO%r1;#qJ`e4r@v>UHC8h0tBQ1_F3~;JZC%tZsqNBTti9Ij zrhh||bnWu^NLJ5uL&B8y@jwbckMqou+YFDjU@Y`bwe2yGByDVm5%UZ)jaMEKeXO~d zv{#3Qq<7QYL^2qFU3W!9D>$dHFfixk=$+=>aP{CR9q3Vw)_Qd*h$Qtu?Bun4cmLYI#xJh;hLZd1mG)0<8XW7hJzKZeX3;{>jYn3; zNR9snb)&F#&D7aTf3l{G@9cNCe$-|D0|t46uJgyQOEfn9PjG!7RTD0vOJ>sM0=pL(2j$H6X0VMu~|$-h~5olNoucMFs--zlkD}vIe?!j6_+jiv!#E% z>x0h=oQYlOGhMQ7eVxAdV+rvF&Bnm*$BKbRR|7YO#XUw%Q0XO(M|cjUueiZOCS?J? zBU_@OWkIcq%LoZia-QGFLZ-RP7_@|Q#RicQDSQuUDR=O*W0z8BF)VAbUILdwF2%2D z8;|%22hD1Fk?NiX+V&M&D@AFm%_cQC85c`6%+BMq4h=7HuA@tueJ1%%h04_ag6oGE z7$vbD%Db-Qq<$rp(oz0jGz&TG|IH^vAL#$HW5;$Sf_RoiP>L2cn+ui&cM^ z3tDNe8Hulg)m_7iRpoBB-SY%@$CY~pV2 zoK8m3Q0$o9%-F(YWF%@`I~8^_1v*te30JT%&;heHq6kBzCp%;yUF#2%Jj;E$ek-(Own#g84|Dn}39WZ+vJ$ zn`E1KICgnvV`Fh%F*Ky1h`d3)Pl3jPzy4BEoooH5q8P$Apd&o)^!50uRDlLmIOkM| z|0*x5Te}?_w6d|VkA;%}FSAHe<$pFjwnsZROrfVZL>{^P?fh8^NK5Lk=Fys#H*pv8 z>LxD!?MR{7MXSg0g}JOlm(;36C~{Ylf*b?L!e*S1GTav0zdt?_Kh?IX^?Va~@Km&V zFPJK=E~7_Nv$&6l6Uy6r-8>*zkOW2g9tCs;B_$q`p221HdrbExEsg*8=oi_M{%D4;tNvwRp#Dg|y24E0g>06d! zx4h9J@Vh1d0J||9Q6uI@%#mJ%KF1MG1mkRf88p;pX3ph__c#7N_ zMWJ~bauSCHDK#o2^_8jq75Bqc?RrE^-`^XD5F6)R+n%hpV(IFCeX4L6%Qoeo9q>ql z7YT2Z2N(69nS3AxG>)Q{bs5mKG@4F*jWI6n>!*nK1wu1ELYvy&B)5*6N3wK?lz$<{ zpg|f=5+-;|#HUk2)RO5`C4o)|3=|$&JL(=?&V-i|lrLI?&oJ>I+}d zz)gSKyE+d#=b)7nT1+TkiIeP|T-%jSf=>=3md1si(7n^VKfD%>a+3;zo+W^F2;jlM zSQyODAc`C73?M5ib(Q@&8awVuLkY^+bNE~a9=99gmS zRbXeuHau==+SW2I`D=XfPg*qb2Kxo@xKN7=Go8d}5}%+;v+_K~D+LraW<1qnel+5z zNkn8sDq7f}s;A%Q;FU6E^?4~Y|AyZ(G`f9NZdtCVSbpTNaZ7Q8%Wk{-cw^|1_La9U zGaDsOq#MUcN}1F@`&qw4l-A3KMr-IoZxt(x{z$c5E~CnovMs4YUmHw^<=@>sR*M28 zZ+*NJH2IOp8m1f?E8*QA9OtmS7C!x)-~{7dzq?8)e~S>sfksQfbx@wc?d0HMS62!y zrHP9W#hESnP|=7=2i7{6?o?XRJ-K^mD^aImo_Ds%;B>qkptw9$=M^{Or)Tw+6{sIf ze;rP4rCvQaY;KBEJ<1a+Z{M5vGx%*>C1I1HWHRBhiRY4(7Mf_VT79%NEkq<_4?O#!6DJA2j1s8I|NcYJW z%f36E842CE8!XbaOQesIC?W^myI;n?P!F%LNnC~&S~^~Z;siAqiyo!J*tpGXw{NF^ zwbjX(o!2BBj!IGm`~Pg5|47h!XSdFG z=VhyJk@LD6vR;1XS`%N?f6A>}@1j|;+Etk4otrzQIe0qtaP>k`VQr(&mQlIXFWo#M z=aKuYHjC)iKNc-o;(Z2#P7bcENPCt*@(dzZtm|2R2J+y0?p6 zbk~BI9k|mfZydFKgIWN{0wZK-2h;cVri zq4}VyH(c#{ls(D!bDXei0sE%@=aMYK!~$MD zsT3bI=~cp|jM&sPl#sQ4vR&J|+Xd=C2e6CUtk#%_jHU{=A0d7DW_MR$?VG;#*@B8d zQv%Zf$v{Hy)3Et_q;~K)$-*)gTs=|ZX@Nqm$tK<{Y8#ZOPYm)vm&8+}Gn!nfEaF41 zd!#ktmVrr;x8=IWiGJxPK1ZJ(#OE;NN4^T(G@dODT;*z zQw6BpE-%=Lr(b8g68r1Mv!Vskt|Y91+o?k$8L&33l7D^^$Uk1o01?Rj9qEjJPomMt ze>qt#Ne9-6XdRl37o%X+wwA=?5{KdM122C5I)s z^ObcZu3edKaINRVPYa=>(@U)5p8_8t4sk}hpxv~IbS=K?JV!#{@)yz&A3@e45HZY5 zYM@bB(ARRSYPaRnyl$yvN=QPF2NV$KdiK=J-SEMyOY7Q6T_~c^`N{bvz z9q46rwrfgj=UR|!-c6NbkULf{#qU8^CDrwPw#{=clUDD8p4(^glFWp9qwuhZhc|`E zJA#Al6-rx5^un~385(1PH$2RoHtyTzKP=_kfv-}?J-x|?CqyztB`yU_^hV{bD&y7@ z<)S>LSGvAwyPytrqO|<1aGA?}uxuh5sEyya<6nQy`>1;J@S&|;_q&F*#nWxZd7k}Z zhq3PXwXynZ(c(TYo%B`3WjuNn0nZwpin+{>*F&+Gw1heycFL>osTo}3R#q!Kp9j_# zdartYJ>YL6j9#s+cS6s^>)taZ!--xZAr$BO!_y@g{i$(e7X*FT$Io>La)weLp9r#? z+h@-B*{y8Y8Le2{xBL>Oi%}P0SUQB|>s+Gb z@3S$Hsk_D#y|-W{!Ao3{jufyOQA&mdx<6|Wm6ds|bE;~vV@+{U`#Aab0*lD^*SsUW4F6k|WX1?fH}bwKNjXe> zPpi&X2@uU$um1=4s$bs@d)!--^{M!;N4@w0V}f$whCmvq5MQ)6R;f#F_u{lhl( zSS>;=i_#95NfkjU1{ixhrw)(!hOCbNlemm9Dk5@k*GKldRkwhMHYO7Koj3*y z*0yFI?p?pcUB3fL;(846h~C3*hQ0$O*v_ z;>_qwr6Wc)B#HV#NAX)X+CL}*!i<=FFffq(;)UoCMn1*|ka`H~uhUoay}it$JZox> znZ{0BJ+|%cKDaxP8~@BrGX2by=%R-UPIppuIEXFxkU4A68Ek)9&qcJn$>gm)b?#84 zS|eGfUB{r&p-F1Mg@Mi;0ycLeMM&%4wz9>Fr|Dasr4HmE)+%7LgzhjJJKl3L(ORCa*VX!> zU2(NYWo02lxG&61AYyPpH5!l1o^?o;zbBo8{QWiZ8!kZw(L`E-lb1YU(mCvsMiRez1?!5%6biNCT3C!KrT+%|=NafySGz(K|!n1H8*_f1GX50~W< zRcC?*D~;Neos{v)c;CvIgr6VBwA%ZukxgpviYe_ZTtAsvWQ0$90){E|j%@ts5@;u* ze{7%d$#b6T4~El2LjJg0Lc{K8h8Ju_CLLoj4nB~^4(dTzPjB70t_VYpR7Q(PG%s=D zx=5Adc;Gd3{^+M9KM8NPe*t6N-0yW+N+GE9GIb{L2PPW$78CEdO4WUd$QJ$UOA3uz zJvE!^R4HT@?=ePZzO)h|x;+%sU`4r{Oa|0HZDrv>9aj(t6 zF1TI(Osk?gEkiWc*KvajKKiMmpIHNVPI_OW4dUG%98ozEGx;p8MPZ)?^dg@TVO&Q) z#*>6zcG^ic$%T$ldL;_R!E^g1@aqv|Gi}c>)}!9&9Rlldvv$%6XdOP$(w{d_oRh%a z+5C!5o^a?yq4S70KwwvlkT!&}=!AD(LTih8QC@rXXFw5^1RheNtEeTcCa}rLpQ`)i zGbpHV(F_4!Upd!KpW$>KJn|B~eA`NZ%`&!c3?yql^v!M&(KsqfvB?{e-n`RYKGt5Q zeM8*^&r%N$mC=mJd-|0jxAiHh&a_CqC*PkzFh30l5{=Yjo<}bDJTOLtm_@lvXjI6S zPZ&{1(B>mIdQ)AzR+a9>YBix>J4yolPf(~ah{JqgB26Op{8eckWYQ`VVKc&PB02_i zF5kN=ngu*N=_Yiu^7SYR!H9LX2EqsJ{4`>JcNK+Lso{^ACb{xi11n-~7Ky*VDPLaQ zrq^@)tb6ecb$yM@I4#VPd{`HKSS>|6Y_SHrx{hSntxi|w*+BWE5uCCFQ4j;#(fPQI^&{z;#gnhc_tNK z(8@pe_dB8?6sP--dxEilzU!{(Cd2>!NnMLy^7ED2%DU+fmn+Vfic7D&nLEe%6(O+> zT}D$rc`;c%p}n*4Np7><@uhBk&A|Y56T{wCGRybHN)&d32rl5(Kk#jQUVEzCl6<`) z7!jgC1(g}(-@mulHx55$Y;3$0CZC~}rQ6n%aBh@*z7X&*-D}HV zR6lUk{xD{;8u$aO6}$eQB#3h$niWW5b% z+(f*^yT-1l8}!EBSEx1{he&cuw|HzX>0?;>3lAe%5DZ#{7eS?9kpdRFg(B-^JA9^) z%~l-VL#aw;nI(^y)Od($WUd}Kh=5^B7Lq=D2n~IwiBzv7aN-ja6C;{32zJLBofGR5 z`}+PVt&Tig@GBsSDB4TP9|rt?)BlGABuEWn9HYaOWDSO*3E0j9TRyFIV=`W!cCz#G;S*19NnY??E={OjRlW>)4&rLoRjR$KQ!k`(#k9Z^_;rj;Ak|hsjd{ zt!QrJ0`NUVm&>PjPx^xH=LQwtr7~mzWy+-s9(=hei37l`ZjI`G!sUf==oXPCWk_E( z)*7&+I^d&usG!Qc4Fu^T^jgu-`Xxs0e7^hD*tm_H#fXcG+kM&SB61C$G=%xaFl#Sd zTymx%b!)=nEv7Et~cjd1ZW9DXJpu24`x}V%3Iuh`k)lx(N$Y( zZ*FW%P7LFW0J5Lt-gkFBUaAu*!oIMVvW-v#y#=Srti z`|O*t52#JOa+jQ5HYXmj_7Q%=K&`wym*d|*6hvQ{HHXzWgW)dSo*g5?b${*Ge5UcIY_7| zao{Pxsi{e}ZAdPSLVzi+V(=SY;b3qq%=ZL9G#^ZqcW&yGyr-8}o}lCI$czo&qFC z+paCVTYfy_wF16-Zdc-NB#h(Xxci#hFI1^Mj~RV3wOTd~|BiocZS8~`%@hW3b8{oJ z8*uN!{mi)hDS9)eT?(`U?>-=BU^FM4H?^p$s@iyWFjGV*n!Q~i3zZJDX*9q8dw+j} z>D`OkL-I;6iMCEsPnIH26yVft_3n~eolcMg5~f+<<>5h2mBJ+1yS{sRyqzwkFOGR4ir^;=l|k zp)p~w7SvI(Y&OMEr)Koi&;(!7!0!8PdCwuc)Foe95{7FzNg(&Xbe?(zR!MUelrk8^ zjYbIzpaEdPc-$T@Wa+Anv3knJp zxK_zrw*~jgf15w2>>IO!OIdZ%@`E|2xH{3hl}^@GVyQP_kQbw zFYu^8dX94gl?2Lni_@g;_T?-QyqSjq?k3T5mX4EmdrKZy&Qb_+e59mCc?ht= zZ=eqo#r%Qxi3T$z-tE_VNpCm^fO&_IzpY8H+lw=c^E|P+&hSiT98|GucVQR{{80JU5_GO7O3Q^5I~2`dy%>nAv!8ckGErncMen27Jc~8 z36n!j64{NwW>ULK1sd{6^?v2nrVWqbQky*M-b<<~9l0V4D8>|0uT9R2+6`L&)CL0y zZV(D8Fx+~FuEwW<6`Jy6^GHPF{Ym{VodT0f(Xxn^zgJaM(5E=H>MRK91cu=gA;k9# zz>m~735`vQE0@aYa@67DTTuY9{mByuJOuU3Z{#m&-!CJ@#RCuqr1E9OW;wU7vsl-6 zRpXH;>hpf2BeNG#Y$^butNf|QfqzKcEO6OBX=D5Ev^(co&V9|HMXS7`z<+WX8jkIf z0^vd}G)!Txow%5`?V{{?YKWY!y(Ob;qsN?Bnr{vBU-i3ocYNET1UV=gRmmLva-;Ve>|HSf%whc9r9k zI~3@GP_)XX+fS9W%3lq1Kexf4=L4d#wgX|25C>}WC^!vWvV{*~V89?>ATg~rNrGeQ zOq3{E<&#_BQsn(qwgAKj8lqrPD9EJTv97_NW9J_b_gAB1l9h(owv-W+d*OvYJ1`P< z6f6S3bULrH#Un;yeHQbF+ouJEd0q z+wq-iIXlQHo*i<32Y-~8mlt-EtU6aItydPX4-V|+J(GY>l2A6x`Pfg7OWNo;!Oj>F zkTJ>$&3{hf^F_{eh4_v9aZbr?F2 zrkIO)!cqnwfzVT%kwPH*1&ddA1_8(faNrQ1fJA6}#8{c&iKr`QIurr166Txfvb=EAmBM0><4*Bnz}ZMOrX2w5H1AIK zE)kA6s@zly{V=jQCKRnXoAf{k1izQUgp4nAegk+{=4hjmt78@kq6-uVBhg;TjnUfZ zT3UdL0lE3_r3Q(dfS{xv8W@GH-JJvu(Q(O(AyJ3FlLIbsJcYT&Z5w%$OXII}QvK)8 z>!=KdxGht@Ve%G~(wgb~&M?FrVF4;Fu+7SGA+W>dCsn_@5&=@=7Z3V0F~BAzto3ww zLk4z~8k927vA(W%JN6mVMFO=ccbA);3juOA3IQzhUVDbn+b5j^6U+&92n>xefL@?r zhJlj91O+7l5FncQHwk^kfJ8PM=!c#Y|4V~(M*;BvtC&6J2n7_I`!me$OU$310B9Z| zAD_-hy)(CsE}x);&v^p6qbB~n8yZh#?d{r)&CO`1{b_-LKviAcDibL{C&??s$spMp ze)jL~>+1`5|54}O?AxusFT9S*jxVMsTWx~U(z^li<|tSkfNbr`L_E%5Nzj=(WZWl_m$W>^h)|pfIn7ZXv2X8LQd06Kh~zmRUNFw{Zp}pt+-uSQO%z$r`(p6d zJ+Xkd#>rBhsEeyBohG)%XuF}Rj7*ePtw9&h)dc(C0p7SszVwHKFDp%DKi{6NRVY{nFNqR}?_rw03}E%| zu?v#aM5`JJKF)vroTXxCw_zY&)zBceLZ&<#oa29P^X1D=OESU%$^nCp6y7D0rHYE0 zF0K8kCGr8-um*#$Pc}>We&nBdFu~MNh&|4;Qw0r%>s)m`Ty%-;$Njc%^;-ejxv zI<*#;3>yr4YT8eE8jS`KZzg%qzVV(t+qs-?3hP=+j<4M;>ep1aY)i9NK4`f|JY)6Z z&7(!|o^>c!>9(syACWd%%v}62CV5KFWG@v)5{VnGYWDGYDK9OR6r>>by%)Y2z>+W5 ztT#i69GYQ*9oRtOT+N>pcp?l*m1^J-c@`eoF1T7Uj)Q3IyZB)+2o+ch&B4+5=lP0R zf#kji&RJ&t_GCp>TRRI^9^J@d^l8Fepr|=c;PD%i*Ed2}7nicZD7=afcF*5das7()?BHOU;}MCuL&4{9 zjjk3d(ZV3~bD6zbaC0@Yc9Sdz zC#Q7+OaR#;Za=$8f>h8mO;y^cC|C!$dV!*48d( z04-KUMez_EU~OZgIFho81TA2gY;0$gise=}=a`V?HJj8wlgnz9CHUU94cw0HZ6AU#!qQh7trrbAQM3>Y!VR69?fm5V?>C`m z6PGGuE+6*un5&?v52jl8b1QuB+BXiv#QE^@OG-35AAV~;3j2SIr)~g|G4eo^O$;5r z`H$#yflh{_O&q}|=%>b<^RO@QS|^(d9%ABXfv0P38;VnWR}JtTBDxo%T+UFU;J)5h z5b;tWhxNj@{XG4uxIE`sI)X^Y2o;I_!|L7oNgf&goB2FdzMb0+fg0Sbk#34oJ)av@ z%ZrubxqiWLu(PIdG?^#I0T{FfLj#!eflYt?1~WE3u9}NH@=4;Ytn{_`v}GqYoQ35N z%d=}(b=vK1(d>`sj$bVfMTDMUE##>Aa@C3kuHb+;HygAzpFH13f*ZI)%wKCQ+QjI1 zWi>u0YCor^4dfFyAb!QH5TMGk-aIVKN$7xslSBoL|2qHihn??-ge(oLDTRmCdh_P+ zLGuQ#_P5ukhIp}MCI$w~ExfmtAL-jG^azBzF_Iw^Ur8A+#abkerhC=!w_5 zrUR-WE{gf%t?8FwpO2fy2|Es$U;!v+BnaxDo+hvI#{lp5WKJoTgv0UH3~?8ap(Tq%!cwkO@FJ?LI6Wcm~YV!cJX z{hhV({I!Ys;C8Ler6LyXQwY2N$-q6o?%&xUo_jbX4k$cpT+~y&TlcDIJN%l3Jp|)> z>Xf&*D#ve<7i^Dv)enbpLN5;usrjBcTgP%WmI)J5aITMDsbGbL}lD?noC08R>>-)mPNNfnrVgn!kSA&|%P8Wusq@ke!l3CWNtnRp^4Zw!@ zK(bIKRICm_3zwjaj;qPHg`iTww}+{t%*UVV`bEQ6F9(u>i4MLMjqnYn!2a0Nyd*gu zkJ_gew6#&TQJ6rx)793tpMZK)`9!U^D*d-MTPd+905Q$dD}+x)@M(FY8oxLL^h^I_vYGycmzaVW`^V*NI?5lxQloVCr0QLR%NXCYA`~VkO+$3L}~2 z$I%!*??Yma#~m~^va?OWL1;Qk?V(lvGfyq}Flwkfddqjgi%=X1&;}@rXC8{7$0NGV zbESj(3AZ$Tg#d-Au>@ZF&)XZ{@?Ng@tx_kRwHpq&_!r(adNpAGB8ukk>wVeA>yA(D z=jlWxH9N@hxm|uF-AXF?05O+1tiSRw^|zP|?j^Yxmi=)7f33AM20mSq@g8802&>a< zBE8`CW#cW5cqm|tjDzE+C&r_)JijXB{G2iptCx-itk^hc0rH+YCAsQYQnuCb>+3hXWEK4>UKUSFIXjT5g|XuBqL@La4jSjOYqUR zl`=pDvNx9HaQIq^(6d=65p#;mMLvHWr})~dZ*86RxL4y_2B*<~Z{(_5dBGq4E|j3r zn}IyYHtqaATZFamh6g8jII!OR2;3mL|Dv{bXvRZ)v$~fJ8#&lD|)y`hih?gx5xS zNxmg9N__Va+=BqK1bZA@5l`;H5{fB-5lWy-<(~3>JuU)j4&4#m#3H=@Da1n5ny}4= zbpG6S$~acjpy<=AC@CNK!!{Z!8tlY%KMV%GN4u!!42cur69(KRAnZ&Ot)!N9n3de@ zGneh(J*s3+vT%lV&G61vXV+c`X^w$t>VJbQEBj( zG5@y$$0!9uxGd-ZbGiWcdUUF+n{k09;vL!b!sgqZAvsd!5ontadSAk-jeT-wa0a*JR|6?)E%FjSGthZr`| z{O;em?BS??8(NFeG7+ZPR<(0S&tHgjyXjl{MKqfZ@v|B**S~7NmY0{`tnjiRzzn^1 zJ)O=)HU8?4z9wQ1yFlIfVAr8o!yM`)+!hYEgI@QJKnw5fet3Akx%=hI%Q-$R(PPd4MqAX4Ku0J>l{NwKj!Vp_zSZsYOOP0Aolrzq(rO#}JP$n=2K?8ub#| zMHpJD>QaN{jsYXdFghMFdiY8QDU9VNRM7i}AT$A*hZgNPoJc%DVx9xc!26GkkyH$A z?x1%G>i9l?mGJ_L#S~A57vk$EdlsfHYJ+yX@eWP1Yjdf`IL1iycJ3jT1FVV0e5$9X z_tpH4#A$+6i=q$~LB5p}$zo<_vSXnwr!Ox4FMT@cwFTsOXWvmO#G_LgFD} z5oA<{g4=F~9V*=&Nk$Sw(4!n=7%;5xc}*6MMDD6*WqR^I5FmXotayj!@2;b+UaD1R zRN||UzrynajI+Z*6F^4fjd++95^nI@-C&==X)d zzit z;>KVD)#fns$98Nnfn4(19xYii*-&}Xfv`I!$G+V`HZ*BuI+}>r)gr)x`3gQxU0fo~ zbO3H9^m?WcO&haDlo*|PfJO@g1DDb7n-JMXtxtFo6Df@Acw-?Tn?kxtR2~9s#Ki%k zS*VQjo1&`;@IT}0hSL)ULB+UQDE1bk1PA!-uqb>mJedqG5I9 z^3`qocON_tTn_s{ygqU)A%ehoQBBd3Y1v8t{JKt zhy>NRl%)x6AS2p+SS~{V4B|w{?-1tN!io!ebB63R1-z5r>m|hO6CQ-YC`o}{Z%Zvl&4=fPW`@DkwxDb3MG@k^jU~6D1&4!qS3(^&d{IBmita zdkvD4_&@LUNs&YR=Z_81>HlzMOZfw*Ga#@c*?;N>1r$S2BH8y^60-krPEw$6AZATY z@H%lw0X1&cE7Ju>zv1(JhUtr%{7UNpvG*_*-6&Q$l=`*dRh=Ral$)0J0Cf$8?bhU_QL`0Nr=ZV z^ii=bgt22Vu=zzyl2&Zc7l4Z}w~JZRPXR)pd^&piMtBozTQhx`(mmokTpO5mJDIcx zn6a_3d!F%$?q~#l=vXLOn#qZ927KhP@v)$c1kK-$DnNIz@_kHXI2<+D|C3SRCLumD zzko9Z`d<^93GbIA^bW(IS?8TDgINm9teC$m+2>87Tn52+bIAM|`5C?Ol+TNA3m7AX+v-=6;L3mGmekx8Rv z_$-#@4P;8wCKB)lcV5p%{h2OU*rgu9{vHdNI}5tA19{UK=DAf?)<-Sj7TYti_~Z+> zyjK(TGyqA80cbfg7Oz{v#>)ZK@`mE%Bc-ngeffnNdU{M{A@jy@FQnpSY#BCWkRE?l zXP4SnQdMQ7l9)ioi<86LTAB&uB|7+wBt3~8vJz?oJkVq#0Yl{> zDDfO+?vCvC;O=}PW^2ea)Lm?vhRmCn^yC zSXW)W;ym#}A^Z$C7FshHZ({Z6_>a<;sxM~cRN21h9m)f zHLb1UH@CMAgtzz8Gk5rqZP_qjpuY~``tEK#x_i)SZwyOCRh6=x)X4a~3p28p*OTH6 z#M!QJ+t95+L`0OhnF^waw}erU;(NJP{<%Fke5eY;#`q>T!b}(TbbFGf3ZZ~aH)62^xa@>0+5Pa?OV{xvo6fQ1qIRs}=0+=nD?k&SZh!9C?&9Wp!;C#eh^Y>Vre(VZlLsGuHOAe>Hc z{p9b^@SuQYNyVDb3@(BcxRu~h>s16u8alE-iV3hukG45 z>blTEZajUy>shq(cs7Xc?eE9n#1Bh>Z2v|oiVBALrfq(C!Ht7Kj!ZE@A(L# z#RPNVe^?zLCNTN~4*x&V1|e4nATSYoG*bWH^$Zly0s&EnVj_tlJCA=q9EcY(6U~NQ zb~XRI62=RyLKJjILOkxDzQ+?mNcB5BmkfXTKtEtAKbB7I{@uWcLSo5$4UY46G}vP^ z?DA)pNnV~0>^Juan|dfAffsQ3Q!p3+w_p1(y%Vh#5TTzY#=QkER_hjd3*>!gSU4Y+ zw=*Jr_U-oEOG%@`O)mh024NtmbHzf9pL*-sDRyag`)I3CG=|YbQM5l(!NL zUlQVv9&TZS{?Hq%v-LH{YHa_qj!hyWN#zW}IWh?1Z}H`eB6D|#MCa9{woO7N%ism9 zSO_7`V3DLH^^jz27Ybx)7Y$s&KydGTIgCXg`Op6XC~s_Z8Q9^240`+u%rwgK&!1(2 zt)BzU<$)QZ{<=LGPF3m3|BN*l9y0#H1|}5$OH$krNwM#1v6lWX8aiZ(BJ_@c!61H^ z78o(l7IsxghzHn!mEqi|l%|f@Ghb&WO%ZPo)XAjY0uvO(2(gSbzSwZF-ByebO8N}x zbx@dP*ZU{IFP149S5-ehd+%MiVcdP+Tae>6uj(LSMgz!kAX3Ujg73-9d|$ns;&>VHnT44-E|k9x zOt1ogYIqMht5F7tC;f^*?xF_)45=Z&>ffNw#{|L$l$}Y{C-yg^`aR~WngZo1z+b08 z3wX$u1vn8i)9*If%cbPiG+_tny$?Zd0Z))26lXww$z%p3Q3p_9Z2muNy=7QbU)VPa z!vF)4BHgWYNJ~g}ND4@Iigbgtq;!jPD&5@@5>nDB-Q8#LocFx{=fk-!KXJKdX0N^0 z9lyAD|0s>WY4v|gv#bV|MzMus2>-vMK@$ePEVi9P`@e<$$N-*Kfga(%@c(b)`az#$ z3~Vw6gv|e4$(sO5G1Z@Fr~kVYdrGM^4YRr@mztSKQMt9A5G09evdN?dGbF6 zO40rH@=EhEfRbbI-})qE+#3<<3eE@*7uPoL5z8sy@eKO}1O#w}Tr>fDv5&eK{>;2i z!o~Lrqphc>2j$gBxhAOX_`@0-`7`v>sYy|I{lQZFzfHf3H~z=F)APX%a^E|L z8qmTZ24EPY<=lRa_7We?n9Fk6w^ADsx-FfpGLQBl6bvN4)BV_Sy;EBCTLUoWHydYI zCWkGTnSg&wnmAZ!su4*O_RR!Ps#5sN(mxKw)&3$PWF*KFJCnZZ_ATeNse+y^-41K4 zQ+SqZ{vU_ArVH1ih4p}Ns2K410|8P+-nSP;83L}w_CBX+s7S3@Sri&C=0O3+h6Eq4 zGUNY@rCub%Z8fYoCr50-dHTZ>rxpGNRshJXqPF|Fwn?LdNS;OIpDh=uKX|jLV4f8Q z9gq)-nx$IbmBYj(u&8Xhg3)&XZ!zrKX7 znE)Fw?d<2R;~F!=U@GVt3JD!mwVCc+#8XygFs%d zO?VwN)Q>)41I451ZI-Sk^WaB7x^Gx@|#=8^733{Pp2j{D!mB4C}^+nQ7&fm_ELW}Pb zn5I_&8yC(0vUo$P8?6p92H1Al_b9K4>(Q)(lBql5X`yu?x zm+J2l3O>WBTt;V@CAf@=%Jkp7Dj68lzAN%?o7|UcwRqIrv2ebXnomqh+I_v~Y2SqR z70bP!voTYN*Dx;%Vbi4xou}~7_wn|IL^2`*86+^H=OQVUOi6g|w6ul-hJoaeWnSHx z;P}wCW`B()pO!~4@|*JbW7X=6ziqy49-A4i;&+JEkFl}u_WSMvw#43)<62MuPzMlj z6s*n9&O2SeHM5$AEC&>tYRlk=kyp2S=E4OUKXi+9n0V~w3m!`UV1|b83KzlnZYc zYYc*{y%;O@W1*{-6uIg+>Z&(xR+5pCIs0|Y15^sS(BM6Ufl94F!|(Eh(;*uc=H?-t zZtcaz3?D9(k3EC~)vC1hAFfoQXk-o+=pB0eRz2 z=v}Mdj}&T1=#Qx>Jlh$=y%jyTmC!Gl%GsFx!qpU_rG`vnI7)B6H~CYb9WRSK6KTdU z9954zptBhh4Qn(_Z&P-#2WZ!KXZ5Y(P@ zkv~ZHa*Ry6?DpX6PpmyC6J~JRrYZ_E+2n|w@&HSm$epX}&S~!p8Ik>^HYL~FP76`D z?>*>o>;@JpRegRtT1FVX#E_$Q|D%YvS>keJ-6T$3|4;KIs(9lRapWIyN#$eJmF8t_?dADY%?pq3@*wE=+ zoDQv$kOJVc-m2&!=C+BzrTHh+9Z(Uxbt91q;%`3MTXz+0dh2tn7ZIx6Ga@t3ff)wk zf0gz&Jx1pr^Sz@4$Z#=eB}2Yjtg;?*Evz z(UX8gg5Fak>H9679DkX9n_`Dz79wU_SHdp@c1}C9bEE5`L+``5Mn)nRL@`kyrf3^W zP;6VG|1c_b_62e2@u4l+(@170uptgg2e6ipv2cWM8lcKe`Yktn3klDRBi}pAC9x!& z-wov5I%hWCU(7i_Gjy6}G&|vX{n}y;eI?j!*?cgOnTqa_YN00Mxm4akYs*e?$z+I- zQ_I9q2vIk?%NBsSH7}QZzj056B5=8FhfZ}K-t7KNLhj=b{D+C7;B!&+?N{uJpX259 zmo5f-G+1$P;xk}LN6vFGO9{$NemzfVcW5Ld=CxmhIM3NGP2g~3H|bOEfmlXkStIav z4id48yPr=V^XBnx$G=)sd%`gG*L(AfA{|r(I;;oshn2(g7_2>=8moj_PJ(9|wBeA3CSnOmdCP5*ps*t`NnFiOW42~7zLdNOH<6WDn#?iKi zE%Q((I+EB9IKM%92t#D-jUJffLb*i5uDd8y$%b#Nr))kjhEPh`k_NPmb%xR!gIs~o zm~Xf02j_LWUw5t)D$K+lz||{sw}NTiMzDtP_G(jDIB&Gm?|`gS1@GRE{n!DNQZc#^ zEH&3f_X4uR%OWLjZOzfq9esdotQ~vp2rOOyE=MKWgHB;~L$TF}*yT-DI)nK4K)R=Q za2}XhayH^I+y1feq^{AvYYCwL%=UN{{g{PFF-0aWfg|{QclzhF+|1r=jVk7chKC!> zumygxVH_RDgN98jcwPdOwiuCA5MuLD-o%j#HQB$WV>=*_zCZuztAO{RRUreg1sr5Q zC0%>6H};o1`VR=1IIBztHJm}cfgyPt_w7#2d(W6$Rm=M0QfrHcq1ME}7-EEk(J>nv zMp;mk>*1KN;d%uDv)QdEoMHyQf}sn!8Nrgvob9_VSJgYhkMpr25R@z~4BT=z(!Fkw ze>@kAKmJkR*{*OUcnony1m%?8hp1tBgYK|Ht+VT+20L++<+fgmK4z$gTusz z2|qJEUE+zU@zX~S6M_921$x&w9)kbe-qY zjG!f{UL9HJ?vfO2UKgtmD&xvNAF?|$R6BmVPb9j>`RzL5$F{!vhPnqSYeY>42fGXM zBbGmwr^TBkzsODl&r$nV99 ziPTB#9zh=OjRwz-aa#db?Jzvd%&~3#ErQs^Qoz5`exT|&fVubBu&D5eKtHeVs6p4P zrM+A!u*(FCnqzY(5O(!mXQm8lM=!da#VJDhp=u>M{kIEN=OrTua zgUdaeJmipyX9S2TWz580zx@U7}sh?oOxZK0NbGqI3y)6g5+DjChj549XdW?x$b0 zvlq*el!a(UXh)fX1Y#sVvQ{CKzE|C!Lh-o~OP~~jGpU2DZMtoYB$nwPJ5d^U@vVc1 zR0tT!4ygUrfLc_ZcArG?ndI?^??c>B)_t>8455Qt$iZr^nImch}nM;&M~iigwc zwz$sO-Xc&Pe168F#R=AOsbQ#*E5I!~f~@8NOFbm|#09 zD(8mw@N(%<_(t81uC=1pEh~^*Ht42|ilAR`ooRvNYE%q!=fI?SG!rCj125>P2N-ID zB*<3+u zP4RF9xgdWy3JE|cZff#$;$ft7C+Lb|V>>9k2h^-EnT_+{;Hkw5GJ9wuf2auX&%-2~ zTH4`^V=lv4TiJeu5nHH>>g)KVIH0Qf{GtVF|{eqNG|@(p|Bc|T?5?}_B(`+y<}M=X(v|Q6u~J5!i@nH zJMDn2XTC5po_B$`_X!M=`)nCH->vp|pi!9NQa^4@ide6LN|2=l?~dbuci0_-fL|0? zKKyjl|Cqk0B3-ipYM!3}r|}myoY=%cMKJkd4a!Q$NUiN0Qs1(Ha*YF9rkU=@lk7&0 z?Be@WoBOHRf`Xc=s(@Bb$oHCO65Ro_*I%i(gfIk}d0s3GULc3_;aP_xi+`(DypWOW zl>Q)cgy1g+PY`Z!cZ)!NZ8iTQ_gAITBM@B{(9RHz21HAX6jpX}H zgu{Wo!$Zye{rwy7=F$!GMOtpI8_>MDAA3_Sai+Q_jUMw!(pJ>fX*xSQk16s#SoQp- z*n{d7Xaafn$;nBt*OJGcRy~gnz;exY6G%6jDd1Z4e%c|D^blj3!ukLHt!O)Q{GdvW z%1JRdWQ2qzg8A0fm1kcZNw;LI66T4C4GJ)>R35Uyw~%ybYyduK!H`B9Nz2h2+vlNi zZ~)DV-U5%PG7e78)kRM;Q#}eRD`w>Dv*AC$0fd#6kqm8S!jOee?!iwgko*rUs{oZ& zZ+7Ps&-;J=#q>O3##gH+6aPJNU^@8?iINDqrGmY{#bspT`RqWGZ2M&cTJGFX3iG`K z>a`97>5F3he@bY-CSbr)5W%|uxP-Oq$>6Q!)iEmjB?Q19!y1=;A^>Qad1_o` zKv%*265!|0UpSroD)@qZS=iuc{_AtLdgD2(JPnOK3XE9FxV(JnL2&Bu@UHWmK|F!r5c-AW7^-P7+WwG{rC}|IQ40AZ0r@1cL@LW zy6Q_f^p|#|3cx~}qJ8&vZ}{6%maLo{FvEM!&u^NUpV{w4FPcBvK9UU{S{$bukLh@( zUmW)8H{VA1+T?UaoO_nCjjcRGJyG}e&L}P`(*WGvZe4Y{+p5e9zl-qpLkFB?0zkEF z{E(V>6D{bHIk8n@yUOb7PIEfc^}*8ghSI9rnd6V~JWUJ?QjU#9-$y1OEP6*vW!KA| z4#%*rnmDATjUsAmUy)=G_Z((+4L6idy6+DxHB%>}Y#t+(mxAC>f=G;Q#fO()UvKhJ#3d@jV^$GAplxvA%b!Fc96JAF zED#S=wU{Y9hGyMXvzCsr;jAM_SpYUqK+bo=4Gs)Cik%b<;0}{KAZ!OtG%+YpHXHv- z>z$u37{0i>A572>y{P&O_l@nn1EKqK3IlzPjtA1dY#q96->&FWlIizOi(H0LM%W_m zacvPFpM1A=Bh3|FN*L8M%S5h_sJEmxea{?hxYXzHbK}v{~`r0Dlyx7^$4&-2dDxm_7Kw-e1D6qhndlWq)^&i z`q%dz+d42@1wcma;v=z<5xqjX97072yw?mSurbjDg!1$4qa2_5PSE&Ys4N=N-`w1s zxrU0YMcA_GQ~_}v6*hSwsKClZOe@OEm3?_|<#B>_p7)D`43pwE`H6Rgtr_6x0jrq3 zL`ZLDL;FNu>?30$cp1z;0g?VZAG#_!9mxT5_76*B1EUtQifP26C*aRnJ%>6&p!zqk zg{_v#QJSrQ2J49hv|*Xw6@jp=vTXsTpi#p94U*v0HUTwePE`GId;8#?f~Ha_+Y{=& ztP6Shr;tXp96%oOUEgu-IeyQe&X9M%*VZo9L7*o-G&$)mr(t^!eq|hO|IsT-xUW-_ zlX=#sakxHXgtf%3Z{7f*!b?aFC4luz;Nau+tFJmV+RbRRvpO`gbgqF}*CQO&IQvgO z+|ZCwB380)%}Y6oqu1#QaAl5cTZP`l6^RifT`@TuSyff4!f#@~BCP?&Q5pl@X_Dv| zIqfTxp&3I~xlBF}6`y&xmJCG$*r{=YNfk=5a8`4If`Zz2w1Zi5RTknD5dvgX|0D6H zhf@h2NPAWHtMnxik#v`uJf+aKG{v7Mx^WhF@cUhA$8@1-f#@A!we+*HNUY4QciBMG z@sR}{#_n{P-E5SGdTee%!R&;bRuOC_4B%uG@SU713>y|?kt#}k+hR~rw`_I)np-Db z_x<~E0cdSwpY`Of?Q2CK|ogoHJBK?34veOexWB#>@K1= zgPJ@c_q$)lpc9A@y0uoE=1>)3DMz#>?XREsBOEt60S@?3=83|L(Uag1R5CWj{=xok zw?OEpXunr!ibYwJ|41dl>|m!UkXXKwSF?=*!3u9U95gA8 z!CVnL=Xay*P^2oz^!(=o>mOSwY7HhbmLn)6d}h%10vS8s#E2xr4NyaqRLyp}Gz|J- z`_RKt`KNn+rO6<5g0f}_%~I8d^CxrvG!Q^1WrvtahY}&%9%eco=1zo5h4~Kzg7);* zyWaxtK)|Cy&j(Xke@0Fp_Ku$2NKcv~0ji8)NS{76YsOHnIH<|+6aCKs$6;xR_VQuK}w$FE6r80r2EJAOjFA&d--s*VNqcCW{tWfnd=?b>IcCnX6-c zDsyQ3nyag;rCr^0I!E{j(3H@2x3*%gqPI|ur8ZS+E;jI`eqm#NZSMy$M#`*<8UboB ze=L*Gbp0=F@^18V8$Smm+)Rca!NR6z1{z?dc`4yw%OaXLJGPOlF4 zn|BS(c_j$H_{*0s@vLt-$N9214uK?01yHMd(t5F#(4Et`hYQhC;f z)z{QtoNLRsm~=rsOnvZt9Ys#D`_|>Tlu2qzKLMovXL&k`p2F6pz^=1#Iqu77#nL+p z{qmncf*Zh1oq z&H6Q-VuMT0#@9ZMB)IMX9G&Q9J^Ocy;_;!3i14sEZ5^Ew#;UMT$4ZA$MfpSnmtZh< z@%Jv#^!tvNyua8)Ojg#!&3XB1COg80k_(3D4GugDGEl^eR8NDwRuFXlO}ydX_uvrT zrd%E>u=UVlQ-4kn=l7>Ezi)_5Fh(Tt`A4hwPFAgzXWYw{3ZU;?rdeix`QJk@p#tGo zzS(Ms^S{5z|Ns6sMaZx2lHNC%)D*@)dL$IBI$F1!V~4Lusz~v$UBPoQ;ezL6@3L{I zG`z*OWHq3+ps&2q=)aE3`JeI1Rq*YhYXQy!H@7VeSg`&+);0u$Ro2{S<9#u&@;e1R zGNF|q70YGL+Z)bH8Z1nB>I-F1Isr!|&@C_!p#7pR%n>mi+o4&V>Q9H8aGJlj7lwYMScd=$%BUu&PX;_sdtW@B~z{7$eGw z>00-wdpHLqK(1(To2*(+r@Xtl@lf~lEby9DXTwrUgtdI+VMF87z6(}-5lm(;*r9{2 zycSGrs4pL{+Vw<^ z{*roNj|TXmZU7ZrE-srAUSUXC;pnr?9Mc$FNdb1B5jDtHSD*Y$#M2B;s6jfw(z@B| z!d2|2KMz;t4)z+Wda_#tMhf=c82C?>heSoOIIBTT8eknjZu4Odl#uM%%Pc_dk4EK( zN*+iqPSQ<+XC~S6kqI9!}6(VzES^3J^6#(3>m4VXxlG}x_SHo=7-eiG+JTYtzWHlKZT7Q zkdd))1F!Po1M^S&;<1J9F`uvW)pSlE&M2KZL@<_?q-B$HjP+fm0shek;xNM269;^6mI zrb)eR%@qjowV~OAytCiy;9atdieBBsFfcPa@9naLm?jETW5zPxrm>UkMRS1CCdmfW4`;o9q!Zhd`aH&YuPxog>|t zT{d?!U81ny_4niR^Tli$7B^=e3$!+O6li3UeI$+%pFd7^j*(30$cr8qp%Zh5iU{&& zFGs*S$pJut4ORX<3RxN*&0=ZXq7u|y172&)=rQ7>u!9*)uVpLde&E~ZZKvXKGHau0={{he<1 z!PO$^j*_p(J7|*T^uOC1j|p?(TRvd(rmFF|_n4fixBi&VVs*>RY} z3S~FFJgwjR4(vcT6F7tMp*GSFE7JGl`!LC8T z8FX*N2x+YA@S)>pes!s!fP8+Cn!3{=OKvj+K=0xw_t8H7>T8S(%aKgMoy)d1sRj4V z46A=bDgUrQ*^dVfyk$qGXIQ5N9xm?SRSE;4wPv<({w>r9%=*!#GgaGSd0s^x&-Q?{ zc0bCG?0~=DImlg4yC_S_W41@h+_WM=u-CPYNw5WwF-w^@G@Up|LY2fQl0;NkV1{|Y z7W{JPvo`i;SeR(kWEGgq0Uxig$?n_36gA1$g~2gpN6BJpTyUR7YFErE?KDf~2(kq^ z3(vPVDtU@0{xB*k3jTPyWYF5_Gge^okcx%|Rc-#eJL$nTg)G8HLmli1 z<+#{B$l4bdhr(GwdhYIjvtZ&tNpeMjwkkGzeT_JK(f)hvs~-*w%{QB$QgcX5A7`tJP(_CWBY)hbu<>Gq&>Bz> zRD6Qdri>;b6lz9rI9#aGAJ!kuVhkzAwQoMo_c-q1idC#HfKQlvQgZ>-kVB*M*cnIi z85lzD^wcfkyXOlqz!PXh)L2>zlFI7*v)Lb?>s8Zou76igIYw#R=;9 z0FRk86TDil@>gs7Ljt;ok7yetV*JaJ&%LQPU=L#h!q!vr3s&{6hkD!ae)j~Z*e||^ zB(^s=66aQKC04ZW;`Xv+7r-0e+cj+9F(WAm`5uSsZ&h^vPX@^iX@4|76l|vv81Oty zE$0+SbBwQU8fVxn7kvpQ3K36S7VAZtBn*Zl5{r}$=OMnR)xIK^D0U7l`Mn=;TF^(;y|{Y zzBSf|)in7;ug!@W`BXHksL_f;p1cOSr*4dyRxsK@MU~B74OayBj zjAf`l^xAAX0Gg{9{rXOm1D=*`?e`A#))ASh!GDAOD3yhvZu*q^XnzKXK77MokGS@J zy@SV;Ec}H)Q5@$amD{$MX;F(mUxlG6!gMa>zO_KIv2l|iaQU^HSt^(2E_X)Nsnb@pxc%W$h z*;p1w=iQ)k@EVz8dpvzLoX*!Zg3iM5m*gu!r1JBA2p89cj*S2U;jqnPEM<wMCw|lEZvk(Yvlo@@XTY4# z2t9(hg7Ja%X?Bgj?$_ysEAV`Z$U^gsVe2A(mvJ7Saoe>uDHJhDp^-SDHmV<+#kRaIw)(5PfTKlCHGPh+PVR(s+)%nuxUSOKEIY;Vs!g^;K424_n6mfd zYU-}w`&8GFAN`TsLNu|Mji6MPd0MU90+qGK@;b6p^TMH}lf+$>)fOh?RVoU*lYfHA3AQ zVrY=Hq_o(2F)MsRTt99tgGnoJ_s#1BK;mMRn18{nk~Lv%nQe<}0!U3D+^NmBV2(pV z3WQ_wTP+yk<6dC@`WwHn@}P#39{MNJfcJc&Ml1gTWx7#)DD6 z>%$#l=G|D#-WKmm)7jmwy>FDtmQO2QP5&o_d^o)BrV8aOr{ zt~c)l`U)XpCCxeVxRjAJ=q6PXlFy>Z_=L^_BpFrT)@$Vru|QVhV`*L*>R%GxPVr?6V(Tz*j8B5L}F*Gn`Co`9A4k=;1>%rOq3rH*I6L)`sf`)5sp>7>* zz(HXeoP9SMYtk=e3e4A^S~taf+S-O7i9=SV=Z}k*t((FczqZU9z=DUhm%gtu{XU7{ zv(&7Aop5<;No8wmOWooj6;0+4Ez~%>;B%`vPn%Tw3Fwdi!LVYOh$3XpA1r$--^cT} zWR^nz^-H@jyZuYy@=1MGfRw@xWk3hPD&a!hN{bve8A~fS44Ce1 zY12_vIPi}s2YSyCUyBSk0T_m|viT}j7#gqMPJTO6M%~D&UELo|fo0c=RY(JkfYTWN zQ;6`PkiwajsjQt+arTpWYX(Ne^WVifwK^(oCKOgjj!~Vc#FyIz-wuT3w@Ublx8?qY zX|bFKwa33wHQ|>qBYr7Oc1Z3V*lHhvK_W0k{m{3GSVGcQ$?i;z5+Ib$)9iV<_%%~X zgYFwp(NkY;VNcP@Y?-by_Ws5k;P8znXk`$B&& zg1k7%(s+)uD+;%5A7BVe|?7-SJ-p znKNYJ%4iK?6KIg;kPjb7IXK!SM^l1{oRQFZ7MEV5@v}d&Rz+LN%I2zk9IUbeX*-}pfO7PswBBTr_Hnk5& zlfnbLn%=*q7lX`3$tygJw`FJqECL6td}ML4#WTcn9?X_B|I;3tDeZzN?RZ8N`AH{p zGTaZleaG8{tB8;JdU27g4_h__lU&xlpNsPn6C#aiFt3}N>IKtyDVBwbl~qyixalHn zO0$sJCU1Pa+!5e|SpHl82Lz_j!!OkiGst*^W2m@_qpP`EIOU_m5KT@r6w~~DYbs5Y zF$DIYh$&@znBZKUeH?Z`*V_+YhftTtq+i!->Mt0z_~yK=XhMDZkRahK4>ip3K9-vh z3};z;gOgKBj*=M8g1Ei8y1q-v;F5&h;}oqFbbncKKfbQHy0#t9t@oupvIakL6+FRf z93GW;ek$?%4uXV*1ddKmmYKsq6ddj?uz+|g8H~61+3dDoXX@11;gPyr4;K#V84R|A zb^0RehZ8(irnBj}^CvaG!f_L$P1ADRSzX2NJ&l+F0r;c~)j}=k%pG4O9v3+_MsA4T z#pNZkm>ll2gjZ&;31&$1PpK%UCUoMd+XaMi3V)k>Bge;`3wo^@#Jb3=l8Z3~-D6q% zvi$Gl?i_nVWA;pUS@e~$k={-W*V}4sc9zWnJz2d-`#+mcVo9joE9|QszO6UGD<(v! z$7;Pey<<}32ccIU`BKd>FFV^cvP^@a#z=%!aWTs@!>LF1Cv}?29|T_Sn=`d<>OY1$ zEWlW>T0sSt7^YGSsM}!_nc&V+hin;rc2G|1DEfSk;YRb}&Q5GuHU^!Z=7uU=CiS`2 z__>8pV~Tv^)A!&%s(lJsF7N*=TQaUN9?n`w^LLosi>;pVFWWqq^Uj2d)Q);(Yrf7o zg$VUil^xe^HI^!Pm0UYEJDX~;miQGyG+`Mt;+2Umm<=MJB;*vnB+A4F!+=!KhMA%4 zTDGiEO{(UPMNK}}rShO|D7wnV`+R>@N{>{Vb4n3G_>^Kg1o$D6p2X6r*J_N{q)>d$ zV38N#DX56=wPs^U8*hbuX^v$)J%Z2kS4wVcuqO7ma>qP-HA*16mLW~8#Ox^KlDC_r zL@1ws2JVMRFh9`<1=}E$b9%haom|8o->@nW9jsS|hsnMKf2TSpO>wcc`vFzBd>wG+ZulfUhuiqR-ZRUao| zH0=euulN}}t)C30yJBYgUh&$F2SdJX9#zcUm3AJLp@-?)thaX|CMDPl_ADs@Q@~TT z0GvN0j@Gf62-4QA6%{i7&Z=b};;NS42_1HHQ2J)Sz5Y}lkM2KC3HNv0)oH5q?;PKv&i?gaqWnb42DgpY61~ou(``hjx$;3e^Zo2wa1{67 z400rx^nY*~qqRGjzkb(1t5RY51s=s1V|nkdqofdvDZ_H1$#u-vO`!MQ1p_lCCNvN@ zqX8(gaL|MQjXdw$0s$l;818VWGbsFjS_V$DA^9_J^onJ)L*Q1D$+5LsrE%IG&pQBf zLS*i?WG~3(D~y}wsx8G}K@I5s%~+uC!IwX0b~^4!lI;G5&81b2Qx!n@f~<2vm28=vFi!n1HZ=0xKemblA&S}sVxz@ORKRi(b~_<}MB z@HW;tKV(_-J?>|gFvo47Y`+RV2)Gl%mu8s1uZ@+@e8YE~$eiHr==e!6iqZ{CC#;NR ztI81H`d00^IPWfFZh1`zk)Uk<%-pX}#Q0I&a0g8q+tDqKdp#w^H<<)JHzHkx!}VZ3 zH{AMq5(aecxsqfieBp0WwMP2p>J<9da@Cni} zQ8*_|;!slkV}Zi&!ZSgc<46NU8W`^78@I7Wh8ULD6<=gccY^B%VeuuIF!r2U16X3S zTVAY9|CknS+oyy-JE2E1X-#TRdmcB|IT^$AOd+*f&s*W$&3G&qgX%xH@k$oWywv@# z?D1Ttvj4{5XJ|m_2IjQ^BAH*^CFJDoUR+pFu3sh`)>=Yfe0cRnTMCG8^9hI z*I0bkauFY7Ta=s_>-2%ZJJCg72Ygj&*=CBCWwMvo#b5IV>mLoX%nzq{RtaQ*t-!3ByYn@22#fP~kr1980 z1NE!lT&-3K%6>!D`=y;RLm@FquC1qKmC;)hUr)B0qNTPp{I_m#E*A?+XHzi(9d{;Ph5cLx z(Kep+nOT3KL`m`Me4FC6P>~2R({gt0Brw-z#C8U}9_7}K8$u9}=8hZEzQwBRV5wEw zC~jbZ_7;LvRz87l_=S@}s=kraAquD9wZ;pM@2Sl8+3QNq&-Uv-S-R;qtlh;FY>v=< zG8g-ES3>H))%5ztn_TCu$xh%}-tUV=k|bWe5VlY4|Bhwal#Xf^7Z;WDRL+Uu4I89T zka!pZ%Iwh1lHZ4Yze%x$$wNV)#d$ljnurb`{Hd|2$v8~wFGoUP$|HW6+h7X0<>skB zV;x3fE2T0cY%9Hs=GSv)q(4TnLsc4?NeR?~pj^xkY1v3e09RY6(984|HvggU<1;9y z5NpSJi3RUL@;8ai(h(Q|yFJO`SaLQr|Ia3R&za{B#{o^Jfrq|{`X*zLPu5 zJ~3kqp*hWcF+3ax9URI%98Vf}_Zm7bL9tDZo5I-l^`lH2d$8Z2CBdxOkMD$HS!ytv zB_%zEVde}qYzyl&rjl{)1~5dU|I6R^w6pdK${YyS%tA;j{lZa(S^KL7*mjy*RLD8p4`xOsT48w-nG{YAt%g9D=h zF_d(E?Y&@=tw5Rgqnv94hBHoVH2SP!i879-TKg|T$qVQ0S2|Vy_*}tcRYL9z4Wr8L zMa>qD-0`m#LCBO~@?{;EZg&umZu)n=u{rpTU!ch(`X_VcC-w%x!2ulxxdkjRX91m| zmKRKnj5~8mdd&D7%Kk~Diy`c;I=6Wc7*SkrFYA)Lp#Ie$SaHe2?Z=fRrwKYh_oiPv zpzU%XdTdkpdS2`?TON`dnAIAk?BKDav+CqlCnoEf`RaPT0if`*o{weAZW38L4H|Fd zQK0N12u#+nBgHYt9?9N~0~KU}lVlFlN}F||X@xYFQ#hP-?YG`ncc(x5VWQi+7>c?B z>G>QkbLrZ*!&>gKVj$yu(wf#)Jd3TVNoZ9c@#6I$;AFlsp>JX9Gpj_ir$4SJZXgfD z?Z*mz>!&C3g9h!8+2mDZ!>1H#n!kaKXHYhjJ*MazvGzvNox>E(w_%)V@?vapKl>V1S_Ui9K)}I#k+3G&g`ccJebg`Bv`loO9{dqxl?UE+60>^!0riuBXPxJ>#Vi@3;>`l1R3b

08I(;J|U0v@LkmK5zDHE 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 includes several pre-configured adapter–labware combinations and standalone adapter definitions that help make the Heater-Shaker ready to use right out of the box. See :ref:`labware-on-adapters` for examples of loading labware on modules. + +Pre-configured Combinations +--------------------------- + +The Heater-Shaker supports these thermal adapter and labware combinations by default. These let you load the adapter and labware with a single definition. + +.. list-table:: + :header-rows: 1 + + * - Adapter/Labware Combination + - API Load Name + * - Opentrons 96 Deep Well Adapter with NEST Deep Well Plate 2 mL + - ``opentrons_96_deep_well_adapter_nest_wellplate_2ml_deep`` + * - Opentrons 96 Flat Bottom Adapter with NEST 96 Well Plate 200 µL Flat + - ``opentrons_96_flat_bottom_adapter_nest_wellplate_200ul_flat`` + * - Opentrons 96 PCR Adapter with Armadillo Well Plate 200 µL + - ``opentrons_96_pcr_adapter_armadillo_wellplate_200ul`` + * - Opentrons 96 PCR Adapter with NEST Well Plate 100 µL + - ``opentrons_96_pcr_adapter_nest_wellplate_100ul_pcr_full_skirt`` + * - Opentrons Universal Flat Adapter with Corning 384 Well Plate 112 µL Flat + - ``opentrons_universal_flat_adapter_corning_384_wellplate_112ul_flat`` + +Standalone Well-Plate Adapters +------------------------------ + +You can use these standalone adapter definitions to load Opentrons verified or custom labware on top of the Heater-Shaker. + +.. list-table:: + :header-rows: 1 + + * - Adapter Type + - API Load Name + * - Opentrons Universal Flat Adapter + - ``opentrons_universal_flat_adapter`` + * - Opentrons 96 PCR Adapter + - ``opentrons_96_pcr_adapter`` + * - Opentrons 96 Deep Well Adapter + - ``opentrons_96_deep_well_adapter`` + * - Opentrons 96 Flat Bottom Adapter + - ``opentrons_96_flat_bottom_adapter`` + +Custom Flat-Bottom Labware +-------------------------- + +Custom flat-bottom labware can be used with the Universal Flat Adapter. See the support article `Requesting a Custom Labware Definition `_ if you need assistance creating custom labware definitions for the Heater-Shaker. + +Heating and Shaking +=================== + +The API treats heating and shaking as separate, independent activities due to the amount of time they take. + +Increasing or reducing shaking speed takes a few seconds, so the API treats these actions as *blocking* commands. All other commands cannot run until the module reaches the required speed. + +Heating the module, or letting it passively cool, takes more time than changing the shaking speed. As a result, the API gives you the flexibility to perform other pipetting actions while waiting for the module to reach a target temperature. When holding at temperature, you can design your protocol to run in a blocking or non-blocking manner. + +.. note:: + + Since API version 2.13, only the Heater-Shaker Module supports non-blocking command execution. All other modules' methods are blocking commands. + +Blocking commands +----------------- + +This example uses a blocking command and shakes a sample for one minute. No other commands will execute until a minute has elapsed. The three commands in this example start the shake, wait for one minute, and then stop the shake:: + + hs_mod.set_and_wait_for_shake_speed(500) + protocol.delay(minutes=1) + hs_mod.deactivate_shaker() + +These actions will take about 65 seconds total. Compare this with similar-looking commands for holding a sample at a temperature for one minute: + +.. code-block:: python + + hs_mod.set_and_wait_for_temperature(75) + protocol.delay(minutes=1) + hs_mod.deactivate_heater() + +This may take much longer, depending on the thermal block used, the volume and type of liquid contained in the labware, and the initial temperature of the module. + +Non-blocking commands +--------------------- + +To pipette while the Heater-Shaker is heating, use :py:meth:`~.HeaterShakerContext.set_target_temperature` and :py:meth:`~.HeaterShakerContext.wait_for_temperature` instead of :py:meth:`~.HeaterShakerContext.set_and_wait_for_temperature`: + +.. code-block:: python + + hs_mod.set_target_temperature(75) + pipette.pick_up_tip() + pipette.aspirate(50, plate['A1']) + pipette.dispense(50, plate['B1']) + pipette.drop_tip() + hs_mod.wait_for_temperature() + protocol.delay(minutes=1) + hs_mod.deactivate_heater() + +This example would likely take just as long as the blocking version above; it’s unlikely that one aspirate and one dispense action would take longer than the time for the module to heat. However, be careful when putting a lot of commands between a ``set_target_temperature()`` call and a ``delay()`` call. In this situation, you’re relying on ``wait_for_temperature()`` to resume execution of commands once heating is complete. But if the temperature has already been reached, the delay will begin later than expected and the Heater-Shaker will hold at its target temperature longer than intended. + +Additionally, if you want to pipette while the module holds a temperature for a certain length of time, you need to track the holding time yourself. One of the simplest ways to do this is with Python’s ``time`` module. First, add ``import time`` at the start of your protocol. Then, use :py:func:`time.monotonic` to set a reference time when the target is reached. Finally, add a delay that calculates how much holding time is remaining after the pipetting actions: + +.. code-block:: python + + hs_mod.set_and_wait_for_temperature(75) + start_time = time.monotonic() # set reference time + pipette.pick_up_tip() + pipette.aspirate(50, plate['A1']) + pipette.dispense(50, plate['B1']) + pipette.drop_tip() + # delay for the difference between now and 60 seconds after the reference time + protocol.delay(max(0, start_time+60 - time.monotonic())) + hs_mod.deactivate_heater() + +Provided that the parallel pipetting actions don’t take more than one minute, this code will deactivate the heater one minute after its target was reached. If more than one minute has elapsed, the value passed to ``protocol.delay()`` will equal 0, and the protocol will continue immediately. + +Deactivating +============ + +Deactivating the heater and shaker are done separately using the :py:meth:`~.HeaterShakerContext.deactivate_heater` and :py:meth:`~.HeaterShakerContext.deactivate_shaker` methods, respectively. There is no method to deactivate both simultaneously. Call the two methods in sequence if you need to stop both heating and shaking. + +.. note:: + + The robot will not automatically deactivate the Heater-Shaker at the end of a protocol. If you need to deactivate the module after a protocol is completed or canceled, use the Heater-Shaker module controls on the device detail page in the Opentrons App or run these methods in Jupyter notebook. + diff --git a/api/docs/v2/modules/magnetic_block.rst b/api/docs/v2/modules/magnetic_block.rst new file mode 100644 index 000000000000..98d52b33bdee --- /dev/null +++ b/api/docs/v2/modules/magnetic_block.rst @@ -0,0 +1,34 @@ +.. _magnetic-block: + +************** +Magnetic Block +************** + +.. note:: + The Magnetic Block is compatible with Opentrons Flex only. If you have an OT-2, use the :ref:`magnetic-module`. + +The Magnetic Block is an unpowered, 96-well plate that holds labware close to its high-strength neodymium magnets. This module is suitable for many magnetic bead-based protocols, but does not move beads up or down in solution. + +Because the Magnetic Block is unpowered, neither your robot nor the Opentrons App aware of this module. You "control" it via protocols to load labware onto the module and use the Opentrons Flex Gripper to move labware on and off the module. See :ref:`moving-labware` for more information. + +The Magnetic Block is represented by a :py:class:`~opentrons.protocol_api.MagneticBlockContext` object which lets you load labware on top of the module. + +.. code-block:: python + + def run(protocol: protocol_api.ProtocolContext): + + # Load the Magnetic Block in deck slot D1 + magnetic_block = protocol.load_module( + module_name='magneticBlockV1', + location='D1') + + # Load a 96-well plate on the magnetic block + well_plate = magnetic_block.load_labware( + name="biorad_96_wellplate_200ul_pcr") + + # Use the Gripper to move labware + protocol.move_labware(well_plate, + new_location="B2", + use_gripper=True) + +.. versionadded:: 2.15 diff --git a/api/docs/v2/modules/magnetic_module.rst b/api/docs/v2/modules/magnetic_module.rst new file mode 100644 index 000000000000..7c3561ab5e75 --- /dev/null +++ b/api/docs/v2/modules/magnetic_module.rst @@ -0,0 +1,105 @@ +.. _magnetic-module: + +*************** +Magnetic Module +*************** + +.. note:: + The Magnetic Module is compatible with the OT-2 only. If you have a Flex, use the :ref:`magnetic-block`. + +The Magnetic Module controls a set of permanent magnets which can move vertically to induce a magnetic field in the labware loaded on the module. + +The Magnetic Module is represented by a :py:class:`.MagneticModuleContext` object, which has methods for engaging (raising) and disengaging (lowering) its magnets. + +The examples in this section apply to an OT-2 with a Magnetic Module GEN2 loaded in slot 6: + +.. code-block:: python + + def run(protocol: protocol_api.ProtocolContext): + mag_mod = protocol.load_module( + module_name='magnetic module gen2', + location='6') + plate = mag_mod.load_labware( + name='nest_96_wellplate_100ul_pcr_full_skirt') + +.. versionadded:: 2.3 + +Loading Labware +=============== + +Like with all modules, use the Magnetic Module’s :py:meth:`~.MagneticModuleContext.load_labware` method to specify what you will place on the module. The Magnetic Module supports 96-well PCR plates and deep well plates. For the best compatibility, use a labware definition that specifies how far the magnets should move when engaging with the labware. The following plates in the `Opentrons Labware Library `_ include this measurement: + +.. list-table:: + :widths: 50 50 + :header-rows: 1 + + * - Labware Name + - API Load Name + * - Bio-Rad 96 Well Plate 200 µL PCR + - ``biorad_96_wellplate_200ul_pcr`` + * - NEST 96 Well Plate 100 µL PCR Full Skirt + - ``nest_96_wellplate_100ul_pcr_full_skirt`` + * - NEST 96 Deep Well Plate 2mL + - ``nest_96_wellplate_2ml_deep`` + * - Thermo Scientific Nunc 96 Well Plate 1300 µL + - ``thermoscientificnunc_96_wellplate_1300ul`` + * - Thermo Scientific Nunc 96 Well Plate 2000 µL + - ``thermoscientificnunc_96_wellplate_2000ul`` + * - USA Scientific 96 Deep Well Plate 2.4 mL + - ``usascientific_96_wellplate_2.4ml_deep`` + +To check whether a custom labware definition specifies this measurement, load the labware and query its :py:attr:`~.Labware.magdeck_engage_height` property. If has a numerical value, the labware is ready for use with the Magnetic Module. + +.. _magnetic-module-engage: + +Engaging and Disengaging +======================== + +Raise and lower the module's magnets with the :py:meth:`~.MagneticModuleContext.engage` and :py:meth:`~.MagneticModuleContext.disengage` functions, respectively. + +If your loaded labware is fully compatible with the Magnetic Module, you can call ``engage()`` with no argument: + + .. code-block:: python + + mag_mod.engage() + + .. versionadded:: 2.0 + +This will move the magnets upward to the default height for the labware, which should be close to the bottom of the labware's wells. If your loaded labware doesn't specify a default height, this will raise an ``ExceptionInProtocolError``. + +For certain applications, you may want to move the magnets to a different height. The recommended way is to use the ``height_from_base`` parameter, which represents the distance above the base of the labware (its lowest point, where it rests on the module). Setting ``height_from_base=0`` should move the tops of the magnets level with the base of the labware. Alternatively, you can use the ``offset`` parameter, which represents the distance above *or below* the labware's default position (close to the bottom of its wells). Like using ``engage()`` with no argument, this will raise an error if there is no default height for the loaded labware. + +.. note:: + There is up to 1 mm of manufacturing variance across Magnetic Module units, so observe the exact position and adjust as necessary before running your protocol. + +Here are some examples of where the magnets will move when using the different parameters in combination with the loaded NEST PCR plate, which specifies a default height of 20 mm: + + .. code-block:: python + + mag_mod.engage(height_from_base=13.5) # 13.5 mm + mag_mod.engage(offset=-2) # 15.5 mm + +Note that ``offset`` takes into account the fact that the magnets' home position is measured as −2.5 mm for GEN2 modules. + + .. versionadded:: 2.0 + .. versionchanged:: 2.2 + Added the ``height_from_base`` parameter. + +When you need to retract the magnets back to their home position, call :py:meth:`~.MagneticModuleContext.disengage`. + + .. code-block:: python + + mag_mod.disengage() # -2.5 mm + +.. versionadded:: 2.0 + +If at any point you need to check whether the magnets are engaged or not, use the :py:obj:`~.MagneticModuleContext.status` property. This will return either the string ``engaged`` or ``disengaged``, not the exact height of the magnets. + +.. note:: + + The OT-2 will not automatically deactivate the Magnetic Module at the end of a protocol. If you need to deactivate the module after a protocol is completed or canceled, use the Magnetic Module controls on the device detail page in the Opentrons App or run ``deactivate()`` in Jupyter notebook. + +Changes with the GEN2 Magnetic Module +===================================== + +The GEN2 Magnetic Module uses smaller magnets than the GEN1 version. This change helps mitigate an issue with the magnets attracting beads from their retracted position, but it also takes longer for the GEN2 module to attract beads. The recommended attraction time is 5 minutes for liquid volumes up to 50 µL and 7 minutes for volumes greater than 50 µL. If your application needs additional magnetic strength to attract beads within these timeframes, use the available `Adapter Magnets `_. diff --git a/api/docs/v2/modules/multiple_same_type.rst b/api/docs/v2/modules/multiple_same_type.rst new file mode 100644 index 000000000000..123dfd93a8cf --- /dev/null +++ b/api/docs/v2/modules/multiple_same_type.rst @@ -0,0 +1,70 @@ +.. _moam: + +********************************* +Multiple Modules of the Same Type +********************************* + +You can use multiple modules of the same type within a single protocol. The exception is the Thermocycler Module, which has only one supported deck location because of its size. Running protocols with multiple modules of the same type requires version 4.3 or newer of the Opentrons App and robot server. + +When working with multiple modules of the same type, load them in your protocol according to their USB port number. Deck coordinates are required by the :py:meth:`~.ProtocolContext.load_labware` method, but location does not determine which module loads first. Your robot will use the module with the lowest USB port number *before* using a module of the same type that's connected to higher numbered USB port. The USB port number (not deck location) determines module load sequence, starting with the lowest port number first. + +.. Recommend being formal-ish with protocol code samples. + +.. tabs:: + + .. tab:: Flex + + In this example, ``temperature_module_1`` loads first because it's connected to USB port 2. ``temperature_module_2`` loads next because it's connected to USB port 6. + + .. code-block:: python + :substitutions: + + from opentrons import protocol_api + + requirements = {"robotType": "Flex", "apiLevel": "|apiLevel|"} + + def run(protocol: protocol_api.ProtocolContext): + # Load Temperature Module 1 in deck slot D1 on USB port 2 + temperature_module_1 = protocol.load_module( + module_name='temperature module gen2', + location="D1") + + # Load Temperature Module 2 in deck slot C1 on USB port 6 + temperature_module_2 = protocol.load_module( + module_name='temperature module gen2', + location="C1") + + The Temperature Modules are connected as shown here: + + .. image:: ../../img/modules/flex-usb-order.png + :width: 250 + + .. tab:: OT-2 + + In this example, ``temperature_module_1`` loads first because it's connected to USB port 1. ``temperature_module_2`` loads next because it's connected to USB port 3. + + .. code-block:: python + + from opentrons import protocol_api + + metadata = { 'apiLevel': '2.14'} + + def run(protocol: protocol_api.ProtocolContext): + # Load Temperature Module 1 in deck slot C1 on USB port 1 + temperature_module_1 = protocol.load_module( + load_name='temperature module gen2', + location="1") + + # Load Temperature Module 2 in deck slot D3 on USB port 2 + temperature_module_2 = protocol.load_module( + load_name='temperature module gen2', + location="3") + + The Temperature Modules are connected as shown here: + + .. image:: ../../img/modules/multiples_of_a_module.svg + + +Before running your protocol, it's a good idea to use the module controls in the Opentrons App to check that commands are being sent where you expect. + +See the support article `Using Modules of the Same Type `_ for more information. diff --git a/api/docs/v2/modules/setup.rst b/api/docs/v2/modules/setup.rst new file mode 100644 index 000000000000..7c5b54c9ece3 --- /dev/null +++ b/api/docs/v2/modules/setup.rst @@ -0,0 +1,131 @@ +.. _module-setup: + +************ +Module Setup +************ + +Loading Modules onto the Deck +============================= + +Similar to labware and pipettes, you must inform the API about the modules you want to use in your protocol. Even if you don't use the module anywhere else in your protocol, the Opentrons App and the robot won't let you start the protocol run until all loaded modules that use power are connected via USB and turned on. + +Use :py:meth:`.ProtocolContext.load_module` to load a module. + +.. tabs:: + + .. tab:: Flex + + .. code-block:: python + :substitutions: + + from opentrons import protocol_api + + requirements = {'robotType': 'Flex', 'apiLevel': '|apiLevel|'} + + def run(protocol: protocol_api.ProtocolContext): + # Load a Heater-Shaker Module GEN1 in deck slot D1. + heater_shaker = protocol.load_module( + module_name='heaterShakerModuleV1', location='D1') + + # Load a Temperature Module GEN2 in deck slot D3. + temperature_module = protocol.load_module( + module_name='temperature module gen2', location='D3') + + After the ``load_module()`` method loads labware into your protocol, it returns the :py:class:`~opentrons.protocol_api.HeaterShakerContext` and :py:class:`~opentrons.protocol_api.TemperatureModuleContext` objects. + + .. tab:: OT-2 + + .. code-block:: python + :substitutions: + + from opentrons import protocol_api + + metadata = {'apiLevel': '2.13'} + + def run(protocol: protocol_api.ProtocolContext): + # Load a Magnetic Module GEN2 in deck slot 1. + magnetic_module = protocol.load_module( + module_name='magnetic module gen2', location=1) + + # Load a Temperature Module GEN1 in deck slot 3. + temperature_module = protocol.load_module( + module_name='temperature module', location=3) + + After the ``load_module()`` method loads labware into your protocol, it returns the :py:class:`~opentrons.protocol_api.MagneticModuleContext` and :py:class:`~opentrons.protocol_api.TemperatureModuleContext` objects. + + +.. versionadded:: 2.0 + +.. _available_modules: + +Available Modules +----------------- + +The first parameter of :py:meth:`.ProtocolContext.load_module` is the module's *API load name*. The load name tells your robot which module you're going to use in a protocol. The table below lists the API load names for the currently available modules. + +.. table:: + :widths: 4 5 2 + + +--------------------+-------------------------------+---------------------------+ + | Module | API Load Name | Introduced in API Version | + +====================+===============================+===========================+ + | Temperature Module | ``temperature module`` | 2.0 | + | GEN1 | or ``tempdeck`` | | + +--------------------+-------------------------------+---------------------------+ + | Temperature Module | ``temperature module gen2`` | 2.3 | + | GEN2 | | | + +--------------------+-------------------------------+---------------------------+ + | Magnetic Module | ``magnetic module`` | 2.0 | + | GEN1 | or ``magdeck`` | | + +--------------------+-------------------------------+---------------------------+ + | Magnetic Module | ``magnetic module gen2`` | 2.3 | + | GEN2 | | | + +--------------------+-------------------------------+---------------------------+ + | Thermocycler | ``thermocycler module`` | 2.0 | + | Module GEN1 | or ``thermocycler`` | | + +--------------------+-------------------------------+---------------------------+ + | Thermocycler | ``thermocycler module gen2`` | 2.13 | + | Module GEN2 | or ``thermocyclerModuleV2`` | | + +--------------------+-------------------------------+---------------------------+ + | Heater-Shaker | ``heaterShakerModuleV1`` | 2.13 | + | Module GEN1 | | | + +--------------------+-------------------------------+---------------------------+ + | Magnetic Block | ``magneticBlockV1`` | 2.15 | + | GEN1 | | | + +--------------------+-------------------------------+---------------------------+ + +Some modules were added to our Python API later than others, and others span multiple hardware generations. When writing a protocol that requires a module, make sure your ``requirements`` or ``metadata`` code block specifies a :ref:`Protocol API version ` high enough to support all the module generations you want to use. + +.. _load-labware-module: + +Loading Labware onto a Module +============================= + +Use the ``load_labware()`` method on the module context to load labware on a module. For example, to load the `Opentrons 24 Well Aluminum Block `_ on top of a Temperature Module:: + + def run(protocol: protocol_api.ProtocolContext): + temp_mod = protocol.load_module( + module_name="temperature module gen2", + location="D1") + temp_labware = temp_mod.load_labware( + name="opentrons_24_aluminumblock_generic_2ml_screwcap", + label="Temperature-Controlled Tubes") + +.. versionadded:: 2.0 + +When you load labware on a module, you don’t need to specify the deck slot. In the above example, the ``load_module()`` method already specifies where the module is on the deck: ``location= "D1"``. + +Any :ref:`v2-custom-labware` added to your Opentrons App is also accessible when loading labware onto a module. You can find and copy its load name by going to its card on the Labware page. + +.. versionadded:: 2.1 + +Module and Labware Compatibility +-------------------------------- + +It's your responsibility to ensure the labware and module combinations you load together work together. The Protocol API won't raise a warning or error if you load an unusual combination, like placing a tube rack on a Thermocycler. See `What labware can I use with my modules? `_ for more information about labware/module combinations. + + +Additional Labware Parameters +----------------------------- + +In addition to the mandatory ``load_name`` argument, you can also specify additional parameters. For example, if you specify a ``label``, this name will appear in the Opentrons App and the run log instead of the load name. For labware that has multiple definitions, you can specify ``version`` and ``namespace`` (though most of the time you won't have to). The :py:meth:`~.ProtocolContext.load_labware` methods of all module contexts accept these additional parameters. diff --git a/api/docs/v2/modules/temperature_module.rst b/api/docs/v2/modules/temperature_module.rst new file mode 100644 index 000000000000..f1fd8782da4e --- /dev/null +++ b/api/docs/v2/modules/temperature_module.rst @@ -0,0 +1,59 @@ +.. _temperature-module: + +****************** +Temperature Module +****************** + +The Temperature Module acts as both a cooling and heating device. It can control the temperature of its deck between 4 °C and 95 °C with a resolution of 1 °C. + +The Temperature Module is represented in code by a :py:class:`.TemperatureModuleContext` object, which has methods for setting target temperatures and reading the module's status. This example demonstrates loading a Temperature Module GEN2 and loading a well plate on top of it. + +.. code-block:: python + :substitutions: + + def run(protocol: protocol_api.ProtocolContext): + temp_mod = protocol.load_module( + module_name='temperature module gen2', + location='D3') + plate = temp_mod.load_labware( + name='corning_96_wellplate_360ul_flat') + +.. versionadded:: 2.3 + +Temperature Control +=================== + +The primary function of the module is to control the temperature of its deck, using :py:meth:`~.TemperatureModuleContext.set_temperature`, which takes one parameter: ``celsius``. For example, to set the Temperature Module to 4 °C: + +.. code-block:: python + + temp_mod.set_temperature(celsius=4) + +When using ``set_temperature()``, your protocol will wait until the target temperature is reached before proceeding to further commands. In other words, you can pipette to or from the Temperature Module when it is holding at a temperature or idle, but not while it is actively changing temperature. Whenever the module reaches its target temperature, it will hold the temperature until you set a different target or call :py:meth:`~.TemperatureModuleContext.deactivate`, which will stop heating or cooling and will turn off the fan. + +.. note:: + + Your robot will not automatically deactivate the Temperature Module at the end of a protocol. If you need to deactivate the module after a protocol is completed or canceled, use the Temperature Module controls on the device detail page in the Opentrons App or run ``deactivate()`` in Jupyter notebook. + +.. versionadded:: 2.0 + +Temperature Status +================== + +If you need to confirm in software whether the Temperature Module is holding at a temperature or is idle, use the :py:obj:`~.TemperatureModuleContext.status` property: + +.. code-block:: python + + temp_mod.set_temperature(celsius=90) + temp_mod.status # 'holding at target' + temp_mod.deactivate() + temp_mod.status # 'idle' + +If you don't need to use the status value in your code, and you have physical access to the module, you can read its status and temperature from the LED and display on the module. + +.. versionadded:: 2.0 + +Changes with the GEN2 Temperature Module +======================================== + +All methods of :py:class:`.TemperatureModuleContext` work with both the GEN1 and GEN2 Temperature Module. Physically, the GEN2 module has a plastic insulating rim around the plate, and plastic insulating shrouds designed to fit over Opentrons aluminum blocks. This mitigates an issue where the GEN1 module would have trouble cooling to very low temperatures, especially if it shared the deck with a running Thermocycler. diff --git a/api/docs/v2/modules/thermocycler.rst b/api/docs/v2/modules/thermocycler.rst new file mode 100644 index 000000000000..972db81a1593 --- /dev/null +++ b/api/docs/v2/modules/thermocycler.rst @@ -0,0 +1,145 @@ +.. _thermocycler-module: + +******************* +Thermocycler Module +******************* + +The Thermocycler Module provides on-deck, fully automated thermocycling, and can heat and cool very quickly during operation. The module's block can reach and maintain temperatures between 4 and 99 °C. The module's lid can heat up to 110 °C. + +The Thermocycler is represented in code by a :py:class:`.ThermocyclerContext` object, which has methods for controlling the lid, controlling the block, and setting *profiles* — timed heating and cooling routines that can be repeated automatically. + +The examples in this section will use a Thermocycler Module GEN2 loaded as follows: + +.. code-block:: python + + def run(protocol: protocol_api.ProtocolContext): + tc_mod = protocol.load_module(module_name='thermocyclerModuleV2') + plate = tc_mod.load_labware(name='nest_96_wellplate_100ul_pcr_full_skirt') + +.. versionadded:: 2.13 + +Lid Control +=========== + +The Thermocycler can control the position and temperature of its lid. + +To change the lid position, use :py:meth:`~.ThermocyclerContext.open_lid` and :py:meth:`~.ThermocyclerContext.close_lid`. When the lid is open, the pipettes can access the loaded labware. + +You can also control the temperature of the lid. Acceptable target temperatures are between 37 and 110 °C. Use :py:meth:`~.ThermocyclerContext.set_lid_temperature`, which takes one parameter: the target ``temperature`` (in degrees Celsius) as an integer. For example, to set the lid to 50 °C: + +.. code-block:: python + + tc_mod.set_lid_temperature(temperature=50) + +The protocol will only proceed once the lid temperature reaches 50 °C. This is the case whether the previous temperature was lower than 50 °C (in which case the lid will actively heat) or higher than 50 °C (in which case the lid will passively cool). + +You can turn off the lid heater at any time with :py:meth:`~.ThermocyclerContext.deactivate_lid`. + +.. note:: + + Lid temperature is not affected by Thermocycler profiles. Therefore you should set an appropriate lid temperature to hold during your profile *before* executing it. See :ref:`thermocycler-profiles` for more information on defining and executing profiles. + +.. versionadded:: 2.0 + +Block Control +============= + +The Thermocycler can control its block temperature, including holding at a temperature and adjusting for the volume of liquid held in its loaded plate. + +Temperature +----------- + +To set the block temperature inside the Thermocycler, use :py:meth:`~.ThermocyclerContext.set_block_temperature`. At minimum you have to specify a ``temperature`` in degrees Celsius: + +.. code-block:: python + + tc_mod.set_block_temperature(temperature=4) + +If you don't specify any other parameters, the Thermocycler will hold this temperature until a new temperature is set, :py:meth:`~.ThermocyclerContext.deactivate_block` is called, or the module is powered off. + +.. versionadded:: 2.0 + +Hold Time +--------- + +You can optionally instruct the Thermocycler to hold its block temperature for a specific amount of time. You can specify ``hold_time_minutes``, ``hold_time_seconds``, or both (in which case they will be added together). For example, this will set the block to 4 °C for 4 minutes and 15 seconds:: + + tc_mod.set_block_temperature( + temperature=4, + hold_time_minutes=4, + hold_time_seconds=15) + +.. note :: + + Your protocol will not proceed to further commands while holding at a temperature. If you don't specify a hold time, the protocol will proceed as soon as the target temperature is reached. + +.. versionadded:: 2.0 + +Block Max Volume +---------------- + +The Thermocycler's block temperature controller varies its behavior based on the amount of liquid in the wells of its labware. Accurately specifying the liquid volume allows the Thermocycler to more precisely control the temperature of the samples. You should set the ``block_max_volume`` parameter to the amount of liquid in the *fullest* well, measured in µL. If not specified, the Thermocycler will assume samples of 25 µL. + +It is especially important to specify ``block_max_volume`` when holding at a temperature. For example, say you want to hold larger samples at a temperature for a short time:: + + tc_mod.set_block_temperature( + temperature=4, + hold_time_seconds=20, + block_max_volume=80) + +If the Thermocycler assumes these samples are 25 µL, it may not cool them to 4 °C before starting the 20-second timer. In fact, with such a short hold time they may not reach 4 °C at all! + +.. versionadded:: 2.0 + + +.. _thermocycler-profiles: + +Thermocycler Profiles +===================== + +In addition to executing individual temperature commands, the Thermocycler can automatically cycle through a sequence of block temperatures to perform heat-sensitive reactions. These sequences are called *profiles*, which are defined in the Protocol API as lists of dictionaries. Each dictionary within the profile should have a ``temperature`` key, which specifies the temperature of the step, and either or both of ``hold_time_seconds`` and ``hold_time_minutes``, which specify the duration of the step. + +For example, this profile commands the Thermocycler to reach 10 °C and hold for 30 seconds, and then to reach 60 °C and hold for 45 seconds: + +.. code-block:: python + + profile = [ + {'temperature':10, 'hold_time_seconds':30}, + {'temperature':60, 'hold_time_seconds':45} + ] + +Once you have written the steps of your profile, execute it with :py:meth:`~.ThermocyclerContext.execute_profile`. This function executes your profile steps multiple times depending on the ``repetitions`` parameter. It also takes a ``block_max_volume`` parameter, which is the same as that of the :py:meth:`~.ThermocyclerContext.set_block_temperature` function. + +For instance, a PCR prep protocol might define and execute a profile like this: + +.. code-block:: python + + profile = [ + {'temperature':95, 'hold_time_seconds':30}, + {'temperature':57, 'hold_time_seconds':30}, + {'temperature':72, 'hold_time_seconds':60} + ] + tc_mod.execute_profile(steps=profile, repetitions=20, block_max_volume=32) + +In terms of the actions that the Thermocycler performs, this would be equivalent to nesting ``set_block_temperature`` commands in a ``for`` loop: + +.. code-block:: python + + for i in range(20): + tc_mod.set_block_temperature(95, hold_time_seconds=30, block_max_volume=32) + tc_mod.set_block_temperature(57, hold_time_seconds=30, block_max_volume=32) + tc_mod.set_block_temperature(72, hold_time_seconds=60, block_max_volume=32) + +However, this code would generate 60 lines in the protocol's run log, while executing a profile is summarized in a single line. Additionally, you can set a profile once and execute it multiple times (with different numbers of repetitions and maximum volumes, if needed). + +.. note:: + + Temperature profiles only control the temperature of the `block` in the Thermocycler. You should set a lid temperature before executing the profile using :py:meth:`~.ThermocyclerContext.set_lid_temperature`. + +.. versionadded:: 2.0 + + +Changes with the GEN2 Thermocycler Module +========================================= + +All methods of :py:class:`.ThermocyclerContext` work with both the GEN1 and GEN2 Thermocycler. One practical difference is that the GEN2 module has a plate lift feature to make it easier to remove the plate manually or with the Opentrons Flex Gripper. To activate the plate lift, press the button on the Thermocycler for three seconds while the lid is open. If you need to do this in the middle of a run, call :py:meth:`~.ProtocolContext.pause`, lift and move the plate, and then resume the run. diff --git a/api/docs/v2/new_modules.rst b/api/docs/v2/new_modules.rst index 21bc67c4b95c..8483f8e78b4e 100644 --- a/api/docs/v2/new_modules.rst +++ b/api/docs/v2/new_modules.rst @@ -2,607 +2,36 @@ .. _new_modules: -################ +**************** Hardware Modules -################ +**************** -Hardware modules are first-party peripherals that attach to the OT-2 to extend its capabilities. The Python API currently supports four modules that attach to the OT-2 deck and are controlled over a USB connection: the :ref:`Temperature `, :ref:`Magnetic `, :ref:`Thermocycler `, and :ref:`Heater-Shaker ` Modules. +.. toctree:: + modules/setup + modules/heater_shaker + modules/magnetic_block + modules/magnetic_module + modules/temperature_module + modules/thermocycler + modules/multiple_same_type -************ -Module Setup -************ +Hardware modules are powered and unpowered deck-mounted peripherals. The Flex and OT-2 are aware of deck-mounted powered modules when they're attached via a USB connection and used in an uploaded protocol. The robots do not know about unpowered modules until you use one in a protocol and upload it to the Opentrons App. -Loading a Module onto the Deck -============================== +Powered modules include the Heater-Shaker Module, Magnetic Module, Temperature Module, and Thermocycler Module. The 96-well Magnetic Block is an unpowered module. -Like labware and pipettes, you must inform the Protocol API of the modules you will use in your protocol. +Pages in this section of the documentation cover: -Use :py:meth:`.ProtocolContext.load_module` to load a module. It will return an object representing the module. - -.. code-block:: python - :substitutions: - - from opentrons import protocol_api - - metadata = {'apiLevel': '|apiLevel|'} - - def run(protocol: protocol_api.ProtocolContext): - # Load a Magnetic Module GEN2 in deck slot 1. - magnetic_module = protocol.load_module('magnetic module gen2', 1) - - # Load a Temperature Module GEN1 in deck slot 3. - temperature_module = protocol.load_module('temperature module', 3) - -When you load a module in a protocol, you inform the OT-2 that you want the specified module to be present. Even if you don't use the module anywhere else in your protocol, the Opentrons App and the OT-2 won't let you start the protocol run until all loaded modules are connected to the OT-2 and powered on. - -.. versionadded:: 2.0 - -.. _available_modules: - -Available Modules ------------------ - -The first parameter of :py:meth:`.ProtocolContext.load_module`, the module's *load name*, specifies the kind of module to load. The table below lists the load names for each kind of module. - -Some modules were added to the Protocol API later than others, and some modules have multiple hardware generations (GEN2 modules have a "GEN2" label on the device). Make sure your protocol's metadata specifies a :ref:`Protocol API version ` high enough to support all the modules you want to use. - -.. table:: - :widths: 4 5 2 - - +--------------------+-------------------------------+---------------------------+ - | Module | Load Name | Introduced in API Version | - +====================+===============================+===========================+ - | Temperature Module | ``temperature module`` | 2.0 | - | GEN1 | or ``tempdeck`` | | - +--------------------+-------------------------------+---------------------------+ - | Temperature Module | ``temperature module gen2`` | 2.3 | - | GEN2 | | | - +--------------------+-------------------------------+---------------------------+ - | Magnetic Module | ``magnetic module`` | 2.0 | - | GEN1 | or ``magdeck`` | | - +--------------------+-------------------------------+---------------------------+ - | Magnetic Module | ``magnetic module gen2`` | 2.3 | - | GEN2 | | | - +--------------------+-------------------------------+---------------------------+ - | Thermocycler | ``thermocycler module`` | 2.0 | - | Module GEN1 | or ``thermocycler`` | | - +--------------------+-------------------------------+---------------------------+ - | Thermocycler | ``thermocycler module gen2`` | 2.13 | - | Module GEN2 | or ``thermocyclerModuleV2`` | | - +--------------------+-------------------------------+---------------------------+ - | Heater-Shaker | ``heaterShakerModuleV1`` | 2.13 | - | Module | | | - +--------------------+-------------------------------+---------------------------+ - -Loading Labware onto a Module -============================= - -Like specifying labware that will be placed directly on the deck of the OT-2, you must specify labware that will be present on the module you have just loaded, using ``load_labware()``. For instance, to load an `aluminum block for 2 mL tubes `_ on top of a Temperature Module: - -.. code-block:: python - - from opentrons import protocol_api - - metadata = {'apiLevel': '2.3'} - - def run(protocol: protocol_api.ProtocolContext): - temp_mod = protocol.load_module("temperature module gen2", 1) - temp_labware = temp_mod.load_labware( - "opentrons_24_aluminumblock_generic_2ml_screwcap", - label="Temperature-Controlled Tubes", - ) - -.. versionadded:: 2.0 - -Notice that when you load labware on a module, you don't need to specify the labware's deck slot. The labware is loaded on the module, on whichever deck slot the module occupies. - -Any :ref:`v2-custom-labware` added to your Opentrons App is also accessible when loading labware onto a module. You can find and copy its load name by going to its card on the Labware page. - -.. versionadded:: 2.1 - - -Module and Labware Compatibility --------------------------------- - -It's up to you to make sure that the labware and modules you load make sense together. The Protocol API won't raise a warning or error if you load a nonsensical combination, like a tube rack on a Thermocycler. - -For further information on what combinations are possible, see the support article `What labware can I use with my modules? `_ - - -Additional Labware Parameters ------------------------------ - -In addition to the mandatory ``load_name`` argument, you can also specify additional parameters. If you specify a ``label``, this name will appear in the Opentrons App and the run log instead of the load name. For labware that has multiple definitions, you can specify ``version`` and ``namespace`` (though most of the time you won't have to). See :py:meth:`.MagneticModuleContext.load_labware`, :py:meth:`.TemperatureModuleContext.load_labware`, :py:meth:`.ThermocyclerContext.load_labware`, or :py:meth:`.HeaterShakerContext.load_labware` for more details. - - -.. _temperature-module: - -************************** -Using a Temperature Module -************************** - -The Temperature Module acts as both a cooling and heating device. It can control the temperature of its deck between 4 °C and 95 °C with a resolution of 1 °C. - -The Temperature Module is represented in code by a :py:class:`.TemperatureModuleContext` object, which has methods for setting target temperatures and reading the module's status. - -The examples in this section will use a Temperature Module loaded in slot 3: - -.. code-block:: python - :substitutions: - - from opentrons import protocol_api - - metadata = {'apiLevel': '2.3'} - - def run(protocol: protocol_api.ProtocolContext): - temp_mod = protocol.load_module('temperature module gen2', '3') - plate = temp_mod.load_labware('corning_96_wellplate_360ul_flat') - -In order to prevent physical obstruction of other slots, it's best to load the Temperature Module in a slot on the horizontal edges of the deck (1, 4, 7, or 10 on the left or 3, 6, or 9 on the right), with the USB cable and power cord pointing away from the deck. - -.. versionadded:: 2.0 - -Temperature Control -=================== - -The primary function of the module is to control the temperature of its deck, using :py:meth:`~.TemperatureModuleContext.set_temperature`, which takes one parameter: ``celsius``. For example, to set the Temperature Module to 4 °C: - -.. code-block:: python - - temp_mod.set_temperature(celsius=4) - -When using ``set_temperature``, your protocol will wait until the target temperature is reached before proceeding to further commands. In other words, you can pipette to or from the Temperature Module when it is holding at a temperature or idle, but not while it is actively changing temperature. Whenever the module reaches its target temperature, it will hold the temperature until you set a different target or call :py:meth:`~.TemperatureModuleContext.deactivate`, which will stop heating or cooling and will turn off the fan. - -.. note:: - - The OT-2 will not automatically deactivate the Temperature Module at the end of a protocol. If you need to deactivate the module after a protocol is completed or canceled, use the Temperature Module controls on the device detail page in the Opentrons App or run ``deactivate()`` in Jupyter notebook. - -.. versionadded:: 2.0 - -Temperature Status -================== - -If you need to confirm in software whether the Temperature Module is holding at a temperature or is idle, use the :py:obj:`~.TemperatureModuleContext.status` property: - -.. code-block:: python - - temp_mod.set_temperature(celsius=90) - temp_mod.status # 'holding at target' - temp_mod.deactivate() - temp_mod.status # 'idle' - -If you don't need to use the status value in your code, and you have physical access to the module, you can read its status and temperature from the LED and display on the module. + - :ref:`Setting up modules and their labware `. + - Working with the module contexts for each type of module. -.. versionadded:: 2.0 - -Changes with the GEN2 Temperature Module -======================================== - -All methods of :py:class:`.TemperatureModuleContext` work with both the GEN1 and GEN2 Temperature Module. Physically, the GEN2 module has a plastic insulating rim around the plate, and plastic insulating shrouds designed to fit over Opentrons aluminum blocks. This mitigates an issue where the GEN1 module would have trouble cooling to very low temperatures, especially if it shared the deck with a running Thermocycler. - - -.. _magnetic-module: - -*********************** -Using a Magnetic Module -*********************** - -The Magnetic Module controls a set of permanent magnets which can move vertically to induce a magnetic field in the labware loaded on the module. - -The Magnetic Module is represented by a :py:class:`.MagneticModuleContext` object, which has methods for engaging (raising) and disengaging (lowering) its magnets. - -The examples in this section will use a Magnetic Module loaded in slot 6: - -.. code-block:: python - :substitutions: - - from opentrons import protocol_api - - metadata = {'apiLevel': '2.3'} - - def run(protocol: protocol_api.ProtocolContext): - mag_mod = protocol.load_module('magnetic module gen2', '6') - plate = mag_mod.load_labware('nest_96_wellplate_100ul_pcr_full_skirt') - -.. versionadded:: 2.0 - -Loading Labware -=============== - -Like with all modules, use the Magnetic Module’s :py:meth:`~.MagneticModuleContext.load_labware` method to specify what you will place on the module. The Magnetic Module supports 96-well PCR plates and deep well plates. For the best compatibility, use a labware definition that specifies how far the magnets should move when engaging with the labware. The following plates in the Opentrons Labware Library include this measurement: - -- ``biorad_96_wellplate_200ul_pcr`` -- ``nest_96_wellplate_100ul_pcr_full_skirt`` -- ``nest_96_wellplate_2ml_deep`` -- ``thermoscientificnunc_96_wellplate_1300ul`` -- ``thermoscientificnunc_96_wellplate_2000ul`` -- ``usascientific_96_wellplate_2.4ml_deep`` - -To check whether a custom labware definition specifies this measurement, load the labware and query its :py:attr:`~.Labware.magdeck_engage_height` property. If has a numerical value, the labware is ready for use with the Magnetic Module. - -.. _magnetic-module-engage: - -Engaging and Disengaging -======================== - -Raising and lowering the module's magnets are done with the :py:meth:`~.MagneticModuleContext.engage` and :py:meth:`~.MagneticModuleContext.disengage` functions, respectively. - -If your loaded labware is fully compatible with the Magnetic Module, you can call ``engage()`` with no argument: - - .. code-block:: python - - mag_mod.engage() - - .. versionadded:: 2.0 - -This will move the magnets upward to the default height for the labware, which should be close to the bottom of the labware's wells. If your loaded labware doesn't specify a default height, this will raise an ``ExceptionInProtocolError``. - -For certain applications, you may want to move the magnets to a different height. The recommended way is to use the ``height_from_base`` parameter, which represents the distance above the base of the labware (its lowest point, where it rests on the module). Setting ``height_from_base=0`` should move the tops of the magnets level with the base of the labware. Alternatively, you can use the ``offset`` parameter, which represents the distance above *or below* the labware's default position (close to the bottom of its wells). Like using ``engage()`` with no argument, this will raise an error if there is no default height for the loaded labware. + - :ref:`Heater-Shaker Module ` + - :ref:`Magnetic Block ` + - :ref:`Magnetic Module ` + - :ref:`Temperature Module ` + - :ref:`Thermocycler Module ` + - Working with :ref:`multiple modules of the same type ` in a single protocol. .. note:: - There is up to 1 mm of manufacturing variance across Magnetic Module units, so observe the exact position and adjust as necessary before running your protocol. - -Here are some examples of where the magnets will move when using the different parameters in combination with the loaded NEST PCR plate, which specifies a default height of 20 mm: - - .. code-block:: python - - mag_mod.engage(height_from_base=13.5) # 13.5 mm - mag_mod.engage(offset=-2) # 15.5 mm - -Note that ``offset`` takes into account the fact that the magnets' home position is measured as −2.5 mm for GEN2 modules. - - .. versionadded:: 2.0 - .. versionchanged:: 2.2 - Added the ``height_from_base`` parameter. - -When you need to retract the magnets back to their home position, call :py:meth:`~.MagneticModuleContext.disengage`. - - .. code-block:: python - - mag_mod.disengage() # -2.5 mm - -.. versionadded:: 2.0 - -If at any point you need to check whether the magnets are engaged or not, use the :py:obj:`~.MagneticModuleContext.status` property. This will return either the string ``engaged`` or ``disengaged``, not the exact height of the magnets. - -.. note:: - - The OT-2 will not automatically deactivate the Magnetic Module at the end of a protocol. If you need to deactivate the module after a protocol is completed or canceled, use the Magnetic Module controls on the device detail page in the Opentrons App or run ``deactivate()`` in Jupyter notebook. - -Changes with the GEN2 Magnetic Module -===================================== - -The GEN2 Magnetic Module uses smaller magnets than the GEN1 version to mitigate an issue with the magnets attracting beads even from their retracted position. This means it takes longer for the GEN2 module to attract beads. The recommended attraction time is 5 minutes for liquid volumes up to 50 µL and 7 minutes for volumes greater than 50 µL. If your application needs additional magnetic strength to attract beads within these timeframes, use the available `Adapter Magnets `_. - - -.. _thermocycler-module: - -*************************** -Using a Thermocycler Module -*************************** - - -The Thermocycler Module provides on-deck, fully automated thermocycling and can heat and cool very quickly during operation. The module's block can heat and cool between 4 and 99 °C, and the module's lid can heat up to 110 °C. - -The Thermocycler is represented in code by a :py:class:`.ThermocyclerContext` object, which has methods for controlling the lid, controlling the block, and setting *profiles* — timed heating and cooling routines that can be automatically repeated. - -The examples in this section will use a Thermocycler loaded as follows: - -.. code-block:: python - :substitutions: - - from opentrons import protocol_api - - metadata = {'apiLevel': '2.13'} - - def run(protocol: protocol_api.ProtocolContext): - tc_mod = protocol.load_module('thermocyclerModuleV2') - plate = tc_mod.load_labware('nest_96_wellplate_100ul_pcr_full_skirt') - -The ``location`` parameter of :py:meth:`.load_module` isn't required for the Thermocycler. It only has one valid deck location, which covers :ref:`slots ` 7, 8, 10, and 11 on an OT-2 or A1 and B1 on a Flex. Attempting to load any other modules or labware in these slots while a Thermocycler is there will raise an error. - - -.. versionadded:: 2.0 - - -Lid Control -=========== - -The Thermocycler can control the position and temperature of its lid. - -To change the lid position, use :py:meth:`~.ThermocyclerContext.open_lid` and :py:meth:`~.ThermocyclerContext.close_lid`. When the lid is open, the pipettes can access the loaded labware. - -You can also control the temperature of the lid. Acceptable target temperatures are between 37 and 110 °C. Use :py:meth:`~.ThermocyclerContext.set_lid_temperature`, which takes one parameter: the target ``temperature`` (in degrees Celsius) as an integer. For example, to set the lid to 50 °C: - -.. code-block:: python - - tc_mod.set_lid_temperature(temperature=50) - -The protocol will only proceed once the lid temperature reaches 50 °C. This is the case whether the previous temperature was lower than 50 °C (in which case the lid will actively heat) or higher than 50 °C (in which case the lid will passively cool). - -You can turn off the lid heater at any time with :py:meth:`~.ThermocyclerContext.deactivate_lid`. - -.. note:: - - Lid temperature is not affected by Thermocycler profiles. Therefore you should set an appropriate lid temperature to hold during your profile *before* executing it. See :ref:`thermocycler-profiles` for more information on defining and executing profiles. - -.. versionadded:: 2.0 - -Block Control -============= - -The Thermocycler can control its block temperature, including holding at a temperature and adjusting for the volume of liquid held in its loaded plate. - -Temperature ------------ - -To set the block temperature inside the Thermocycler, use :py:meth:`~.ThermocyclerContext.set_block_temperature`. At minimum you have to specify a ``temperature`` in degrees Celsius: - -.. code-block:: python - - tc_mod.set_block_temperature(temperature=4) - -If you don't specify any other parameters, the Thermocycler will hold this temperature until a new temperature is set, :py:meth:`~.ThermocyclerContext.deactivate_block` is called, or the module is powered off. - -.. versionadded:: 2.0 - -Hold Time ---------- - -You can optionally instruct the Thermocycler to hold its block temperature for a specific amount of time. You can specify ``hold_time_minutes``, ``hold_time_seconds``, or both (in which case they will be added together). For example, this will set the block to 4 °C for 4 minutes and 15 seconds: - -.. code-block:: python - - tc_mod.set_block_temperature(temperature=4, hold_time_minutes=4, - hold_time_seconds=15) - -.. note :: - - Your protocol will not proceed to further commands while holding at a temperature. If you don't specify a hold time, the protocol will proceed as soon as the target temperature is reached. - -.. versionadded:: 2.0 - -Block Max Volume ----------------- - -The Thermocycler's block temperature controller varies its behavior based on the amount of liquid in the wells of its labware. Accurately specifying the liquid volume allows the Thermocycler to more precisely control the temperature of the samples. You should set the ``block_max_volume`` parameter to the amount of liquid in the *fullest* well, measured in µL. If not specified, the Thermocycler will assume samples of 25 µL. - -It is especially important to specify ``block_max_volume`` when holding at a temperature. For example, say you want to hold larger samples at a temperature for a short time: - -.. code-block:: python - - tc_mod.set_block_temperature(temperature=4, hold_time_seconds=20, - block_max_volume=80) - -If the Thermocycler assumes these samples are 25 µL, it may not cool them to 4 °C before starting the 20-second timer. In fact, with such a short hold time they may not reach 4 °C at all! - -.. versionadded:: 2.0 - - -.. _thermocycler-profiles: - -Thermocycler Profiles -===================== - -In addition to executing individual temperature commands, the Thermocycler can automatically cycle through a sequence of block temperatures to perform heat-sensitive reactions. These sequences are called *profiles*, which are defined in the Protocol API as lists of dicts. Each dict should have a ``temperature`` key, which specifies the temperature of the step, and either or both of ``hold_time_seconds`` and ``hold_time_minutes``, which specify the duration of the step. - -For example, this profile commands the Thermocycler to reach 10 °C and hold for 30 seconds, and then to reach 60 °C and hold for 45 seconds: - -.. code-block:: python - - profile = [ - {'temperature': 10, 'hold_time_seconds': 30}, - {'temperature': 60, 'hold_time_seconds': 45} - ] - -Once you have written the steps of your profile, execute it with :py:meth:`~.ThermocyclerContext.execute_profile`. This function executes your profile steps multiple times depending on the ``repetitions`` parameter. It also takes a ``block_max_volume`` parameter, which is the same as that of the :py:meth:`~.ThermocyclerContext.set_block_temperature` function. - -For instance, a PCR prep protocol might define and execute a profile like this: - -.. code-block:: python - - profile = [ - {'temperature': 95, 'hold_time_seconds': 30}, - {'temperature': 57, 'hold_time_seconds': 30}, - {'temperature': 72, 'hold_time_seconds': 60} - ] - tc_mod.execute_profile(steps=profile, repetitions=20, block_max_volume=32) - -In terms of the actions that the Thermocycler performs, this would be equivalent to nesting ``set_block_temperature`` commands in a ``for`` loop: - -.. code-block:: python - - for i in range(20): - tc_mod.set_block_temperature(95, hold_time_seconds=30, block_max_volume=32) - tc_mod.set_block_temperature(57, hold_time_seconds=30, block_max_volume=32) - tc_mod.set_block_temperature(72, hold_time_seconds=60, block_max_volume=32) - -However, this code would generate 60 lines in the protocol's run log, while executing a profile is summarized in a single line. Additionally, you can set a profile once and execute it multiple times (with different numbers of repetitions and maximum volumes, if needed). - -.. note:: - - Temperature profiles only control the temperature of the `block` in the Thermocycler. You should set a lid temperature before executing the profile using :py:meth:`~.ThermocyclerContext.set_lid_temperature`. - -.. versionadded:: 2.0 - - -Changes with the GEN2 Thermocycler Module -========================================= - -All methods of :py:class:`.ThermocyclerContext` work with both the GEN1 and GEN2 Thermocycler. One practical difference is that the GEN2 module has a plate lift feature to make it easier to remove the plate manually or with a robotic gripper. To activate the plate lift, press the button on the Thermocycler for three seconds while the lid is open. If you need to do this in the middle of a run, call :py:meth:`~.ProtocolContext.pause`, lift and move the plate, and then resume the run from the Opentrons App. - - -.. _heater-shaker-module: - -**************************** -Using a Heater-Shaker Module -**************************** - -The Heater-Shaker Module provides on-deck heating and orbital shaking. The module can heat from 37 to 95 °C, and can shake samples from 200 to 3000 rpm. - -The Heater-Shaker Module is represented in code by a :py:class:`.HeaterShakerContext` object. The examples in this section will use a Heater-Shaker loaded in slot 1: - -.. code-block:: python - - from opentrons import protocol_api - - metadata = {'apiLevel': '2.13'} - - def run(protocol: protocol_api.ProtocolContext): - hs_mod = protocol.load_module('heaterShakerModuleV1', 1) - -.. versionadded:: 2.13 - - -Placement Restrictions -====================== - -To allow for proper anchoring and cable routing, the Heater-Shaker should only be loaded in slot 1, 3, 4, 6, 7, or 10. - -In general, it's best to leave all slots adjacent to the Heater-Shaker empty, in both directions. If your protocol requires filling those slots, you’ll need to observe certain restrictions put in place to avoid physical crashes involving the Heater-Shaker. - -First, you can’t place any other modules adjacent to the Heater-Shaker in any direction. This prevents collisions both while shaking and while opening the labware latch. Attempting to load a module next to the Heater-Shaker will raise a ``DeckConflictError``. - -Next, you can’t place tall labware (defined as >53 mm) to the left or right of the Heater-Shaker. This prevents the Heater-Shaker’s latch from colliding with the adjacent labware. Attempting to load tall labware to the right or left of the Heater-Shaker will also raise a ``DeckConflictError``. Common labware that exceed the height limit include Opentrons tube racks and Opentrons 1000 µL tip racks. - -Finally, if you are using an 8-channel pipette, you can't perform pipetting actions in `any` adjacent slots. Attempting to do so will raise a ``PipetteMovementRestrictedByHeaterShakerError``. This prevents the pipette ejector from crashing on the module housing or labware latch. There is one exception: to the front or back of the Heater-Shaker, an 8-channel pipette can access tip racks only. Attempting to pipette to non-tip-rack labware will also raise a ``PipetteMovementRestrictedByHeaterShakerError``. - -Latch Control -============= - -To easily add and remove labware from the Heater-Shaker, you can control its labware latch within your protocol using :py:meth:`.open_labware_latch` and :py:meth:`.close_labware_latch`. Shaking requires the labware latch to be closed, so you may want to issue a close command before the first shake command in your protocol: - -.. code-block:: python - - hs_mod.close_labware_latch() - hs_mod.set_and_wait_for_shake_speed(500) - -If the labware latch is already closed, ``close_labware_latch()`` will succeed immediately; you don’t have to check the status of the latch before opening or closing it. - -To prepare the deck before running a protocol, use the labware latch controls in the Opentrons App or run these methods in Jupyter notebook. - -Loading Labware -=============== - -Like with all modules, use the Heater-Shaker’s :py:meth:`~.HeaterShakerContext.load_labware` method to specify what you will place on the module. For the Heater-Shaker, you must use a definition that describes the combination of a thermal adapter and labware that fits it. Currently, only the following combinations are supported in the Opentrons Labware Library: - -+-------------------------+-------------------------------------------+----------------------------------------------------------------------+ -| Adapter | Labware | Definition | -+=========================+===========================================+======================================================================+ -| Deep Well Adapter | NEST 96 Deep Well Plate 2mL | ``opentrons_96_deep_well_adapter_nest_wellplate_2ml_deep`` | -+-------------------------+-------------------------------------------+----------------------------------------------------------------------+ -| 96 Flat Bottom Adapter | NEST 96 Well Plate 200 µL Flat | ``opentrons_96_flat_bottom_adapter_nest_wellplate_200ul_flat`` | -+-------------------------+-------------------------------------------+----------------------------------------------------------------------+ -| PCR Adapter | NEST 96 Well Plate 100 µL PCR Full Skirt | ``opentrons_96_pcr_adapter_nest_wellplate_100ul_pcr_full_skirt`` | -+-------------------------+-------------------------------------------+----------------------------------------------------------------------+ -| PCR Adapter | Thermo Scientific Armadillo PCR Plate | ``opentrons_96_pcr_adapter_armadillo_wellplate_200ul`` | -+-------------------------+-------------------------------------------+----------------------------------------------------------------------+ -| Universal Flat Adapter | Corning 384 Well Plate 112 µL Flat | ``opentrons_universal_flat_adapter_corning_384_wellplate_112ul_flat``| -+-------------------------+-------------------------------------------+----------------------------------------------------------------------+ - - -Custom flat-bottom labware can be used with the Universal Flat Adapter. If you need assistance creating custom labware definitions for the Heater-Shaker, `submit a request `_. - - -Heating and Shaking -=================== - -Heating and shaking operations are controlled independently, and are treated differently due to the amount of time they take. Speeding up or slowing down the shaker takes at most a few seconds, so it is treated as a *blocking* command — all other command execution must wait until it is complete. In contrast, heating the module or letting it passively cool can take much longer, so the Python API gives you the flexibility to perform other pipetting actions while waiting to reach a target temperature. When holding at a target, you can design your protocol to run in a blocking or non-blocking manner. - -.. note:: - - As of version 2.13 of the API, only the Heater-Shaker Module supports non-blocking command execution. All other modules' methods are blocking commands. - -Blocking commands ------------------ - -Here is an example of how to shake a sample for one minute in a blocking manner — no other commands will execute until the minute has elapsed. This can be done with three commands, which start the shake, wait the minute, and stop the shake: - -.. code-block:: python - - hs_mod.set_and_wait_for_shake_speed(500) - protocol.delay(minutes=1) - hs_mod.deactivate_shaker() - -These actions will take about 65 seconds total. Compare this with similar-looking commands for holding a sample at a temperature for one minute: - -.. code-block:: python - - hs_mod.set_and_wait_for_temperature(75) - protocol.delay(minutes=1) - hs_mod.deactivate_heater() - -This may take much longer, depending on the thermal block used, the volume and type of liquid contained in the labware, and the initial temperature of the module. - -Non-blocking commands ---------------------- - -To pipette while the Heater-Shaker is heating, use :py:meth:`~.HeaterShakerContext.set_target_temperature` and :py:meth:`~.HeaterShakerContext.wait_for_temperature` instead of :py:meth:`~.HeaterShakerContext.set_and_wait_for_temperature`: - -.. code-block:: python - - hs_mod.set_target_temperature(75) - pipette.pick_up_tip() - pipette.aspirate(50, plate['A1']) - pipette.dispense(50, plate['B1']) - pipette.drop_tip() - hs_mod.wait_for_temperature() - protocol.delay(minutes=1) - hs_mod.deactivate_heater() - -This example would likely take just as long as the blocking version above; it’s unlikely that one aspirate and one dispense action would take longer than the time for the module to heat. However, be careful when putting a lot of commands between a ``set_target_temperature()`` call and a ``delay()`` call. In this situation, you’re relying on ``wait_for_temperature()`` to resume execution of commands once heating is complete. But if the temperature has already been reached, the delay will begin later than expected and the Heater-Shaker will hold at its target temperature longer than intended. - -Additionally, if you want to pipette while the module holds at a target for a certain length of time, you need to track the holding time yourself. One of the simplest ways to do this is with Python’s ``time`` module. First, add ``import time`` at the start of your protocol. Then, use :py:func:`time.monotonic` to set a reference time when the target is reached. Finally, add a delay that calculates how much holding time is remaining after the pipetting actions: - -.. code-block:: python - - hs_mod.set_and_wait_for_temperature(75) - start_time = time.monotonic() # set reference time - pipette.pick_up_tip() - pipette.aspirate(50, plate['A1']) - pipette.dispense(50, plate['B1']) - pipette.drop_tip() - # delay for the difference between now and 60 seconds after the reference time - protocol.delay(max(0, start_time+60 - time.monotonic())) - hs_mod.deactivate_heater() - -Provided that the parallel pipetting actions don’t take more than one minute, this code will deactivate the heater one minute after its target was reached. If more than one minute has elapsed, the value passed to ``protocol.delay`` will equal 0, and the protocol will continue immediately. - -Deactivating -============ - -As with setting targets, deactivating the heater and shaker are done separately, with :py:meth:`~.HeaterShakerContext.deactivate_heater` and :py:meth:`~.HeaterShakerContext.deactivate_shaker` respectively. There is no method to deactivate both simultaneously, so call the two methods in sequence if you need to stop both heating and shaking. - -.. note:: - - The OT-2 will not automatically deactivate the Heater-Shaker at the end of a protocol. If you need to deactivate the module after a protocol is completed or canceled, use the Heater-Shaker module controls on the device detail page in the Opentrons App or run these methods in Jupyter notebook. - - -*************************************** -Using Multiple Modules of the Same Type -*************************************** - -It's possible to use multiples of most module types within a single protocol. The exception is the Thermocycler Module, which only has one supported deck location due to its size. Running protocols with multiple modules of the same type requires version 4.3 or newer of the Opentrons App and OT-2 robot server. - -In order to send commands to the correct module on the deck, you need to load the modules in your protocol in a specific order. Whenever you call :py:meth:`.load_module` for a particular module type, the OT-2 will initialize the matching module attached to the lowest-numbered USB port. Deck slot numbers play no role in the ordering of modules; you could load a Temperature Module in slot 4 first, followed by another one in slot 3: - -.. code-block:: python - - from opentrons import protocol_api - - metadata = {'apiLevel': '2.3'} - - def run(protocol: protocol_api.ProtocolContext): - # Load Temperature Module 1 in deck slot 4 on USB port 1 - temperature_module_1 = protocol.load_module('temperature module gen2', 4) - - # Load Temperature Module 2 in deck slot 3 on USB port 2 - temperature_module_2 = protocol.load_module('temperature module gen2', 3) - -For this code to work as expected, ``temperature_module_1`` should be plugged into a lower-numbered USB port than ``temperature_module_2``. Assuming there are no other modules used in this protocol, it's simplest to use ports 1 and 2, like this: - -.. image:: ../img/modules/multiples_of_a_module.svg -Before running your protocol, it's a good idea to use the module controls in the Opentrons App to check that commands are being sent where you expect. + Throughout these pages, most code examples use coordinate deck slot locations (e.g. ``'D1'``, ``'D2'``), like those found on Flex. If you have an OT-2 and are using API version 2.14 or earlier, replace the coordinate with its numeric OT-2 equivalent. For example, slot D1 on Flex corresponds to slot 1 on an OT-2. See :ref:`deck-slots` for more information. -For additional information, including using modules with USB hubs, see our `support article on Using Multiple Modules of the Same Type `_. From 09b78729002bede159df4ac91a62fb2484d3452f Mon Sep 17 00:00:00 2001 From: koji Date: Thu, 27 Jul 2023 18:17:43 -0400 Subject: [PATCH 18/25] feat(api-client, react-api-client): add new hooks for estop (#13055) * feat(api-client, react-api-client): add new hooks for estop status --- api-client/src/request.ts | 1 + api-client/src/robot/getEstopStatus.ts | 11 +++ api-client/src/robot/index.ts | 10 ++- .../src/robot/setEstopPhysicalStatus.ts | 17 +++++ api-client/src/robot/types.ts | 16 ++++ .../__tests__/EmergencyStop.test.tsx | 55 ++++++++++++-- app/src/pages/EmergencyStop/index.tsx | 14 +++- app/src/pages/OnDeviceDisplay/NameRobot.tsx | 3 +- .../__tests__/NameRobot.test.tsx | 41 ++++++---- .../robot/__tests__/useEstopQuery.test.tsx | 76 +++++++++++++++++++ ...useSetEstopPhysicalStatusMutation.test.tsx | 75 ++++++++++++++++++ react-api-client/src/robot/index.ts | 2 + react-api-client/src/robot/useEstopQuery.ts | 19 +++++ .../useSetEstopPhysicalStatusMutation.ts | 53 +++++++++++++ 14 files changed, 368 insertions(+), 25 deletions(-) create mode 100644 api-client/src/robot/getEstopStatus.ts create mode 100644 api-client/src/robot/setEstopPhysicalStatus.ts create mode 100644 react-api-client/src/robot/__tests__/useEstopQuery.test.tsx create mode 100644 react-api-client/src/robot/__tests__/useSetEstopPhysicalStatusMutation.test.tsx create mode 100644 react-api-client/src/robot/useEstopQuery.ts create mode 100644 react-api-client/src/robot/useSetEstopPhysicalStatusMutation.ts diff --git a/api-client/src/request.ts b/api-client/src/request.ts index aa51369dc661..87b4997925b0 100644 --- a/api-client/src/request.ts +++ b/api-client/src/request.ts @@ -17,6 +17,7 @@ export const GET = 'GET' export const POST = 'POST' export const PATCH = 'PATCH' export const DELETE = 'DELETE' +export const PUT = 'PUT' export function request( method: Method, diff --git a/api-client/src/robot/getEstopStatus.ts b/api-client/src/robot/getEstopStatus.ts new file mode 100644 index 000000000000..da6bcf6553e7 --- /dev/null +++ b/api-client/src/robot/getEstopStatus.ts @@ -0,0 +1,11 @@ +import { GET, request } from '../request' + +import type { ResponsePromise } from '../request' +import type { HostConfig } from '../types' +import type { EstopStatus } from './types' + +export function getEstopStatus( + config: HostConfig +): ResponsePromise { + return request(GET, '/robot/control/estopStatus', null, config) +} diff --git a/api-client/src/robot/index.ts b/api-client/src/robot/index.ts index 2b76353de38c..570fc8152c4b 100644 --- a/api-client/src/robot/index.ts +++ b/api-client/src/robot/index.ts @@ -1,3 +1,11 @@ +export { getEstopStatus } from './getEstopStatus' +export { setEstopPhysicalStatus } from './setEstopPhysicalStatus' export { getLights } from './getLights' export { setLights } from './setLights' -export type { Lights, SetLightsData } from './types' +export type { + EstopPhysicalStatus, + EstopState, + EstopStatus, + Lights, + SetLightsData, +} from './types' diff --git a/api-client/src/robot/setEstopPhysicalStatus.ts b/api-client/src/robot/setEstopPhysicalStatus.ts new file mode 100644 index 000000000000..8ec5bf06f14a --- /dev/null +++ b/api-client/src/robot/setEstopPhysicalStatus.ts @@ -0,0 +1,17 @@ +import { PUT, request } from '../request' + +import type { ResponsePromise } from '../request' +import type { HostConfig } from '../types' +import type { EstopStatus } from './types' + +export function setEstopPhysicalStatus( + config: HostConfig, + data: null +): ResponsePromise { + return request( + PUT, + '/robot/control/acknowledgeEstopDisengage', + data, + config + ) +} diff --git a/api-client/src/robot/types.ts b/api-client/src/robot/types.ts index a16ad38e30b3..062d8592afd1 100644 --- a/api-client/src/robot/types.ts +++ b/api-client/src/robot/types.ts @@ -1,3 +1,19 @@ +export type EstopState = + | 'physicallyEngaged' + | 'logicallyEngaged' + | 'notPresent' + | 'disengaged' + +export type EstopPhysicalStatus = 'engaged' | 'disengaged' | 'notPresent' + +export interface EstopStatus { + data: { + status: EstopState + leftEstopPhysicalStatus: EstopPhysicalStatus + rightEstopPhysicalStatus: EstopPhysicalStatus + } +} + export interface Lights { on: boolean } diff --git a/app/src/pages/EmergencyStop/__tests__/EmergencyStop.test.tsx b/app/src/pages/EmergencyStop/__tests__/EmergencyStop.test.tsx index de745496de06..d29fba0e21da 100644 --- a/app/src/pages/EmergencyStop/__tests__/EmergencyStop.test.tsx +++ b/app/src/pages/EmergencyStop/__tests__/EmergencyStop.test.tsx @@ -1,11 +1,20 @@ import * as React from 'react' - +import { useEstopQuery } from '@opentrons/react-api-client' import { renderWithProviders } from '@opentrons/components' import { i18n } from '../../../i18n' import { EmergencyStop } from '..' -// const ESTOP_IMAGE_NAME = 'install_e_stop.png' +jest.mock('@opentrons/react-api-client') + +const ESTOP_IMAGE_NAME = 'install_e_stop.png' +const mockDisconnectedEstop = { + data: { + status: 'notPresent', + leftEstopPhysicalStatus: 'notPresent', + rightEstopPhysicalStatus: 'notPresent', + }, +} as any const mockPush = jest.fn() jest.mock('react-router-dom', () => { const reactRouterDom = jest.requireActual('react-router-dom') @@ -15,6 +24,10 @@ jest.mock('react-router-dom', () => { } }) +const mockUseEstopQuery = useEstopQuery as jest.MockedFunction< + typeof useEstopQuery +> + const render = () => { return renderWithProviders(, { i18nInstance: i18n, @@ -24,16 +37,46 @@ const render = () => { describe('EmergencyStop', () => { // Note (kk:06/28/2023) commented test cases will be activated when added the function to check e-stop status - it.todo( - 'should render text, image, and button when e-stop button is not connected' - ) + beforeEach(() => { + mockUseEstopQuery.mockReturnValue({ data: mockDisconnectedEstop } as any) + }) + + it('should render text, image, and button when e-stop button is not connected', () => { + const [{ getByText, getByRole }] = render() + getByText( + 'Connect the E-stop to an auxiliary port on the back of the robot.' + ) + getByText('Continue') + expect(getByRole('button')).toBeDisabled() + expect(getByRole('img').getAttribute('src')).toEqual(ESTOP_IMAGE_NAME) + }) it('should render text, icon, button when e-stop button is connected', () => { + const mockConnectedEstop = { + data: { + status: 'disengaged', + leftEstopPhysicalStatus: 'disengaged', + rightEstopPhysicalStatus: 'notPresent', + }, + } + mockUseEstopQuery.mockReturnValue({ data: mockConnectedEstop } as any) const [{ getByText, getByTestId, getByRole }] = render() getByTestId('EmergencyStop_connected_icon') getByText('E-stop successfully connected') expect(getByRole('button')).not.toBeDisabled() }) - it.todo('should call a mock function when tapping continue button') + it('should call a mock function when tapping continue button', () => { + const mockConnectedEstop = { + data: { + status: 'disengaged', + leftEstopPhysicalStatus: 'disengaged', + rightEstopPhysicalStatus: 'notPresent', + }, + } as any + mockUseEstopQuery.mockReturnValue({ data: mockConnectedEstop } as any) + const [{ getByRole }] = render() + getByRole('button').click() + expect(mockPush).toHaveBeenCalledWith('/robot-settings/rename-robot') + }) }) diff --git a/app/src/pages/EmergencyStop/index.tsx b/app/src/pages/EmergencyStop/index.tsx index 68fb1085c4c7..68a017cd8ef3 100644 --- a/app/src/pages/EmergencyStop/index.tsx +++ b/app/src/pages/EmergencyStop/index.tsx @@ -13,6 +13,7 @@ import { ALIGN_CENTER, TYPOGRAPHY, } from '@opentrons/components' +import { useEstopQuery } from '@opentrons/react-api-client' import { StyledText } from '../../atoms/text' import { MediumButton } from '../../atoms/buttons' @@ -20,12 +21,19 @@ import { StepMeter } from '../../atoms/StepMeter' import estopImg from '../../assets/images/on-device-display/install_e_stop.png' +const ESTOP_STATUS_REFETCH_INTERVAL_MS = 10000 + export function EmergencyStop(): JSX.Element { const { i18n, t } = useTranslation(['device_settings', 'shared']) const history = useHistory() - // Note (kk:06/28/2023) this IF is for test and it will be removed when the e-stop status check function - // I will add the function soon - const isEstopConnected = true + + // Note here the touchscreen app is using status since status is linked to EstopPhysicalStatuses + // left notPresent + right disengaged => disengaged + // left notPresent + right notPresent => notPresent + const { data: estopStatusData } = useEstopQuery({ + refetchInterval: ESTOP_STATUS_REFETCH_INTERVAL_MS, + }) + const isEstopConnected = estopStatusData?.data?.status !== 'notPresent' return ( <> diff --git a/app/src/pages/OnDeviceDisplay/NameRobot.tsx b/app/src/pages/OnDeviceDisplay/NameRobot.tsx index bf1559971ce9..c1674a6d5997 100644 --- a/app/src/pages/OnDeviceDisplay/NameRobot.tsx +++ b/app/src/pages/OnDeviceDisplay/NameRobot.tsx @@ -177,9 +177,10 @@ export function NameRobot(): JSX.Element { > { if (isInitialSetup) { - history.push('/robot-settings/update-robot') + history.push('/emergency-stop') } else { history.push('/robot-settings') } diff --git a/app/src/pages/OnDeviceDisplay/__tests__/NameRobot.test.tsx b/app/src/pages/OnDeviceDisplay/__tests__/NameRobot.test.tsx index f0dfbbe591b9..08b4b9264f50 100644 --- a/app/src/pages/OnDeviceDisplay/__tests__/NameRobot.test.tsx +++ b/app/src/pages/OnDeviceDisplay/__tests__/NameRobot.test.tsx @@ -23,6 +23,16 @@ jest.mock('../../../redux/discovery/selectors') jest.mock('../../../redux/config') jest.mock('../../../redux/analytics') +const mockPush = jest.fn() + +jest.mock('react-router-dom', () => { + const reactRouterDom = jest.requireActual('react-router-dom') + return { + ...reactRouterDom, + useHistory: () => ({ push: mockPush } as any), + } +}) + const mockSettings = { sleepMs: 0, brightness: 1, @@ -78,16 +88,15 @@ describe('NameRobot', () => { it('should display a letter when typing a letter', () => { const [{ getByRole }] = render() const input = getByRole('textbox') - fireEvent.click(getByRole('button', { name: 'a' })) - fireEvent.click(getByRole('button', { name: 'b' })) - fireEvent.click(getByRole('button', { name: 'c' })) + getByRole('button', { name: 'a' }).click() + getByRole('button', { name: 'b' }).click() + getByRole('button', { name: 'c' }).click() expect(input).toHaveValue('abc') }) it('should show an error message when tapping confirm without typing anything', async () => { const [{ findByText, getByLabelText }] = render() - const button = getByLabelText('SmallButton_primary') - fireEvent.click(button) + getByLabelText('SmallButton_primary').click() const error = await findByText( 'Oops! Robot name must follow the character count and limitations' ) @@ -102,8 +111,7 @@ describe('NameRobot', () => { fireEvent.change(input, { target: { value: 'connectableOtie' }, }) - const nameButton = getByLabelText('SmallButton_primary') - fireEvent.click(nameButton) + getByLabelText('SmallButton_primary').click() const error = await findByText( 'Oops! Name is already in use. Choose a different name.' ) @@ -118,8 +126,7 @@ describe('NameRobot', () => { fireEvent.change(input, { target: { value: 'reachableOtie' }, }) - const nameButton = getByLabelText('SmallButton_primary') - fireEvent.click(nameButton) + getByLabelText('SmallButton_primary').click() const error = await findByText( 'Oops! Name is already in use. Choose a different name.' ) @@ -130,11 +137,10 @@ describe('NameRobot', () => { it('should call a mock function when tapping the confirm button', () => { const [{ getByRole, getByLabelText }] = render() - fireEvent.click(getByRole('button', { name: 'a' })) - fireEvent.click(getByRole('button', { name: 'b' })) - fireEvent.click(getByRole('button', { name: 'c' })) - const button = getByLabelText('SmallButton_primary') - fireEvent.click(button) + getByRole('button', { name: 'a' }).click() + getByRole('button', { name: 'b' }).click() + getByRole('button', { name: 'c' }).click() + getByLabelText('SmallButton_primary').click() expect(mockTrackEvent).toHaveBeenCalled() }) @@ -149,4 +155,11 @@ describe('NameRobot', () => { getByText('Enter up to 17 characters (letters and numbers only)') getByText('Confirm') }) + + it('should call a mock function when tapping back button', () => { + mockSettings.unfinishedUnboxingFlowRoute = null + const [{ getByTestId }] = render() + getByTestId('name_back_button').click() + expect(mockPush).toHaveBeenCalledWith('/robot-settings') + }) }) diff --git a/react-api-client/src/robot/__tests__/useEstopQuery.test.tsx b/react-api-client/src/robot/__tests__/useEstopQuery.test.tsx new file mode 100644 index 000000000000..746c7502254f --- /dev/null +++ b/react-api-client/src/robot/__tests__/useEstopQuery.test.tsx @@ -0,0 +1,76 @@ +import * as React from 'react' +import { when } from 'jest-when' +import { QueryClient, QueryClientProvider } from 'react-query' +import { renderHook } from '@testing-library/react-hooks' + +import { getEstopStatus } from '@opentrons/api-client' +import { useHost } from '../../api' +import { useEstopQuery } from '..' + +import type { HostConfig, Response, EstopStatus } from '@opentrons/api-client' + +jest.mock('@opentrons/api-client') +jest.mock('../../api/useHost') + +const mockGetEstopStatus = getEstopStatus as jest.MockedFunction< + typeof getEstopStatus +> +const mockUseHost = useHost as jest.MockedFunction + +const HOST_CONFIG: HostConfig = { hostname: 'localhost' } +const ESTOP_STATE_RESPONSE: EstopStatus = { + data: { + status: 'disengaged', + leftEstopPhysicalStatus: 'disengaged', + rightEstopPhysicalStatus: 'disengaged', + }, +} + +describe('useEstopQuery hook', () => { + let wrapper: React.FunctionComponent<{}> + + beforeEach(() => { + const queryClient = new QueryClient() + const clientProvider: React.FunctionComponent<{}> = ({ children }) => ( + {children} + ) + + wrapper = clientProvider + }) + + afterEach(() => { + jest.resetAllMocks() + }) + + it('should return no data if no host', () => { + when(mockUseHost).calledWith().mockReturnValue(null) + + const { result } = renderHook(useEstopQuery, { wrapper }) + + expect(result.current?.data).toBeUndefined() + }) + + it('should return no data if estop request fails', () => { + when(useHost).calledWith().mockReturnValue(HOST_CONFIG) + when(mockGetEstopStatus).calledWith(HOST_CONFIG).mockRejectedValue('oh no') + + const { result } = renderHook(useEstopQuery, { wrapper }) + + expect(result.current?.data).toBeUndefined() + }) + + it('should return estop state response data', async () => { + when(useHost).calledWith().mockReturnValue(HOST_CONFIG) + when(mockGetEstopStatus) + .calledWith(HOST_CONFIG) + .mockResolvedValue({ + data: ESTOP_STATE_RESPONSE, + } as Response) + + const { result, waitFor } = renderHook(useEstopQuery, { wrapper }) + + await waitFor(() => result.current?.data != null) + + expect(result.current?.data).toEqual(ESTOP_STATE_RESPONSE) + }) +}) diff --git a/react-api-client/src/robot/__tests__/useSetEstopPhysicalStatusMutation.test.tsx b/react-api-client/src/robot/__tests__/useSetEstopPhysicalStatusMutation.test.tsx new file mode 100644 index 000000000000..b731048d9210 --- /dev/null +++ b/react-api-client/src/robot/__tests__/useSetEstopPhysicalStatusMutation.test.tsx @@ -0,0 +1,75 @@ +import * as React from 'react' +import { when, resetAllWhenMocks } from 'jest-when' +import { QueryClient, QueryClientProvider } from 'react-query' +import { act, renderHook } from '@testing-library/react-hooks' +import { setEstopPhysicalStatus } from '@opentrons/api-client' +import { useSetEstopPhysicalStatusMutation } from '..' + +import type { HostConfig, Response, EstopStatus } from '@opentrons/api-client' +import { useHost } from '../../api' + +jest.mock('@opentrons/api-client') +jest.mock('../../api/useHost.ts') + +const mockSetEstopPhysicalStatus = setEstopPhysicalStatus as jest.MockedFunction< + typeof setEstopPhysicalStatus +> +const mockUseHost = useHost as jest.MockedFunction +const HOST_CONFIG: HostConfig = { hostname: 'localhost' } + +describe('useSetEstopPhysicalStatusMutation hook', () => { + let wrapper: React.FunctionComponent<{}> + const updatedEstopPhysicalStatus: EstopStatus = { + data: { + status: 'disengaged', + leftEstopPhysicalStatus: 'disengaged', + rightEstopPhysicalStatus: 'disengaged', + }, + } + + beforeEach(() => { + const queryClient = new QueryClient() + const clientProvider: React.FunctionComponent<{}> = ({ children }) => ( + {children} + ) + wrapper = clientProvider + }) + + afterEach(() => { + resetAllWhenMocks() + }) + + it('should return no data when calling setEstopPhysicalStatus if the request fails', async () => { + when(mockUseHost).calledWith().mockReturnValue(HOST_CONFIG) + when(mockSetEstopPhysicalStatus) + .calledWith(HOST_CONFIG, null) + .mockRejectedValue('oh no') + const { result, waitFor } = renderHook( + () => useSetEstopPhysicalStatusMutation(), + { wrapper } + ) + expect(result.current.data).toBeUndefined() + result.current.setEstopPhysicalStatus(null) + await waitFor(() => { + return result.current.status !== 'loading' + }) + expect(result.current.data).toBeUndefined() + }) + + it('should update a estop status when calling the setEstopPhysicalStatus with empty payload', async () => { + when(mockUseHost).calledWith().mockReturnValue(HOST_CONFIG) + when(mockSetEstopPhysicalStatus) + .calledWith(HOST_CONFIG, null) + .mockResolvedValue({ + data: updatedEstopPhysicalStatus, + } as Response) + + const { result, waitFor } = renderHook( + () => useSetEstopPhysicalStatusMutation(), + { wrapper } + ) + act(() => result.current.setEstopPhysicalStatus(null)) + await waitFor(() => result.current.data != null) + expect(result.current.data).toEqual(updatedEstopPhysicalStatus) + }) +}) diff --git a/react-api-client/src/robot/index.ts b/react-api-client/src/robot/index.ts index 5f88aa3cc837..ebc02cb6b6bb 100644 --- a/react-api-client/src/robot/index.ts +++ b/react-api-client/src/robot/index.ts @@ -1,2 +1,4 @@ +export { useEstopQuery } from './useEstopQuery' export { useLightsQuery } from './useLightsQuery' +export { useSetEstopPhysicalStatusMutation } from './useSetEstopPhysicalStatusMutation' export { useSetLightsMutation } from './useSetLightsMutation' diff --git a/react-api-client/src/robot/useEstopQuery.ts b/react-api-client/src/robot/useEstopQuery.ts new file mode 100644 index 000000000000..190cbdab5aaf --- /dev/null +++ b/react-api-client/src/robot/useEstopQuery.ts @@ -0,0 +1,19 @@ +import { useQuery } from 'react-query' +import { getEstopStatus } from '@opentrons/api-client' +import { useHost } from '../api' + +import type { UseQueryResult, UseQueryOptions } from 'react-query' +import type { HostConfig, EstopStatus } from '@opentrons/api-client' + +export function useEstopQuery( + options: UseQueryOptions = {} +): UseQueryResult { + const host = useHost() + const query = useQuery( + [host as HostConfig, 'robot/control/estopStatus'], + () => getEstopStatus(host as HostConfig).then(response => response.data), + { enabled: host !== null, ...options } + ) + + return query +} diff --git a/react-api-client/src/robot/useSetEstopPhysicalStatusMutation.ts b/react-api-client/src/robot/useSetEstopPhysicalStatusMutation.ts new file mode 100644 index 000000000000..c0017992b450 --- /dev/null +++ b/react-api-client/src/robot/useSetEstopPhysicalStatusMutation.ts @@ -0,0 +1,53 @@ +import { + HostConfig, + EstopStatus, + setEstopPhysicalStatus, +} from '@opentrons/api-client' + +import { + UseMutationResult, + useMutation, + UseMutateFunction, + UseMutationOptions, +} from 'react-query' + +import { useHost } from '../api' +import type { AxiosError } from 'axios' + +export type UseSetEstopPhysicalStatusMutationResult = UseMutationResult< + EstopStatus, + AxiosError, + null +> & { + setEstopPhysicalStatus: UseMutateFunction +} + +export type UseSetEstopPhysicalStatusMutationOptions = UseMutationOptions< + EstopStatus, + AxiosError, + null +> + +export function useSetEstopPhysicalStatusMutation( + options: UseSetEstopPhysicalStatusMutationOptions = {}, + hostOverride?: HostConfig | null +): UseSetEstopPhysicalStatusMutationResult { + const contextHost = useHost() + const host = + hostOverride != null ? { ...contextHost, ...hostOverride } : contextHost + + const mutation = useMutation( + [host, 'robot/control/acknowledgeEstopDisengage'], + (newStatus: null) => + setEstopPhysicalStatus(host as HostConfig, newStatus) + .then(response => response.data) + .catch(e => { + throw e + }), + options + ) + return { + ...mutation, + setEstopPhysicalStatus: mutation.mutate, + } +} From f451d963afc677d1f642b650ac6898d7638fc22a Mon Sep 17 00:00:00 2001 From: Andy Sigler Date: Fri, 28 Jul 2023 11:14:03 -0400 Subject: [PATCH 19/25] feat(api, shared-data): Increase plunger-acceleration only during aspirate/dispense/blow-out (#13154) --- .../hardware_control/backends/ot3utils.py | 24 +++++++++ .../instruments/ot3/pipette.py | 15 ++++++ .../instruments/ot3/pipette_handler.py | 19 +++++++ api/src/opentrons/hardware_control/ot3api.py | 51 ++++++++++++------ api/src/opentrons/hardware_control/types.py | 2 +- .../backends/test_ot3_utils.py | 18 ++++++- .../gravimetric/liquid_class/pipetting.py | 53 +------------------ .../2/liquid/eight_channel/p1000/1_0.json | 3 ++ .../2/liquid/eight_channel/p1000/3_0.json | 3 ++ .../2/liquid/eight_channel/p1000/3_3.json | 3 ++ .../2/liquid/eight_channel/p1000/3_4.json | 3 ++ .../2/liquid/eight_channel/p1000/3_5.json | 3 ++ .../2/liquid/eight_channel/p50/3_0.json | 1 + .../2/liquid/eight_channel/p50/3_3.json | 1 + .../2/liquid/eight_channel/p50/3_4.json | 1 + .../2/liquid/eight_channel/p50/3_5.json | 1 + .../liquid/ninety_six_channel/p1000/1_0.json | 3 ++ .../liquid/ninety_six_channel/p1000/3_0.json | 3 ++ .../liquid/ninety_six_channel/p1000/3_3.json | 3 ++ .../liquid/ninety_six_channel/p1000/3_4.json | 3 ++ .../liquid/ninety_six_channel/p1000/3_5.json | 3 ++ .../2/liquid/single_channel/p1000/3_0.json | 3 ++ .../2/liquid/single_channel/p1000/3_3.json | 3 ++ .../2/liquid/single_channel/p1000/3_4.json | 3 ++ .../2/liquid/single_channel/p1000/3_5.json | 3 ++ .../2/liquid/single_channel/p50/3_0.json | 1 + .../2/liquid/single_channel/p50/3_3.json | 1 + .../2/liquid/single_channel/p50/3_4.json | 1 + .../2/liquid/single_channel/p50/3_5.json | 1 + .../2/pipetteLiquidPropertiesSchema.json | 3 ++ .../pipette/pipette_definition.py | 5 ++ 31 files changed, 171 insertions(+), 69 deletions(-) diff --git a/api/src/opentrons/hardware_control/backends/ot3utils.py b/api/src/opentrons/hardware_control/backends/ot3utils.py index 098c4177bcfd..6d962d7a8349 100644 --- a/api/src/opentrons/hardware_control/backends/ot3utils.py +++ b/api/src/opentrons/hardware_control/backends/ot3utils.py @@ -279,6 +279,30 @@ def get_system_constraints_for_calibration( return constraints +def get_system_constraints_for_plunger_acceleration( + config: OT3MotionSettings, + gantry_load: GantryLoad, + mount: OT3Mount, + acceleration: float, +) -> "SystemConstraints[Axis]": + old_constraints = config.by_gantry_load(gantry_load) + new_constraints = {} + axis_kinds = set([k for _, v in old_constraints.items() for k in v.keys()]) + for axis_kind in axis_kinds: + for axis in Axis.of_kind(axis_kind): + if axis == Axis.of_main_tool_actuator(mount): + _accel = acceleration + else: + _accel = old_constraints["acceleration"][axis_kind] + new_constraints[axis] = AxisConstraints.build( + _accel, + old_constraints["max_speed_discontinuity"][axis_kind], + old_constraints["direction_change_speed_discontinuity"][axis_kind], + old_constraints["default_max_speed"][axis_kind], + ) + return new_constraints + + def _convert_to_node_id_dict( axis_pos: Coordinates[Axis, CoordinateValue], ) -> NodeIdMotionValues: diff --git a/api/src/opentrons/hardware_control/instruments/ot3/pipette.py b/api/src/opentrons/hardware_control/instruments/ot3/pipette.py index b468e38cc772..681429a386f6 100644 --- a/api/src/opentrons/hardware_control/instruments/ot3/pipette.py +++ b/api/src/opentrons/hardware_control/instruments/ot3/pipette.py @@ -116,6 +116,7 @@ def __init__( self._blowout_flow_rates_lookup = ( self._active_tip_settings.default_blowout_flowrate.values_by_api_level ) + self._aspirate_flow_rate = ( self._active_tip_settings.default_aspirate_flowrate.default ) @@ -125,6 +126,7 @@ def __init__( self._blow_out_flow_rate = ( self._active_tip_settings.default_blowout_flowrate.default ) + self._flow_acceleration = self._active_tip_settings.default_flow_acceleration self._tip_overlap_lookup = self._config.tip_overlap_dictionary @@ -229,6 +231,7 @@ def reset_state(self) -> None: self._blow_out_flow_rate = ( self._active_tip_settings.default_blowout_flowrate.default ) + self._flow_acceleration = self._active_tip_settings.default_flow_acceleration self._tip_overlap_lookup = self._config.tip_overlap_dictionary @@ -401,6 +404,16 @@ def blow_out_flow_rate(self, new_flow_rate: float) -> None: assert new_flow_rate > 0 self._blow_out_flow_rate = new_flow_rate + @property + def flow_acceleration(self) -> float: + """Current active flow acceleration (not config value)""" + return self._flow_acceleration + + @flow_acceleration.setter + def flow_acceleration(self, new_flow_acceleration: float) -> None: + assert new_flow_acceleration > 0 + self._flow_acceleration = new_flow_acceleration + @property def aspirate_flow_rates_lookup(self) -> Dict[str, float]: return self._aspirate_flow_rates_lookup @@ -525,9 +538,11 @@ def as_dict(self) -> "Pipette.DictType": "aspirate_flow_rate": self.aspirate_flow_rate, "dispense_flow_rate": self.dispense_flow_rate, "blow_out_flow_rate": self.blow_out_flow_rate, + "flow_acceleration": self.flow_acceleration, "default_aspirate_flow_rates": self.active_tip_settings.default_aspirate_flowrate.values_by_api_level, "default_blow_out_flow_rates": self.active_tip_settings.default_blowout_flowrate.values_by_api_level, "default_dispense_flow_rates": self.active_tip_settings.default_dispense_flowrate.values_by_api_level, + "default_flow_acceleration": self.active_tip_settings.default_flow_acceleration, "tip_length": self.current_tip_length, "return_tip_height": self.active_tip_settings.default_return_tip_height, "tip_overlap": self.tip_overlap, diff --git a/api/src/opentrons/hardware_control/instruments/ot3/pipette_handler.py b/api/src/opentrons/hardware_control/instruments/ot3/pipette_handler.py index 8c805c0e4661..2ef97036db7f 100644 --- a/api/src/opentrons/hardware_control/instruments/ot3/pipette_handler.py +++ b/api/src/opentrons/hardware_control/instruments/ot3/pipette_handler.py @@ -56,6 +56,7 @@ class LiquidActionSpec: volume: float plunger_distance: float speed: float + acceleration: float instr: Pipette current: float @@ -469,6 +470,12 @@ def plunger_flowrate( ul_per_s = mm_per_s * instr.ul_per_mm(instr.config.max_volume, action) return round(ul_per_s, 6) + def plunger_acceleration(self, instr: Pipette, ul_per_s_per_s: float) -> float: + # using nominal ul/mm, to make sure accelerations are always the same + # regardless of volume being aspirated/dispensed + mm_per_s_per_s = ul_per_s_per_s / instr.config.shaft_ul_per_mm + return round(mm_per_s_per_s, 6) + def plan_check_aspirate( self, mount: OT3Mount, @@ -514,12 +521,16 @@ def plan_check_aspirate( speed = self.plunger_speed( instrument, instrument.aspirate_flow_rate * rate, "aspirate" ) + acceleration = self.plunger_acceleration( + instrument, instrument.flow_acceleration + ) return LiquidActionSpec( axis=Axis.of_main_tool_actuator(mount), volume=asp_vol, plunger_distance=dist, speed=speed, + acceleration=acceleration, instr=instrument, current=instrument.plunger_motor_current.run, ) @@ -570,11 +581,15 @@ def plan_check_dispense( speed = self.plunger_speed( instrument, instrument.dispense_flow_rate * rate, "dispense" ) + acceleration = self.plunger_acceleration( + instrument, instrument.flow_acceleration + ) return LiquidActionSpec( axis=Axis.of_main_tool_actuator(mount), volume=disp_vol, plunger_distance=dist, speed=speed, + acceleration=acceleration, instr=instrument, current=instrument.plunger_motor_current.run, ) @@ -586,6 +601,9 @@ def plan_check_blow_out( instrument = self.get_pipette(mount) self.ready_for_tip_action(instrument, HardwareAction.BLOWOUT) speed = self.plunger_speed(instrument, instrument.blow_out_flow_rate, "blowout") + acceleration = self.plunger_acceleration( + instrument, instrument.flow_acceleration + ) if volume is None: ul = self.get_attached_instrument(mount)["default_blow_out_volume"] else: @@ -597,6 +615,7 @@ def plan_check_blow_out( volume=0, plunger_distance=distance_mm, speed=speed, + acceleration=acceleration, instr=instrument, current=instrument.plunger_motor_current.run, ) diff --git a/api/src/opentrons/hardware_control/ot3api.py b/api/src/opentrons/hardware_control/ot3api.py index c5b23ff4c313..4ff05386248f 100644 --- a/api/src/opentrons/hardware_control/ot3api.py +++ b/api/src/opentrons/hardware_control/ot3api.py @@ -68,6 +68,7 @@ from .backends.ot3utils import ( get_system_constraints, get_system_constraints_for_calibration, + get_system_constraints_for_plunger_acceleration, ) from .backends.errors import SubsystemUpdating from .execution_manager import ExecutionManagerProvider @@ -261,6 +262,14 @@ async def set_system_constraints_for_calibration(self) -> None: f"Set system constraints for calibration: {self._move_manager.get_constraints()}" ) + async def set_system_constraints_for_plunger_acceleration( + self, mount: OT3Mount, acceleration: float + ) -> None: + new_constraints = get_system_constraints_for_plunger_acceleration( + self._config.motion_settings, self._gantry_load, mount, acceleration + ) + self._move_manager.update_constraints(new_constraints) + @contextlib.asynccontextmanager async def restore_system_constrants(self) -> AsyncIterator[None]: old_system_constraints = deepcopy(self._move_manager.get_constraints()) @@ -1546,11 +1555,15 @@ async def aspirate( await self._backend.set_active_current( {aspirate_spec.axis: aspirate_spec.current} ) - await self._move( - target_pos, - speed=aspirate_spec.speed, - home_flagged_axes=False, - ) + async with self.restore_system_constrants(): + await self.set_system_constraints_for_plunger_acceleration( + realmount, aspirate_spec.acceleration + ) + await self._move( + target_pos, + speed=aspirate_spec.speed, + home_flagged_axes=False, + ) except Exception: self._log.exception("Aspirate failed") aspirate_spec.instr.set_current_volume(0) @@ -1582,11 +1595,15 @@ async def dispense( await self._backend.set_active_current( {dispense_spec.axis: dispense_spec.current} ) - await self._move( - target_pos, - speed=dispense_spec.speed, - home_flagged_axes=False, - ) + async with self.restore_system_constrants(): + await self.set_system_constraints_for_plunger_acceleration( + realmount, dispense_spec.acceleration + ) + await self._move( + target_pos, + speed=dispense_spec.speed, + home_flagged_axes=False, + ) except Exception: self._log.exception("Dispense failed") dispense_spec.instr.set_current_volume(0) @@ -1630,11 +1647,15 @@ async def blow_out( ) try: - await self._move( - target_pos, - speed=blowout_spec.speed, - home_flagged_axes=False, - ) + async with self.restore_system_constrants(): + await self.set_system_constraints_for_plunger_acceleration( + realmount, blowout_spec.acceleration + ) + await self._move( + target_pos, + speed=blowout_spec.speed, + home_flagged_axes=False, + ) except Exception: self._log.exception("Blow out failed") raise diff --git a/api/src/opentrons/hardware_control/types.py b/api/src/opentrons/hardware_control/types.py index 20f50606f251..d86056ff485a 100644 --- a/api/src/opentrons/hardware_control/types.py +++ b/api/src/opentrons/hardware_control/types.py @@ -152,7 +152,7 @@ def to_kind(cls, axis: "Axis") -> OT3AxisKind: cls.Z_L: OT3AxisKind.Z, cls.Z_R: OT3AxisKind.Z, cls.Z_G: OT3AxisKind.Z_G, - cls.Q: OT3AxisKind.OTHER, + cls.Q: OT3AxisKind.Q, cls.G: OT3AxisKind.OTHER, } return kind_map[axis] diff --git a/api/tests/opentrons/hardware_control/backends/test_ot3_utils.py b/api/tests/opentrons/hardware_control/backends/test_ot3_utils.py index 06de82820283..c1946b61802e 100644 --- a/api/tests/opentrons/hardware_control/backends/test_ot3_utils.py +++ b/api/tests/opentrons/hardware_control/backends/test_ot3_utils.py @@ -1,9 +1,11 @@ from opentrons_hardware.hardware_control.motion_planning import Move from opentrons.hardware_control.backends import ot3utils from opentrons_hardware.firmware_bindings.constants import NodeId -from opentrons.hardware_control.types import Axis +from opentrons.hardware_control.types import Axis, OT3Mount from numpy import float64 as f64 +from opentrons.config import defaults_ot3, types as conf_types + def test_create_step() -> None: origin = { @@ -75,3 +77,17 @@ def test_nodeid_replace_gripper() -> None: assert ot3utils.replace_gripper_node(set([NodeId.gripper_g])) == set( [NodeId.gripper_g] ) + + +def test_get_system_contraints_for_plunger() -> None: + set_acceleration = 2 + axis = Axis.P_L + config = defaults_ot3.build_with_defaults({}) + updated_contraints = ot3utils.get_system_constraints_for_plunger_acceleration( + config.motion_settings, + conf_types.GantryLoad.LOW_THROUGHPUT, + OT3Mount.LEFT, + set_acceleration, + ) + + assert updated_contraints[axis].max_acceleration == set_acceleration diff --git a/hardware-testing/hardware_testing/gravimetric/liquid_class/pipetting.py b/hardware-testing/hardware_testing/gravimetric/liquid_class/pipetting.py index 2457042ad5af..1970366f22fa 100644 --- a/hardware-testing/hardware_testing/gravimetric/liquid_class/pipetting.py +++ b/hardware-testing/hardware_testing/gravimetric/liquid_class/pipetting.py @@ -1,12 +1,8 @@ """Pipette motions.""" -from math import pi from dataclasses import dataclass from typing import Optional, Callable, Tuple -from opentrons.config.defaults_ot3 import ( - DEFAULT_ACCELERATIONS, - DEFAULT_MAX_SPEED_DISCONTINUITY, -) +from opentrons.config.defaults_ot3 import DEFAULT_MAX_SPEED_DISCONTINUITY from opentrons.protocol_api import InstrumentContext, ProtocolContext from opentrons.protocol_api.labware import Well @@ -159,49 +155,6 @@ def _retract( hw_api.set_gantry_load(hw_api.gantry_load) -def _change_plunger_acceleration( - ctx: ProtocolContext, pipette: InstrumentContext, ul_per_sec_per_sec: float -) -> None: - hw_api = ctx._core.get_hardware() - # NOTE: set plunger accelerations by converting ul/sec/sec to mm/sec/sec, - # making sure to use the nominal ul/mm to convert so that the - # mm/sec/sec we move is constant regardless of changes to the function - if "p50" in pipette.name: - shaft_diameter_mm = 1.0 - else: - shaft_diameter_mm = 4.5 - nominal_ul_per_mm = pi * pow(shaft_diameter_mm * 0.5, 2) - p_accel = ul_per_sec_per_sec / nominal_ul_per_mm - if pipette.channels == 96: - hw_api.config.motion_settings.acceleration.high_throughput[ - OT3AxisKind.P - ] = p_accel - else: - hw_api.config.motion_settings.acceleration.low_throughput[ - OT3AxisKind.P - ] = p_accel - # NOTE: re-setting the gantry-load will reset the move-manager's per-axis constraints - hw_api.set_gantry_load(hw_api.gantry_load) - - -def _reset_plunger_acceleration( - ctx: ProtocolContext, pipette: InstrumentContext -) -> None: - hw_api = ctx._core.get_hardware() - if pipette.channels == 96: - p_accel = DEFAULT_ACCELERATIONS.high_throughput[OT3AxisKind.P] - hw_api.config.motion_settings.acceleration.high_throughput[ - OT3AxisKind.P - ] = p_accel - else: - p_accel = DEFAULT_ACCELERATIONS.low_throughput[OT3AxisKind.P] - hw_api.config.motion_settings.acceleration.low_throughput[ - OT3AxisKind.P - ] = p_accel - # NOTE: re-setting the gantry-load will reset the move-manager's per-axis constraints - hw_api.set_gantry_load(hw_api.gantry_load) - - def _pipette_with_liquid_settings( ctx: ProtocolContext, pipette: InstrumentContext, @@ -316,9 +269,6 @@ def _dispense_on_retract() -> None: pipette.flow_rate.aspirate = liquid_class.aspirate.plunger_flow_rate pipette.flow_rate.dispense = liquid_class.dispense.plunger_flow_rate pipette.flow_rate.blow_out = liquid_class.dispense.plunger_flow_rate - _change_plunger_acceleration( - ctx, pipette, liquid_class.dispense.plunger_acceleration - ) pipette.move_to(well.bottom(approach_mm).move(channel_offset)) _aspirate_on_approach() if aspirate else _dispense_on_approach() @@ -339,7 +289,6 @@ def _dispense_on_retract() -> None: # EXIT callbacks.on_exiting() hw_api.retract(hw_mount) - _reset_plunger_acceleration(ctx, pipette) def aspirate_with_liquid_class( diff --git a/shared-data/pipette/definitions/2/liquid/eight_channel/p1000/1_0.json b/shared-data/pipette/definitions/2/liquid/eight_channel/p1000/1_0.json index b940fdadab2f..3e538dfa9985 100644 --- a/shared-data/pipette/definitions/2/liquid/eight_channel/p1000/1_0.json +++ b/shared-data/pipette/definitions/2/liquid/eight_channel/p1000/1_0.json @@ -20,6 +20,7 @@ "2.14": 80 } }, + "defaultFlowAcceleration": 24000.0, "defaultTipLength": 57.9, "defaultReturnTipHeight": 0.83, "aspirate": { @@ -139,6 +140,7 @@ "2.14": 80 } }, + "defaultFlowAcceleration": 24000.0, "defaultTipLength": 58.35, "defaultReturnTipHeight": 0.83, "aspirate": { @@ -256,6 +258,7 @@ "2.14": 80 } }, + "defaultFlowAcceleration": 24000.0, "defaultTipLength": 95.6, "defaultReturnTipHeight": 0.83, "aspirate": { diff --git a/shared-data/pipette/definitions/2/liquid/eight_channel/p1000/3_0.json b/shared-data/pipette/definitions/2/liquid/eight_channel/p1000/3_0.json index bb73169716c4..0f64394baa37 100644 --- a/shared-data/pipette/definitions/2/liquid/eight_channel/p1000/3_0.json +++ b/shared-data/pipette/definitions/2/liquid/eight_channel/p1000/3_0.json @@ -20,6 +20,7 @@ "2.14": 80 } }, + "defaultFlowAcceleration": 24000.0, "defaultTipLength": 57.9, "defaultReturnTipHeight": 0.71, "aspirate": { @@ -139,6 +140,7 @@ "2.14": 80 } }, + "defaultFlowAcceleration": 24000.0, "defaultTipLength": 58.35, "defaultReturnTipHeight": 0.71, "aspirate": { @@ -256,6 +258,7 @@ "2.14": 80 } }, + "defaultFlowAcceleration": 24000.0, "defaultTipLength": 95.6, "defaultReturnTipHeight": 0.82, "aspirate": { diff --git a/shared-data/pipette/definitions/2/liquid/eight_channel/p1000/3_3.json b/shared-data/pipette/definitions/2/liquid/eight_channel/p1000/3_3.json index bb73169716c4..0f64394baa37 100644 --- a/shared-data/pipette/definitions/2/liquid/eight_channel/p1000/3_3.json +++ b/shared-data/pipette/definitions/2/liquid/eight_channel/p1000/3_3.json @@ -20,6 +20,7 @@ "2.14": 80 } }, + "defaultFlowAcceleration": 24000.0, "defaultTipLength": 57.9, "defaultReturnTipHeight": 0.71, "aspirate": { @@ -139,6 +140,7 @@ "2.14": 80 } }, + "defaultFlowAcceleration": 24000.0, "defaultTipLength": 58.35, "defaultReturnTipHeight": 0.71, "aspirate": { @@ -256,6 +258,7 @@ "2.14": 80 } }, + "defaultFlowAcceleration": 24000.0, "defaultTipLength": 95.6, "defaultReturnTipHeight": 0.82, "aspirate": { diff --git a/shared-data/pipette/definitions/2/liquid/eight_channel/p1000/3_4.json b/shared-data/pipette/definitions/2/liquid/eight_channel/p1000/3_4.json index 239c0be58828..934f022b1dc6 100644 --- a/shared-data/pipette/definitions/2/liquid/eight_channel/p1000/3_4.json +++ b/shared-data/pipette/definitions/2/liquid/eight_channel/p1000/3_4.json @@ -20,6 +20,7 @@ "2.14": 160 } }, + "defaultFlowAcceleration": 24000.0, "defaultTipLength": 57.9, "defaultReturnTipHeight": 0.71, "aspirate": { @@ -139,6 +140,7 @@ "2.14": 160 } }, + "defaultFlowAcceleration": 24000.0, "defaultTipLength": 58.35, "defaultReturnTipHeight": 0.71, "aspirate": { @@ -256,6 +258,7 @@ "2.14": 160 } }, + "defaultFlowAcceleration": 24000.0, "defaultTipLength": 95.6, "defaultReturnTipHeight": 0.82, "aspirate": { diff --git a/shared-data/pipette/definitions/2/liquid/eight_channel/p1000/3_5.json b/shared-data/pipette/definitions/2/liquid/eight_channel/p1000/3_5.json index 239c0be58828..934f022b1dc6 100644 --- a/shared-data/pipette/definitions/2/liquid/eight_channel/p1000/3_5.json +++ b/shared-data/pipette/definitions/2/liquid/eight_channel/p1000/3_5.json @@ -20,6 +20,7 @@ "2.14": 160 } }, + "defaultFlowAcceleration": 24000.0, "defaultTipLength": 57.9, "defaultReturnTipHeight": 0.71, "aspirate": { @@ -139,6 +140,7 @@ "2.14": 160 } }, + "defaultFlowAcceleration": 24000.0, "defaultTipLength": 58.35, "defaultReturnTipHeight": 0.71, "aspirate": { @@ -256,6 +258,7 @@ "2.14": 160 } }, + "defaultFlowAcceleration": 24000.0, "defaultTipLength": 95.6, "defaultReturnTipHeight": 0.82, "aspirate": { diff --git a/shared-data/pipette/definitions/2/liquid/eight_channel/p50/3_0.json b/shared-data/pipette/definitions/2/liquid/eight_channel/p50/3_0.json index 1e1bccface9d..542ce2c5522d 100644 --- a/shared-data/pipette/definitions/2/liquid/eight_channel/p50/3_0.json +++ b/shared-data/pipette/definitions/2/liquid/eight_channel/p50/3_0.json @@ -14,6 +14,7 @@ "default": 4, "valuesByApiLevel": { "2.14": 4 } }, + "defaultFlowAcceleration": 1200.0, "defaultTipLength": 57.9, "defaultReturnTipHeight": 0.71, "aspirate": { diff --git a/shared-data/pipette/definitions/2/liquid/eight_channel/p50/3_3.json b/shared-data/pipette/definitions/2/liquid/eight_channel/p50/3_3.json index 1e1bccface9d..542ce2c5522d 100644 --- a/shared-data/pipette/definitions/2/liquid/eight_channel/p50/3_3.json +++ b/shared-data/pipette/definitions/2/liquid/eight_channel/p50/3_3.json @@ -14,6 +14,7 @@ "default": 4, "valuesByApiLevel": { "2.14": 4 } }, + "defaultFlowAcceleration": 1200.0, "defaultTipLength": 57.9, "defaultReturnTipHeight": 0.71, "aspirate": { diff --git a/shared-data/pipette/definitions/2/liquid/eight_channel/p50/3_4.json b/shared-data/pipette/definitions/2/liquid/eight_channel/p50/3_4.json index 5672590879e6..e889473054e7 100644 --- a/shared-data/pipette/definitions/2/liquid/eight_channel/p50/3_4.json +++ b/shared-data/pipette/definitions/2/liquid/eight_channel/p50/3_4.json @@ -14,6 +14,7 @@ "default": 57, "valuesByApiLevel": { "2.14": 57 } }, + "defaultFlowAcceleration": 1200.0, "defaultTipLength": 57.9, "defaultReturnTipHeight": 0.71, "aspirate": { diff --git a/shared-data/pipette/definitions/2/liquid/eight_channel/p50/3_5.json b/shared-data/pipette/definitions/2/liquid/eight_channel/p50/3_5.json index 5672590879e6..e889473054e7 100644 --- a/shared-data/pipette/definitions/2/liquid/eight_channel/p50/3_5.json +++ b/shared-data/pipette/definitions/2/liquid/eight_channel/p50/3_5.json @@ -14,6 +14,7 @@ "default": 57, "valuesByApiLevel": { "2.14": 57 } }, + "defaultFlowAcceleration": 1200.0, "defaultTipLength": 57.9, "defaultReturnTipHeight": 0.71, "aspirate": { diff --git a/shared-data/pipette/definitions/2/liquid/ninety_six_channel/p1000/1_0.json b/shared-data/pipette/definitions/2/liquid/ninety_six_channel/p1000/1_0.json index 45870070b5fa..1f01e4ade69e 100644 --- a/shared-data/pipette/definitions/2/liquid/ninety_six_channel/p1000/1_0.json +++ b/shared-data/pipette/definitions/2/liquid/ninety_six_channel/p1000/1_0.json @@ -20,6 +20,7 @@ "2.14": 80 } }, + "defaultFlowAcceleration": 16000.0, "defaultTipLength": 57.9, "defaultReturnTipHeight": 0.2, "aspirate": { @@ -139,6 +140,7 @@ "2.14": 80 } }, + "defaultFlowAcceleration": 16000.0, "defaultTipLength": 58.35, "defaultReturnTipHeight": 0.2, "aspirate": { @@ -256,6 +258,7 @@ "2.14": 80 } }, + "defaultFlowAcceleration": 16000.0, "defaultTipLength": 95.6, "defaultReturnTipHeight": 0.2, "aspirate": { diff --git a/shared-data/pipette/definitions/2/liquid/ninety_six_channel/p1000/3_0.json b/shared-data/pipette/definitions/2/liquid/ninety_six_channel/p1000/3_0.json index 297569c16e54..7215346d5f1b 100644 --- a/shared-data/pipette/definitions/2/liquid/ninety_six_channel/p1000/3_0.json +++ b/shared-data/pipette/definitions/2/liquid/ninety_six_channel/p1000/3_0.json @@ -20,6 +20,7 @@ "2.14": 80 } }, + "defaultFlowAcceleration": 16000.0, "defaultTipLength": 57.9, "defaultReturnTipHeight": 0.2, "aspirate": { @@ -139,6 +140,7 @@ "2.14": 80 } }, + "defaultFlowAcceleration": 16000.0, "defaultTipLength": 58.35, "defaultReturnTipHeight": 0.2, "aspirate": { @@ -256,6 +258,7 @@ "2.14": 80 } }, + "defaultFlowAcceleration": 16000.0, "defaultTipLength": 95.6, "defaultReturnTipHeight": 0.2, "aspirate": { diff --git a/shared-data/pipette/definitions/2/liquid/ninety_six_channel/p1000/3_3.json b/shared-data/pipette/definitions/2/liquid/ninety_six_channel/p1000/3_3.json index 297569c16e54..7215346d5f1b 100644 --- a/shared-data/pipette/definitions/2/liquid/ninety_six_channel/p1000/3_3.json +++ b/shared-data/pipette/definitions/2/liquid/ninety_six_channel/p1000/3_3.json @@ -20,6 +20,7 @@ "2.14": 80 } }, + "defaultFlowAcceleration": 16000.0, "defaultTipLength": 57.9, "defaultReturnTipHeight": 0.2, "aspirate": { @@ -139,6 +140,7 @@ "2.14": 80 } }, + "defaultFlowAcceleration": 16000.0, "defaultTipLength": 58.35, "defaultReturnTipHeight": 0.2, "aspirate": { @@ -256,6 +258,7 @@ "2.14": 80 } }, + "defaultFlowAcceleration": 16000.0, "defaultTipLength": 95.6, "defaultReturnTipHeight": 0.2, "aspirate": { diff --git a/shared-data/pipette/definitions/2/liquid/ninety_six_channel/p1000/3_4.json b/shared-data/pipette/definitions/2/liquid/ninety_six_channel/p1000/3_4.json index e9c0e9d3707d..242bdff0414a 100644 --- a/shared-data/pipette/definitions/2/liquid/ninety_six_channel/p1000/3_4.json +++ b/shared-data/pipette/definitions/2/liquid/ninety_six_channel/p1000/3_4.json @@ -20,6 +20,7 @@ "2.14": 80 } }, + "defaultFlowAcceleration": 16000.0, "defaultTipLength": 57.9, "defaultReturnTipHeight": 0.2, "defaultBlowoutVolume": 3.2, @@ -139,6 +140,7 @@ "2.14": 80 } }, + "defaultFlowAcceleration": 16000.0, "defaultTipLength": 58.35, "defaultReturnTipHeight": 0.2, "defaultBlowoutVolume": 3.2, @@ -256,6 +258,7 @@ "2.14": 80 } }, + "defaultFlowAcceleration": 16000.0, "defaultTipLength": 95.6, "defaultReturnTipHeight": 0.2, "defaultBlowoutVolume": 3.2, diff --git a/shared-data/pipette/definitions/2/liquid/ninety_six_channel/p1000/3_5.json b/shared-data/pipette/definitions/2/liquid/ninety_six_channel/p1000/3_5.json index e9c0e9d3707d..242bdff0414a 100644 --- a/shared-data/pipette/definitions/2/liquid/ninety_six_channel/p1000/3_5.json +++ b/shared-data/pipette/definitions/2/liquid/ninety_six_channel/p1000/3_5.json @@ -20,6 +20,7 @@ "2.14": 80 } }, + "defaultFlowAcceleration": 16000.0, "defaultTipLength": 57.9, "defaultReturnTipHeight": 0.2, "defaultBlowoutVolume": 3.2, @@ -139,6 +140,7 @@ "2.14": 80 } }, + "defaultFlowAcceleration": 16000.0, "defaultTipLength": 58.35, "defaultReturnTipHeight": 0.2, "defaultBlowoutVolume": 3.2, @@ -256,6 +258,7 @@ "2.14": 80 } }, + "defaultFlowAcceleration": 16000.0, "defaultTipLength": 95.6, "defaultReturnTipHeight": 0.2, "defaultBlowoutVolume": 3.2, diff --git a/shared-data/pipette/definitions/2/liquid/single_channel/p1000/3_0.json b/shared-data/pipette/definitions/2/liquid/single_channel/p1000/3_0.json index b889b8a824ef..3e427d3b325a 100644 --- a/shared-data/pipette/definitions/2/liquid/single_channel/p1000/3_0.json +++ b/shared-data/pipette/definitions/2/liquid/single_channel/p1000/3_0.json @@ -20,6 +20,7 @@ "2.14": 80 } }, + "defaultFlowAcceleration": 24000.0, "defaultTipLength": 57.9, "defaultReturnTipHeight": 0.71, "aspirate": { @@ -139,6 +140,7 @@ "2.14": 80 } }, + "defaultFlowAcceleration": 24000.0, "defaultTipLength": 58.35, "defaultReturnTipHeight": 0.71, "aspirate": { @@ -256,6 +258,7 @@ "2.14": 80 } }, + "defaultFlowAcceleration": 24000.0, "defaultTipLength": 95.6, "defaultReturnTipHeight": 0.82, "aspirate": { diff --git a/shared-data/pipette/definitions/2/liquid/single_channel/p1000/3_3.json b/shared-data/pipette/definitions/2/liquid/single_channel/p1000/3_3.json index b889b8a824ef..3e427d3b325a 100644 --- a/shared-data/pipette/definitions/2/liquid/single_channel/p1000/3_3.json +++ b/shared-data/pipette/definitions/2/liquid/single_channel/p1000/3_3.json @@ -20,6 +20,7 @@ "2.14": 80 } }, + "defaultFlowAcceleration": 24000.0, "defaultTipLength": 57.9, "defaultReturnTipHeight": 0.71, "aspirate": { @@ -139,6 +140,7 @@ "2.14": 80 } }, + "defaultFlowAcceleration": 24000.0, "defaultTipLength": 58.35, "defaultReturnTipHeight": 0.71, "aspirate": { @@ -256,6 +258,7 @@ "2.14": 80 } }, + "defaultFlowAcceleration": 24000.0, "defaultTipLength": 95.6, "defaultReturnTipHeight": 0.82, "aspirate": { diff --git a/shared-data/pipette/definitions/2/liquid/single_channel/p1000/3_4.json b/shared-data/pipette/definitions/2/liquid/single_channel/p1000/3_4.json index 5c2a3efa1756..0fa958345e2d 100644 --- a/shared-data/pipette/definitions/2/liquid/single_channel/p1000/3_4.json +++ b/shared-data/pipette/definitions/2/liquid/single_channel/p1000/3_4.json @@ -20,6 +20,7 @@ "2.14": 160 } }, + "defaultFlowAcceleration": 24000.0, "defaultTipLength": 57.9, "defaultReturnTipHeight": 0.71, "aspirate": { @@ -113,6 +114,7 @@ "2.14": 160 } }, + "defaultFlowAcceleration": 24000.0, "defaultTipLength": 58.35, "defaultReturnTipHeight": 0.71, "aspirate": { @@ -206,6 +208,7 @@ "2.14": 160 } }, + "defaultFlowAcceleration": 24000.0, "defaultTipLength": 95.6, "defaultReturnTipHeight": 0.82, "aspirate": { diff --git a/shared-data/pipette/definitions/2/liquid/single_channel/p1000/3_5.json b/shared-data/pipette/definitions/2/liquid/single_channel/p1000/3_5.json index 5c2a3efa1756..0fa958345e2d 100644 --- a/shared-data/pipette/definitions/2/liquid/single_channel/p1000/3_5.json +++ b/shared-data/pipette/definitions/2/liquid/single_channel/p1000/3_5.json @@ -20,6 +20,7 @@ "2.14": 160 } }, + "defaultFlowAcceleration": 24000.0, "defaultTipLength": 57.9, "defaultReturnTipHeight": 0.71, "aspirate": { @@ -113,6 +114,7 @@ "2.14": 160 } }, + "defaultFlowAcceleration": 24000.0, "defaultTipLength": 58.35, "defaultReturnTipHeight": 0.71, "aspirate": { @@ -206,6 +208,7 @@ "2.14": 160 } }, + "defaultFlowAcceleration": 24000.0, "defaultTipLength": 95.6, "defaultReturnTipHeight": 0.82, "aspirate": { diff --git a/shared-data/pipette/definitions/2/liquid/single_channel/p50/3_0.json b/shared-data/pipette/definitions/2/liquid/single_channel/p50/3_0.json index 1e1bccface9d..542ce2c5522d 100644 --- a/shared-data/pipette/definitions/2/liquid/single_channel/p50/3_0.json +++ b/shared-data/pipette/definitions/2/liquid/single_channel/p50/3_0.json @@ -14,6 +14,7 @@ "default": 4, "valuesByApiLevel": { "2.14": 4 } }, + "defaultFlowAcceleration": 1200.0, "defaultTipLength": 57.9, "defaultReturnTipHeight": 0.71, "aspirate": { diff --git a/shared-data/pipette/definitions/2/liquid/single_channel/p50/3_3.json b/shared-data/pipette/definitions/2/liquid/single_channel/p50/3_3.json index 1e1bccface9d..542ce2c5522d 100644 --- a/shared-data/pipette/definitions/2/liquid/single_channel/p50/3_3.json +++ b/shared-data/pipette/definitions/2/liquid/single_channel/p50/3_3.json @@ -14,6 +14,7 @@ "default": 4, "valuesByApiLevel": { "2.14": 4 } }, + "defaultFlowAcceleration": 1200.0, "defaultTipLength": 57.9, "defaultReturnTipHeight": 0.71, "aspirate": { diff --git a/shared-data/pipette/definitions/2/liquid/single_channel/p50/3_4.json b/shared-data/pipette/definitions/2/liquid/single_channel/p50/3_4.json index fc892dcd27c1..de97ed26f392 100644 --- a/shared-data/pipette/definitions/2/liquid/single_channel/p50/3_4.json +++ b/shared-data/pipette/definitions/2/liquid/single_channel/p50/3_4.json @@ -14,6 +14,7 @@ "default": 57, "valuesByApiLevel": { "2.14": 57 } }, + "defaultFlowAcceleration": 1200.0, "defaultTipLength": 57.9, "defaultReturnTipHeight": 0.71, "aspirate": { diff --git a/shared-data/pipette/definitions/2/liquid/single_channel/p50/3_5.json b/shared-data/pipette/definitions/2/liquid/single_channel/p50/3_5.json index fc892dcd27c1..de97ed26f392 100644 --- a/shared-data/pipette/definitions/2/liquid/single_channel/p50/3_5.json +++ b/shared-data/pipette/definitions/2/liquid/single_channel/p50/3_5.json @@ -14,6 +14,7 @@ "default": 57, "valuesByApiLevel": { "2.14": 57 } }, + "defaultFlowAcceleration": 1200.0, "defaultTipLength": 57.9, "defaultReturnTipHeight": 0.71, "aspirate": { diff --git a/shared-data/pipette/schemas/2/pipetteLiquidPropertiesSchema.json b/shared-data/pipette/schemas/2/pipetteLiquidPropertiesSchema.json index 45d0ae6f2ea0..f7a76e0cde05 100644 --- a/shared-data/pipette/schemas/2/pipetteLiquidPropertiesSchema.json +++ b/shared-data/pipette/schemas/2/pipetteLiquidPropertiesSchema.json @@ -76,6 +76,9 @@ "defaultBlowOutFlowRate": { "$ref": "#/definitions/flowRate" }, + "defaultFlowAcceleration": { + "$ref": "#/definitions/positiveNumber" + }, "defaultTipLength": { "$ref": "#/definitions/positiveNumber" }, diff --git a/shared-data/python/opentrons_shared_data/pipette/pipette_definition.py b/shared-data/python/opentrons_shared_data/pipette/pipette_definition.py index 77ea1130e851..32f410dc01bc 100644 --- a/shared-data/python/opentrons_shared_data/pipette/pipette_definition.py +++ b/shared-data/python/opentrons_shared_data/pipette/pipette_definition.py @@ -80,6 +80,11 @@ class SupportedTipsDefinition(BaseModel): description="The flowrate used in blowouts by default.", alias="defaultBlowOutFlowRate", ) + default_flow_acceleration: float = Field( + float("inf"), # no default works for all pipettes + description="The acceleration used during aspirate/dispense/blowout in ul/s^2.", + alias="defaultFlowAcceleration", + ) default_tip_length: float = Field( ..., description="The default tip length associated with this tip type.", From 9a499f929e3859d27e23ae40f95a69ae7fe4bfaf Mon Sep 17 00:00:00 2001 From: Andy Sigler Date: Fri, 28 Jul 2023 11:55:51 -0400 Subject: [PATCH 20/25] fix(hardware-testing, hardware): PVT gripper assembly QC small edits (#13188) --- api/src/opentrons/config/gripper_config.py | 3 ++- .../gripper_assembly_qc_ot3/__main__.py | 1 + .../gripper_assembly_qc_ot3/test_probe.py | 12 +++++++-- .../gripper_assembly_qc_ot3/test_width.py | 4 +-- .../scripts/provision_gripper.py | 12 ++++++++- .../gripper/definitions/1/gripperV1.2.json | 27 +++++++++++++++++++ .../gripper/gripper_definition.py | 2 ++ 7 files changed, 55 insertions(+), 6 deletions(-) create mode 100644 shared-data/gripper/definitions/1/gripperV1.2.json diff --git a/api/src/opentrons/config/gripper_config.py b/api/src/opentrons/config/gripper_config.py index 7e2bbc6e904c..0c364bc749ce 100644 --- a/api/src/opentrons/config/gripper_config.py +++ b/api/src/opentrons/config/gripper_config.py @@ -21,9 +21,10 @@ def info_num_to_model(num: str) -> GripperModel: minor_model = num[2] # we provisioned the some EVT grippers as 01 and some as 10 # DVT will now be 1.1 + # PVT will now be 1.2 model_map = { "0": {"0": GripperModel.v1, "1": GripperModel.v1}, - "1": {"0": GripperModel.v1, "1": GripperModel.v1_1}, + "1": {"0": GripperModel.v1, "1": GripperModel.v1_1, "2": GripperModel.v1_2}, } return model_map[major_model][minor_model] diff --git a/hardware-testing/hardware_testing/production_qc/gripper_assembly_qc_ot3/__main__.py b/hardware-testing/hardware_testing/production_qc/gripper_assembly_qc_ot3/__main__.py index 7f4a571fea5e..daa9f5bb2369 100644 --- a/hardware-testing/hardware_testing/production_qc/gripper_assembly_qc_ot3/__main__.py +++ b/hardware-testing/hardware_testing/production_qc/gripper_assembly_qc_ot3/__main__.py @@ -44,6 +44,7 @@ async def _main(cfg: TestConfig) -> None: else: report.set_operator("simulation") report.set_version(get_git_description()) + report.set_firmware(api.fw_version) # RUN TESTS for section, test_run in cfg.tests.items(): diff --git a/hardware-testing/hardware_testing/production_qc/gripper_assembly_qc_ot3/test_probe.py b/hardware-testing/hardware_testing/production_qc/gripper_assembly_qc_ot3/test_probe.py index cfc356831b5a..ddeb38423c79 100644 --- a/hardware-testing/hardware_testing/production_qc/gripper_assembly_qc_ot3/test_probe.py +++ b/hardware-testing/hardware_testing/production_qc/gripper_assembly_qc_ot3/test_probe.py @@ -31,6 +31,9 @@ PROBE_POS_OFFSET = Point(13, 13, 0) JAW_ALIGNMENT_MM_X = 0.5 JAW_ALIGNMENT_MM_Z = 0.5 +PROBE_PF_MAX = 6.0 +DECK_PF_MIN = 9.0 +DECK_PF_MAX = 15.0 def _get_test_tag(probe: GripperProbe) -> str: @@ -44,7 +47,9 @@ def build_csv_lines() -> List[Union[CSVLine, CSVLineRepeating]]: tag = _get_test_tag(p) lines.append(CSVLine(f"{tag}-open-air-pf", [float])) lines.append(CSVLine(f"{tag}-probe-pf", [float])) + lines.append(CSVLine(f"{tag}-probe-pf-max-allowed", [float])) lines.append(CSVLine(f"{tag}-deck-pf", [float])) + lines.append(CSVLine(f"{tag}-deck-pf-min-max-allowed", [float, float])) lines.append(CSVLine(f"{tag}-result", [CSVResult])) for p in GripperProbe: lines.append(CSVLine(f"jaw-probe-{p.name.lower()}-xyz", [float, float, float])) @@ -155,12 +160,15 @@ async def run(api: OT3API, report: CSVReport, section: str) -> None: deck_pf = await _read_from_sensor(api, s_driver, cap_sensor, 10) print(f"Reading on deck: {deck_pf}") - result = deck_pf > probe_pf - + result = ( + open_air_pf < probe_pf < PROBE_PF_MAX < DECK_PF_MIN < deck_pf < DECK_PF_MAX + ) _tag = _get_test_tag(probe) report(section, f"{_tag}-open-air-pf", [open_air_pf]) report(section, f"{_tag}-probe-pf", [probe_pf]) + report(section, f"{_tag}-probe-pf-max-allowed", [PROBE_PF_MAX]) report(section, f"{_tag}-deck-pf", [deck_pf]) + report(section, f"{_tag}-deck-pf-min-max-allowed", [DECK_PF_MIN, DECK_PF_MAX]) report(section, f"{_tag}-result", [CSVResult.from_bool(result)]) await api.home_z() await api.ungrip() diff --git a/hardware-testing/hardware_testing/production_qc/gripper_assembly_qc_ot3/test_width.py b/hardware-testing/hardware_testing/production_qc/gripper_assembly_qc_ot3/test_width.py index dfc3a9bb5fd7..e74b6a5c8373 100644 --- a/hardware-testing/hardware_testing/production_qc/gripper_assembly_qc_ot3/test_width.py +++ b/hardware-testing/hardware_testing/production_qc/gripper_assembly_qc_ot3/test_width.py @@ -15,7 +15,7 @@ from hardware_testing.opentrons_api import helpers_ot3 from hardware_testing.opentrons_api.types import Axis, OT3Mount, Point -FAILURE_THRESHOLD_MM = 3 +FAILURE_THRESHOLD_MM = -3 GAUGE_HEIGHT_MM = 40 GRIP_HEIGHT_MM = 30 TEST_WIDTHS_MM: List[float] = [60, 85.75, 62] @@ -73,7 +73,7 @@ async def _save_result(_width: float, _force: float, _cache_error: bool) -> floa _error_when_gripping_itself = _width_error _width_error_adjusted = _width_error - _error_when_gripping_itself # should always fail in the negative direction - result = CSVResult.from_bool(0 <= _width_error_adjusted <= FAILURE_THRESHOLD_MM) + result = CSVResult.from_bool(0 >= _width_error_adjusted >= FAILURE_THRESHOLD_MM) tag = _get_test_tag(_width, _force) print(f"{tag}-width-error: {_width_error}") print(f"{tag}-width-error-adjusted: {_width_error_adjusted}") diff --git a/hardware/opentrons_hardware/scripts/provision_gripper.py b/hardware/opentrons_hardware/scripts/provision_gripper.py index f9dc8a42a3cd..2c37612468c6 100644 --- a/hardware/opentrons_hardware/scripts/provision_gripper.py +++ b/hardware/opentrons_hardware/scripts/provision_gripper.py @@ -48,11 +48,21 @@ async def flash_serials( return +def _read_input_and_confirm(prompt: str) -> str: + inp = input(prompt).strip() + if "y" in input(f"read serial '{inp}', write to gripper? (y/n): "): + return inp + else: + return _read_input_and_confirm(prompt) + + async def get_serial(prompt: str, base_log: logging.Logger) -> Tuple[int, bytes]: """Get a serial number that is correct and parseable.""" loop = asyncio.get_running_loop() while True: - serial = await loop.run_in_executor(None, lambda: input(prompt)) + serial = await loop.run_in_executor( + None, lambda: _read_input_and_confirm(prompt) + ) try: model, data = serials.gripper_info_from_serial_string(serial) except Exception as e: diff --git a/shared-data/gripper/definitions/1/gripperV1.2.json b/shared-data/gripper/definitions/1/gripperV1.2.json new file mode 100644 index 000000000000..7c30bcc49109 --- /dev/null +++ b/shared-data/gripper/definitions/1/gripperV1.2.json @@ -0,0 +1,27 @@ +{ + "$otSharedSchema": "gripper/schemas/1", + "model": "gripperV1.2", + "schemaVersion": 1, + "displayName": "Flex Gripper", + "gripForceProfile": { + "polynomial": [ + [0, 4.1194669], + [1, 1.4181001], + [2, 0.0135956] + ], + "defaultGripForce": 15.0, + "defaultHomeForce": 12.0, + "min": 2.0, + "max": 30.0 + }, + "geometry": { + "baseOffsetFromMount": [19.5, -74.325, -94.825], + "jawCenterOffsetFromBase": [0.0, 0.0, -86.475], + "pinOneOffsetFromBase": [6.0, -54.0, -98.475], + "pinTwoOffsetFromBase": [6.0, 54.0, -98.475], + "jawWidth": { + "min": 60.0, + "max": 92.0 + } + } +} diff --git a/shared-data/python/opentrons_shared_data/gripper/gripper_definition.py b/shared-data/python/opentrons_shared_data/gripper/gripper_definition.py index 34baea28ccda..91f892fafc81 100644 --- a/shared-data/python/opentrons_shared_data/gripper/gripper_definition.py +++ b/shared-data/python/opentrons_shared_data/gripper/gripper_definition.py @@ -23,12 +23,14 @@ class GripperModel(str, Enum): v1 = "gripperV1" v1_1 = "gripperV1.1" + v1_2 = "gripperV1.2" def __str__(self) -> str: """Model name.""" enum_to_str = { self.__class__.v1: "gripperV1", self.__class__.v1_1: "gripperV1.1", + self.__class__.v1_2: "gripperV1.2", } return enum_to_str[self] From 589786295fc311fd6036593f0f9a2d1f072388c7 Mon Sep 17 00:00:00 2001 From: Derek Maggio Date: Fri, 28 Jul 2023 13:41:28 -0700 Subject: [PATCH 21/25] test: Flex protocol analysis e2e (#13091) * chore: Set shell to bash * chore: Print error text if unexpected analysis error * docs: Clarify what package.json to use * test: Add custom labware * test: Tests for days son * Formatting * Remove debugging print statement * Consolidate and fix protocols * Format * Upload fixed protocol --- app-testing/README.md | 10 +- app-testing/automation/data/protocol_files.py | 10 + app-testing/automation/data/protocols.py | 96 ++ .../ci-tools/linux_get_chromedriver.sh | 2 + app-testing/example.env | 48 +- .../opentrons_ot3_96_tiprack_1000ul_rss.json | 1017 ++++++++++++++++ .../opentrons_ot3_96_tiprack_200ul_rss.json | 1017 ++++++++++++++++ .../opentrons_ot3_96_tiprack_50ul_rss.json | 1017 ++++++++++++++++ ...TC_TM_2_15_ABR3_Illumina_DNA_Enrichment.py | 795 ++++++++++++ ...TM_2_15_ABR3_Illumina_DNA_Enrichment_v4.py | 1073 +++++++++++++++++ ...M_MM_TC_2_15_ABR4_Illumina_DNA_Prep_24x.py | 692 +++++++++++ ...ne_2_15_ABR_Simple_Normalize_Long_Right.py | 257 ++++ ...ABR5_6_HDQ_Bacteria_ParkTips_96_channel.py | 497 ++++++++ ...000_96_HS_TM_MM_2_15_MagMaxRNACells96Ch.py | 534 ++++++++ ...5_6_Illumina_DNA_Prep_96x_Head_PART_III.py | 357 ++++++ ...5_6_IDT_xGen_EZ_96x_Head_PART_I_III_ABR.py | 888 ++++++++++++++ ...0_96_HS_TM_2_15_Quick_Zymo_RNA_Bacteria.py | 524 ++++++++ ...ght_None_2_15_ABRKAPALibraryQuantLongv2.py | 1001 +++++++++++++++ app-testing/tests/protocol_analyze_test.py | 7 +- 19 files changed, 9823 insertions(+), 19 deletions(-) create mode 100644 app-testing/files/labware/opentrons_ot3_96_tiprack_1000ul_rss.json create mode 100644 app-testing/files/labware/opentrons_ot3_96_tiprack_200ul_rss.json create mode 100644 app-testing/files/labware/opentrons_ot3_96_tiprack_50ul_rss.json create mode 100644 app-testing/files/protocols/py/OT3_P1000MLeft_P50MRight_HS_MM_TC_TM_2_15_ABR3_Illumina_DNA_Enrichment.py create mode 100644 app-testing/files/protocols/py/OT3_P1000MLeft_P50MRight_HS_MM_TC_TM_2_15_ABR3_Illumina_DNA_Enrichment_v4.py create mode 100644 app-testing/files/protocols/py/OT3_P1000MLeft_P50MRight_HS_TM_MM_TC_2_15_ABR4_Illumina_DNA_Prep_24x.py create mode 100644 app-testing/files/protocols/py/OT3_P1000SRight_None_2_15_ABR_Simple_Normalize_Long_Right.py create mode 100644 app-testing/files/protocols/py/OT3_P1000_96_HS_TM_MM_2_15_ABR5_6_HDQ_Bacteria_ParkTips_96_channel.py create mode 100644 app-testing/files/protocols/py/OT3_P1000_96_HS_TM_MM_2_15_MagMaxRNACells96Ch.py create mode 100644 app-testing/files/protocols/py/OT3_P1000_96_HS_TM_TC_MM_2_15_ABR5_6_Illumina_DNA_Prep_96x_Head_PART_III.py create mode 100644 app-testing/files/protocols/py/OT3_P1000_96_None_2_15_ABR5_6_IDT_xGen_EZ_96x_Head_PART_I_III_ABR.py create mode 100644 app-testing/files/protocols/py/OT3_P100_96_HS_TM_2_15_Quick_Zymo_RNA_Bacteria.py create mode 100644 app-testing/files/protocols/py/OT3_P50MLeft_P1000MRight_None_2_15_ABRKAPALibraryQuantLongv2.py diff --git a/app-testing/README.md b/app-testing/README.md index 408d712ecada..19e8f937ee31 100644 --- a/app-testing/README.md +++ b/app-testing/README.md @@ -18,15 +18,7 @@ Slices of the tests will be selected as candidates for automation and then perfo 1. for Mac 1. `make -C app-shell dist-osx` 3. Install Chromedriver - 1. in the app-testing directory - 1. `sudo ./ci-tools/mac_get_chromedriver.sh 21.3.1` per the version of electron in the root package.json for electron - 1. Windows `sudo .\ci-tools\windows_get_chromedriver.ps1 -version 21.3.1` - 1. if you experience `wget: command not found` - 1. brew install wget and try again - 2. when you run `chromedriver --version` - 1. It should work - 2. It should output the below. The chromedriver version must match Electron version we build into the App. - 1. ChromeDriver 106.0.5249.181 (7e86549ea18ccbc17d7b600e3cd4190f45db35c7-refs/heads/main@{#1045491}) + = 1. in the app-testing directory 1. `sudo ./ci-tools/mac_get_chromedriver.sh 21.3.1` per the version of electron in the repository root package.json (`/opentrons/package.json`) for electron 1. Windows `sudo .\ci-tools\windows_get_chromedriver.ps1 -version 21.3.1` 1. if you experience `wget: command not found` 1. brew install wget and try again 2. when you run `chromedriver --version` 1. It should work 2. It should output the below. The chromedriver version must match Electron version we build into the App. 1. ChromeDriver 106.0.5249.181 (7e86549ea18ccbc17d7b600e3cd4190f45db35c7-refs/heads/main@{#1045491}) 4. Create .env from example.env `cp example.env .env` 1. Fill in values (if there are secrets) 2. Make sure the paths work on your machine diff --git a/app-testing/automation/data/protocol_files.py b/app-testing/automation/data/protocol_files.py index 452dc796e4cb..36c04e025874 100644 --- a/app-testing/automation/data/protocol_files.py +++ b/app-testing/automation/data/protocol_files.py @@ -25,4 +25,14 @@ "OT2_P300SLeft_MM1_MM_TM_2_3_Mix", "OT2_P300S_Thermocycler_Moam_Error", "OT2_P300S_Twinning_Error", + "OT3_P1000_96_HS_TM_MM_2_15_MagMaxRNACells96Ch", + "OT3_P1000SRight_None_2_15_ABR_Simple_Normalize_Long_Right", + "OT3_P50MLeft_P1000MRight_None_2_15_ABRKAPALibraryQuantLongv2", + "OT3_P1000MLeft_P50MRight_HS_MM_TC_TM_2_15_ABR3_Illumina_DNA_Enrichment_v4", + "OT3_P1000MLeft_P50MRight_HS_MM_TC_TM_2_15_ABR3_Illumina_DNA_Enrichment", + "OT3_P1000MLeft_P50MRight_HS_TM_MM_TC_2_15_ABR4_Illumina_DNA_Prep_24x", + "OT3_P1000_96_HS_TM_MM_2_15_ABR5_6_HDQ_Bacteria_ParkTips_96_channel", + "OT3_P1000_96_None_2_15_ABR5_6_IDT_xGen_EZ_96x_Head_PART_I_III_ABR", + "OT3_P1000_96_HS_TM_TC_MM_2_15_ABR5_6_Illumina_DNA_Prep_96x_Head_PART_III", + "OT3_P100_96_HS_TM_2_15_Quick_Zymo_RNA_Bacteria", ] diff --git a/app-testing/automation/data/protocols.py b/app-testing/automation/data/protocols.py index d8c45bcc784a..c3a42c9f8ddf 100644 --- a/app-testing/automation/data/protocols.py +++ b/app-testing/automation/data/protocols.py @@ -237,3 +237,99 @@ class Protocols: robot_error=True, robot_analysis_error="?", ) + + OT3_P1000_96_HS_TM_MM_2_15_MagMaxRNACells96Ch: Protocol = Protocol( + file_name="OT3_P1000_96_HS_TM_MM_2_15_MagMaxRNACells96Ch", + file_extension="py", + protocol_name="MagMax RNA Extraction: Cells 96 ABR TESTING", + robot="OT-3", + app_error=False, + robot_error=False, + custom_labware=["opentrons_ot3_96_tiprack_200ul_rss"], + ) + + OT3_P1000SRight_None_2_15_ABR_Simple_Normalize_Long_Right: Protocol = Protocol( + file_name="OT3_P1000SRight_None_2_15_ABR_Simple_Normalize_Long_Right", + file_extension="py", + protocol_name="OT3 ABR Simple Normalize Long", + robot="OT-3", + app_error=False, + robot_error=False, + custom_labware=["opentrons_ot3_96_tiprack_200ul_rss"], + ) + + OT3_P50MLeft_P1000MRight_None_2_15_ABRKAPALibraryQuantLongv2: Protocol = Protocol( + file_name="OT3_P50MLeft_P1000MRight_None_2_15_ABRKAPALibraryQuantLongv2", + file_extension="py", + protocol_name="OT3 ABR KAPA Library Quant v2", + robot="OT-3", + app_error=False, + robot_error=False, + ) + + OT3_P1000MLeft_P50MRight_HS_MM_TC_TM_2_15_ABR3_Illumina_DNA_Enrichment_v4: Protocol = Protocol( + file_name="OT3_P1000MLeft_P50MRight_HS_MM_TC_TM_2_15_ABR3_Illumina_DNA_Enrichment_v4", + file_extension="py", + protocol_name="Illumina DNA Enrichment v4", + robot="OT-3", + app_error=False, + robot_error=False, + ) + + OT3_P1000MLeft_P50MRight_HS_MM_TC_TM_2_15_ABR3_Illumina_DNA_Enrichment: Protocol = Protocol( + file_name="OT3_P1000MLeft_P50MRight_HS_MM_TC_TM_2_15_ABR3_Illumina_DNA_Enrichment", + file_extension="py", + protocol_name="Illumina DNA Enrichment", + robot="OT-3", + app_error=False, + robot_error=False, + ) + + OT3_P1000MLeft_P50MRight_HS_TM_MM_TC_2_15_ABR4_Illumina_DNA_Prep_24x: Protocol = Protocol( + file_name="OT3_P1000MLeft_P50MRight_HS_TM_MM_TC_2_15_ABR4_Illumina_DNA_Prep_24x", + file_extension="py", + protocol_name="Illumina DNA Prep 24x", + robot="OT-3", + app_error=False, + robot_error=False, + ) + + OT3_P1000_96_HS_TM_MM_2_15_ABR5_6_HDQ_Bacteria_ParkTips_96_channel: Protocol = Protocol( + file_name="OT3_P1000_96_HS_TM_MM_2_15_ABR5_6_HDQ_Bacteria_ParkTips_96_channel", + file_extension="py", + protocol_name="Omega HDQ DNA Extraction: Bacteria 96 FOR ABR TESTING", + robot="OT-3", + app_error=False, + robot_error=False, + custom_labware=["opentrons_ot3_96_tiprack_1000ul_rss"], + ) + + OT3_P1000_96_None_2_15_ABR5_6_IDT_xGen_EZ_96x_Head_PART_I_III_ABR: Protocol = Protocol( + file_name="OT3_P1000_96_None_2_15_ABR5_6_IDT_xGen_EZ_96x_Head_PART_I_III_ABR", + file_extension="py", + protocol_name="IDT xGen EZ 96x Head PART I-III ABR", + robot="OT-3", + app_error=False, + robot_error=False, + custom_labware=["opentrons_ot3_96_tiprack_50ul_rss", "opentrons_ot3_96_tiprack_200ul_rss"], + ) + + OT3_P1000_96_HS_TM_TC_MM_2_15_ABR5_6_Illumina_DNA_Prep_96x_Head_PART_III: Protocol = Protocol( + file_name="OT3_P1000_96_HS_TM_TC_MM_2_15_ABR5_6_Illumina_DNA_Prep_96x_Head_PART_III", + file_extension="py", + protocol_name="Illumina DNA Prep 96x Head PART III", + robot="OT-3", + app_error=False, + robot_error=False, + custom_labware=["opentrons_ot3_96_tiprack_200ul_rss", "opentrons_ot3_96_tiprack_50ul_rss"], + ) + + OT3_P100_96_HS_TM_2_15_Quick_Zymo_RNA_Bacteria: Protocol = Protocol( + file_name="OT3_P100_96_HS_TM_2_15_Quick_Zymo_RNA_Bacteria", + file_extension="py", + protocol_name="Quick Zymo Magbead RNA Extraction with Lysis: Bacteria 96 Channel Deletion Test", + robot="OT-3", + app_error=False, + robot_error=False, + custom_labware=["opentrons_ot3_96_tiprack_1000ul_rss"], + ) diff --git a/app-testing/ci-tools/linux_get_chromedriver.sh b/app-testing/ci-tools/linux_get_chromedriver.sh index bfc6b1f01c76..a064ba8dd420 100755 --- a/app-testing/ci-tools/linux_get_chromedriver.sh +++ b/app-testing/ci-tools/linux_get_chromedriver.sh @@ -1,3 +1,5 @@ +#! /bin/bash + set -eo pipefail VERSION=$1 diff --git a/app-testing/example.env b/app-testing/example.env index c0fa9f52e96f..d5a97f48962f 100644 --- a/app-testing/example.env +++ b/app-testing/example.env @@ -14,13 +14,43 @@ LOCALHOST=false # run all tests # possible values in \automation\data\protocol_files.py # dynamically generate with make print-protocols -#APP_ANALYSIS_TEST_PROTOCOLS="OT2_P1000SLeft_None_6_1_SimpleTransfer, OT2_P20SRight_None_6_1_SimpleTransferError, OT2_P20S_P300M_HS_6_1_HS_WithCollision_Error, -OT2_P20S_P300M_NoMods_6_1_TransferReTransferLiquid, OT2_P300M_P20S_HS_6_1_Smoke620release, OT2_P300M_P20S_MM_HS_TD_TC_6_1_AllMods_Error, OT2_P300M_P20S_MM_TM_TC1_5_2_6_PD40, -OT2_P300M_P20S_MM_TM_TC1_5_2_6_PD40Error, OT2_P300M_P20S_NoMod_6_1_MixTransferManyLiquids, OT2_P300M_P300S_HS_6_1_HS_NormalUseWithTransfer, -OT2_P300SG1_None_5_2_6_Gen1PipetteSimple, OT2_P300SLeft_MM_TM_TM_5_2_6_MOAMTemps, OT2_None_None_2_12_Python310SyntaxRobotAnalysisOnlyError, OT2_None_None_2_13_PythonSyntaxError, -OT2_P10S_P300M_TC1_TM_MM_2_11_Swift, OT2_P20S_None_2_7_Walkthrough, OT2_P300MLeft_MM_TM_2_4_Zymo, OT2_P300M_P20S_None_2_12_FailOnRun, -OT2_P300M_P20S_TC_MM_TM_6_13_Smoke620Release, OT2_P300SLeft_MM1_MM_2_2_EngageMagHeightFromBase, OT2_P300SLeft_MM1_MM_TM_2_3_Mix, OT2_P300S_Thermocycler_Moam_Error, -OT2_P300S_Twinning_Error" +# OT-2 Protocols +# APP_ANALYSIS_TEST_PROTOCOLS="OT2_P1000SLeft_None_6_1_SimpleTransfer, +# OT2_P20SRight_None_6_1_SimpleTransferError, +# OT2_P20S_P300M_HS_6_1_HS_WithCollision_Error, +# OT2_P20S_P300M_NoMods_6_1_TransferReTransferLiquid, +# OT2_P300M_P20S_HS_6_1_Smoke620release, +# OT2_P300M_P20S_MM_HS_TD_TC_6_1_AllMods_Error, +# OT2_P300M_P20S_MM_TM_TC1_5_2_6_PD40, +# OT2_P300M_P20S_MM_TM_TC1_5_2_6_PD40Error, +# OT2_P300M_P20S_NoMod_6_1_MixTransferManyLiquids, +# OT2_P300M_P300S_HS_6_1_HS_NormalUseWithTransfer, +# OT2_P300SG1_None_5_2_6_Gen1PipetteSimple, +# OT2_P300SLeft_MM_TM_TM_5_2_6_MOAMTemps, +# OT2_None_None_2_12_Python310SyntaxRobotAnalysisOnlyError, +# OT2_None_None_2_13_PythonSyntaxError, +# OT2_P10S_P300M_TC1_TM_MM_2_11_Swift, +# OT2_P20S_None_2_7_Walkthrough, +# OT2_P300MLeft_MM_TM_2_4_Zymo, +# OT2_P300M_P20S_None_2_12_FailOnRun, +# OT2_P300M_P20S_TC_MM_TM_6_13_Smoke620Release, +# OT2_P300SLeft_MM1_MM_2_2_EngageMagHeightFromBase, +# OT2_P300SLeft_MM1_MM_TM_2_3_Mix, +# OT2_P300S_Thermocycler_Moam_Error, +# OT2_P300S_Twinning_Error" + +# Flex Protocols +APP_ANALYSIS_TEST_PROTOCOLS="OT3_P1000_96_HS_TM_MM_2_15_MagMaxRNACells96Ch, +OT3_P1000SRight_None_2_15_ABR_Simple_Normalize_Long_Right, +OT3_P50MLeft_P1000MRight_None_2_15_ABRKAPALibraryQuantLongv2, +OT3_P1000MLeft_P50MRight_HS_MM_TC_TM_2_15_ABR3_Illumina_DNA_Enrichment_v4, +OT3_P1000MLeft_P50MRight_HS_MM_TC_TM_2_15_ABR3_Illumina_DNA_Enrichment, +OT3_P1000MLeft_P50MRight_HS_TM_MM_TC_2_15_ABR4_Illumina_DNA_Prep_24x, +OT3_P1000_96_HS_TM_MM_2_15_ABR5_6_HDQ_Bacteria_ParkTips_96_channel, +OT3_P1000_96_None_2_15_ABR5_6_IDT_xGen_EZ_96x_Head_PART_I_III_ABR, +OT3_P1000_96_HS_TM_TC_MM_2_15_ABR5_6_Illumina_DNA_Prep_96x_Head_PART_III, +OT3_P100_96_HS_TM_2_15_Quick_Zymo_RNA_Bacteria" + # run one -APP_ANALYSIS_TEST_PROTOCOLS="OT2_P1000SLeft_None_6_1_SimpleTransfer" -FILES_FOLDER="files" +# APP_ANALYSIS_TEST_PROTOCOLS="OT2_P1000SLeft_None_6_1_SimpleTransfer" +FILES_FOLDER="files" \ No newline at end of file diff --git a/app-testing/files/labware/opentrons_ot3_96_tiprack_1000ul_rss.json b/app-testing/files/labware/opentrons_ot3_96_tiprack_1000ul_rss.json new file mode 100644 index 000000000000..234d87e8db7b --- /dev/null +++ b/app-testing/files/labware/opentrons_ot3_96_tiprack_1000ul_rss.json @@ -0,0 +1,1017 @@ +{ + "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": "Opentrons", + "brandId": ["RSS_made"] + }, + "metadata": { + "displayName": "Opentrons Flex 96 Tip Rack 1000 µL with adapter", + "displayCategory": "tipRack", + "displayVolumeUnits": "µL", + "tags": [] + }, + "dimensions": { + "xDimension": 156.5, + "yDimension": 93, + "zDimension": 132 + }, + "wells": { + "A1": { + "depth": 97.5, + "totalLiquidVolume": 1000, + "shape": "circular", + "diameter": 5.47, + "x": 28.75, + "y": 78, + "z": 12.5 + }, + "B1": { + "depth": 97.5, + "totalLiquidVolume": 1000, + "shape": "circular", + "diameter": 5.47, + "x": 28.75, + "y": 69, + "z": 12.5 + }, + "C1": { + "depth": 97.5, + "totalLiquidVolume": 1000, + "shape": "circular", + "diameter": 5.47, + "x": 28.75, + "y": 60, + "z": 12.5 + }, + "D1": { + "depth": 97.5, + "totalLiquidVolume": 1000, + "shape": "circular", + "diameter": 5.47, + "x": 28.75, + "y": 51, + "z": 12.5 + }, + "E1": { + "depth": 97.5, + "totalLiquidVolume": 1000, + "shape": "circular", + "diameter": 5.47, + "x": 28.75, + "y": 42, + "z": 12.5 + }, + "F1": { + "depth": 97.5, + "totalLiquidVolume": 1000, + "shape": "circular", + "diameter": 5.47, + "x": 28.75, + "y": 33, + "z": 12.5 + }, + "G1": { + "depth": 97.5, + "totalLiquidVolume": 1000, + "shape": "circular", + "diameter": 5.47, + "x": 28.75, + "y": 24, + "z": 12.5 + }, + "H1": { + "depth": 97.5, + "totalLiquidVolume": 1000, + "shape": "circular", + "diameter": 5.47, + "x": 28.75, + "y": 15, + "z": 12.5 + }, + "A2": { + "depth": 97.5, + "totalLiquidVolume": 1000, + "shape": "circular", + "diameter": 5.47, + "x": 37.75, + "y": 78, + "z": 12.5 + }, + "B2": { + "depth": 97.5, + "totalLiquidVolume": 1000, + "shape": "circular", + "diameter": 5.47, + "x": 37.75, + "y": 69, + "z": 12.5 + }, + "C2": { + "depth": 97.5, + "totalLiquidVolume": 1000, + "shape": "circular", + "diameter": 5.47, + "x": 37.75, + "y": 60, + "z": 12.5 + }, + "D2": { + "depth": 97.5, + "totalLiquidVolume": 1000, + "shape": "circular", + "diameter": 5.47, + "x": 37.75, + "y": 51, + "z": 12.5 + }, + "E2": { + "depth": 97.5, + "totalLiquidVolume": 1000, + "shape": "circular", + "diameter": 5.47, + "x": 37.75, + "y": 42, + "z": 12.5 + }, + "F2": { + "depth": 97.5, + "totalLiquidVolume": 1000, + "shape": "circular", + "diameter": 5.47, + "x": 37.75, + "y": 33, + "z": 12.5 + }, + "G2": { + "depth": 97.5, + "totalLiquidVolume": 1000, + "shape": "circular", + "diameter": 5.47, + "x": 37.75, + "y": 24, + "z": 12.5 + }, + "H2": { + "depth": 97.5, + "totalLiquidVolume": 1000, + "shape": "circular", + "diameter": 5.47, + "x": 37.75, + "y": 15, + "z": 12.5 + }, + "A3": { + "depth": 97.5, + "totalLiquidVolume": 1000, + "shape": "circular", + "diameter": 5.47, + "x": 46.75, + "y": 78, + "z": 12.5 + }, + "B3": { + "depth": 97.5, + "totalLiquidVolume": 1000, + "shape": "circular", + "diameter": 5.47, + "x": 46.75, + "y": 69, + "z": 12.5 + }, + "C3": { + "depth": 97.5, + "totalLiquidVolume": 1000, + "shape": "circular", + "diameter": 5.47, + "x": 46.75, + "y": 60, + "z": 12.5 + }, + "D3": { + "depth": 97.5, + "totalLiquidVolume": 1000, + "shape": "circular", + "diameter": 5.47, + "x": 46.75, + "y": 51, + "z": 12.5 + }, + "E3": { + "depth": 97.5, + "totalLiquidVolume": 1000, + "shape": "circular", + "diameter": 5.47, + "x": 46.75, + "y": 42, + "z": 12.5 + }, + "F3": { + "depth": 97.5, + "totalLiquidVolume": 1000, + "shape": "circular", + "diameter": 5.47, + "x": 46.75, + "y": 33, + "z": 12.5 + }, + "G3": { + "depth": 97.5, + "totalLiquidVolume": 1000, + "shape": "circular", + "diameter": 5.47, + "x": 46.75, + "y": 24, + "z": 12.5 + }, + "H3": { + "depth": 97.5, + "totalLiquidVolume": 1000, + "shape": "circular", + "diameter": 5.47, + "x": 46.75, + "y": 15, + "z": 12.5 + }, + "A4": { + "depth": 97.5, + "totalLiquidVolume": 1000, + "shape": "circular", + "diameter": 5.47, + "x": 55.75, + "y": 78, + "z": 12.5 + }, + "B4": { + "depth": 97.5, + "totalLiquidVolume": 1000, + "shape": "circular", + "diameter": 5.47, + "x": 55.75, + "y": 69, + "z": 12.5 + }, + "C4": { + "depth": 97.5, + "totalLiquidVolume": 1000, + "shape": "circular", + "diameter": 5.47, + "x": 55.75, + "y": 60, + "z": 12.5 + }, + "D4": { + "depth": 97.5, + "totalLiquidVolume": 1000, + "shape": "circular", + "diameter": 5.47, + "x": 55.75, + "y": 51, + "z": 12.5 + }, + "E4": { + "depth": 97.5, + "totalLiquidVolume": 1000, + "shape": "circular", + "diameter": 5.47, + "x": 55.75, + "y": 42, + "z": 12.5 + }, + "F4": { + "depth": 97.5, + "totalLiquidVolume": 1000, + "shape": "circular", + "diameter": 5.47, + "x": 55.75, + "y": 33, + "z": 12.5 + }, + "G4": { + "depth": 97.5, + "totalLiquidVolume": 1000, + "shape": "circular", + "diameter": 5.47, + "x": 55.75, + "y": 24, + "z": 12.5 + }, + "H4": { + "depth": 97.5, + "totalLiquidVolume": 1000, + "shape": "circular", + "diameter": 5.47, + "x": 55.75, + "y": 15, + "z": 12.5 + }, + "A5": { + "depth": 97.5, + "totalLiquidVolume": 1000, + "shape": "circular", + "diameter": 5.47, + "x": 64.75, + "y": 78, + "z": 12.5 + }, + "B5": { + "depth": 97.5, + "totalLiquidVolume": 1000, + "shape": "circular", + "diameter": 5.47, + "x": 64.75, + "y": 69, + "z": 12.5 + }, + "C5": { + "depth": 97.5, + "totalLiquidVolume": 1000, + "shape": "circular", + "diameter": 5.47, + "x": 64.75, + "y": 60, + "z": 12.5 + }, + "D5": { + "depth": 97.5, + "totalLiquidVolume": 1000, + "shape": "circular", + "diameter": 5.47, + "x": 64.75, + "y": 51, + "z": 12.5 + }, + "E5": { + "depth": 97.5, + "totalLiquidVolume": 1000, + "shape": "circular", + "diameter": 5.47, + "x": 64.75, + "y": 42, + "z": 12.5 + }, + "F5": { + "depth": 97.5, + "totalLiquidVolume": 1000, + "shape": "circular", + "diameter": 5.47, + "x": 64.75, + "y": 33, + "z": 12.5 + }, + "G5": { + "depth": 97.5, + "totalLiquidVolume": 1000, + "shape": "circular", + "diameter": 5.47, + "x": 64.75, + "y": 24, + "z": 12.5 + }, + "H5": { + "depth": 97.5, + "totalLiquidVolume": 1000, + "shape": "circular", + "diameter": 5.47, + "x": 64.75, + "y": 15, + "z": 12.5 + }, + "A6": { + "depth": 97.5, + "totalLiquidVolume": 1000, + "shape": "circular", + "diameter": 5.47, + "x": 73.75, + "y": 78, + "z": 12.5 + }, + "B6": { + "depth": 97.5, + "totalLiquidVolume": 1000, + "shape": "circular", + "diameter": 5.47, + "x": 73.75, + "y": 69, + "z": 12.5 + }, + "C6": { + "depth": 97.5, + "totalLiquidVolume": 1000, + "shape": "circular", + "diameter": 5.47, + "x": 73.75, + "y": 60, + "z": 12.5 + }, + "D6": { + "depth": 97.5, + "totalLiquidVolume": 1000, + "shape": "circular", + "diameter": 5.47, + "x": 73.75, + "y": 51, + "z": 12.5 + }, + "E6": { + "depth": 97.5, + "totalLiquidVolume": 1000, + "shape": "circular", + "diameter": 5.47, + "x": 73.75, + "y": 42, + "z": 12.5 + }, + "F6": { + "depth": 97.5, + "totalLiquidVolume": 1000, + "shape": "circular", + "diameter": 5.47, + "x": 73.75, + "y": 33, + "z": 12.5 + }, + "G6": { + "depth": 97.5, + "totalLiquidVolume": 1000, + "shape": "circular", + "diameter": 5.47, + "x": 73.75, + "y": 24, + "z": 12.5 + }, + "H6": { + "depth": 97.5, + "totalLiquidVolume": 1000, + "shape": "circular", + "diameter": 5.47, + "x": 73.75, + "y": 15, + "z": 12.5 + }, + "A7": { + "depth": 97.5, + "totalLiquidVolume": 1000, + "shape": "circular", + "diameter": 5.47, + "x": 82.75, + "y": 78, + "z": 12.5 + }, + "B7": { + "depth": 97.5, + "totalLiquidVolume": 1000, + "shape": "circular", + "diameter": 5.47, + "x": 82.75, + "y": 69, + "z": 12.5 + }, + "C7": { + "depth": 97.5, + "totalLiquidVolume": 1000, + "shape": "circular", + "diameter": 5.47, + "x": 82.75, + "y": 60, + "z": 12.5 + }, + "D7": { + "depth": 97.5, + "totalLiquidVolume": 1000, + "shape": "circular", + "diameter": 5.47, + "x": 82.75, + "y": 51, + "z": 12.5 + }, + "E7": { + "depth": 97.5, + "totalLiquidVolume": 1000, + "shape": "circular", + "diameter": 5.47, + "x": 82.75, + "y": 42, + "z": 12.5 + }, + "F7": { + "depth": 97.5, + "totalLiquidVolume": 1000, + "shape": "circular", + "diameter": 5.47, + "x": 82.75, + "y": 33, + "z": 12.5 + }, + "G7": { + "depth": 97.5, + "totalLiquidVolume": 1000, + "shape": "circular", + "diameter": 5.47, + "x": 82.75, + "y": 24, + "z": 12.5 + }, + "H7": { + "depth": 97.5, + "totalLiquidVolume": 1000, + "shape": "circular", + "diameter": 5.47, + "x": 82.75, + "y": 15, + "z": 12.5 + }, + "A8": { + "depth": 97.5, + "totalLiquidVolume": 1000, + "shape": "circular", + "diameter": 5.47, + "x": 91.75, + "y": 78, + "z": 12.5 + }, + "B8": { + "depth": 97.5, + "totalLiquidVolume": 1000, + "shape": "circular", + "diameter": 5.47, + "x": 91.75, + "y": 69, + "z": 12.5 + }, + "C8": { + "depth": 97.5, + "totalLiquidVolume": 1000, + "shape": "circular", + "diameter": 5.47, + "x": 91.75, + "y": 60, + "z": 12.5 + }, + "D8": { + "depth": 97.5, + "totalLiquidVolume": 1000, + "shape": "circular", + "diameter": 5.47, + "x": 91.75, + "y": 51, + "z": 12.5 + }, + "E8": { + "depth": 97.5, + "totalLiquidVolume": 1000, + "shape": "circular", + "diameter": 5.47, + "x": 91.75, + "y": 42, + "z": 12.5 + }, + "F8": { + "depth": 97.5, + "totalLiquidVolume": 1000, + "shape": "circular", + "diameter": 5.47, + "x": 91.75, + "y": 33, + "z": 12.5 + }, + "G8": { + "depth": 97.5, + "totalLiquidVolume": 1000, + "shape": "circular", + "diameter": 5.47, + "x": 91.75, + "y": 24, + "z": 12.5 + }, + "H8": { + "depth": 97.5, + "totalLiquidVolume": 1000, + "shape": "circular", + "diameter": 5.47, + "x": 91.75, + "y": 15, + "z": 12.5 + }, + "A9": { + "depth": 97.5, + "totalLiquidVolume": 1000, + "shape": "circular", + "diameter": 5.47, + "x": 100.75, + "y": 78, + "z": 12.5 + }, + "B9": { + "depth": 97.5, + "totalLiquidVolume": 1000, + "shape": "circular", + "diameter": 5.47, + "x": 100.75, + "y": 69, + "z": 12.5 + }, + "C9": { + "depth": 97.5, + "totalLiquidVolume": 1000, + "shape": "circular", + "diameter": 5.47, + "x": 100.75, + "y": 60, + "z": 12.5 + }, + "D9": { + "depth": 97.5, + "totalLiquidVolume": 1000, + "shape": "circular", + "diameter": 5.47, + "x": 100.75, + "y": 51, + "z": 12.5 + }, + "E9": { + "depth": 97.5, + "totalLiquidVolume": 1000, + "shape": "circular", + "diameter": 5.47, + "x": 100.75, + "y": 42, + "z": 12.5 + }, + "F9": { + "depth": 97.5, + "totalLiquidVolume": 1000, + "shape": "circular", + "diameter": 5.47, + "x": 100.75, + "y": 33, + "z": 12.5 + }, + "G9": { + "depth": 97.5, + "totalLiquidVolume": 1000, + "shape": "circular", + "diameter": 5.47, + "x": 100.75, + "y": 24, + "z": 12.5 + }, + "H9": { + "depth": 97.5, + "totalLiquidVolume": 1000, + "shape": "circular", + "diameter": 5.47, + "x": 100.75, + "y": 15, + "z": 12.5 + }, + "A10": { + "depth": 97.5, + "totalLiquidVolume": 1000, + "shape": "circular", + "diameter": 5.47, + "x": 109.75, + "y": 78, + "z": 12.5 + }, + "B10": { + "depth": 97.5, + "totalLiquidVolume": 1000, + "shape": "circular", + "diameter": 5.47, + "x": 109.75, + "y": 69, + "z": 12.5 + }, + "C10": { + "depth": 97.5, + "totalLiquidVolume": 1000, + "shape": "circular", + "diameter": 5.47, + "x": 109.75, + "y": 60, + "z": 12.5 + }, + "D10": { + "depth": 97.5, + "totalLiquidVolume": 1000, + "shape": "circular", + "diameter": 5.47, + "x": 109.75, + "y": 51, + "z": 12.5 + }, + "E10": { + "depth": 97.5, + "totalLiquidVolume": 1000, + "shape": "circular", + "diameter": 5.47, + "x": 109.75, + "y": 42, + "z": 12.5 + }, + "F10": { + "depth": 97.5, + "totalLiquidVolume": 1000, + "shape": "circular", + "diameter": 5.47, + "x": 109.75, + "y": 33, + "z": 12.5 + }, + "G10": { + "depth": 97.5, + "totalLiquidVolume": 1000, + "shape": "circular", + "diameter": 5.47, + "x": 109.75, + "y": 24, + "z": 12.5 + }, + "H10": { + "depth": 97.5, + "totalLiquidVolume": 1000, + "shape": "circular", + "diameter": 5.47, + "x": 109.75, + "y": 15, + "z": 12.5 + }, + "A11": { + "depth": 97.5, + "totalLiquidVolume": 1000, + "shape": "circular", + "diameter": 5.47, + "x": 118.75, + "y": 78, + "z": 12.5 + }, + "B11": { + "depth": 97.5, + "totalLiquidVolume": 1000, + "shape": "circular", + "diameter": 5.47, + "x": 118.75, + "y": 69, + "z": 12.5 + }, + "C11": { + "depth": 97.5, + "totalLiquidVolume": 1000, + "shape": "circular", + "diameter": 5.47, + "x": 118.75, + "y": 60, + "z": 12.5 + }, + "D11": { + "depth": 97.5, + "totalLiquidVolume": 1000, + "shape": "circular", + "diameter": 5.47, + "x": 118.75, + "y": 51, + "z": 12.5 + }, + "E11": { + "depth": 97.5, + "totalLiquidVolume": 1000, + "shape": "circular", + "diameter": 5.47, + "x": 118.75, + "y": 42, + "z": 12.5 + }, + "F11": { + "depth": 97.5, + "totalLiquidVolume": 1000, + "shape": "circular", + "diameter": 5.47, + "x": 118.75, + "y": 33, + "z": 12.5 + }, + "G11": { + "depth": 97.5, + "totalLiquidVolume": 1000, + "shape": "circular", + "diameter": 5.47, + "x": 118.75, + "y": 24, + "z": 12.5 + }, + "H11": { + "depth": 97.5, + "totalLiquidVolume": 1000, + "shape": "circular", + "diameter": 5.47, + "x": 118.75, + "y": 15, + "z": 12.5 + }, + "A12": { + "depth": 97.5, + "totalLiquidVolume": 1000, + "shape": "circular", + "diameter": 5.47, + "x": 127.75, + "y": 78, + "z": 12.5 + }, + "B12": { + "depth": 97.5, + "totalLiquidVolume": 1000, + "shape": "circular", + "diameter": 5.47, + "x": 127.75, + "y": 69, + "z": 12.5 + }, + "C12": { + "depth": 97.5, + "totalLiquidVolume": 1000, + "shape": "circular", + "diameter": 5.47, + "x": 127.75, + "y": 60, + "z": 12.5 + }, + "D12": { + "depth": 97.5, + "totalLiquidVolume": 1000, + "shape": "circular", + "diameter": 5.47, + "x": 127.75, + "y": 51, + "z": 12.5 + }, + "E12": { + "depth": 97.5, + "totalLiquidVolume": 1000, + "shape": "circular", + "diameter": 5.47, + "x": 127.75, + "y": 42, + "z": 12.5 + }, + "F12": { + "depth": 97.5, + "totalLiquidVolume": 1000, + "shape": "circular", + "diameter": 5.47, + "x": 127.75, + "y": 33, + "z": 12.5 + }, + "G12": { + "depth": 97.5, + "totalLiquidVolume": 1000, + "shape": "circular", + "diameter": 5.47, + "x": 127.75, + "y": 24, + "z": 12.5 + }, + "H12": { + "depth": 97.5, + "totalLiquidVolume": 1000, + "shape": "circular", + "diameter": 5.47, + "x": 127.75, + "y": 15, + "z": 12.5 + } + }, + "groups": [ + { + "metadata": {}, + "wells": [ + "A1", + "B1", + "C1", + "D1", + "E1", + "F1", + "G1", + "H1", + "A2", + "B2", + "C2", + "D2", + "E2", + "F2", + "G2", + "H2", + "A3", + "B3", + "C3", + "D3", + "E3", + "F3", + "G3", + "H3", + "A4", + "B4", + "C4", + "D4", + "E4", + "F4", + "G4", + "H4", + "A5", + "B5", + "C5", + "D5", + "E5", + "F5", + "G5", + "H5", + "A6", + "B6", + "C6", + "D6", + "E6", + "F6", + "G6", + "H6", + "A7", + "B7", + "C7", + "D7", + "E7", + "F7", + "G7", + "H7", + "A8", + "B8", + "C8", + "D8", + "E8", + "F8", + "G8", + "H8", + "A9", + "B9", + "C9", + "D9", + "E9", + "F9", + "G9", + "H9", + "A10", + "B10", + "C10", + "D10", + "E10", + "F10", + "G10", + "H10", + "A11", + "B11", + "C11", + "D11", + "E11", + "F11", + "G11", + "H11", + "A12", + "B12", + "C12", + "D12", + "E12", + "F12", + "G12", + "H12" + ] + } + ], + "parameters": { + "format": "96Standard", + "quirks": [], + "isTiprack": true, + "tipLength": 95.6, + "tipOverlap": 10.5, + "isMagneticModuleCompatible": false, + "loadName": "opentrons_ot3_96_tiprack_1000ul_rss" + }, + "namespace": "custom_beta", + "version": 1, + "schemaVersion": 2, + "cornerOffsetFromSlot": { + "x": -14.375, + "y": -3.625, + "z": 0 + } +} diff --git a/app-testing/files/labware/opentrons_ot3_96_tiprack_200ul_rss.json b/app-testing/files/labware/opentrons_ot3_96_tiprack_200ul_rss.json new file mode 100644 index 000000000000..58069728e046 --- /dev/null +++ b/app-testing/files/labware/opentrons_ot3_96_tiprack_200ul_rss.json @@ -0,0 +1,1017 @@ +{ + "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": "Opentrons", + "brandId": ["RSS_made"] + }, + "metadata": { + "displayName": "Opentrons Flex 96 Tip Rack 200 µL with adapter", + "displayCategory": "tipRack", + "displayVolumeUnits": "µL", + "tags": [] + }, + "dimensions": { + "xDimension": 156.5, + "yDimension": 93, + "zDimension": 132 + }, + "wells": { + "A1": { + "depth": 97.5, + "totalLiquidVolume": 200, + "shape": "circular", + "diameter": 5.59, + "x": 28.75, + "y": 78, + "z": 12.5 + }, + "B1": { + "depth": 97.5, + "totalLiquidVolume": 200, + "shape": "circular", + "diameter": 5.59, + "x": 28.75, + "y": 69, + "z": 12.5 + }, + "C1": { + "depth": 97.5, + "totalLiquidVolume": 200, + "shape": "circular", + "diameter": 5.59, + "x": 28.75, + "y": 60, + "z": 12.5 + }, + "D1": { + "depth": 97.5, + "totalLiquidVolume": 200, + "shape": "circular", + "diameter": 5.59, + "x": 28.75, + "y": 51, + "z": 12.5 + }, + "E1": { + "depth": 97.5, + "totalLiquidVolume": 200, + "shape": "circular", + "diameter": 5.59, + "x": 28.75, + "y": 42, + "z": 12.5 + }, + "F1": { + "depth": 97.5, + "totalLiquidVolume": 200, + "shape": "circular", + "diameter": 5.59, + "x": 28.75, + "y": 33, + "z": 12.5 + }, + "G1": { + "depth": 97.5, + "totalLiquidVolume": 200, + "shape": "circular", + "diameter": 5.59, + "x": 28.75, + "y": 24, + "z": 12.5 + }, + "H1": { + "depth": 97.5, + "totalLiquidVolume": 200, + "shape": "circular", + "diameter": 5.59, + "x": 28.75, + "y": 15, + "z": 12.5 + }, + "A2": { + "depth": 97.5, + "totalLiquidVolume": 200, + "shape": "circular", + "diameter": 5.59, + "x": 37.75, + "y": 78, + "z": 12.5 + }, + "B2": { + "depth": 97.5, + "totalLiquidVolume": 200, + "shape": "circular", + "diameter": 5.59, + "x": 37.75, + "y": 69, + "z": 12.5 + }, + "C2": { + "depth": 97.5, + "totalLiquidVolume": 200, + "shape": "circular", + "diameter": 5.59, + "x": 37.75, + "y": 60, + "z": 12.5 + }, + "D2": { + "depth": 97.5, + "totalLiquidVolume": 200, + "shape": "circular", + "diameter": 5.59, + "x": 37.75, + "y": 51, + "z": 12.5 + }, + "E2": { + "depth": 97.5, + "totalLiquidVolume": 200, + "shape": "circular", + "diameter": 5.59, + "x": 37.75, + "y": 42, + "z": 12.5 + }, + "F2": { + "depth": 97.5, + "totalLiquidVolume": 200, + "shape": "circular", + "diameter": 5.59, + "x": 37.75, + "y": 33, + "z": 12.5 + }, + "G2": { + "depth": 97.5, + "totalLiquidVolume": 200, + "shape": "circular", + "diameter": 5.59, + "x": 37.75, + "y": 24, + "z": 12.5 + }, + "H2": { + "depth": 97.5, + "totalLiquidVolume": 200, + "shape": "circular", + "diameter": 5.59, + "x": 37.75, + "y": 15, + "z": 12.5 + }, + "A3": { + "depth": 97.5, + "totalLiquidVolume": 200, + "shape": "circular", + "diameter": 5.59, + "x": 46.75, + "y": 78, + "z": 12.5 + }, + "B3": { + "depth": 97.5, + "totalLiquidVolume": 200, + "shape": "circular", + "diameter": 5.59, + "x": 46.75, + "y": 69, + "z": 12.5 + }, + "C3": { + "depth": 97.5, + "totalLiquidVolume": 200, + "shape": "circular", + "diameter": 5.59, + "x": 46.75, + "y": 60, + "z": 12.5 + }, + "D3": { + "depth": 97.5, + "totalLiquidVolume": 200, + "shape": "circular", + "diameter": 5.59, + "x": 46.75, + "y": 51, + "z": 12.5 + }, + "E3": { + "depth": 97.5, + "totalLiquidVolume": 200, + "shape": "circular", + "diameter": 5.59, + "x": 46.75, + "y": 42, + "z": 12.5 + }, + "F3": { + "depth": 97.5, + "totalLiquidVolume": 200, + "shape": "circular", + "diameter": 5.59, + "x": 46.75, + "y": 33, + "z": 12.5 + }, + "G3": { + "depth": 97.5, + "totalLiquidVolume": 200, + "shape": "circular", + "diameter": 5.59, + "x": 46.75, + "y": 24, + "z": 12.5 + }, + "H3": { + "depth": 97.5, + "totalLiquidVolume": 200, + "shape": "circular", + "diameter": 5.59, + "x": 46.75, + "y": 15, + "z": 12.5 + }, + "A4": { + "depth": 97.5, + "totalLiquidVolume": 200, + "shape": "circular", + "diameter": 5.59, + "x": 55.75, + "y": 78, + "z": 12.5 + }, + "B4": { + "depth": 97.5, + "totalLiquidVolume": 200, + "shape": "circular", + "diameter": 5.59, + "x": 55.75, + "y": 69, + "z": 12.5 + }, + "C4": { + "depth": 97.5, + "totalLiquidVolume": 200, + "shape": "circular", + "diameter": 5.59, + "x": 55.75, + "y": 60, + "z": 12.5 + }, + "D4": { + "depth": 97.5, + "totalLiquidVolume": 200, + "shape": "circular", + "diameter": 5.59, + "x": 55.75, + "y": 51, + "z": 12.5 + }, + "E4": { + "depth": 97.5, + "totalLiquidVolume": 200, + "shape": "circular", + "diameter": 5.59, + "x": 55.75, + "y": 42, + "z": 12.5 + }, + "F4": { + "depth": 97.5, + "totalLiquidVolume": 200, + "shape": "circular", + "diameter": 5.59, + "x": 55.75, + "y": 33, + "z": 12.5 + }, + "G4": { + "depth": 97.5, + "totalLiquidVolume": 200, + "shape": "circular", + "diameter": 5.59, + "x": 55.75, + "y": 24, + "z": 12.5 + }, + "H4": { + "depth": 97.5, + "totalLiquidVolume": 200, + "shape": "circular", + "diameter": 5.59, + "x": 55.75, + "y": 15, + "z": 12.5 + }, + "A5": { + "depth": 97.5, + "totalLiquidVolume": 200, + "shape": "circular", + "diameter": 5.59, + "x": 64.75, + "y": 78, + "z": 12.5 + }, + "B5": { + "depth": 97.5, + "totalLiquidVolume": 200, + "shape": "circular", + "diameter": 5.59, + "x": 64.75, + "y": 69, + "z": 12.5 + }, + "C5": { + "depth": 97.5, + "totalLiquidVolume": 200, + "shape": "circular", + "diameter": 5.59, + "x": 64.75, + "y": 60, + "z": 12.5 + }, + "D5": { + "depth": 97.5, + "totalLiquidVolume": 200, + "shape": "circular", + "diameter": 5.59, + "x": 64.75, + "y": 51, + "z": 12.5 + }, + "E5": { + "depth": 97.5, + "totalLiquidVolume": 200, + "shape": "circular", + "diameter": 5.59, + "x": 64.75, + "y": 42, + "z": 12.5 + }, + "F5": { + "depth": 97.5, + "totalLiquidVolume": 200, + "shape": "circular", + "diameter": 5.59, + "x": 64.75, + "y": 33, + "z": 12.5 + }, + "G5": { + "depth": 97.5, + "totalLiquidVolume": 200, + "shape": "circular", + "diameter": 5.59, + "x": 64.75, + "y": 24, + "z": 12.5 + }, + "H5": { + "depth": 97.5, + "totalLiquidVolume": 200, + "shape": "circular", + "diameter": 5.59, + "x": 64.75, + "y": 15, + "z": 12.5 + }, + "A6": { + "depth": 97.5, + "totalLiquidVolume": 200, + "shape": "circular", + "diameter": 5.59, + "x": 73.75, + "y": 78, + "z": 12.5 + }, + "B6": { + "depth": 97.5, + "totalLiquidVolume": 200, + "shape": "circular", + "diameter": 5.59, + "x": 73.75, + "y": 69, + "z": 12.5 + }, + "C6": { + "depth": 97.5, + "totalLiquidVolume": 200, + "shape": "circular", + "diameter": 5.59, + "x": 73.75, + "y": 60, + "z": 12.5 + }, + "D6": { + "depth": 97.5, + "totalLiquidVolume": 200, + "shape": "circular", + "diameter": 5.59, + "x": 73.75, + "y": 51, + "z": 12.5 + }, + "E6": { + "depth": 97.5, + "totalLiquidVolume": 200, + "shape": "circular", + "diameter": 5.59, + "x": 73.75, + "y": 42, + "z": 12.5 + }, + "F6": { + "depth": 97.5, + "totalLiquidVolume": 200, + "shape": "circular", + "diameter": 5.59, + "x": 73.75, + "y": 33, + "z": 12.5 + }, + "G6": { + "depth": 97.5, + "totalLiquidVolume": 200, + "shape": "circular", + "diameter": 5.59, + "x": 73.75, + "y": 24, + "z": 12.5 + }, + "H6": { + "depth": 97.5, + "totalLiquidVolume": 200, + "shape": "circular", + "diameter": 5.59, + "x": 73.75, + "y": 15, + "z": 12.5 + }, + "A7": { + "depth": 97.5, + "totalLiquidVolume": 200, + "shape": "circular", + "diameter": 5.59, + "x": 82.75, + "y": 78, + "z": 12.5 + }, + "B7": { + "depth": 97.5, + "totalLiquidVolume": 200, + "shape": "circular", + "diameter": 5.59, + "x": 82.75, + "y": 69, + "z": 12.5 + }, + "C7": { + "depth": 97.5, + "totalLiquidVolume": 200, + "shape": "circular", + "diameter": 5.59, + "x": 82.75, + "y": 60, + "z": 12.5 + }, + "D7": { + "depth": 97.5, + "totalLiquidVolume": 200, + "shape": "circular", + "diameter": 5.59, + "x": 82.75, + "y": 51, + "z": 12.5 + }, + "E7": { + "depth": 97.5, + "totalLiquidVolume": 200, + "shape": "circular", + "diameter": 5.59, + "x": 82.75, + "y": 42, + "z": 12.5 + }, + "F7": { + "depth": 97.5, + "totalLiquidVolume": 200, + "shape": "circular", + "diameter": 5.59, + "x": 82.75, + "y": 33, + "z": 12.5 + }, + "G7": { + "depth": 97.5, + "totalLiquidVolume": 200, + "shape": "circular", + "diameter": 5.59, + "x": 82.75, + "y": 24, + "z": 12.5 + }, + "H7": { + "depth": 97.5, + "totalLiquidVolume": 200, + "shape": "circular", + "diameter": 5.59, + "x": 82.75, + "y": 15, + "z": 12.5 + }, + "A8": { + "depth": 97.5, + "totalLiquidVolume": 200, + "shape": "circular", + "diameter": 5.59, + "x": 91.75, + "y": 78, + "z": 12.5 + }, + "B8": { + "depth": 97.5, + "totalLiquidVolume": 200, + "shape": "circular", + "diameter": 5.59, + "x": 91.75, + "y": 69, + "z": 12.5 + }, + "C8": { + "depth": 97.5, + "totalLiquidVolume": 200, + "shape": "circular", + "diameter": 5.59, + "x": 91.75, + "y": 60, + "z": 12.5 + }, + "D8": { + "depth": 97.5, + "totalLiquidVolume": 200, + "shape": "circular", + "diameter": 5.59, + "x": 91.75, + "y": 51, + "z": 12.5 + }, + "E8": { + "depth": 97.5, + "totalLiquidVolume": 200, + "shape": "circular", + "diameter": 5.59, + "x": 91.75, + "y": 42, + "z": 12.5 + }, + "F8": { + "depth": 97.5, + "totalLiquidVolume": 200, + "shape": "circular", + "diameter": 5.59, + "x": 91.75, + "y": 33, + "z": 12.5 + }, + "G8": { + "depth": 97.5, + "totalLiquidVolume": 200, + "shape": "circular", + "diameter": 5.59, + "x": 91.75, + "y": 24, + "z": 12.5 + }, + "H8": { + "depth": 97.5, + "totalLiquidVolume": 200, + "shape": "circular", + "diameter": 5.59, + "x": 91.75, + "y": 15, + "z": 12.5 + }, + "A9": { + "depth": 97.5, + "totalLiquidVolume": 200, + "shape": "circular", + "diameter": 5.59, + "x": 100.75, + "y": 78, + "z": 12.5 + }, + "B9": { + "depth": 97.5, + "totalLiquidVolume": 200, + "shape": "circular", + "diameter": 5.59, + "x": 100.75, + "y": 69, + "z": 12.5 + }, + "C9": { + "depth": 97.5, + "totalLiquidVolume": 200, + "shape": "circular", + "diameter": 5.59, + "x": 100.75, + "y": 60, + "z": 12.5 + }, + "D9": { + "depth": 97.5, + "totalLiquidVolume": 200, + "shape": "circular", + "diameter": 5.59, + "x": 100.75, + "y": 51, + "z": 12.5 + }, + "E9": { + "depth": 97.5, + "totalLiquidVolume": 200, + "shape": "circular", + "diameter": 5.59, + "x": 100.75, + "y": 42, + "z": 12.5 + }, + "F9": { + "depth": 97.5, + "totalLiquidVolume": 200, + "shape": "circular", + "diameter": 5.59, + "x": 100.75, + "y": 33, + "z": 12.5 + }, + "G9": { + "depth": 97.5, + "totalLiquidVolume": 200, + "shape": "circular", + "diameter": 5.59, + "x": 100.75, + "y": 24, + "z": 12.5 + }, + "H9": { + "depth": 97.5, + "totalLiquidVolume": 200, + "shape": "circular", + "diameter": 5.59, + "x": 100.75, + "y": 15, + "z": 12.5 + }, + "A10": { + "depth": 97.5, + "totalLiquidVolume": 200, + "shape": "circular", + "diameter": 5.59, + "x": 109.75, + "y": 78, + "z": 12.5 + }, + "B10": { + "depth": 97.5, + "totalLiquidVolume": 200, + "shape": "circular", + "diameter": 5.59, + "x": 109.75, + "y": 69, + "z": 12.5 + }, + "C10": { + "depth": 97.5, + "totalLiquidVolume": 200, + "shape": "circular", + "diameter": 5.59, + "x": 109.75, + "y": 60, + "z": 12.5 + }, + "D10": { + "depth": 97.5, + "totalLiquidVolume": 200, + "shape": "circular", + "diameter": 5.59, + "x": 109.75, + "y": 51, + "z": 12.5 + }, + "E10": { + "depth": 97.5, + "totalLiquidVolume": 200, + "shape": "circular", + "diameter": 5.59, + "x": 109.75, + "y": 42, + "z": 12.5 + }, + "F10": { + "depth": 97.5, + "totalLiquidVolume": 200, + "shape": "circular", + "diameter": 5.59, + "x": 109.75, + "y": 33, + "z": 12.5 + }, + "G10": { + "depth": 97.5, + "totalLiquidVolume": 200, + "shape": "circular", + "diameter": 5.59, + "x": 109.75, + "y": 24, + "z": 12.5 + }, + "H10": { + "depth": 97.5, + "totalLiquidVolume": 200, + "shape": "circular", + "diameter": 5.59, + "x": 109.75, + "y": 15, + "z": 12.5 + }, + "A11": { + "depth": 97.5, + "totalLiquidVolume": 200, + "shape": "circular", + "diameter": 5.59, + "x": 118.75, + "y": 78, + "z": 12.5 + }, + "B11": { + "depth": 97.5, + "totalLiquidVolume": 200, + "shape": "circular", + "diameter": 5.59, + "x": 118.75, + "y": 69, + "z": 12.5 + }, + "C11": { + "depth": 97.5, + "totalLiquidVolume": 200, + "shape": "circular", + "diameter": 5.59, + "x": 118.75, + "y": 60, + "z": 12.5 + }, + "D11": { + "depth": 97.5, + "totalLiquidVolume": 200, + "shape": "circular", + "diameter": 5.59, + "x": 118.75, + "y": 51, + "z": 12.5 + }, + "E11": { + "depth": 97.5, + "totalLiquidVolume": 200, + "shape": "circular", + "diameter": 5.59, + "x": 118.75, + "y": 42, + "z": 12.5 + }, + "F11": { + "depth": 97.5, + "totalLiquidVolume": 200, + "shape": "circular", + "diameter": 5.59, + "x": 118.75, + "y": 33, + "z": 12.5 + }, + "G11": { + "depth": 97.5, + "totalLiquidVolume": 200, + "shape": "circular", + "diameter": 5.59, + "x": 118.75, + "y": 24, + "z": 12.5 + }, + "H11": { + "depth": 97.5, + "totalLiquidVolume": 200, + "shape": "circular", + "diameter": 5.59, + "x": 118.75, + "y": 15, + "z": 12.5 + }, + "A12": { + "depth": 97.5, + "totalLiquidVolume": 200, + "shape": "circular", + "diameter": 5.59, + "x": 127.75, + "y": 78, + "z": 12.5 + }, + "B12": { + "depth": 97.5, + "totalLiquidVolume": 200, + "shape": "circular", + "diameter": 5.59, + "x": 127.75, + "y": 69, + "z": 12.5 + }, + "C12": { + "depth": 97.5, + "totalLiquidVolume": 200, + "shape": "circular", + "diameter": 5.59, + "x": 127.75, + "y": 60, + "z": 12.5 + }, + "D12": { + "depth": 97.5, + "totalLiquidVolume": 200, + "shape": "circular", + "diameter": 5.59, + "x": 127.75, + "y": 51, + "z": 12.5 + }, + "E12": { + "depth": 97.5, + "totalLiquidVolume": 200, + "shape": "circular", + "diameter": 5.59, + "x": 127.75, + "y": 42, + "z": 12.5 + }, + "F12": { + "depth": 97.5, + "totalLiquidVolume": 200, + "shape": "circular", + "diameter": 5.59, + "x": 127.75, + "y": 33, + "z": 12.5 + }, + "G12": { + "depth": 97.5, + "totalLiquidVolume": 200, + "shape": "circular", + "diameter": 5.59, + "x": 127.75, + "y": 24, + "z": 12.5 + }, + "H12": { + "depth": 97.5, + "totalLiquidVolume": 200, + "shape": "circular", + "diameter": 5.59, + "x": 127.75, + "y": 15, + "z": 12.5 + } + }, + "groups": [ + { + "metadata": {}, + "wells": [ + "A1", + "B1", + "C1", + "D1", + "E1", + "F1", + "G1", + "H1", + "A2", + "B2", + "C2", + "D2", + "E2", + "F2", + "G2", + "H2", + "A3", + "B3", + "C3", + "D3", + "E3", + "F3", + "G3", + "H3", + "A4", + "B4", + "C4", + "D4", + "E4", + "F4", + "G4", + "H4", + "A5", + "B5", + "C5", + "D5", + "E5", + "F5", + "G5", + "H5", + "A6", + "B6", + "C6", + "D6", + "E6", + "F6", + "G6", + "H6", + "A7", + "B7", + "C7", + "D7", + "E7", + "F7", + "G7", + "H7", + "A8", + "B8", + "C8", + "D8", + "E8", + "F8", + "G8", + "H8", + "A9", + "B9", + "C9", + "D9", + "E9", + "F9", + "G9", + "H9", + "A10", + "B10", + "C10", + "D10", + "E10", + "F10", + "G10", + "H10", + "A11", + "B11", + "C11", + "D11", + "E11", + "F11", + "G11", + "H11", + "A12", + "B12", + "C12", + "D12", + "E12", + "F12", + "G12", + "H12" + ] + } + ], + "parameters": { + "format": "96Standard", + "quirks": [], + "isTiprack": true, + "tipLength": 58.35, + "tipOverlap": 10.5, + "isMagneticModuleCompatible": false, + "loadName": "opentrons_ot3_96_tiprack_200ul_rss" + }, + "namespace": "custom_beta", + "version": 1, + "schemaVersion": 2, + "cornerOffsetFromSlot": { + "x": -14.375, + "y": -3.625, + "z": 0 + } +} diff --git a/app-testing/files/labware/opentrons_ot3_96_tiprack_50ul_rss.json b/app-testing/files/labware/opentrons_ot3_96_tiprack_50ul_rss.json new file mode 100644 index 000000000000..772dfd493ff9 --- /dev/null +++ b/app-testing/files/labware/opentrons_ot3_96_tiprack_50ul_rss.json @@ -0,0 +1,1017 @@ +{ + "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": "Opentrons", + "brandId": ["RSS_made"] + }, + "metadata": { + "displayName": "Opentrons Flex 96 Tip Rack 50 µL with adapter", + "displayCategory": "tipRack", + "displayVolumeUnits": "µL", + "tags": [] + }, + "dimensions": { + "xDimension": 156.5, + "yDimension": 93, + "zDimension": 132 + }, + "wells": { + "A1": { + "depth": 97.5, + "totalLiquidVolume": 50, + "shape": "circular", + "diameter": 5.58, + "x": 28.75, + "y": 78, + "z": 12.5 + }, + "B1": { + "depth": 97.5, + "totalLiquidVolume": 50, + "shape": "circular", + "diameter": 5.58, + "x": 28.75, + "y": 69, + "z": 12.5 + }, + "C1": { + "depth": 97.5, + "totalLiquidVolume": 50, + "shape": "circular", + "diameter": 5.58, + "x": 28.75, + "y": 60, + "z": 12.5 + }, + "D1": { + "depth": 97.5, + "totalLiquidVolume": 50, + "shape": "circular", + "diameter": 5.58, + "x": 28.75, + "y": 51, + "z": 12.5 + }, + "E1": { + "depth": 97.5, + "totalLiquidVolume": 50, + "shape": "circular", + "diameter": 5.58, + "x": 28.75, + "y": 42, + "z": 12.5 + }, + "F1": { + "depth": 97.5, + "totalLiquidVolume": 50, + "shape": "circular", + "diameter": 5.58, + "x": 28.75, + "y": 33, + "z": 12.5 + }, + "G1": { + "depth": 97.5, + "totalLiquidVolume": 50, + "shape": "circular", + "diameter": 5.58, + "x": 28.75, + "y": 24, + "z": 12.5 + }, + "H1": { + "depth": 97.5, + "totalLiquidVolume": 50, + "shape": "circular", + "diameter": 5.58, + "x": 28.75, + "y": 15, + "z": 12.5 + }, + "A2": { + "depth": 97.5, + "totalLiquidVolume": 50, + "shape": "circular", + "diameter": 5.58, + "x": 37.75, + "y": 78, + "z": 12.5 + }, + "B2": { + "depth": 97.5, + "totalLiquidVolume": 50, + "shape": "circular", + "diameter": 5.58, + "x": 37.75, + "y": 69, + "z": 12.5 + }, + "C2": { + "depth": 97.5, + "totalLiquidVolume": 50, + "shape": "circular", + "diameter": 5.58, + "x": 37.75, + "y": 60, + "z": 12.5 + }, + "D2": { + "depth": 97.5, + "totalLiquidVolume": 50, + "shape": "circular", + "diameter": 5.58, + "x": 37.75, + "y": 51, + "z": 12.5 + }, + "E2": { + "depth": 97.5, + "totalLiquidVolume": 50, + "shape": "circular", + "diameter": 5.58, + "x": 37.75, + "y": 42, + "z": 12.5 + }, + "F2": { + "depth": 97.5, + "totalLiquidVolume": 50, + "shape": "circular", + "diameter": 5.58, + "x": 37.75, + "y": 33, + "z": 12.5 + }, + "G2": { + "depth": 97.5, + "totalLiquidVolume": 50, + "shape": "circular", + "diameter": 5.58, + "x": 37.75, + "y": 24, + "z": 12.5 + }, + "H2": { + "depth": 97.5, + "totalLiquidVolume": 50, + "shape": "circular", + "diameter": 5.58, + "x": 37.75, + "y": 15, + "z": 12.5 + }, + "A3": { + "depth": 97.5, + "totalLiquidVolume": 50, + "shape": "circular", + "diameter": 5.58, + "x": 46.75, + "y": 78, + "z": 12.5 + }, + "B3": { + "depth": 97.5, + "totalLiquidVolume": 50, + "shape": "circular", + "diameter": 5.58, + "x": 46.75, + "y": 69, + "z": 12.5 + }, + "C3": { + "depth": 97.5, + "totalLiquidVolume": 50, + "shape": "circular", + "diameter": 5.58, + "x": 46.75, + "y": 60, + "z": 12.5 + }, + "D3": { + "depth": 97.5, + "totalLiquidVolume": 50, + "shape": "circular", + "diameter": 5.58, + "x": 46.75, + "y": 51, + "z": 12.5 + }, + "E3": { + "depth": 97.5, + "totalLiquidVolume": 50, + "shape": "circular", + "diameter": 5.58, + "x": 46.75, + "y": 42, + "z": 12.5 + }, + "F3": { + "depth": 97.5, + "totalLiquidVolume": 50, + "shape": "circular", + "diameter": 5.58, + "x": 46.75, + "y": 33, + "z": 12.5 + }, + "G3": { + "depth": 97.5, + "totalLiquidVolume": 50, + "shape": "circular", + "diameter": 5.58, + "x": 46.75, + "y": 24, + "z": 12.5 + }, + "H3": { + "depth": 97.5, + "totalLiquidVolume": 50, + "shape": "circular", + "diameter": 5.58, + "x": 46.75, + "y": 15, + "z": 12.5 + }, + "A4": { + "depth": 97.5, + "totalLiquidVolume": 50, + "shape": "circular", + "diameter": 5.58, + "x": 55.75, + "y": 78, + "z": 12.5 + }, + "B4": { + "depth": 97.5, + "totalLiquidVolume": 50, + "shape": "circular", + "diameter": 5.58, + "x": 55.75, + "y": 69, + "z": 12.5 + }, + "C4": { + "depth": 97.5, + "totalLiquidVolume": 50, + "shape": "circular", + "diameter": 5.58, + "x": 55.75, + "y": 60, + "z": 12.5 + }, + "D4": { + "depth": 97.5, + "totalLiquidVolume": 50, + "shape": "circular", + "diameter": 5.58, + "x": 55.75, + "y": 51, + "z": 12.5 + }, + "E4": { + "depth": 97.5, + "totalLiquidVolume": 50, + "shape": "circular", + "diameter": 5.58, + "x": 55.75, + "y": 42, + "z": 12.5 + }, + "F4": { + "depth": 97.5, + "totalLiquidVolume": 50, + "shape": "circular", + "diameter": 5.58, + "x": 55.75, + "y": 33, + "z": 12.5 + }, + "G4": { + "depth": 97.5, + "totalLiquidVolume": 50, + "shape": "circular", + "diameter": 5.58, + "x": 55.75, + "y": 24, + "z": 12.5 + }, + "H4": { + "depth": 97.5, + "totalLiquidVolume": 50, + "shape": "circular", + "diameter": 5.58, + "x": 55.75, + "y": 15, + "z": 12.5 + }, + "A5": { + "depth": 97.5, + "totalLiquidVolume": 50, + "shape": "circular", + "diameter": 5.58, + "x": 64.75, + "y": 78, + "z": 12.5 + }, + "B5": { + "depth": 97.5, + "totalLiquidVolume": 50, + "shape": "circular", + "diameter": 5.58, + "x": 64.75, + "y": 69, + "z": 12.5 + }, + "C5": { + "depth": 97.5, + "totalLiquidVolume": 50, + "shape": "circular", + "diameter": 5.58, + "x": 64.75, + "y": 60, + "z": 12.5 + }, + "D5": { + "depth": 97.5, + "totalLiquidVolume": 50, + "shape": "circular", + "diameter": 5.58, + "x": 64.75, + "y": 51, + "z": 12.5 + }, + "E5": { + "depth": 97.5, + "totalLiquidVolume": 50, + "shape": "circular", + "diameter": 5.58, + "x": 64.75, + "y": 42, + "z": 12.5 + }, + "F5": { + "depth": 97.5, + "totalLiquidVolume": 50, + "shape": "circular", + "diameter": 5.58, + "x": 64.75, + "y": 33, + "z": 12.5 + }, + "G5": { + "depth": 97.5, + "totalLiquidVolume": 50, + "shape": "circular", + "diameter": 5.58, + "x": 64.75, + "y": 24, + "z": 12.5 + }, + "H5": { + "depth": 97.5, + "totalLiquidVolume": 50, + "shape": "circular", + "diameter": 5.58, + "x": 64.75, + "y": 15, + "z": 12.5 + }, + "A6": { + "depth": 97.5, + "totalLiquidVolume": 50, + "shape": "circular", + "diameter": 5.58, + "x": 73.75, + "y": 78, + "z": 12.5 + }, + "B6": { + "depth": 97.5, + "totalLiquidVolume": 50, + "shape": "circular", + "diameter": 5.58, + "x": 73.75, + "y": 69, + "z": 12.5 + }, + "C6": { + "depth": 97.5, + "totalLiquidVolume": 50, + "shape": "circular", + "diameter": 5.58, + "x": 73.75, + "y": 60, + "z": 12.5 + }, + "D6": { + "depth": 97.5, + "totalLiquidVolume": 50, + "shape": "circular", + "diameter": 5.58, + "x": 73.75, + "y": 51, + "z": 12.5 + }, + "E6": { + "depth": 97.5, + "totalLiquidVolume": 50, + "shape": "circular", + "diameter": 5.58, + "x": 73.75, + "y": 42, + "z": 12.5 + }, + "F6": { + "depth": 97.5, + "totalLiquidVolume": 50, + "shape": "circular", + "diameter": 5.58, + "x": 73.75, + "y": 33, + "z": 12.5 + }, + "G6": { + "depth": 97.5, + "totalLiquidVolume": 50, + "shape": "circular", + "diameter": 5.58, + "x": 73.75, + "y": 24, + "z": 12.5 + }, + "H6": { + "depth": 97.5, + "totalLiquidVolume": 50, + "shape": "circular", + "diameter": 5.58, + "x": 73.75, + "y": 15, + "z": 12.5 + }, + "A7": { + "depth": 97.5, + "totalLiquidVolume": 50, + "shape": "circular", + "diameter": 5.58, + "x": 82.75, + "y": 78, + "z": 12.5 + }, + "B7": { + "depth": 97.5, + "totalLiquidVolume": 50, + "shape": "circular", + "diameter": 5.58, + "x": 82.75, + "y": 69, + "z": 12.5 + }, + "C7": { + "depth": 97.5, + "totalLiquidVolume": 50, + "shape": "circular", + "diameter": 5.58, + "x": 82.75, + "y": 60, + "z": 12.5 + }, + "D7": { + "depth": 97.5, + "totalLiquidVolume": 50, + "shape": "circular", + "diameter": 5.58, + "x": 82.75, + "y": 51, + "z": 12.5 + }, + "E7": { + "depth": 97.5, + "totalLiquidVolume": 50, + "shape": "circular", + "diameter": 5.58, + "x": 82.75, + "y": 42, + "z": 12.5 + }, + "F7": { + "depth": 97.5, + "totalLiquidVolume": 50, + "shape": "circular", + "diameter": 5.58, + "x": 82.75, + "y": 33, + "z": 12.5 + }, + "G7": { + "depth": 97.5, + "totalLiquidVolume": 50, + "shape": "circular", + "diameter": 5.58, + "x": 82.75, + "y": 24, + "z": 12.5 + }, + "H7": { + "depth": 97.5, + "totalLiquidVolume": 50, + "shape": "circular", + "diameter": 5.58, + "x": 82.75, + "y": 15, + "z": 12.5 + }, + "A8": { + "depth": 97.5, + "totalLiquidVolume": 50, + "shape": "circular", + "diameter": 5.58, + "x": 91.75, + "y": 78, + "z": 12.5 + }, + "B8": { + "depth": 97.5, + "totalLiquidVolume": 50, + "shape": "circular", + "diameter": 5.58, + "x": 91.75, + "y": 69, + "z": 12.5 + }, + "C8": { + "depth": 97.5, + "totalLiquidVolume": 50, + "shape": "circular", + "diameter": 5.58, + "x": 91.75, + "y": 60, + "z": 12.5 + }, + "D8": { + "depth": 97.5, + "totalLiquidVolume": 50, + "shape": "circular", + "diameter": 5.58, + "x": 91.75, + "y": 51, + "z": 12.5 + }, + "E8": { + "depth": 97.5, + "totalLiquidVolume": 50, + "shape": "circular", + "diameter": 5.58, + "x": 91.75, + "y": 42, + "z": 12.5 + }, + "F8": { + "depth": 97.5, + "totalLiquidVolume": 50, + "shape": "circular", + "diameter": 5.58, + "x": 91.75, + "y": 33, + "z": 12.5 + }, + "G8": { + "depth": 97.5, + "totalLiquidVolume": 50, + "shape": "circular", + "diameter": 5.58, + "x": 91.75, + "y": 24, + "z": 12.5 + }, + "H8": { + "depth": 97.5, + "totalLiquidVolume": 50, + "shape": "circular", + "diameter": 5.58, + "x": 91.75, + "y": 15, + "z": 12.5 + }, + "A9": { + "depth": 97.5, + "totalLiquidVolume": 50, + "shape": "circular", + "diameter": 5.58, + "x": 100.75, + "y": 78, + "z": 12.5 + }, + "B9": { + "depth": 97.5, + "totalLiquidVolume": 50, + "shape": "circular", + "diameter": 5.58, + "x": 100.75, + "y": 69, + "z": 12.5 + }, + "C9": { + "depth": 97.5, + "totalLiquidVolume": 50, + "shape": "circular", + "diameter": 5.58, + "x": 100.75, + "y": 60, + "z": 12.5 + }, + "D9": { + "depth": 97.5, + "totalLiquidVolume": 50, + "shape": "circular", + "diameter": 5.58, + "x": 100.75, + "y": 51, + "z": 12.5 + }, + "E9": { + "depth": 97.5, + "totalLiquidVolume": 50, + "shape": "circular", + "diameter": 5.58, + "x": 100.75, + "y": 42, + "z": 12.5 + }, + "F9": { + "depth": 97.5, + "totalLiquidVolume": 50, + "shape": "circular", + "diameter": 5.58, + "x": 100.75, + "y": 33, + "z": 12.5 + }, + "G9": { + "depth": 97.5, + "totalLiquidVolume": 50, + "shape": "circular", + "diameter": 5.58, + "x": 100.75, + "y": 24, + "z": 12.5 + }, + "H9": { + "depth": 97.5, + "totalLiquidVolume": 50, + "shape": "circular", + "diameter": 5.58, + "x": 100.75, + "y": 15, + "z": 12.5 + }, + "A10": { + "depth": 97.5, + "totalLiquidVolume": 50, + "shape": "circular", + "diameter": 5.58, + "x": 109.75, + "y": 78, + "z": 12.5 + }, + "B10": { + "depth": 97.5, + "totalLiquidVolume": 50, + "shape": "circular", + "diameter": 5.58, + "x": 109.75, + "y": 69, + "z": 12.5 + }, + "C10": { + "depth": 97.5, + "totalLiquidVolume": 50, + "shape": "circular", + "diameter": 5.58, + "x": 109.75, + "y": 60, + "z": 12.5 + }, + "D10": { + "depth": 97.5, + "totalLiquidVolume": 50, + "shape": "circular", + "diameter": 5.58, + "x": 109.75, + "y": 51, + "z": 12.5 + }, + "E10": { + "depth": 97.5, + "totalLiquidVolume": 50, + "shape": "circular", + "diameter": 5.58, + "x": 109.75, + "y": 42, + "z": 12.5 + }, + "F10": { + "depth": 97.5, + "totalLiquidVolume": 50, + "shape": "circular", + "diameter": 5.58, + "x": 109.75, + "y": 33, + "z": 12.5 + }, + "G10": { + "depth": 97.5, + "totalLiquidVolume": 50, + "shape": "circular", + "diameter": 5.58, + "x": 109.75, + "y": 24, + "z": 12.5 + }, + "H10": { + "depth": 97.5, + "totalLiquidVolume": 50, + "shape": "circular", + "diameter": 5.58, + "x": 109.75, + "y": 15, + "z": 12.5 + }, + "A11": { + "depth": 97.5, + "totalLiquidVolume": 50, + "shape": "circular", + "diameter": 5.58, + "x": 118.75, + "y": 78, + "z": 12.5 + }, + "B11": { + "depth": 97.5, + "totalLiquidVolume": 50, + "shape": "circular", + "diameter": 5.58, + "x": 118.75, + "y": 69, + "z": 12.5 + }, + "C11": { + "depth": 97.5, + "totalLiquidVolume": 50, + "shape": "circular", + "diameter": 5.58, + "x": 118.75, + "y": 60, + "z": 12.5 + }, + "D11": { + "depth": 97.5, + "totalLiquidVolume": 50, + "shape": "circular", + "diameter": 5.58, + "x": 118.75, + "y": 51, + "z": 12.5 + }, + "E11": { + "depth": 97.5, + "totalLiquidVolume": 50, + "shape": "circular", + "diameter": 5.58, + "x": 118.75, + "y": 42, + "z": 12.5 + }, + "F11": { + "depth": 97.5, + "totalLiquidVolume": 50, + "shape": "circular", + "diameter": 5.58, + "x": 118.75, + "y": 33, + "z": 12.5 + }, + "G11": { + "depth": 97.5, + "totalLiquidVolume": 50, + "shape": "circular", + "diameter": 5.58, + "x": 118.75, + "y": 24, + "z": 12.5 + }, + "H11": { + "depth": 97.5, + "totalLiquidVolume": 50, + "shape": "circular", + "diameter": 5.58, + "x": 118.75, + "y": 15, + "z": 12.5 + }, + "A12": { + "depth": 97.5, + "totalLiquidVolume": 50, + "shape": "circular", + "diameter": 5.58, + "x": 127.75, + "y": 78, + "z": 12.5 + }, + "B12": { + "depth": 97.5, + "totalLiquidVolume": 50, + "shape": "circular", + "diameter": 5.58, + "x": 127.75, + "y": 69, + "z": 12.5 + }, + "C12": { + "depth": 97.5, + "totalLiquidVolume": 50, + "shape": "circular", + "diameter": 5.58, + "x": 127.75, + "y": 60, + "z": 12.5 + }, + "D12": { + "depth": 97.5, + "totalLiquidVolume": 50, + "shape": "circular", + "diameter": 5.58, + "x": 127.75, + "y": 51, + "z": 12.5 + }, + "E12": { + "depth": 97.5, + "totalLiquidVolume": 50, + "shape": "circular", + "diameter": 5.58, + "x": 127.75, + "y": 42, + "z": 12.5 + }, + "F12": { + "depth": 97.5, + "totalLiquidVolume": 50, + "shape": "circular", + "diameter": 5.58, + "x": 127.75, + "y": 33, + "z": 12.5 + }, + "G12": { + "depth": 97.5, + "totalLiquidVolume": 50, + "shape": "circular", + "diameter": 5.58, + "x": 127.75, + "y": 24, + "z": 12.5 + }, + "H12": { + "depth": 97.5, + "totalLiquidVolume": 50, + "shape": "circular", + "diameter": 5.58, + "x": 127.75, + "y": 15, + "z": 12.5 + } + }, + "groups": [ + { + "metadata": {}, + "wells": [ + "A1", + "B1", + "C1", + "D1", + "E1", + "F1", + "G1", + "H1", + "A2", + "B2", + "C2", + "D2", + "E2", + "F2", + "G2", + "H2", + "A3", + "B3", + "C3", + "D3", + "E3", + "F3", + "G3", + "H3", + "A4", + "B4", + "C4", + "D4", + "E4", + "F4", + "G4", + "H4", + "A5", + "B5", + "C5", + "D5", + "E5", + "F5", + "G5", + "H5", + "A6", + "B6", + "C6", + "D6", + "E6", + "F6", + "G6", + "H6", + "A7", + "B7", + "C7", + "D7", + "E7", + "F7", + "G7", + "H7", + "A8", + "B8", + "C8", + "D8", + "E8", + "F8", + "G8", + "H8", + "A9", + "B9", + "C9", + "D9", + "E9", + "F9", + "G9", + "H9", + "A10", + "B10", + "C10", + "D10", + "E10", + "F10", + "G10", + "H10", + "A11", + "B11", + "C11", + "D11", + "E11", + "F11", + "G11", + "H11", + "A12", + "B12", + "C12", + "D12", + "E12", + "F12", + "G12", + "H12" + ] + } + ], + "parameters": { + "format": "96Standard", + "quirks": [], + "isTiprack": true, + "tipLength": 57.9, + "tipOverlap": 10.5, + "isMagneticModuleCompatible": false, + "loadName": "opentrons_ot3_96_tiprack_50ul_rss" + }, + "namespace": "custom_beta", + "version": 1, + "schemaVersion": 2, + "cornerOffsetFromSlot": { + "x": -14.375, + "y": -3.625, + "z": 0 + } +} diff --git a/app-testing/files/protocols/py/OT3_P1000MLeft_P50MRight_HS_MM_TC_TM_2_15_ABR3_Illumina_DNA_Enrichment.py b/app-testing/files/protocols/py/OT3_P1000MLeft_P50MRight_HS_MM_TC_TM_2_15_ABR3_Illumina_DNA_Enrichment.py new file mode 100644 index 000000000000..2e6b1e08c12c --- /dev/null +++ b/app-testing/files/protocols/py/OT3_P1000MLeft_P50MRight_HS_MM_TC_TM_2_15_ABR3_Illumina_DNA_Enrichment.py @@ -0,0 +1,795 @@ +from opentrons import protocol_api +from opentrons import types + +metadata = { + "protocolName": "Illumina DNA Enrichment", + "author": "Opentrons ", + "source": "Protocol Library", +} + +requirements = { + "robotType": "OT-3", + "apiLevel": "2.15", +} + +# SCRIPT SETTINGS +DRYRUN = "YES" # YES or NO, DRYRUN = 'YES' will return tips, skip incubation times, shorten mix, for testing purposes +USE_GRIPPER = True + + +# PROTOCOL SETTINGS +SAMPLES = "8x" # 8x +HYBRIDDECK = True +HYBRIDTIME = 1.6 # Hours + +# PROTOCOL BLOCKS +STEP_VOLPOOL = 1 +STEP_CAPTURE = 1 +STEP_WASH = 1 +STEP_PCR = 1 +STEP_PCRDECK = 1 +STEP_POSTPCR = 1 +STEP_CLEANUP = 1 + +############################################################################################################################################ +############################################################################################################################################ +############################################################################################################################################ + + +def run(protocol: protocol_api.ProtocolContext): + global DRYRUN + + protocol.comment("THIS IS A DRY RUN") if DRYRUN == "YES" else protocol.comment("THIS IS A REACTION RUN") + + # DECK SETUP AND LABWARE + # ========== FIRST ROW =========== + heatershaker = protocol.load_module("heaterShakerModuleV1", "1") + sample_plate_2 = heatershaker.load_labware("nest_96_wellplate_2ml_deep") + tiprack_200_1 = protocol.load_labware("opentrons_ot3_96_tiprack_200ul", "2") + temp_block = protocol.load_module("temperature module gen2", "3") + reagent_plate = temp_block.load_labware("nest_96_wellplate_100ul_pcr_full_skirt") + # ========== SECOND ROW ========== + MAG_PLATE_SLOT = protocol.load_module("magneticBlockV1", "4") + reservoir = protocol.load_labware("nest_96_wellplate_2ml_deep", "5") + tiprack_200_2 = protocol.load_labware("opentrons_ot3_96_tiprack_200ul", "6") + # ========== THIRD ROW =========== + thermocycler = protocol.load_module("thermocycler module gen2") + sample_plate_1 = thermocycler.load_labware("nest_96_wellplate_100ul_pcr_full_skirt") + tiprack_20 = protocol.load_labware("opentrons_ot3_96_tiprack_50ul", "9") + # ========== FOURTH ROW ========== + + # reagent + + AMPure = reservoir["A1"] + SMB = reservoir["A2"] + EEW = reservoir["A3"] + EtOH = reservoir["A4"] + RSB = reservoir["A5"] + Liquid_trash = reservoir["A12"] + + EEW_1 = sample_plate_1.wells_by_name()["A8"] + EEW_2 = sample_plate_1.wells_by_name()["A9"] + EEW_3 = sample_plate_1.wells_by_name()["A10"] + EEW_4 = sample_plate_1.wells_by_name()["A11"] + + NHB2 = reagent_plate.wells_by_name()["A1"] + Panel = reagent_plate.wells_by_name()["A2"] + EHB2 = reagent_plate.wells_by_name()["A3"] + Elute = reagent_plate.wells_by_name()["A4"] + ET2 = reagent_plate.wells_by_name()["A5"] + PPC = reagent_plate.wells_by_name()["A6"] + EPM = reagent_plate.wells_by_name()["A7"] + + # pipette + p1000 = protocol.load_instrument("p1000_multi_gen3", "left", tip_racks=[tiprack_200_1, tiprack_200_2]) + p50 = protocol.load_instrument("p50_multi_gen3", "right", tip_racks=[tiprack_20]) + + # tip and sample tracking + sample_well = "A3" + + WASHES = [EEW_1, EEW_2, EEW_3, EEW_4] + + def grip_offset(action, item, slot=None): + """Grip offset.""" + from opentrons.types import Point + + # EDIT these values + # NOTE: we are still testing to determine our software's defaults + # but we also expect users will want to edit these + _pick_up_offsets = { + "deck": Point(), + "mag-plate": Point(), + "heater-shaker": Point(z=1.0), + "temp-module": Point(), + "thermo-cycler": Point(), + } + # EDIT these values + # NOTE: we are still testing to determine our software's defaults + # but we also expect users will want to edit these + _drop_offsets = { + "deck": Point(), + "mag-plate": Point(z=0.5), + "heater-shaker": Point(y=-0.5), + "temp-module": Point(), + "thermo-cycler": Point(), + } + # do NOT edit these values + # NOTE: these values will eventually be in our software + # and will not need to be inside a protocol + _hw_offsets = { + "deck": Point(), + "mag-plate": Point(z=2.5), + "heater-shaker-right": Point(z=2.5), + "heater-shaker-left": Point(z=2.5), + "temp-module": Point(z=5.0), + "thermo-cycler": Point(z=2.5), + } + # make sure arguments are correct + action_options = ["pick-up", "drop"] + item_options = list(_hw_offsets.keys()) + item_options.remove("heater-shaker-left") + item_options.remove("heater-shaker-right") + item_options.append("heater-shaker") + if action not in action_options: + raise ValueError(f'"{action}" not recognized, available options: {action_options}') + if item not in item_options: + raise ValueError(f'"{item}" not recognized, available options: {item_options}') + if item == "heater-shaker": + assert slot, 'argument slot= is required when using "heater-shaker"' + if slot in [1, 4, 7, 10]: + side = "left" + elif slot in [3, 6, 9, 12]: + side = "right" + else: + raise ValueError("heater shaker must be on either left or right side") + hw_offset = _hw_offsets[f"{item}-{side}"] + else: + hw_offset = _hw_offsets[item] + if action == "pick-up": + offset = hw_offset + _pick_up_offsets[item] + else: + offset = hw_offset + _drop_offsets[item] + + # convert from Point() to dict() + return {"x": offset.x, "y": offset.y, "z": offset.z} + + ############################################################################################################################################ + ############################################################################################################################################ + ############################################################################################################################################ + # commands + heatershaker.open_labware_latch() + if DRYRUN == "NO": + protocol.comment("SETTING THERMO and TEMP BLOCK Temperature") + thermocycler.set_block_temperature(4) + thermocycler.set_lid_temperature(100) + temp_block.set_temperature(4) + thermocycler.open_lid() + protocol.pause("Ready") + heatershaker.close_labware_latch() + + if STEP_VOLPOOL == 1: + protocol.comment("==============================================") + protocol.comment("--> Quick Vol Pool") + protocol.comment("==============================================") + + if STEP_CAPTURE == 1: + protocol.comment("==============================================") + protocol.comment("--> Capture") + protocol.comment("==============================================") + + protocol.comment("--> Adding NHB2") + NHB2Vol = 50 + p50.pick_up_tip() + p50.aspirate(NHB2Vol, NHB2.bottom()) + p50.dispense(NHB2Vol, sample_plate_1[sample_well].bottom()) + p50.return_tip() + + protocol.comment("--> Adding Panel") + PanelVol = 10 + p50.pick_up_tip() + p50.aspirate(PanelVol, Panel.bottom()) + p50.dispense(PanelVol, sample_plate_1[sample_well].bottom()) + p50.return_tip() + + protocol.comment("--> Adding EHB2") + EHB2Vol = 10 + EHB2MixRep = 10 if DRYRUN == "NO" else 1 + EHB2MixVol = 90 + p1000.pick_up_tip() + p1000.aspirate(EHB2Vol, EHB2.bottom()) + p1000.dispense(EHB2Vol, sample_plate_1[sample_well].bottom()) + p1000.move_to(sample_plate_1[sample_well].bottom()) + p1000.mix(EHB2MixRep, EHB2MixVol) + p1000.return_tip() + + if HYBRIDDECK == True: + protocol.comment("Hybridize on Deck") + ############################################################################################################################################ + thermocycler.close_lid() + if DRYRUN == "NO": + profile_TAGSTOP = [ + {"temperature": 98, "hold_time_minutes": 5}, + {"temperature": 97, "hold_time_minutes": 1}, + {"temperature": 95, "hold_time_minutes": 1}, + {"temperature": 93, "hold_time_minutes": 1}, + {"temperature": 91, "hold_time_minutes": 1}, + {"temperature": 89, "hold_time_minutes": 1}, + {"temperature": 87, "hold_time_minutes": 1}, + {"temperature": 85, "hold_time_minutes": 1}, + {"temperature": 83, "hold_time_minutes": 1}, + {"temperature": 81, "hold_time_minutes": 1}, + {"temperature": 79, "hold_time_minutes": 1}, + {"temperature": 77, "hold_time_minutes": 1}, + {"temperature": 75, "hold_time_minutes": 1}, + {"temperature": 73, "hold_time_minutes": 1}, + {"temperature": 71, "hold_time_minutes": 1}, + {"temperature": 69, "hold_time_minutes": 1}, + {"temperature": 67, "hold_time_minutes": 1}, + {"temperature": 65, "hold_time_minutes": 1}, + {"temperature": 63, "hold_time_minutes": 1}, + {"temperature": 62, "hold_time_minutes": HYBRIDTIME * 60}, + ] + thermocycler.execute_profile(steps=profile_TAGSTOP, repetitions=1, block_max_volume=100) + thermocycler.set_block_temperature(10) + thermocycler.open_lid() + ############################################################################################################################################ + else: + protocol.comment("Hybridize off Deck") + + if STEP_CAPTURE == 1: + if DRYRUN == "NO": + heatershaker.set_and_wait_for_temperature(62) + + protocol.comment("--> Heating EEW") + EEWVol = 120 + p1000.pick_up_tip() + for loop, X in enumerate(["A8", "A9", "A10", "A11"]): + p1000.aspirate(EEWVol + 1, EEW.bottom(z=0.25), rate=0.25) + p1000.dispense(EEWVol + 5, sample_plate_1[sample_well].bottom(z=1)) + p1000.return_tip() # <---------------- Tip Return + + protocol.comment("--> Transfer Hybridization") + TransferSup = 100 + p1000.pick_up_tip() + p1000.move_to(sample_plate_1[sample_well].bottom(z=0.25)) + p1000.aspirate(TransferSup + 1, rate=0.25) + p1000.dispense(TransferSup + 5, sample_plate_2[sample_well].bottom(z=1)) + p1000.return_tip() + + thermocycler.close_lid() + + protocol.comment("--> ADDING SMB") + SMBVol = 250 + SampleVol = 100 + SMBMixRep = 15 * 60 if DRYRUN == "NO" else 0.1 * 60 + SMBPremix = 3 if DRYRUN == "NO" else 1 + # ========NEW SINGLE TIP DISPENSE=========== + p1000.pick_up_tip() + p1000.mix(SMBMixRep, 200, SMB.bottom(z=1)) + p1000.aspirate(SMBVol / 2, SMB.bottom(z=1), rate=0.25) + p1000.dispense(SMBVol / 2, sample_plate_2[sample_well].top(z=2), rate=0.25) + p1000.aspirate(SMBVol / 2, SMB.bottom(z=1), rate=0.25) + p1000.dispense(SMBVol / 2, sample_plate_2[sample_well].bottom(z=1), rate=0.25) + p1000.default_speed = 5 + p1000.move_to(sample_plate_2[sample_well].bottom(z=5)) + for Mix in range(2): + p1000.aspirate(100, rate=0.5) + p1000.move_to(sample_plate_2[sample_well].bottom(z=1)) + p1000.aspirate(80, rate=0.5) + p1000.dispense(80, rate=0.5) + p1000.move_to(sample_plate_2[sample_well].bottom(z=5)) + p1000.dispense(100, rate=0.5) + Mix += 1 + p1000.blow_out(sample_plate_2[sample_well].top(z=2)) + p1000.default_speed = 400 + p1000.move_to(sample_plate_2[sample_well].top(z=5)) + p1000.move_to(sample_plate_2[sample_well].top(z=0)) + p1000.move_to(sample_plate_2[sample_well].top(z=5)) + p1000.return_tip() + # ========NEW HS MIX========================= + protocol.delay(SMBMixRep) + + # ============================================================================================ + # GRIPPER MOVE sample_plate_2 FROM heatershaker TO MAGPLATE + heatershaker.open_labware_latch() + protocol.move_labware( + labware=sample_plate_2, + new_location=MAG_PLATE_SLOT, + use_gripper=USE_GRIPPER, + pick_up_offset=grip_offset("pick-up", "heater-shaker", 1), + drop_offset=grip_offset("drop", "mag-plate"), + ) + heatershaker.close_labware_latch() + # ============================================================================================ + + thermocycler.open_lid() + + if DRYRUN == "NO": + protocol.delay(minutes=2) + + protocol.comment("==============================================") + protocol.comment("--> WASH") + protocol.comment("==============================================") + + protocol.comment("--> Remove SUPERNATANT") + p1000.pick_up_tip() + p1000.move_to(sample_plate_2[sample_well].bottom(4)) + p1000.aspirate(200, rate=0.25) + p1000.dispense(200, Liquid_trash) + p1000.aspirate(200, rate=0.25) + p1000.dispense(200, Liquid_trash) + p1000.move_to(Liquid_trash.top(z=5)) + protocol.delay(minutes=0.1) + p1000.blow_out(Liquid_trash.top(z=5)) + p1000.aspirate(20) + p1000.return_tip() + + # ============================================================================================ + # GRIPPER MOVE sample_plate_2 FROM MAGPLATE TO heatershaker + heatershaker.open_labware_latch() + protocol.move_labware( + labware=sample_plate_2, + new_location=heatershaker, + use_gripper=USE_GRIPPER, + pick_up_offset=grip_offset("pick-up", "mag-plate"), + drop_offset=grip_offset("drop", "heater-shaker", 1), + ) + heatershaker.close_labware_latch() + # ============================================================================================ + + protocol.comment("--> Repeating 3 washes") + washreps = 3 + for wash in range(washreps): + + protocol.comment("--> Adding EEW") + EEWVol = 200 + p1000.pick_up_tip() + p1000.aspirate(EEWVol, WASHES[wash].bottom()) + p1000.dispense(EEWVol, sample_plate_2[sample_well].bottom()) + p1000.return_tip() + + heatershaker.close_labware_latch() + heatershaker.set_and_wait_for_shake_speed(rpm=1600) + protocol.delay(seconds=4 * 60) + heatershaker.deactivate_shaker() + heatershaker.open_labware_latch() + + if DRYRUN == "NO": + protocol.delay(seconds=5 * 60) + + # ============================================================================================ + # GRIPPER MOVE sample_plate_2 FROM heatershaker TO MAGPLATE + heatershaker.open_labware_latch() + protocol.move_labware( + labware=sample_plate_2, + new_location=MAG_PLATE_SLOT, + use_gripper=USE_GRIPPER, + pick_up_offset=grip_offset("pick-up", "heater-shaker", 1), + drop_offset=grip_offset("drop", "mag-plate"), + ) + heatershaker.close_labware_latch() + # ============================================================================================ + + protocol.comment("--> Removing Supernatant") + RemoveSup = 200 + p1000.pick_up_tip() + p1000.move_to(sample_plate_2[sample_well].bottom(z=3.5)) + p1000.aspirate(RemoveSup - 100, rate=0.25) + protocol.delay(minutes=0.1) + p1000.move_to(sample_plate_2[sample_well].bottom(z=0.5)) + p1000.aspirate(100, rate=0.25) + p1000.move_to(sample_plate_2[sample_well].top(z=2)) + p1000.dispense(200, Liquid_trash.top(z=0)) + protocol.delay(minutes=0.1) + p1000.blow_out(Liquid_trash.top(z=0)) + p1000.aspirate(20) + p1000.return_tip() + + # ============================================================================================ + # GRIPPER MOVE sample_plate_2 FROM MAGPLATE TO heatershaker + heatershaker.open_labware_latch() + protocol.move_labware( + labware=sample_plate_2, + new_location=heatershaker, + use_gripper=USE_GRIPPER, + pick_up_offset=grip_offset("pick-up", "mag-plate"), + drop_offset=grip_offset("drop", "heater-shaker", 1), + ) + heatershaker.close_labware_latch() + # ============================================================================================ + + protocol.comment("--> Adding EEW") + EEWVol = 200 + p1000.pick_up_tip() + p1000.aspirate(EEWVol, WASHES[3].bottom()) + p1000.dispense(EEWVol, sample_plate_2[sample_well].bottom()) + p1000.return_tip() + + heatershaker.set_and_wait_for_shake_speed(rpm=1600) + if DRYRUN == "NO": + protocol.delay(seconds=4 * 60) + heatershaker.deactivate_shaker() + + protocol.comment("--> Transfer Hybridization") + TransferSup = 200 + p1000.pick_up_tip() + p1000.move_to(sample_plate_2[sample_well].bottom(z=0.25)) + p1000.aspirate(TransferSup, rate=0.25) + sample_well = "A4" + p1000.dispense(TransferSup, sample_plate_2[sample_well].bottom(z=1)) + p1000.return_tip() + + protocol.delay(seconds=5 * 60) + + # ============================================================================================ + # GRIPPER MOVE sample_plate_2 FROM heatershaker TO MAGPLATE + heatershaker.open_labware_latch() + protocol.move_labware( + labware=sample_plate_2, + new_location=MAG_PLATE_SLOT, + use_gripper=USE_GRIPPER, + pick_up_offset=grip_offset("pick-up", "heater-shaker", 1), + drop_offset=grip_offset("drop", "mag-plate"), + ) + heatershaker.close_labware_latch() + # ============================================================================================ + + protocol.comment("--> Removing Supernatant") + RemoveSup = 200 + p1000.pick_up_tip() + p1000.move_to(sample_plate_2[sample_well].bottom(z=3.5)) + p1000.aspirate(RemoveSup - 100, rate=0.25) + protocol.delay(minutes=0.1) + p1000.move_to(sample_plate_2[sample_well].bottom(z=0.5)) + p1000.aspirate(100, rate=0.25) + p1000.move_to(sample_plate_2[sample_well].top(z=2)) + p1000.dispense(200, Liquid_trash.top(z=0)) + protocol.delay(minutes=0.1) + p1000.blow_out(Liquid_trash.top(z=0)) + p1000.aspirate(20) + p1000.return_tip() + + protocol.comment("--> Removing Residual") + p50.pick_up_tip() + p50.move_to(sample_plate_2[sample_well].bottom(z=0)) + p50.aspirate(50, rate=0.25) + p50.default_speed = 200 + p50.dispense(100, Liquid_trash.top(z=0)) + protocol.delay(minutes=0.1) + p50.blow_out() + p50.default_speed = 400 + p50.move_to(Liquid_trash.top(z=-5)) + p50.move_to(Liquid_trash.top(z=0)) + p50.return_tip() + + protocol.comment("==============================================") + protocol.comment("--> ELUTE") + protocol.comment("==============================================") + + protocol.comment("--> Adding EE1") + EluteVol = 23 + p50.pick_up_tip() + p50.aspirate(EluteVol, Elute.bottom()) + p50.dispense(EluteVol, sample_plate_2[sample_well].bottom()) + p50.return_tip() + + # ============================================================================================ + # GRIPPER MOVE sample_plate_2 FROM MAGPLATE TO heatershaker + heatershaker.open_labware_latch() + protocol.move_labware( + labware=sample_plate_2, + new_location=heatershaker, + use_gripper=USE_GRIPPER, + pick_up_offset=grip_offset("pick-up", "mag-plate"), + drop_offset=grip_offset("drop", "heater-shaker", 1), + ) + heatershaker.close_labware_latch() + # ============================================================================================ + + heatershaker.close_labware_latch() + heatershaker.set_and_wait_for_shake_speed(rpm=1600) + if DRYRUN == "NO": + protocol.delay(seconds=2 * 60) + heatershaker.deactivate_shaker() + heatershaker.open_labware_latch() + + if DRYRUN == "NO": + protocol.delay(minutes=2) + + # ============================================================================================ + # GRIPPER MOVE sample_plate_2 FROM heatershaker TO MAGPLATE + heatershaker.open_labware_latch() + protocol.move_labware( + labware=sample_plate_2, + new_location=MAG_PLATE_SLOT, + use_gripper=USE_GRIPPER, + pick_up_offset=grip_offset("pick-up", "heater-shaker", 1), + drop_offset=grip_offset("drop", "mag-plate"), + ) + heatershaker.close_labware_latch() + # ============================================================================================ + + protocol.comment("--> Transfer Elution") + TransferSup = 21 + p50.pick_up_tip() + p50.move_to(sample_plate_2[sample_well].bottom(z=0.25)) + p50.aspirate(TransferSup + 1, rate=0.25) + sample_well = "A5" + p50.dispense(TransferSup + 5, sample_plate_1[sample_well].bottom(z=1)) + p50.return_tip() + + protocol.comment("--> Adding ET2") + ET2Vol = 4 + ET2MixRep = 10 if DRYRUN == "NO" else 1 + ET2MixVol = 20 + p50.pick_up_tip() + p50.aspirate(ET2Vol, ET2.bottom()) + p50.dispense(ET2Vol, sample_plate_1[sample_well].bottom()) + p50.move_to(sample_plate_1[X].bottom()) + p50.mix(ET2MixRep, ET2MixVol) + p50.return_tip() + + if STEP_PCR == 1: + protocol.comment("==============================================") + protocol.comment("--> AMPLIFICATION") + protocol.comment("==============================================") + + protocol.comment("--> Adding PPC") + PPCVol = 5 + p50.pick_up_tip() + p50.aspirate(PPCVol, PPC.bottom()) + p50.dispense(PPCVol, sample_plate_1[sample_well].bottom()) + p50.return_tip() + + protocol.comment("--> Adding EPM") + EPMVol = 20 + EPMMixRep = 10 if DRYRUN == "NO" else 1 + EPMMixVol = 45 + p50.pick_up_tip() + p50.aspirate(EPMVol, EPM.bottom()) + p50.dispense(EPMVol, sample_plate_1[sample_well].bottom()) + p50.move_to(sample_plate_1[sample_well].bottom()) + p50.mix(EPMMixRep, EPMMixVol) + p50.return_tip() + + heatershaker.deactivate_heater() + + if STEP_PCRDECK == 1: + if DRYRUN == "NO": + ############################################################################################################################################ + protocol.pause("Seal, Run PCR (60min)") + if DRYRUN == "NO": + thermocycler.close_lid() + profile_PCR_1 = [{"temperature": 98, "hold_time_seconds": 45}] + thermocycler.execute_profile(steps=profile_PCR_1, repetitions=1, block_max_volume=50) + profile_PCR_2 = [ + {"temperature": 98, "hold_time_seconds": 30}, + {"temperature": 60, "hold_time_seconds": 30}, + {"temperature": 72, "hold_time_seconds": 30}, + ] + thermocycler.execute_profile(steps=profile_PCR_2, repetitions=12, block_max_volume=50) + profile_PCR_3 = [{"temperature": 72, "hold_time_minutes": 1}] + thermocycler.execute_profile(steps=profile_PCR_3, repetitions=1, block_max_volume=50) + thermocycler.set_block_temperature(10) + ############################################################################################################################################ + thermocycler.open_lid() + + if STEP_CLEANUP == 1: + protocol.comment("==============================================") + protocol.comment("--> Cleanup") + protocol.comment("==============================================") + + # ============================================================================================ + # GRIPPER MOVE sample_plate_2 FROM MAGPLATE TO heatershaker + heatershaker.open_labware_latch() + protocol.move_labware( + labware=sample_plate_2, + new_location=heatershaker, + use_gripper=USE_GRIPPER, + pick_up_offset=grip_offset("pick-up", "mag-plate"), + drop_offset=grip_offset("drop", "heater-shaker", 1), + ) + heatershaker.close_labware_latch() + # ============================================================================================ + + protocol.comment("--> Transfer Elution") + TransferSup = 45 + p50.pick_up_tip() + p50.move_to(sample_plate_1[sample_well].bottom(z=0.25)) + p50.aspirate(TransferSup + 1, rate=0.25) + sample_well = "A5" + p50.dispense(TransferSup + 5, sample_plate_2[sample_well].bottom(z=1)) + p50.return_tip() + + protocol.comment("--> ADDING AMPure (0.8x)") + AMPureVol = 40.5 + SampleVol = 45 + AMPureMixRep = 5 * 60 if DRYRUN == "NO" else 0.1 * 60 + AMPurePremix = 3 if DRYRUN == "NO" else 1 + # ========NEW SINGLE TIP DISPENSE=========== + p1000.pick_up_tip() + p1000.mix(AMPurePremix, AMPureVol + 10, AMPure.bottom(z=1)) + p1000.aspirate(AMPureVol, AMPure.bottom(z=1), rate=0.25) + p1000.dispense(AMPureVol, sample_plate_2[sample_well].bottom(z=1), rate=0.25) + p1000.default_speed = 5 + p1000.move_to(sample_plate_2[sample_well].bottom(z=5)) + for Mix in range(2): + p1000.aspirate(60, rate=0.5) + p1000.move_to(sample_plate_2[sample_well].bottom(z=1)) + p1000.aspirate(60, rate=0.5) + p1000.dispense(60, rate=0.5) + p1000.move_to(sample_plate_2[sample_well].bottom(z=5)) + p1000.dispense(30, rate=0.5) + Mix += 1 + p1000.blow_out(sample_plate_2[sample_well].top(z=2)) + p1000.default_speed = 400 + p1000.move_to(sample_plate_2[sample_well].top(z=5)) + p1000.move_to(sample_plate_2[sample_well].top(z=0)) + p1000.move_to(sample_plate_2[sample_well].top(z=5)) + p1000.return_tip() + # ========NEW HS MIX========================= + heatershaker.set_and_wait_for_shake_speed(rpm=1800) + protocol.delay(AMPureMixRep) + heatershaker.deactivate_shaker() + + # ============================================================================================ + # GRIPPER MOVE PLATE FROM HEATER SHAKER TO MAG PLATE + heatershaker.open_labware_latch() + protocol.move_labware( + labware=sample_plate_2, + new_location=MAG_PLATE_SLOT, + use_gripper=USE_GRIPPER, + pick_up_offset=grip_offset("pick-up", "heater-shaker", 1), + drop_offset=grip_offset("drop", "mag-plate"), + ) + heatershaker.close_labware_latch() + # ============================================================================================ + + if DRYRUN == "NO": + protocol.delay(minutes=4) + + protocol.comment("--> Removing Supernatant") + RemoveSup = 200 + p1000.pick_up_tip() + p1000.move_to(sample_plate_2[sample_well].bottom(z=3.5)) + p1000.aspirate(RemoveSup - 100, rate=0.25) + protocol.delay(minutes=0.1) + p1000.move_to(sample_plate_2[sample_well].bottom(z=0.5)) + p1000.aspirate(100, rate=0.25) + p1000.default_speed = 5 + p1000.move_to(sample_plate_2[sample_well].top(z=2)) + p1000.default_speed = 200 + p1000.dispense(200, Liquid_trash.top(z=0)) + protocol.delay(minutes=0.1) + p1000.blow_out() + p1000.default_speed = 400 + p1000.move_to(Liquid_trash.top(z=-5)) + p1000.move_to(Liquid_trash.top(z=0)) + p1000.return_tip() + + for X in range(2): + protocol.comment("--> ETOH Wash") + ETOHMaxVol = 150 + p1000.pick_up_tip() + p1000.aspirate(ETOHMaxVol, EtOH.bottom(z=1)) + p1000.move_to(EtOH.top(z=0)) + p1000.move_to(EtOH.top(z=-5)) + p1000.move_to(EtOH.top(z=0)) + p1000.move_to(sample_plate_2[sample_well].top(z=-2)) + p1000.dispense(ETOHMaxVol, rate=1) + protocol.delay(minutes=0.1) + p1000.blow_out() + p1000.move_to(sample_plate_2[sample_well].top(z=5)) + p1000.move_to(sample_plate_2[sample_well].top(z=0)) + p1000.move_to(sample_plate_2[sample_well].top(z=5)) + p1000.return_tip() + + if DRYRUN == "NO": + protocol.delay(minutes=0.5) + + protocol.comment("--> Remove ETOH Wash") + p1000.pick_up_tip() + p1000.move_to(sample_plate_2[sample_well].bottom(z=3.5)) + p1000.aspirate(RemoveSup - 100, rate=0.25) + protocol.delay(minutes=0.1) + p1000.move_to(sample_plate_2[sample_well].bottom(z=0.5)) + p1000.aspirate(100, rate=0.25) + p1000.default_speed = 5 + p1000.move_to(sample_plate_2[sample_well].top(z=2)) + p1000.default_speed = 200 + p1000.dispense(200, Liquid_trash.top(z=0)) + protocol.delay(minutes=0.1) + p1000.blow_out() + p1000.default_speed = 400 + p1000.move_to(Liquid_trash.top(z=-5)) + p1000.move_to(Liquid_trash.top(z=0)) + p1000.return_tip() + + if DRYRUN == "NO": + protocol.delay(minutes=2) + + protocol.comment("--> Removing Residual ETOH") + p1000.pick_up_tip() + p1000.move_to(sample_plate_2[sample_well].bottom(z=0)) + p1000.aspirate(50, rate=0.25) + p1000.default_speed = 200 + p1000.dispense(100, Liquid_trash.top(z=0)) + protocol.delay(minutes=0.1) + p1000.blow_out() + p1000.default_speed = 400 + p1000.move_to(Liquid_trash.top(z=-5)) + p1000.move_to(Liquid_trash.top(z=0)) + p1000.return_tip() + + if DRYRUN == "NO": + protocol.delay(minutes=1) + + # ============================================================================================ + # GRIPPER MOVE PLATE FROM MAG PLATE TO HEATER SHAKER + heatershaker.open_labware_latch() + protocol.move_labware( + labware=sample_plate_2, + new_location=heatershaker, + use_gripper=USE_GRIPPER, + pick_up_offset=grip_offset("pick-up", "mag-plate"), + drop_offset=grip_offset("drop", "heater-shaker", 1), + ) + heatershaker.close_labware_latch() + # ============================================================================================ + + protocol.comment("--> Adding RSB") + RSBVol = 32 + RSBMixRep = 1 * 60 if DRYRUN == "NO" else 0.1 * 60 + p1000.pick_up_tip() + p1000.aspirate(RSBVol, RSB.bottom(z=1)) + + p1000.move_to((sample_plate_2.wells_by_name()[sample_well].center().move(types.Point(x=1.3 * 0.8, y=0, z=-4)))) + p1000.dispense(RSBVol, rate=1) + p1000.move_to(sample_plate_2.wells_by_name()[sample_well].bottom(z=1)) + p1000.aspirate(RSBVol, rate=1) + p1000.move_to((sample_plate_2.wells_by_name()[sample_well].center().move(types.Point(x=0, y=1.3 * 0.8, z=-4)))) + p1000.dispense(RSBVol, rate=1) + p1000.move_to(sample_plate_2.wells_by_name()[sample_well].bottom(z=1)) + p1000.aspirate(RSBVol, rate=1) + p1000.move_to((sample_plate_2.wells_by_name()[sample_well].center().move(types.Point(x=1.3 * -0.8, y=0, z=-4)))) + p1000.dispense(RSBVol, rate=1) + p1000.move_to(sample_plate_2.wells_by_name()[sample_well].bottom(z=1)) + p1000.aspirate(RSBVol, rate=1) + p1000.move_to((sample_plate_2.wells_by_name()[sample_well].center().move(types.Point(x=0, y=1.3 * -0.8, z=-4)))) + p1000.dispense(RSBVol, rate=1) + p1000.move_to(sample_plate_2.wells_by_name()[sample_well].bottom(z=1)) + p1000.aspirate(RSBVol, rate=1) + p1000.dispense(RSBVol, rate=1) + + p1000.blow_out(sample_plate_2.wells_by_name()[sample_well].center()) + p1000.move_to(sample_plate_2.wells_by_name()[sample_well].top(z=5)) + p1000.move_to(sample_plate_2.wells_by_name()[sample_well].top(z=0)) + p1000.move_to(sample_plate_2.wells_by_name()[sample_well].top(z=5)) + p1000.return_tip() + heatershaker.set_and_wait_for_shake_speed(rpm=1600) + protocol.delay(RSBMixRep) + heatershaker.deactivate_shaker() + + # ============================================================================================ + # GRIPPER MOVE PLATE FROM HEATER SHAKER TO MAG PLATE + heatershaker.open_labware_latch() + protocol.move_labware( + labware=sample_plate_2, + new_location=MAG_PLATE_SLOT, + use_gripper=USE_GRIPPER, + pick_up_offset=grip_offset("pick-up", "heater-shaker", 1), + drop_offset=grip_offset("drop", "mag-plate"), + ) + heatershaker.close_labware_latch() + # ============================================================================================ + + if DRYRUN == "NO": + protocol.delay(minutes=3) + + protocol.comment("--> Transferring Supernatant") + TransferSup = 30 + p1000.pick_up_tip() + p1000.move_to(sample_plate_2[sample_well].bottom(z=0.25)) + p1000.aspirate(TransferSup + 1, rate=0.25) + p1000.dispense(TransferSup + 5, sample_plate_2["A7"].bottom(z=1)) + p1000.return_tip() diff --git a/app-testing/files/protocols/py/OT3_P1000MLeft_P50MRight_HS_MM_TC_TM_2_15_ABR3_Illumina_DNA_Enrichment_v4.py b/app-testing/files/protocols/py/OT3_P1000MLeft_P50MRight_HS_MM_TC_TM_2_15_ABR3_Illumina_DNA_Enrichment_v4.py new file mode 100644 index 000000000000..6b0c92b5298c --- /dev/null +++ b/app-testing/files/protocols/py/OT3_P1000MLeft_P50MRight_HS_MM_TC_TM_2_15_ABR3_Illumina_DNA_Enrichment_v4.py @@ -0,0 +1,1073 @@ +from opentrons import protocol_api +from opentrons import types + +metadata = { + "protocolName": "Illumina DNA Enrichment v4", + "author": "Opentrons ", + "source": "Protocol Library", +} + +requirements = { + "robotType": "OT-3", + "apiLevel": "2.15", +} + +# SCRIPT SETTINGS +DRYRUN = True # True = skip incubation times, shorten mix, for testing purposes +USE_GRIPPER = True # True = Uses Gripper, False = Manual Move +TIP_TRASH = False # True = Used tips go in Trash, False = Used tips go back into rack +HYBRID_PAUSE = True # True = sets a pause on the Hybridization + +# PROTOCOL SETTINGS +COLUMNS = 3 # 1-3 +HYBRIDDECK = True +HYBRIDTIME = 1.6 # Hours + +# PROTOCOL BLOCKS +STEP_VOLPOOL = 0 +STEP_HYB = 0 +STEP_CAPTURE = 1 +STEP_WASH = 1 +STEP_PCR = 1 +STEP_PCRDECK = 1 +STEP_CLEANUP = 1 + +############################################################################################################################################ +############################################################################################################################################ +############################################################################################################################################ + +p200_tips = 0 +p50_tips = 0 + +ABR_TEST = True +if ABR_TEST == True: + DRYRUN = True # Overrides to only DRYRUN + TIP_TRASH = False # Overrides to only REUSING TIPS + RUN = 3 # Repetitions +else: + RUN = 1 + + +def run(protocol: protocol_api.ProtocolContext): + + global p200_tips + global p50_tips + + if ABR_TEST == True: + protocol.comment("THIS IS A ABR RUN WITH " + str(RUN) + " REPEATS") + protocol.comment("THIS IS A DRY RUN") if DRYRUN == True else protocol.comment("THIS IS A REACTION RUN") + protocol.comment("USED TIPS WILL GO IN TRASH") if TIP_TRASH == True else protocol.comment( + "USED TIPS WILL BE RE-RACKED" + ) + + # DECK SETUP AND LABWARE + # ========== FIRST ROW =========== + heatershaker = protocol.load_module("heaterShakerModuleV1", "1") + sample_plate_2 = heatershaker.load_labware("nest_96_wellplate_2ml_deep") + reservoir = protocol.load_labware("nest_96_wellplate_2ml_deep", "2") + temp_block = protocol.load_module("temperature module gen2", "3") + reagent_plate = temp_block.load_labware("nest_96_wellplate_100ul_pcr_full_skirt") + # ========== SECOND ROW ========== + MAG_PLATE_SLOT = 4 + tiprack_200_1 = protocol.load_labware("opentrons_ot3_96_tiprack_200ul", "5") + tiprack_50_1 = protocol.load_labware("opentrons_ot3_96_tiprack_50ul", "6") + # ========== THIRD ROW =========== + thermocycler = protocol.load_module("thermocycler module gen2") + sample_plate_1 = thermocycler.load_labware("nest_96_wellplate_100ul_pcr_full_skirt") + tiprack_200_2 = protocol.load_labware("opentrons_ot3_96_tiprack_200ul", "8") + tiprack_50_2 = protocol.load_labware("opentrons_ot3_96_tiprack_50ul", "9") + # ========== FOURTH ROW ========== + tiprack_200_3 = protocol.load_labware("opentrons_ot3_96_tiprack_200ul", "11") + + # 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"] + Liquid_trash_well_4 = reservoir["A12"] + + # Will Be distributed during the protocol + EEW_1 = sample_plate_2.wells_by_name()["A10"] + EEW_2 = sample_plate_2.wells_by_name()["A11"] + EEW_3 = sample_plate_2.wells_by_name()["A12"] + + NHB2 = reagent_plate.wells_by_name()["A1"] + Panel = reagent_plate.wells_by_name()["A2"] + EHB2 = reagent_plate.wells_by_name()["A3"] + Elute = reagent_plate.wells_by_name()["A4"] + ET2 = reagent_plate.wells_by_name()["A5"] + PPC = reagent_plate.wells_by_name()["A6"] + EPM = reagent_plate.wells_by_name()["A7"] + + # pipette + p1000 = protocol.load_instrument( + "p1000_multi_gen3", "left", tip_racks=[tiprack_200_1, tiprack_200_2, tiprack_200_3] + ) + p50 = protocol.load_instrument("p50_multi_gen3", "right", tip_racks=[tiprack_50_1, tiprack_50_2]) + + # tip and sample tracking + if COLUMNS == 1: + column_1_list = ["A1"] # Plate 1 + column_2_list = ["A1"] # Plate 2 + column_3_list = ["A4"] # Plate 2 + column_4_list = ["A4"] # Plate 1 + column_5_list = ["A7"] # Plate 2 + column_6_list = ["A7"] # Plate 1 + WASHES = [EEW_1] + if COLUMNS == 2: + column_1_list = ["A1", "A2"] # Plate 1 + column_2_list = ["A1", "A2"] # Plate 2 + column_3_list = ["A4", "A5"] # Plate 2 + column_4_list = ["A4", "A5"] # Plate 1 + column_5_list = ["A7", "A8"] # Plate 2 + column_6_list = ["A7", "A8"] # Plate 1 + WASHES = [EEW_1, EEW_2] + if COLUMNS == 3: + column_1_list = ["A1", "A2", "A3"] # Plate 1 + column_2_list = ["A1", "A2", "A3"] # Plate 2 + column_3_list = ["A4", "A5", "A6"] # Plate 2 + column_4_list = ["A4", "A5", "A6"] # Plate 1 + column_5_list = ["A7", "A8", "A9"] # Plate 2 + column_6_list = ["A7", "A8", "A9"] # Plate 1 + WASHES = [EEW_1, EEW_2, EEW_3] + + def tipcheck(): + if p200_tips >= 3 * 12: + if ABR_TEST == True: + p1000.reset_tipracks() + else: + protocol.pause("RESET p200 TIPS") + p1000.reset_tipracks() + p200_tips == 0 + if p50_tips >= 2 * 12: + if ABR_TEST == True: + p50.reset_tipracks() + else: + protocol.pause("RESET p50 TIPS") + p50.reset_tipracks() + p50_tips == 0 + + def grip_offset(action, item, slot=None): + """Grip offset.""" + from opentrons.types import Point + + # EDIT these values + # NOTE: we are still testing to determine our software's defaults + # but we also expect users will want to edit these + _pick_up_offsets = { + "deck": Point(), + "mag-plate": Point(), + "heater-shaker": Point(z=1.0), + "temp-module": Point(), + "thermo-cycler": Point(), + } + # EDIT these values + # NOTE: we are still testing to determine our software's defaults + # but we also expect users will want to edit these + _drop_offsets = { + "deck": Point(), + "mag-plate": Point(x=0.1, y=-0.25, z=0.5), + "heater-shaker": Point(y=-0.5), + "temp-module": Point(), + "thermo-cycler": Point(), + } + # do NOT edit these values + # NOTE: these values will eventually be in our software + # and will not need to be inside a protocol + _hw_offsets = { + "deck": Point(), + "mag-plate": Point(z=34.5), + "heater-shaker-right": Point(z=2.5), + "heater-shaker-left": Point(z=2.5), + "temp-module": Point(z=5.0), + "thermo-cycler": Point(z=2.5), + } + # make sure arguments are correct + action_options = ["pick-up", "drop"] + item_options = list(_hw_offsets.keys()) + item_options.remove("heater-shaker-left") + item_options.remove("heater-shaker-right") + item_options.append("heater-shaker") + if action not in action_options: + raise ValueError(f'"{action}" not recognized, available options: {action_options}') + if item not in item_options: + raise ValueError(f'"{item}" not recognized, available options: {item_options}') + if item == "heater-shaker": + assert slot, 'argument slot= is required when using "heater-shaker"' + if slot in [1, 4, 7, 10]: + side = "left" + elif slot in [3, 6, 9, 12]: + side = "right" + else: + raise ValueError("heater shaker must be on either left or right side") + hw_offset = _hw_offsets[f"{item}-{side}"] + else: + hw_offset = _hw_offsets[item] + if action == "pick-up": + offset = hw_offset + _pick_up_offsets[item] + else: + offset = hw_offset + _drop_offsets[item] + + # convert from Point() to dict() + return {"x": offset.x, "y": offset.y, "z": offset.z} + + ############################################################################################################################################ + ############################################################################################################################################ + ############################################################################################################################################ + # commands + for loop in range(RUN): + thermocycler.open_lid() + heatershaker.open_labware_latch() + if DRYRUN == False: + if STEP_HYB == 1: + protocol.comment("SETTING THERMO and TEMP BLOCK Temperature") + thermocycler.set_block_temperature(4) + thermocycler.set_lid_temperature(100) + temp_block.set_temperature(4) + else: + protocol.comment("SETTING THERMO and TEMP BLOCK Temperature") + thermocycler.set_block_temperature(58) + thermocycler.set_lid_temperature(58) + heatershaker.set_and_wait_for_temperature(58) + protocol.pause("Ready") + heatershaker.close_labware_latch() + Liquid_trash = Liquid_trash_well_1 + + # Sample Plate contains 30ul of DNA + + if STEP_VOLPOOL == 1: + protocol.comment("==============================================") + protocol.comment("--> Quick Vol Pool") + protocol.comment("==============================================") + + if STEP_HYB == 1: + protocol.comment("==============================================") + protocol.comment("--> HYB") + protocol.comment("==============================================") + + protocol.comment("--> Adding NHB2") + NHB2Vol = 50 + for loop, X in enumerate(column_1_list): + p50.pick_up_tip() + p50.aspirate(NHB2Vol, NHB2.bottom()) + p50.dispense(NHB2Vol, sample_plate_1[X].bottom()) + p50.return_tip() if TIP_TRASH == False else p50.drop_tip() + p50_tips += 1 + tipcheck() + + protocol.comment("--> Adding Panel") + PanelVol = 10 + for loop, X in enumerate(column_1_list): + p50.pick_up_tip() + p50.aspirate(PanelVol, Panel.bottom()) + p50.dispense(PanelVol, sample_plate_1[X].bottom()) + p50.return_tip() if TIP_TRASH == False else p50.drop_tip() + p50_tips += 1 + tipcheck() + + protocol.comment("--> Adding EHB2") + EHB2Vol = 10 + EHB2MixRep = 10 if DRYRUN == False else 1 + EHB2MixVol = 90 + for loop, X in enumerate(column_1_list): + p1000.pick_up_tip() + p1000.aspirate(EHB2Vol, EHB2.bottom()) + p1000.dispense(EHB2Vol, sample_plate_1[X].bottom()) + p1000.move_to(sample_plate_1[X].bottom()) + p1000.mix(EHB2MixRep, EHB2MixVol) + p1000.return_tip() if TIP_TRASH == False else p1000.drop_tip() + p50_tips += 1 + tipcheck() + + if HYBRIDDECK == True: + protocol.comment("Hybridize on Deck") + ############################################################################################################################################ + thermocycler.close_lid() + if DRYRUN == False: + profile_TAGSTOP = [ + {"temperature": 98, "hold_time_minutes": 5}, + {"temperature": 97, "hold_time_minutes": 1}, + {"temperature": 95, "hold_time_minutes": 1}, + {"temperature": 93, "hold_time_minutes": 1}, + {"temperature": 91, "hold_time_minutes": 1}, + {"temperature": 89, "hold_time_minutes": 1}, + {"temperature": 87, "hold_time_minutes": 1}, + {"temperature": 85, "hold_time_minutes": 1}, + {"temperature": 83, "hold_time_minutes": 1}, + {"temperature": 81, "hold_time_minutes": 1}, + {"temperature": 79, "hold_time_minutes": 1}, + {"temperature": 77, "hold_time_minutes": 1}, + {"temperature": 75, "hold_time_minutes": 1}, + {"temperature": 73, "hold_time_minutes": 1}, + {"temperature": 71, "hold_time_minutes": 1}, + {"temperature": 69, "hold_time_minutes": 1}, + {"temperature": 67, "hold_time_minutes": 1}, + {"temperature": 65, "hold_time_minutes": 1}, + {"temperature": 63, "hold_time_minutes": 1}, + {"temperature": 62, "hold_time_minutes": HYBRIDTIME * 60}, + ] + thermocycler.execute_profile(steps=profile_TAGSTOP, repetitions=1, block_max_volume=100) + thermocycler.set_block_temperature(62) + if HYBRID_PAUSE == True: + protocol.comment("HYBRIDIZATION PAUSED") + thermocycler.set_block_temperature(10) + thermocycler.open_lid() + ############################################################################################################################################ + else: + protocol.comment("Hybridize off Deck") + + if STEP_CAPTURE == 1: + protocol.comment("==============================================") + protocol.comment("--> Capture") + protocol.comment("==============================================") + # Standard Setup + + if DRYRUN == False: + protocol.comment("SETTING THERMO and TEMP BLOCK Temperature") + thermocycler.set_block_temperature(58) + thermocycler.set_lid_temperature(58) + + if DRYRUN == False: + heatershaker.set_and_wait_for_temperature(58) + + protocol.comment("--> Transfer Hybridization") + TransferSup = 100 + for loop, X in enumerate(column_1_list): + p1000.pick_up_tip() + p1000.move_to(sample_plate_1[X].bottom(z=0.25)) + p1000.aspirate(TransferSup + 1, rate=0.25) + p1000.dispense(TransferSup + 5, sample_plate_2[column_2_list[loop]].bottom(z=1)) + p1000.return_tip() if TIP_TRASH == False else p1000.drop_tip() + p200_tips += 1 + tipcheck() + + thermocycler.close_lid() + + protocol.comment("--> ADDING SMB") + SMBVol = 250 + SampleVol = 100 + SMBMixRPM = 2000 + SMBMixRep = 5 * 60 if DRYRUN == False else 0.1 * 60 + SMBPremix = 3 if DRYRUN == False else 1 + # ============================== + for loop, X in enumerate(column_2_list): + p1000.pick_up_tip() + p1000.mix(SMBPremix, 200, SMB.bottom(z=1)) + p1000.aspirate(SMBVol / 2, SMB.bottom(z=1), rate=0.25) + p1000.dispense(SMBVol / 2, sample_plate_2[X].top(z=-7), rate=0.25) + p1000.aspirate(SMBVol / 2, SMB.bottom(z=1), rate=0.25) + p1000.dispense(SMBVol / 2, sample_plate_2[X].bottom(z=1), rate=0.25) + p1000.default_speed = 5 + p1000.move_to(sample_plate_2[X].bottom(z=5)) + for Mix in range(2): + p1000.aspirate(100, rate=0.5) + p1000.move_to(sample_plate_2[X].bottom(z=1)) + p1000.aspirate(80, rate=0.5) + p1000.dispense(80, rate=0.5) + p1000.move_to(sample_plate_2[X].bottom(z=5)) + p1000.dispense(100, rate=0.5) + Mix += 1 + p1000.blow_out(sample_plate_2[X].top(z=-7)) + p1000.default_speed = 400 + p1000.move_to(sample_plate_2[X].top(z=5)) + p1000.move_to(sample_plate_2[X].top(z=0)) + p1000.move_to(sample_plate_2[X].top(z=5)) + p1000.return_tip() if TIP_TRASH == False else p1000.drop_tip() + p200_tips += 1 + tipcheck() + # ============================== + heatershaker.set_and_wait_for_shake_speed(rpm=SMBMixRPM) + protocol.delay(SMBMixRep) + heatershaker.deactivate_shaker() + + # ============================================================================================ + # GRIPPER MOVE sample_plate_2 FROM heatershaker TO MAGPLATE + heatershaker.open_labware_latch() + protocol.move_labware( + labware=sample_plate_2, + new_location=MAG_PLATE_SLOT, + use_gripper=USE_GRIPPER, + pick_up_offset=grip_offset("pick-up", "heater-shaker", 1), + drop_offset=grip_offset("drop", "mag-plate"), + ) + heatershaker.close_labware_latch() + # ============================================================================================ + + thermocycler.open_lid() + + if DRYRUN == False: + protocol.delay(minutes=2) + + protocol.comment("==============================================") + protocol.comment("--> WASH") + protocol.comment("==============================================") + # Setting Labware to Resume at Cleanup 1 + + protocol.comment("--> Remove SUPERNATANT") + for loop, X in enumerate(column_2_list): + p1000.pick_up_tip() + p1000.move_to(sample_plate_2[X].bottom(4)) + p1000.aspirate(200, rate=0.25) + p1000.dispense(200, Liquid_trash.top(z=-7)) + p1000.move_to(sample_plate_2[X].bottom(0.5)) + p1000.aspirate(200, rate=0.25) + p1000.dispense(200, Liquid_trash.top(z=-7)) + p1000.move_to(Liquid_trash.top(z=-7)) + protocol.delay(minutes=0.1) + p1000.blow_out(Liquid_trash.top(z=-7)) + p1000.aspirate(20) + p1000.return_tip() if TIP_TRASH == False else p1000.drop_tip() + p200_tips += 1 + tipcheck() + + Liquid_trash = Liquid_trash_well_2 + + # ============================================================================================ + # GRIPPER MOVE sample_plate_2 FROM MAGPLATE TO heatershaker + heatershaker.open_labware_latch() + protocol.move_labware( + labware=sample_plate_2, + new_location=heatershaker, + use_gripper=USE_GRIPPER, + pick_up_offset=grip_offset("pick-up", "mag-plate"), + drop_offset=grip_offset("drop", "heater-shaker", 1), + ) + heatershaker.close_labware_latch() + # ============================================================================================ + + protocol.comment("--> Repeating 3 washes") + washreps = 3 + washcount = 0 + for wash in range(washreps): + + protocol.comment("--> Adding EEW") + EEWVol = 200 + for loop, X in enumerate(column_2_list): + p1000.pick_up_tip() + p1000.aspirate(EEWVol, WASHES[loop].bottom()) + p1000.dispense(EEWVol, sample_plate_2[X].bottom()) + p1000.return_tip() if TIP_TRASH == False else p1000.drop_tip() + p200_tips += 1 + tipcheck() + heatershaker.close_labware_latch() + heatershaker.set_and_wait_for_shake_speed(rpm=1800) + if DRYRUN == False: + protocol.delay(seconds=4 * 60) + heatershaker.deactivate_shaker() + heatershaker.open_labware_latch() + + if DRYRUN == False: + protocol.delay(seconds=5 * 60) + + # ============================================================================================ + # GRIPPER MOVE sample_plate_2 FROM heatershaker TO MAGPLATE + heatershaker.open_labware_latch() + protocol.move_labware( + labware=sample_plate_2, + new_location=MAG_PLATE_SLOT, + use_gripper=USE_GRIPPER, + pick_up_offset=grip_offset("pick-up", "heater-shaker", 1), + drop_offset=grip_offset("drop", "mag-plate"), + ) + heatershaker.close_labware_latch() + # ============================================================================================ + + if DRYRUN == False: + protocol.delay(seconds=1 * 60) + + if washcount > 2: + Liquid_trash = Liquid_trash_well_3 + + protocol.comment("--> Removing Supernatant") + RemoveSup = 200 + for loop, X in enumerate(column_2_list): + p1000.pick_up_tip() + p1000.move_to(sample_plate_2[X].bottom(z=3.5)) + p1000.aspirate(RemoveSup - 100, rate=0.25) + protocol.delay(minutes=0.1) + p1000.move_to(sample_plate_2[X].bottom(z=0.5)) + p1000.aspirate(100, rate=0.25) + p1000.move_to(sample_plate_2[X].top(z=0.5)) + p1000.dispense(200, Liquid_trash.top(z=-7)) + protocol.delay(minutes=0.1) + p1000.blow_out(Liquid_trash.top(z=-7)) + p1000.aspirate(20) + p1000.return_tip() if TIP_TRASH == False else p1000.drop_tip() + p200_tips += 1 + tipcheck() + + # ============================================================================================ + # GRIPPER MOVE sample_plate_2 FROM MAGPLATE TO heatershaker + heatershaker.open_labware_latch() + protocol.move_labware( + labware=sample_plate_2, + new_location=heatershaker, + use_gripper=USE_GRIPPER, + pick_up_offset=grip_offset("pick-up", "mag-plate"), + drop_offset=grip_offset("drop", "heater-shaker", 1), + ) + heatershaker.close_labware_latch() + # ============================================================================================ + + washcount += 1 + + protocol.comment("--> Adding EEW") + EEWVol = 200 + for loop, X in enumerate(column_2_list): + p1000.pick_up_tip() + p1000.aspirate(EEWVol, WASHES[loop].bottom()) + p1000.dispense(EEWVol, sample_plate_2[X].bottom()) + p1000.return_tip() if TIP_TRASH == False else p1000.drop_tip() + p200_tips += 1 + tipcheck() + + heatershaker.set_and_wait_for_shake_speed(rpm=1800) + if DRYRUN == False: + protocol.delay(seconds=4 * 60) + heatershaker.deactivate_shaker() + + if DRYRUN == False: + protocol.delay(seconds=1 * 60) + + protocol.comment("--> Transfer Hybridization") + TransferSup = 200 + for loop, X in enumerate(column_2_list): + p1000.pick_up_tip() + p1000.move_to(sample_plate_2[X].bottom(z=0.25)) + p1000.aspirate(TransferSup, rate=0.25) + p1000.dispense(TransferSup, sample_plate_2[column_3_list[loop]].bottom(z=1)) + p1000.return_tip() if TIP_TRASH == False else p1000.drop_tip() + p200_tips += 1 + tipcheck() + + if DRYRUN == False: + protocol.delay(seconds=5 * 60) + + # ============================================================================================ + # GRIPPER MOVE sample_plate_2 FROM heatershaker TO MAGPLATE + heatershaker.open_labware_latch() + protocol.move_labware( + labware=sample_plate_2, + new_location=MAG_PLATE_SLOT, + use_gripper=USE_GRIPPER, + pick_up_offset=grip_offset("pick-up", "heater-shaker", 1), + drop_offset=grip_offset("drop", "mag-plate"), + ) + heatershaker.close_labware_latch() + # ============================================================================================ + + if DRYRUN == False: + protocol.delay(seconds=1 * 60) + + protocol.comment("--> Removing Supernatant") + RemoveSup = 150 + for loop, X in enumerate(column_3_list): + p1000.pick_up_tip() + p1000.move_to(sample_plate_2[X].bottom(z=3.5)) + p1000.aspirate(RemoveSup - 100, rate=0.25) + protocol.delay(minutes=0.1) + p1000.move_to(sample_plate_2[X].bottom(z=0.5)) + p1000.aspirate(100, rate=0.25) + p1000.move_to(sample_plate_2[X].top(z=0.5)) + p1000.dispense(200, Liquid_trash.top(z=-7)) + protocol.delay(minutes=0.1) + p1000.blow_out(Liquid_trash.top(z=-7)) + p1000.aspirate(20) + p1000.return_tip() if TIP_TRASH == False else p1000.drop_tip() + p200_tips += 1 + tipcheck() + + protocol.comment("--> Removing Residual") + for loop, X in enumerate(column_3_list): + p50.pick_up_tip() + p50.move_to(sample_plate_2[X].bottom(z=0)) + p50.aspirate(50, rate=0.25) + p50.default_speed = 200 + p50.dispense(100, Liquid_trash.top(z=-7)) + protocol.delay(minutes=0.1) + p50.blow_out() + p50.default_speed = 400 + p50.move_to(Liquid_trash.top(z=-7)) + p50.move_to(Liquid_trash.top(z=0)) + p50.return_tip() if TIP_TRASH == False else p50.drop_tip() + p50_tips += 1 + tipcheck() + + protocol.comment("==============================================") + protocol.comment("--> ELUTE") + protocol.comment("==============================================") + + protocol.comment("--> Adding Elute") + EluteVol = 23 + for loop, X in enumerate(column_3_list): + p50.pick_up_tip() + p50.aspirate(EluteVol, Elute.bottom()) + p50.dispense(EluteVol, sample_plate_2[X].bottom()) + p50.return_tip() if TIP_TRASH == False else p50.drop_tip() + p50_tips += 1 + tipcheck() + + # ============================================================================================ + # GRIPPER MOVE sample_plate_2 FROM MAGPLATE TO heatershaker + heatershaker.open_labware_latch() + protocol.move_labware( + labware=sample_plate_2, + new_location=heatershaker, + use_gripper=USE_GRIPPER, + pick_up_offset=grip_offset("pick-up", "mag-plate"), + drop_offset=grip_offset("drop", "heater-shaker", 1), + ) + heatershaker.close_labware_latch() + # ============================================================================================ + + heatershaker.close_labware_latch() + heatershaker.set_and_wait_for_shake_speed(rpm=1800) + if DRYRUN == False: + protocol.delay(seconds=2 * 60) + heatershaker.deactivate_shaker() + heatershaker.open_labware_latch() + + if DRYRUN == False: + protocol.delay(minutes=2) + + # ============================================================================================ + # GRIPPER MOVE sample_plate_2 FROM heatershaker TO MAGPLATE + heatershaker.open_labware_latch() + protocol.move_labware( + labware=sample_plate_2, + new_location=MAG_PLATE_SLOT, + use_gripper=USE_GRIPPER, + pick_up_offset=grip_offset("pick-up", "heater-shaker", 1), + drop_offset=grip_offset("drop", "mag-plate"), + ) + heatershaker.close_labware_latch() + # ============================================================================================ + + protocol.comment("--> Transfer Elution") + TransferSup = 21 + for loop, X in enumerate(column_3_list): + p50.pick_up_tip() + p50.move_to(sample_plate_2[X].bottom(z=0.25)) + p50.aspirate(TransferSup + 1, rate=0.25) + p50.dispense(TransferSup + 5, sample_plate_1[column_4_list[loop]].bottom(z=1)) + p50.return_tip() if TIP_TRASH == False else p50.drop_tip() + p50_tips += 1 + tipcheck() + + protocol.comment("--> Adding ET2") + ET2Vol = 4 + ET2MixRep = 10 if DRYRUN == False else 1 + ET2MixVol = 20 + for loop, X in enumerate(column_4_list): + p50.pick_up_tip() + p50.aspirate(ET2Vol, ET2.bottom()) + p50.dispense(ET2Vol, sample_plate_1[X].bottom()) + p50.move_to(sample_plate_1[X].bottom()) + p50.mix(ET2MixRep, ET2MixVol) + p50.return_tip() if TIP_TRASH == False else p50.drop_tip() + p50_tips += 1 + tipcheck() + + if STEP_PCR == 1: + protocol.comment("==============================================") + protocol.comment("--> AMPLIFICATION") + protocol.comment("==============================================") + + protocol.comment("--> Adding PPC") + PPCVol = 5 + for loop, X in enumerate(column_4_list): + p50.pick_up_tip() + p50.aspirate(PPCVol, PPC.bottom()) + p50.dispense(PPCVol, sample_plate_1[X].bottom()) + p50.return_tip() if TIP_TRASH == False else p50.drop_tip() + p50_tips += 1 + tipcheck() + + protocol.comment("--> Adding EPM") + EPMVol = 20 + EPMMixRep = 10 if DRYRUN == False else 1 + EPMMixVol = 45 + for loop, X in enumerate(column_4_list): + p50.pick_up_tip() + p50.aspirate(EPMVol, EPM.bottom()) + p50.dispense(EPMVol, sample_plate_1[X].bottom()) + p50.move_to(sample_plate_1[X].bottom()) + p50.mix(EPMMixRep, EPMMixVol) + p50.return_tip() if TIP_TRASH == False else p50.drop_tip() + p50_tips += 1 + tipcheck() + + if DRYRUN == False: + heatershaker.deactivate_heater() + + if STEP_PCRDECK == 1: + if DRYRUN == False: + ############################################################################################################################################ + if DRYRUN == False: + thermocycler.close_lid() + profile_PCR_1 = [{"temperature": 98, "hold_time_seconds": 45}] + thermocycler.execute_profile(steps=profile_PCR_1, repetitions=1, block_max_volume=50) + profile_PCR_2 = [ + {"temperature": 98, "hold_time_seconds": 30}, + {"temperature": 60, "hold_time_seconds": 30}, + {"temperature": 72, "hold_time_seconds": 30}, + ] + thermocycler.execute_profile(steps=profile_PCR_2, repetitions=12, block_max_volume=50) + profile_PCR_3 = [{"temperature": 72, "hold_time_minutes": 1}] + thermocycler.execute_profile(steps=profile_PCR_3, repetitions=1, block_max_volume=50) + thermocycler.set_block_temperature(10) + ############################################################################################################################################ + + thermocycler.open_lid() + + if STEP_CLEANUP == 1: + protocol.comment("==============================================") + protocol.comment("--> Cleanup") + protocol.comment("==============================================") + + # ============================================================================================ + # GRIPPER MOVE sample_plate_2 FROM MAGPLATE TO heatershaker + heatershaker.open_labware_latch() + protocol.move_labware( + labware=sample_plate_2, + new_location=heatershaker, + use_gripper=USE_GRIPPER, + pick_up_offset=grip_offset("pick-up", "mag-plate"), + drop_offset=grip_offset("drop", "heater-shaker", 1), + ) + heatershaker.close_labware_latch() + # ============================================================================================ + + protocol.comment("--> Transfer Elution") + TransferSup = 45 + for loop, X in enumerate(column_4_list): + p50.pick_up_tip() + p50.move_to(sample_plate_1[X].bottom(z=0.25)) + p50.aspirate(TransferSup + 1, rate=0.25) + p50.dispense(TransferSup + 5, sample_plate_2[column_5_list[loop]].bottom(z=1)) + p50.return_tip() if TIP_TRASH == False else p50.drop_tip() + p50_tips += 1 + tipcheck() + + Liquid_trash = Liquid_trash_well_4 + + protocol.comment("--> ADDING AMPure (0.8x)") + AMPureVol = 40.5 + SampleVol = 45 + AMPureMixRep = 5 * 60 if DRYRUN == False else 0.1 * 60 + AMPurePremix = 3 if DRYRUN == False else 1 + # ========NEW SINGLE TIP DISPENSE=========== + for loop, X in enumerate(column_5_list): + p1000.pick_up_tip() + p1000.mix(AMPurePremix, AMPureVol + 10, AMPure.bottom(z=1)) + p1000.aspirate(AMPureVol, AMPure.bottom(z=1), rate=0.25) + p1000.dispense(AMPureVol, sample_plate_2[X].bottom(z=1), rate=0.25) + p1000.default_speed = 5 + p1000.move_to(sample_plate_2[X].bottom(z=5)) + for Mix in range(2): + p1000.aspirate(60, rate=0.5) + p1000.move_to(sample_plate_2[X].bottom(z=1)) + p1000.aspirate(60, rate=0.5) + p1000.dispense(60, rate=0.5) + p1000.move_to(sample_plate_2[X].bottom(z=5)) + p1000.dispense(30, rate=0.5) + Mix += 1 + p1000.blow_out(sample_plate_2[X].top(z=2)) + p1000.default_speed = 400 + p1000.move_to(sample_plate_2[X].top(z=5)) + p1000.move_to(sample_plate_2[X].top(z=0)) + p1000.move_to(sample_plate_2[X].top(z=5)) + p1000.return_tip() if TIP_TRASH == False else p1000.drop_tip() + p200_tips += 1 + tipcheck() + # ========NEW HS MIX========================= + heatershaker.set_and_wait_for_shake_speed(rpm=1800) + protocol.delay(AMPureMixRep) + heatershaker.deactivate_shaker() + + # ============================================================================================ + # GRIPPER MOVE PLATE FROM HEATER SHAKER TO MAG PLATE + heatershaker.open_labware_latch() + protocol.move_labware( + labware=sample_plate_2, + new_location=MAG_PLATE_SLOT, + use_gripper=USE_GRIPPER, + pick_up_offset=grip_offset("pick-up", "heater-shaker", 1), + drop_offset=grip_offset("drop", "mag-plate"), + ) + heatershaker.close_labware_latch() + # ============================================================================================ + + if DRYRUN == False: + protocol.delay(minutes=4) + + protocol.comment("--> Removing Supernatant") + RemoveSup = 200 + for loop, X in enumerate(column_5_list): + p1000.pick_up_tip() + p1000.move_to(sample_plate_2[X].bottom(z=3.5)) + p1000.aspirate(RemoveSup - 100, rate=0.25) + protocol.delay(minutes=0.1) + p1000.move_to(sample_plate_2[X].bottom(z=0.5)) + p1000.aspirate(100, rate=0.25) + p1000.default_speed = 5 + p1000.move_to(sample_plate_2[X].top(z=2)) + p1000.default_speed = 200 + p1000.dispense(200, Liquid_trash.top(z=-7)) + protocol.delay(minutes=0.1) + p1000.blow_out() + p1000.default_speed = 400 + p1000.move_to(Liquid_trash.top(z=-7)) + p1000.move_to(Liquid_trash.top(z=0)) + p1000.return_tip() if TIP_TRASH == False else p1000.drop_tip() + p200_tips += 1 + tipcheck() + + for X in range(2): + protocol.comment("--> ETOH Wash") + ETOHMaxVol = 150 + for loop, X in enumerate(column_5_list): + p1000.pick_up_tip() + p1000.aspirate(ETOHMaxVol, EtOH.bottom(z=1)) + p1000.move_to(EtOH.top(z=0)) + p1000.move_to(EtOH.top(z=-5)) + p1000.move_to(EtOH.top(z=0)) + p1000.move_to(sample_plate_2[X].top(z=-2)) + p1000.dispense(ETOHMaxVol, rate=1) + protocol.delay(minutes=0.1) + p1000.blow_out() + p1000.move_to(sample_plate_2[X].top(z=5)) + p1000.move_to(sample_plate_2[X].top(z=0)) + p1000.move_to(sample_plate_2[X].top(z=5)) + p1000.return_tip() if TIP_TRASH == False else p1000.drop_tip() + p200_tips += 1 + tipcheck() + + if DRYRUN == False: + protocol.delay(minutes=0.5) + + protocol.comment("--> Remove ETOH Wash") + for loop, X in enumerate(column_5_list): + p1000.pick_up_tip() + p1000.move_to(sample_plate_2[X].bottom(z=3.5)) + p1000.aspirate(RemoveSup - 100, rate=0.25) + protocol.delay(minutes=0.1) + p1000.move_to(sample_plate_2[X].bottom(z=0.5)) + p1000.aspirate(100, rate=0.25) + p1000.default_speed = 5 + p1000.move_to(sample_plate_2[X].top(z=2)) + p1000.default_speed = 200 + p1000.dispense(200, Liquid_trash.top(z=-7)) + protocol.delay(minutes=0.1) + p1000.blow_out() + p1000.default_speed = 400 + p1000.move_to(Liquid_trash.top(z=-7)) + p1000.move_to(Liquid_trash.top(z=0)) + p1000.return_tip() if TIP_TRASH == False else p1000.drop_tip() + p200_tips += 1 + tipcheck() + + if DRYRUN == False: + protocol.delay(minutes=2) + + protocol.comment("--> Removing Residual ETOH") + for loop, X in enumerate(column_5_list): + p1000.pick_up_tip() + p1000.move_to(sample_plate_2[X].bottom(z=0)) + p1000.aspirate(50, rate=0.25) + p1000.default_speed = 200 + p1000.dispense(100, Liquid_trash.top(z=-7)) + protocol.delay(minutes=0.1) + p1000.blow_out() + p1000.default_speed = 400 + p1000.move_to(Liquid_trash.top(z=-7)) + p1000.move_to(Liquid_trash.top(z=0)) + p1000.return_tip() if TIP_TRASH == False else p1000.drop_tip() + p200_tips += 1 + tipcheck() + + if DRYRUN == False: + protocol.delay(minutes=1) + + # ============================================================================================ + # GRIPPER MOVE PLATE FROM MAG PLATE TO HEATER SHAKER + heatershaker.open_labware_latch() + protocol.move_labware( + labware=sample_plate_2, + new_location=heatershaker, + use_gripper=USE_GRIPPER, + pick_up_offset=grip_offset("pick-up", "mag-plate"), + drop_offset=grip_offset("drop", "heater-shaker", 1), + ) + heatershaker.close_labware_latch() + # ============================================================================================ + + protocol.comment("--> Adding RSB") + RSBVol = 32 + RSBMixRep = 1 * 60 if DRYRUN == False else 0.1 * 60 + for loop, X in enumerate(column_5_list): + p1000.pick_up_tip() + p1000.aspirate(RSBVol, RSB.bottom(z=1)) + + p1000.move_to((sample_plate_2.wells_by_name()[X].center().move(types.Point(x=1.3 * 0.8, y=0, z=-4)))) + p1000.dispense(RSBVol, rate=1) + p1000.move_to(sample_plate_2.wells_by_name()[X].bottom(z=1)) + p1000.aspirate(RSBVol, rate=1) + p1000.move_to((sample_plate_2.wells_by_name()[X].center().move(types.Point(x=0, y=1.3 * 0.8, z=-4)))) + p1000.dispense(RSBVol, rate=1) + p1000.move_to(sample_plate_2.wells_by_name()[X].bottom(z=1)) + p1000.aspirate(RSBVol, rate=1) + p1000.move_to((sample_plate_2.wells_by_name()[X].center().move(types.Point(x=1.3 * -0.8, y=0, z=-4)))) + p1000.dispense(RSBVol, rate=1) + p1000.move_to(sample_plate_2.wells_by_name()[X].bottom(z=1)) + p1000.aspirate(RSBVol, rate=1) + p1000.move_to((sample_plate_2.wells_by_name()[X].center().move(types.Point(x=0, y=1.3 * -0.8, z=-4)))) + p1000.dispense(RSBVol, rate=1) + p1000.move_to(sample_plate_2.wells_by_name()[X].bottom(z=1)) + p1000.aspirate(RSBVol, rate=1) + p1000.dispense(RSBVol, rate=1) + + p1000.blow_out(sample_plate_2.wells_by_name()[X].center()) + p1000.move_to(sample_plate_2.wells_by_name()[X].top(z=5)) + p1000.move_to(sample_plate_2.wells_by_name()[X].top(z=0)) + p1000.move_to(sample_plate_2.wells_by_name()[X].top(z=5)) + p1000.return_tip() if TIP_TRASH == False else p1000.drop_tip() + p200_tips += 1 + tipcheck() + if DRYRUN == False: + heatershaker.set_and_wait_for_shake_speed(rpm=1600) + protocol.delay(RSBMixRep) + heatershaker.deactivate_shaker() + + # ============================================================================================ + # GRIPPER MOVE PLATE FROM HEATER SHAKER TO MAG PLATE + heatershaker.open_labware_latch() + protocol.move_labware( + labware=sample_plate_2, + new_location=MAG_PLATE_SLOT, + use_gripper=USE_GRIPPER, + pick_up_offset=grip_offset("pick-up", "heater-shaker", 1), + drop_offset=grip_offset("drop", "mag-plate"), + ) + heatershaker.close_labware_latch() + # ============================================================================================ + + if DRYRUN == False: + protocol.delay(minutes=3) + + protocol.comment("--> Transferring Supernatant") + TransferSup = 30 + for loop, X in enumerate(column_5_list): + p1000.pick_up_tip() + p1000.move_to(sample_plate_2[X].bottom(z=0.25)) + p1000.aspirate(TransferSup + 1, rate=0.25) + p1000.dispense(TransferSup + 5, sample_plate_1[column_6_list[loop]].bottom(z=1)) + p1000.return_tip() if TIP_TRASH == False else p1000.drop_tip() + p200_tips += 1 + tipcheck() + + if ABR_TEST == True: + protocol.comment("==============================================") + protocol.comment("--> Resetting Run") + protocol.comment("==============================================") + + # ============================================================================================ + # GRIPPER MOVE PLATE FROM MAG PLATE TO HEATER SHAKER + heatershaker.open_labware_latch() + protocol.move_labware( + labware=sample_plate_2, + new_location=heatershaker, + use_gripper=USE_GRIPPER, + pick_up_offset=grip_offset("pick-up", "mag-plate"), + drop_offset=grip_offset("drop", "heater-shaker", 1), + ) + heatershaker.close_labware_latch() + # ============================================================================================ + + p1000.pick_up_tip() + # Resetting NHB2 + p1000.aspirate(COLUMNS * 50, Liquid_trash_well_1.bottom(z=1)) + p1000.dispense(COLUMNS * 50, NHB2.bottom(z=1)) + # Resetting Panel + p1000.aspirate(COLUMNS * 10, Liquid_trash_well_1.bottom(z=1)) + p1000.dispense(COLUMNS * 10, Panel.bottom(z=1)) + # Resetting EHB2 + p1000.aspirate(COLUMNS * 10, Liquid_trash_well_1.bottom(z=1)) + p1000.dispense(COLUMNS * 10, EHB2.bottom(z=1)) + # Resetting SMB + for X in range(COLUMNS): + p1000.aspirate(125, Liquid_trash_well_1.bottom(z=1)) + p1000.dispense(125, SMB.bottom(z=1)) + p1000.aspirate(125, Liquid_trash_well_1.bottom(z=1)) + p1000.dispense(125, SMB.bottom(z=1)) + + # Resetting TWB + for X in range(COLUMNS): + + p1000.aspirate(200, Liquid_trash_well_2.bottom(z=1)) + p1000.dispense(200, EEW_1.bottom(z=1)) + p1000.aspirate(200, Liquid_trash_well_2.bottom(z=1)) + p1000.dispense(200, EEW_1.bottom(z=1)) + p1000.aspirate(200, Liquid_trash_well_2.bottom(z=1)) + p1000.dispense(200, EEW_2.bottom(z=1)) + p1000.aspirate(200, Liquid_trash_well_2.bottom(z=1)) + p1000.dispense(200, EEW_2.bottom(z=1)) + p1000.aspirate(200, Liquid_trash_well_2.bottom(z=1)) + p1000.dispense(200, EEW_3.bottom(z=1)) + p1000.aspirate(200, Liquid_trash_well_2.bottom(z=1)) + p1000.dispense(200, EEW_3.bottom(z=1)) + p1000.aspirate(200, Liquid_trash_well_3.bottom(z=1)) + p1000.dispense(200, EEW_1.bottom(z=1)) + p1000.aspirate(200, Liquid_trash_well_3.bottom(z=1)) + p1000.dispense(200, EEW_1.bottom(z=1)) + p1000.aspirate(200, Liquid_trash_well_3.bottom(z=1)) + p1000.dispense(200, EEW_2.bottom(z=1)) + p1000.aspirate(200, Liquid_trash_well_3.bottom(z=1)) + p1000.dispense(200, EEW_2.bottom(z=1)) + p1000.aspirate(200, Liquid_trash_well_3.bottom(z=1)) + p1000.dispense(200, EEW_3.bottom(z=1)) + p1000.aspirate(200, Liquid_trash_well_3.bottom(z=1)) + p1000.dispense(200, EEW_3.bottom(z=1)) + # Resetting ETOH + for X in range(COLUMNS): + p1000.aspirate(150, Liquid_trash_well_4.bottom(z=1)) + p1000.dispense(150, EtOH.bottom(z=1)) + p1000.aspirate(150, Liquid_trash_well_4.bottom(z=1)) + p1000.dispense(150, EtOH.bottom(z=1)) + # Resetting AMPURE + for X in range(COLUMNS): + p1000.aspirate(COLUMNS * 40.5, Liquid_trash_well_4.bottom(z=1)) + p1000.dispense(COLUMNS * 40.5, AMPure.bottom(z=1)) + # Resetting Elute + p1000.aspirate(COLUMNS * 25, Liquid_trash_well_4.bottom(z=1)) + p1000.dispense(COLUMNS * 25, Elute.bottom(z=1)) + # Resetting EPM + p1000.aspirate(COLUMNS * 40, Liquid_trash_well_4.bottom(z=1)) + p1000.dispense(COLUMNS * 40, EPM.bottom(z=1)) + p1000.return_tip() if TIP_TRASH == False else p1000.drop_tip() + p200_tips += 1 + tipcheck() + + p50.pick_up_tip() + # Resetting ET2 + p50.aspirate(COLUMNS * 4, Liquid_trash_well_4.bottom(z=1)) + p50.dispense(COLUMNS * 4, ET2.bottom(z=1)) + # Resetting PPC + p50.aspirate(COLUMNS * 5, Liquid_trash_well_4.bottom(z=1)) + p50.dispense(COLUMNS * 5, PPC.bottom(z=1)) + # Removing Final Samples + for loop, X in enumerate(column_6_list): + p50.aspirate(32, sample_plate_1[X].bottom(z=1)) + p50.dispense(32, Liquid_trash_well_4.bottom(z=1)) + # Resetting Samples + for loop, X in enumerate(column_1_list): + p50.aspirate(30, Liquid_trash_well_4.bottom(z=1)) + p50.dispense(30, sample_plate_1[X].bottom(z=1)) + + p50.return_tip() if TIP_TRASH == False else p50.drop_tip() + p50_tips += 1 + tipcheck() diff --git a/app-testing/files/protocols/py/OT3_P1000MLeft_P50MRight_HS_TM_MM_TC_2_15_ABR4_Illumina_DNA_Prep_24x.py b/app-testing/files/protocols/py/OT3_P1000MLeft_P50MRight_HS_TM_MM_TC_2_15_ABR4_Illumina_DNA_Prep_24x.py new file mode 100644 index 000000000000..41384e4d97ac --- /dev/null +++ b/app-testing/files/protocols/py/OT3_P1000MLeft_P50MRight_HS_TM_MM_TC_2_15_ABR4_Illumina_DNA_Prep_24x.py @@ -0,0 +1,692 @@ +from opentrons import protocol_api +from opentrons import types + +metadata = { + "protocolName": "Illumina DNA Prep 24x", + "author": "Opentrons ", + "source": "Protocol Library", + "apiLevel": "2.15", +} + +requirements = { + "robotType": "OT-3", + "apiLevel": "2.15", +} + +# SCRIPT SETTINGS +DRYRUN = "NO" # YES or NO, DRYRUN = 'YES' will return tips, skip incubation times, shorten mix, for testing purposes +USE_GRIPPER = True + +# PROTOCOL SETTINGS +COLUMNS = 3 # 1-3 + +# PROTOCOL BLOCKS +STEP_TAG = 1 +STEP_WASH = 1 +STEP_PCRDECK = 1 +STEP_CLEANUP = 1 + +############################################################################################################################################ +############################################################################################################################################ +############################################################################################################################################ + + +def run(protocol: protocol_api.ProtocolContext): + global DRYRUN + + protocol.comment("THIS IS A DRY RUN") if DRYRUN == "YES" else protocol.comment("THIS IS A REACTION RUN") + + # DECK SETUP AND LABWARE + # ========== FIRST ROW =========== + heatershaker = protocol.load_module("heaterShakerModuleV1", "1") + sample_plate_1 = heatershaker.load_labware("nest_96_wellplate_100ul_pcr_full_skirt") + tiprack_200_1 = protocol.load_labware("opentrons_ot3_96_tiprack_200ul", "2") + temp_block = protocol.load_module("temperature module gen2", "3") + reagent_plate = temp_block.load_labware("nest_96_wellplate_100ul_pcr_full_skirt") + # ========== SECOND ROW ========== + mag_block = protocol.load_module("magneticBlockV1", 4) + reservoir = protocol.load_labware("nest_96_wellplate_2ml_deep", "5") + tiprack_200_2 = protocol.load_labware("opentrons_ot3_96_tiprack_200ul", "6") + # ========== THIRD ROW =========== + thermocycler = protocol.load_module("thermocycler module gen2") + tiprack_20 = protocol.load_labware("opentrons_ot3_96_tiprack_50ul", "9") + # ========== FOURTH ROW ========== + + # =========== RESERVOIR ========== + AMPure = reservoir["A1"] + WASH_1 = reservoir["A2"] + WASH_2 = reservoir["A3"] + WASH_3 = reservoir["A4"] + EtOH = reservoir["A8"] + RSB = reservoir["A9"] + H20 = reservoir["A10"] + Liquid_trash = reservoir["A11"] + # ========= REAGENT PLATE ========= + TAGMIX = reagent_plate["A1"] + TAGSTOP = reagent_plate["A2"] + EPM = reagent_plate["A3"] + Barcodes1 = reagent_plate.wells_by_name()["A7"] + Barcodes2 = reagent_plate.wells_by_name()["A8"] + Barcodes3 = reagent_plate.wells_by_name()["A9"] + + # pipette + p1000 = protocol.load_instrument("p1000_multi_gen3", "right", tip_racks=[tiprack_200_1, tiprack_200_2]) + p50 = protocol.load_instrument("p50_multi_gen3", "left", tip_racks=[tiprack_20]) + + # tip and sample tracking + if COLUMNS == 3: + column_1_list = ["A1", "A2", "A3"] + column_2_list = ["A5", "A6", "A7"] + column_3_list = ["A9", "A10", "A11"] + barcodes = ["A7", "A8", "A9"] + + TIP_SUP = [tiprack_200_1["A1"], tiprack_200_1["A2"], tiprack_200_1["A3"]] + TIP_WASH = [tiprack_200_1["A4"], tiprack_200_1["A5"], tiprack_200_1["A6"]] + WASH = [WASH_1, WASH_2, WASH_3] + TIP_EPM = [tiprack_200_1["A7"], tiprack_200_1["A8"], tiprack_200_1["A9"]] + TIP_TRANSFER = [tiprack_200_1["A10"], tiprack_200_1["A11"], tiprack_200_1["A12"]] + TIP_CLEANUP = [tiprack_200_2["A1"], tiprack_200_2["A2"], tiprack_200_2["A3"]] + TIP_RSB = [tiprack_200_2["A4"], tiprack_200_2["A5"], tiprack_200_2["A6"]] + Tip_ETOH = tiprack_200_2["A7"] + + def grip_offset(action, item, slot=None): + """Grip offset.""" + from opentrons.types import Point + + # EDIT these values + # NOTE: we are still testing to determine our software's defaults + # but we also expect users will want to edit these + _pick_up_offsets = { + "deck": Point(), + "mag-plate": Point(), + "heater-shaker": Point(z=1.0), + "temp-module": Point(), + "thermo-cycler": Point(), + } + # EDIT these values + # NOTE: we are still testing to determine our software's defaults + # but we also expect users will want to edit these + _drop_offsets = { + "deck": Point(), + "mag-plate": Point(z=0.5), + "heater-shaker": Point(), + "temp-module": Point(), + "thermo-cycler": Point(), + } + # do NOT edit these values + # NOTE: these values will eventually be in our software + # and will not need to be inside a protocol + _hw_offsets = { + "deck": Point(), + "mag-plate": Point(z=2.5), + "heater-shaker-right": Point(z=2.5), + "heater-shaker-left": Point(z=2.5), + "temp-module": Point(z=5.0), + "thermo-cycler": Point(z=4.5), + } + # make sure arguments are correct + action_options = ["pick-up", "drop"] + item_options = list(_hw_offsets.keys()) + item_options.remove("heater-shaker-left") + item_options.remove("heater-shaker-right") + item_options.append("heater-shaker") + if action not in action_options: + raise ValueError(f'"{action}" not recognized, available options: {action_options}') + if item not in item_options: + raise ValueError(f'"{item}" not recognized, available options: {item_options}') + if item == "heater-shaker": + assert slot, 'argument slot= is required when using "heater-shaker"' + if slot in [1, 4, 7, 10]: + side = "left" + elif slot in [3, 6, 9, 12]: + side = "right" + else: + raise ValueError("heater shaker must be on either left or right side") + hw_offset = _hw_offsets[f"{item}-{side}"] + else: + hw_offset = _hw_offsets[item] + if action == "pick-up": + offset = hw_offset + _pick_up_offsets[item] + else: + offset = hw_offset + _drop_offsets[item] + + # convert from Point() to dict() + return {"x": offset.x, "y": offset.y, "z": offset.z} + + ############################################################################################################################################ + ############################################################################################################################################ + ############################################################################################################################################ + # commands + heatershaker.open_labware_latch() + if DRYRUN == "NO": + protocol.comment("SETTING THERMO and TEMP BLOCK Temperature") + thermocycler.set_block_temperature(4) + thermocycler.set_lid_temperature(100) + temp_block.set_temperature(4) + thermocycler.open_lid() + protocol.pause("Ready") + heatershaker.close_labware_latch() + + if STEP_TAG == 1: + protocol.comment("==============================================") + protocol.comment("--> Tagment") + protocol.comment("==============================================") + + protocol.comment("--> ADDING TAGMIX") + TagVol = 20 + SampleVol = 60 + TagMixRep = 5 * 60 if DRYRUN == "NO" else 0.1 * 60 + TagPremix = 3 if DRYRUN == "NO" else 1 + # ========NEW SINGLE TIP DISPENSE=========== + for loop, X in enumerate(column_1_list): + p1000.pick_up_tip(TIP_SUP[loop]) # <---------------- Tip Pickup + p1000.mix(TagPremix, TagVol + 10, TAGMIX.bottom(z=1)) + p1000.aspirate(TagVol, TAGMIX.bottom(z=1), rate=0.25) + p1000.dispense(TagVol, sample_plate_1[X].bottom(z=1), rate=0.25) + p1000.default_speed = 5 + p1000.move_to(sample_plate_1[X].bottom(z=3.5)) + for Mix in range(2): + p1000.aspirate(40, rate=0.5) + p1000.move_to(sample_plate_1[X].bottom(z=1)) + p1000.aspirate(20, rate=0.5) + p1000.dispense(20, rate=0.5) + p1000.move_to(sample_plate_1[X].bottom(z=3.5)) + p1000.dispense(40, rate=0.5) + Mix += 1 + p1000.blow_out(sample_plate_1[X].top(z=2)) + p1000.default_speed = 400 + p1000.move_to(sample_plate_1[X].top(z=5)) + p1000.move_to(sample_plate_1[X].top(z=0)) + p1000.move_to(sample_plate_1[X].top(z=5)) + p1000.return_tip() # <---------------- Tip Return + # ========NEW HS MIX========================= + heatershaker.set_and_wait_for_shake_speed(rpm=1800) + protocol.delay(TagMixRep) + heatershaker.deactivate_shaker() + + # ============================================================================================ + # GRIPPER MOVE sample_plate_1 FROM HEATERSHAKER TO THERMOCYCLER + heatershaker.open_labware_latch() + protocol.move_labware( + labware=sample_plate_1, + new_location=thermocycler, + use_gripper=USE_GRIPPER, + pick_up_offset=grip_offset("pick-up", "heater-shaker", 1), + drop_offset=grip_offset("drop", "thermo-cycler"), + ) + heatershaker.close_labware_latch() + # ============================================================================================ + + ############################################################################################################################################ + thermocycler.close_lid() + if DRYRUN == "NO": + profile_TAG = [{"temperature": 55, "hold_time_minutes": 15}] + thermocycler.execute_profile(steps=profile_TAG, repetitions=1, block_max_volume=50) + thermocycler.set_block_temperature(10) + thermocycler.open_lid() + ############################################################################################################################################ + + protocol.comment("--> Adding TAGSTOP") + TAGSTOPVol = 10 + TAGSTOPMixRep = 10 if DRYRUN == "NO" else 1 + TAGSTOPMixVol = 20 + for loop, X in enumerate(column_1_list): + p50.pick_up_tip() + p50.aspirate(TAGSTOPVol, TAGSTOP.bottom()) + p50.dispense(TAGSTOPVol, sample_plate_1[X].bottom()) + p50.move_to(sample_plate_1[X].bottom()) + p50.mix(TAGSTOPMixRep, TAGSTOPMixVol) + p50.return_tip() + + ############################################################################################################################################ + thermocycler.close_lid() + if DRYRUN == "NO": + profile_TAGSTOP = [{"temperature": 37, "hold_time_minutes": 15}] + thermocycler.execute_profile(steps=profile_TAGSTOP, repetitions=1, block_max_volume=50) + thermocycler.set_block_temperature(10) + thermocycler.open_lid() + ############################################################################################################################################ + + # ============================================================================================ + # GRIPPER MOVE sample_plate_1 FROM THERMOCYCLER TO MAG PLATE + protocol.move_labware( + labware=sample_plate_1, + new_location=mag_block, + use_gripper=USE_GRIPPER, + pick_up_offset=grip_offset("pick-up", "thermo-cycler"), + drop_offset=grip_offset("drop", "mag-plate"), + ) + # ============================================================================================ + + if DRYRUN == "NO": + protocol.delay(minutes=4) + + if STEP_WASH == 1: + protocol.comment("==============================================") + protocol.comment("--> Cleanup") + protocol.comment("==============================================") + + protocol.comment("--> Removing Supernatant") + RemoveSup = 200 + for loop, X in enumerate(column_1_list): + p1000.pick_up_tip(TIP_SUP[loop]) # <---------------- Tip Pickup + p1000.move_to(sample_plate_1[X].bottom(z=3.5)) + p1000.aspirate(RemoveSup - 100, rate=0.25) + protocol.delay(minutes=0.1) + p1000.move_to(sample_plate_1[X].bottom(z=0.5)) + p1000.aspirate(100, rate=0.25) + p1000.move_to(sample_plate_1[X].top(z=2)) + p1000.dispense(200, Liquid_trash.top(z=0)) + protocol.delay(minutes=0.1) + p1000.blow_out(Liquid_trash.top(z=0)) + p1000.aspirate(20) + p1000.return_tip() # <---------------- Tip Return + + for X in range(3): + # ============================================================================================ + # GRIPPER MOVE PLATE FROM MAG PLATE TO HEATER SHAKER + heatershaker.open_labware_latch() + protocol.move_labware( + labware=sample_plate_1, + new_location=heatershaker, + use_gripper=USE_GRIPPER, + pick_up_offset=grip_offset("pick-up", "mag-plate"), + drop_offset=grip_offset("drop", "heater-shaker", 1), + ) + heatershaker.close_labware_latch() + # ============================================================================================ + + protocol.comment("--> Wash ") + WASHMaxVol = 100 + WASHTime = 3 * 60 if DRYRUN == "NO" else 0.1 * 60 + for loop, X in enumerate(column_1_list): + p1000.pick_up_tip(TIP_WASH[loop]) # <---------------- Tip Pickup + p1000.aspirate(WASHMaxVol, WASH[loop].bottom(z=1), rate=0.25) + p1000.move_to(sample_plate_1[X].bottom(z=1)) + p1000.dispense(WASHMaxVol, rate=0.25) + p1000.mix(2, 90, rate=0.5) + p1000.move_to(sample_plate_1[X].top(z=1)) + protocol.delay(minutes=0.1) + p1000.blow_out(sample_plate_1[X].top(z=1)) + p1000.aspirate(20) + p1000.return_tip() # <---------------- Tip Return + + heatershaker.close_labware_latch() + heatershaker.set_and_wait_for_shake_speed(rpm=1600) + protocol.delay(WASHTime) + heatershaker.deactivate_shaker() + heatershaker.open_labware_latch() + + # ============================================================================================ + # GRIPPER MOVE PLATE FROM HEATER SHAKER TO MAG PLATE + heatershaker.open_labware_latch() + protocol.move_labware( + labware=sample_plate_1, + new_location=mag_block, + use_gripper=USE_GRIPPER, + pick_up_offset=grip_offset("pick-up", "heater-shaker", 1), + drop_offset=grip_offset("drop", "mag-plate"), + ) + heatershaker.close_labware_latch() + # ============================================================================================ + + if DRYRUN == "NO": + protocol.delay(minutes=3) + + protocol.comment("--> Remove Wash") + for loop, X in enumerate(column_1_list): + p1000.pick_up_tip(TIP_WASH[loop]) # <---------------- Tip Pickup + p1000.move_to(sample_plate_1[X].bottom(4)) + p1000.aspirate(WASHMaxVol, rate=0.25) + p1000.default_speed = 5 + p1000.move_to(sample_plate_1[X].bottom()) + protocol.delay(minutes=0.1) + p1000.aspirate(200 - WASHMaxVol, rate=0.25) + p1000.default_speed = 400 + p1000.dispense(200, Liquid_trash) + p1000.move_to(Liquid_trash.top(z=5)) + protocol.delay(minutes=0.1) + p1000.blow_out(Liquid_trash.top(z=5)) + p1000.aspirate(20) + p1000.return_tip() # <---------------- Tip Return + + if DRYRUN == "NO": + protocol.delay(minutes=1) + + protocol.comment("--> Removing Residual Wash") + for loop, X in enumerate(column_1_list): + p1000.pick_up_tip(TIP_WASH[loop]) # <---------------- Tip Pickup + p1000.move_to(sample_plate_1[X].bottom(1)) + p1000.aspirate(20, rate=0.25) + p1000.return_tip() # <---------------- Tip Return + + if DRYRUN == "NO": + protocol.delay(minutes=0.5) + + # ============================================================================================ + # GRIPPER MOVE PLATE FROM MAG PLATE TO HEATER SHAKER + heatershaker.open_labware_latch() + protocol.move_labware( + labware=sample_plate_1, + new_location=heatershaker, + use_gripper=USE_GRIPPER, + pick_up_offset=grip_offset("pick-up", "mag-plate"), + drop_offset=grip_offset("drop", "heater-shaker", 1), + ) + heatershaker.close_labware_latch() + # ============================================================================================ + + protocol.comment("--> Adding EPM") + EPMVol = 40 + EPMMixRep = 5 if DRYRUN == "NO" else 1 + EPMMixVol = 35 + EPMVolCount = 0 + for loop, X in enumerate(column_1_list): + p1000.pick_up_tip(TIP_EPM[loop]) # <---------------- Tip Pickup + p1000.aspirate(EPMVol, EPM.bottom(z=1)) + EPMVolCount += 1 + p1000.move_to((sample_plate_1.wells_by_name()[X].center().move(types.Point(x=1.3 * 0.8, y=0, z=-4)))) + p1000.dispense(EPMMixVol, rate=1) + p1000.move_to(sample_plate_1.wells_by_name()[X].bottom(z=1)) + p1000.aspirate(EPMMixVol, rate=1) + p1000.move_to((sample_plate_1.wells_by_name()[X].center().move(types.Point(x=0, y=1.3 * 0.8, z=-4)))) + p1000.dispense(EPMMixVol, rate=1) + p1000.move_to(sample_plate_1.wells_by_name()[X].bottom(z=1)) + p1000.aspirate(EPMMixVol, rate=1) + p1000.move_to((sample_plate_1.wells_by_name()[X].center().move(types.Point(x=1.3 * -0.8, y=0, z=-4)))) + p1000.dispense(EPMMixVol, rate=1) + p1000.move_to(sample_plate_1.wells_by_name()[X].bottom(z=1)) + p1000.aspirate(EPMMixVol, rate=1) + p1000.move_to((sample_plate_1.wells_by_name()[X].center().move(types.Point(x=0, y=1.3 * -0.8, z=-4)))) + p1000.dispense(EPMMixVol, rate=1) + p1000.move_to(sample_plate_1.wells_by_name()[X].bottom(z=1)) + p1000.aspirate(EPMMixVol, rate=1) + p1000.dispense(EPMMixVol, rate=1) + + p1000.blow_out(sample_plate_1.wells_by_name()[X].center()) + p1000.move_to(sample_plate_1.wells_by_name()[X].top(z=5)) + p1000.move_to(sample_plate_1.wells_by_name()[X].top(z=0)) + p1000.move_to(sample_plate_1.wells_by_name()[X].top(z=5)) + p1000.return_tip() # <---------------- Tip Return + heatershaker.close_labware_latch() + heatershaker.set_and_wait_for_shake_speed(rpm=2000) + protocol.delay(minutes=3) + heatershaker.deactivate_shaker() + heatershaker.open_labware_latch() + + # ============================================================================================ + # GRIPPER MOVE PLATE FROM HEATER SHAKER TO THERMOCYCLER + heatershaker.open_labware_latch() + protocol.move_labware( + labware=sample_plate_1, + new_location=thermocycler, + use_gripper=USE_GRIPPER, + pick_up_offset=grip_offset("pick-up", "heater-shaker", 1), + drop_offset=grip_offset("drop", "thermo-cycler"), + ) + heatershaker.close_labware_latch() + # ============================================================================================ + + protocol.comment("--> Adding Barcodes") + BarcodeVol = 10 + BarcodeMixRep = 3 if DRYRUN == "NO" else 1 + BarcodeMixVol = 10 + for loop, X in enumerate(column_1_list): + p50.pick_up_tip() + p50.aspirate(BarcodeVol, reagent_plate.wells_by_name()[barcodes[loop]].bottom(), rate=0.25) + p50.dispense(BarcodeVol, sample_plate_1.wells_by_name()[X].bottom(1)) + p50.mix(BarcodeMixRep, BarcodeMixVol) + p50.return_tip() + + if STEP_PCRDECK == 1: + ############################################################################################################################################ + + if DRYRUN == "NO": + thermocycler.set_lid_temperature(100) + thermocycler.close_lid() + if DRYRUN == "NO": + profile_PCR_1 = [ + {"temperature": 68, "hold_time_seconds": 180}, + {"temperature": 98, "hold_time_seconds": 180}, + ] + thermocycler.execute_profile(steps=profile_PCR_1, repetitions=1, block_max_volume=50) + profile_PCR_2 = [ + {"temperature": 98, "hold_time_seconds": 45}, + {"temperature": 62, "hold_time_seconds": 30}, + {"temperature": 68, "hold_time_seconds": 120}, + ] + thermocycler.execute_profile(steps=profile_PCR_2, repetitions=5, block_max_volume=50) + profile_PCR_3 = [{"temperature": 68, "hold_time_minutes": 1}] + thermocycler.execute_profile(steps=profile_PCR_3, repetitions=1, block_max_volume=50) + thermocycler.set_block_temperature(10) + ############################################################################################################################################ + thermocycler.open_lid() + + if STEP_CLEANUP == 1: + protocol.comment("==============================================") + protocol.comment("--> Cleanup") + protocol.comment("==============================================") + + # ============================================================================================ + # GRIPPER MOVE sample_plate_1 FROM THERMOCYCLER To HEATERSHAKER + heatershaker.open_labware_latch() + protocol.move_labware( + labware=sample_plate_1, + new_location=heatershaker, + use_gripper=USE_GRIPPER, + pick_up_offset=grip_offset("pick-up", "thermo-cycler"), + drop_offset=grip_offset("drop", "heater-shaker", 1), + ) + heatershaker.close_labware_latch() + # ============================================================================================ + + protocol.comment("--> Adding H20 and SAMPLE") + H20Vol = 40 + SampleVol = 45 + for loop, X in enumerate(column_1_list): + p1000.pick_up_tip(TIP_TRANSFER[loop]) # <---------------- Tip Pickup + p1000.aspirate(H20Vol, H20.bottom(), rate=1) + p1000.dispense(H20Vol, sample_plate_1[column_2_list[loop]].bottom(1)) + p1000.aspirate(SampleVol, sample_plate_1[column_1_list[loop]].bottom(), rate=1) + p1000.dispense(SampleVol, sample_plate_1[column_2_list[loop]].bottom(1)) + p1000.return_tip() # <---------------- Tip Return + + protocol.comment("--> ADDING AMPure (0.8x)") + AMPureVol = 45 + SampleVol = 85 + AMPureMixRep = 5 * 60 if DRYRUN == "NO" else 0.1 * 60 + AMPurePremix = 3 if DRYRUN == "NO" else 1 + # ========NEW SINGLE TIP DISPENSE=========== + for loop, X in enumerate(column_2_list): + p1000.pick_up_tip(TIP_CLEANUP[loop]) # <---------------- Tip Pickup + p1000.mix(AMPurePremix, AMPureVol + 10, AMPure.bottom(z=1)) + p1000.aspirate(AMPureVol, AMPure.bottom(z=1), rate=0.25) + p1000.dispense(AMPureVol, sample_plate_1[X].bottom(z=1), rate=0.25) + p1000.default_speed = 5 + p1000.move_to(sample_plate_1[X].bottom(z=5)) + for Mix in range(2): + p1000.aspirate(60, rate=0.5) + p1000.move_to(sample_plate_1[X].bottom(z=1)) + p1000.aspirate(60, rate=0.5) + p1000.dispense(60, rate=0.5) + p1000.move_to(sample_plate_1[X].bottom(z=5)) + p1000.dispense(30, rate=0.5) + Mix += 1 + p1000.blow_out(sample_plate_1[X].top(z=2)) + p1000.default_speed = 400 + p1000.move_to(sample_plate_1[X].top(z=5)) + p1000.move_to(sample_plate_1[X].top(z=0)) + p1000.move_to(sample_plate_1[X].top(z=5)) + p1000.return_tip() # <---------------- Tip Return + # ========NEW HS MIX========================= + heatershaker.set_and_wait_for_shake_speed(rpm=1800) + protocol.delay(AMPureMixRep) + heatershaker.deactivate_shaker() + + # ============================================================================================ + # GRIPPER MOVE PLATE FROM HEATER SHAKER TO MAG PLATE + heatershaker.open_labware_latch() + protocol.move_labware( + labware=sample_plate_1, + new_location=mag_block, + use_gripper=USE_GRIPPER, + pick_up_offset=grip_offset("pick-up", "heater-shaker", 1), + drop_offset=grip_offset("drop", "mag-plate"), + ) + heatershaker.close_labware_latch() + # ============================================================================================ + + if DRYRUN == "NO": + protocol.delay(minutes=4) + + protocol.comment("--> Removing Supernatant") + RemoveSup = 200 + for loop, X in enumerate(column_2_list): + p1000.pick_up_tip(TIP_CLEANUP[loop]) # <---------------- Tip Pickup + p1000.move_to(sample_plate_1[X].bottom(z=3.5)) + p1000.aspirate(RemoveSup - 100, rate=0.25) + protocol.delay(minutes=0.1) + p1000.move_to(sample_plate_1[X].bottom(z=0.5)) + p1000.aspirate(100, rate=0.25) + p1000.default_speed = 5 + p1000.move_to(sample_plate_1[X].top(z=2)) + p1000.default_speed = 200 + p1000.dispense(200, Liquid_trash.top(z=0)) + protocol.delay(minutes=0.1) + p1000.blow_out() + p1000.default_speed = 400 + p1000.move_to(Liquid_trash.top(z=-5)) + p1000.move_to(Liquid_trash.top(z=0)) + p1000.return_tip() # <---------------- Tip Return + + for X in range(2): + protocol.comment("--> ETOH Wash") + ETOHMaxVol = 150 + p1000.pick_up_tip(Tip_ETOH) + for loop, X in enumerate(column_2_list): + p1000.aspirate(ETOHMaxVol, EtOH.bottom(z=1)) + p1000.move_to(EtOH.top(z=0)) + p1000.move_to(EtOH.top(z=-5)) + p1000.move_to(EtOH.top(z=0)) + p1000.move_to(sample_plate_1[X].top(z=-2)) + p1000.dispense(ETOHMaxVol, rate=1) + protocol.delay(minutes=0.1) + p1000.blow_out() + p1000.move_to(sample_plate_1[X].top(z=5)) + p1000.move_to(sample_plate_1[X].top(z=0)) + p1000.move_to(sample_plate_1[X].top(z=5)) + p1000.return_tip() + + if DRYRUN == "NO": + protocol.delay(minutes=0.5) + + protocol.comment("--> Remove ETOH Wash") + for loop, X in enumerate(column_2_list): + p1000.pick_up_tip(TIP_CLEANUP[loop]) # <---------------- Tip Pickup + p1000.move_to(sample_plate_1[X].bottom(z=3.5)) + p1000.aspirate(RemoveSup - 100, rate=0.25) + protocol.delay(minutes=0.1) + p1000.move_to(sample_plate_1[X].bottom(z=0.5)) + p1000.aspirate(100, rate=0.25) + p1000.default_speed = 5 + p1000.move_to(sample_plate_1[X].top(z=2)) + p1000.default_speed = 200 + p1000.dispense(200, Liquid_trash.top(z=0)) + protocol.delay(minutes=0.1) + p1000.blow_out() + p1000.default_speed = 400 + p1000.move_to(Liquid_trash.top(z=-5)) + p1000.move_to(Liquid_trash.top(z=0)) + p1000.return_tip() # <---------------- Tip Return + + if DRYRUN == "NO": + protocol.delay(minutes=2) + + protocol.comment("--> Removing Residual ETOH") + for loop, X in enumerate(column_2_list): + p1000.pick_up_tip(TIP_CLEANUP[loop]) # <---------------- Tip Pickup + p1000.move_to(sample_plate_1[X].bottom(z=0)) + p1000.aspirate(50, rate=0.25) + p1000.default_speed = 200 + p1000.dispense(100, Liquid_trash.top(z=0)) + protocol.delay(minutes=0.1) + p1000.blow_out() + p1000.default_speed = 400 + p1000.move_to(Liquid_trash.top(z=-5)) + p1000.move_to(Liquid_trash.top(z=0)) + p1000.return_tip() # <---------------- Tip Return + + if DRYRUN == "NO": + protocol.delay(minutes=1) + + # ============================================================================================ + # GRIPPER MOVE PLATE FROM MAG PLATE TO HEATER SHAKER + heatershaker.open_labware_latch() + protocol.move_labware( + labware=sample_plate_1, + new_location=heatershaker, + use_gripper=USE_GRIPPER, + pick_up_offset=grip_offset("pick-up", "mag-plate"), + drop_offset=grip_offset("drop", "heater-shaker", 1), + ) + heatershaker.close_labware_latch() + # ============================================================================================ + + protocol.comment("--> Adding RSB") + RSBVol = 32 + RSBMixRep = 1 * 60 if DRYRUN == "NO" else 0.1 * 60 + for loop, X in enumerate(column_2_list): + p1000.pick_up_tip(TIP_RSB[loop]) # <---------------- Tip Pickup + p1000.aspirate(RSBVol, RSB.bottom(z=1)) + + p1000.move_to((sample_plate_1.wells_by_name()[X].center().move(types.Point(x=1.3 * 0.8, y=0, z=-4)))) + p1000.dispense(RSBVol, rate=1) + p1000.move_to(sample_plate_1.wells_by_name()[X].bottom(z=1)) + p1000.aspirate(RSBVol, rate=1) + p1000.move_to((sample_plate_1.wells_by_name()[X].center().move(types.Point(x=0, y=1.3 * 0.8, z=-4)))) + p1000.dispense(RSBVol, rate=1) + p1000.move_to(sample_plate_1.wells_by_name()[X].bottom(z=1)) + p1000.aspirate(RSBVol, rate=1) + p1000.move_to((sample_plate_1.wells_by_name()[X].center().move(types.Point(x=1.3 * -0.8, y=0, z=-4)))) + p1000.dispense(RSBVol, rate=1) + p1000.move_to(sample_plate_1.wells_by_name()[X].bottom(z=1)) + p1000.aspirate(RSBVol, rate=1) + p1000.move_to((sample_plate_1.wells_by_name()[X].center().move(types.Point(x=0, y=1.3 * -0.8, z=-4)))) + p1000.dispense(RSBVol, rate=1) + p1000.move_to(sample_plate_1.wells_by_name()[X].bottom(z=1)) + p1000.aspirate(RSBVol, rate=1) + p1000.dispense(RSBVol, rate=1) + + p1000.blow_out(sample_plate_1.wells_by_name()[X].center()) + p1000.move_to(sample_plate_1.wells_by_name()[X].top(z=5)) + p1000.move_to(sample_plate_1.wells_by_name()[X].top(z=0)) + p1000.move_to(sample_plate_1.wells_by_name()[X].top(z=5)) + p1000.return_tip() # <---------------- Tip Return + heatershaker.set_and_wait_for_shake_speed(rpm=1600) + protocol.delay(RSBMixRep) + heatershaker.deactivate_shaker() + + # ============================================================================================ + # GRIPPER MOVE PLATE FROM HEATER SHAKER TO MAG PLATE + heatershaker.open_labware_latch() + protocol.move_labware( + labware=sample_plate_1, + new_location=mag_block, + use_gripper=USE_GRIPPER, + pick_up_offset=grip_offset("pick-up", "heater-shaker", 1), + drop_offset=grip_offset("drop", "mag-plate"), + ) + heatershaker.close_labware_latch() + # ============================================================================================ + + if DRYRUN == "NO": + protocol.delay(minutes=3) + + protocol.comment("--> Transferring Supernatant") + TransferSup = 30 + for loop, X in enumerate(column_2_list): + p1000.pick_up_tip(TIP_RSB[loop]) # <---------------- Tip Pickup + p1000.move_to(sample_plate_1[X].bottom(z=0.25)) + p1000.aspirate(TransferSup + 1, rate=0.25) + p1000.dispense(TransferSup + 5, sample_plate_1[column_3_list[loop]].bottom(z=1)) + p1000.return_tip() # <---------------- Tip Return diff --git a/app-testing/files/protocols/py/OT3_P1000SRight_None_2_15_ABR_Simple_Normalize_Long_Right.py b/app-testing/files/protocols/py/OT3_P1000SRight_None_2_15_ABR_Simple_Normalize_Long_Right.py new file mode 100644 index 000000000000..c96db008e674 --- /dev/null +++ b/app-testing/files/protocols/py/OT3_P1000SRight_None_2_15_ABR_Simple_Normalize_Long_Right.py @@ -0,0 +1,257 @@ +import inspect +from dataclasses import replace + +from opentrons import protocol_api, types + +metadata = { + "protocolName": "OT3 ABR Simple Normalize Long", + "author": "Opentrons Engineering ", + "source": "Software Testing Team", + "description": ("OT3 ABR Simple Normalize Long"), +} + +requirements = { + "robotType": "OT-3", + "apiLevel": "2.15", +} + + +# settings +DRYRUN = "NO" # YES or NO, DRYRUN = 'YES' will return tips, skip incubation times, shorten mix, for testing purposes +MEASUREPAUSE = "NO" + + +def run(protocol: protocol_api.ProtocolContext): + + if DRYRUN == "YES": + protocol.comment("THIS IS A DRY RUN") + else: + protocol.comment("THIS IS A REACTION RUN") + + # DECK SETUP AND LABWARE + # ========== FIRST ROW =========== + protocol.comment("THIS IS A NO MODULE RUN") + reservoir = protocol.load_labware("nest_12_reservoir_15ml", "1") + sample_plate_1 = protocol.load_labware("nest_96_wellplate_100ul_pcr_full_skirt", "3") + # ========== SECOND ROW ========== + tiprack_200_1 = protocol.load_labware("opentrons_ot3_96_tiprack_200ul", "4") + tiprack_200_2 = protocol.load_labware("opentrons_ot3_96_tiprack_200ul", "5") + sample_plate_2 = protocol.load_labware("nest_96_wellplate_100ul_pcr_full_skirt", "6") + # ========== THIRD ROW =========== + tiprack_200_3 = protocol.load_labware("opentrons_ot3_96_tiprack_200ul", "7") + tiprack_200_4 = protocol.load_labware("opentrons_ot3_96_tiprack_200ul", "8") + sample_plate_3 = protocol.load_labware("nest_96_wellplate_100ul_pcr_full_skirt", "9") + # ========== FOURTH ROW ========== + tiprack_200_5 = protocol.load_labware("opentrons_ot3_96_tiprack_200ul", "10") + tiprack_200_6 = protocol.load_labware("opentrons_ot3_96_tiprack_200ul", "11") + + # reagent + Dye_1 = reservoir["A1"] + Dye_2 = reservoir["A2"] + Dye_3 = reservoir["A3"] + Diluent_1 = reservoir["A4"] + Diluent_2 = reservoir["A5"] + Diluent_3 = reservoir["A6"] + + # pipette + p1000 = protocol.load_instrument( + "p1000_single_gen3", + "right", + tip_racks=[tiprack_200_1, tiprack_200_2, tiprack_200_3, tiprack_200_4, tiprack_200_5, tiprack_200_6], + ) + + sample_quant_csv = """ + sample_plate_1, Sample_well,DYE,DILUENT + sample_plate_1,A1,0,100 + sample_plate_1,B1,5,95 + sample_plate_1,C1,10,90 + sample_plate_1,D1,20,80 + sample_plate_1,E1,40,60 + sample_plate_1,F1,60,40 + sample_plate_1,G1,80,20 + sample_plate_1,H1,100,0 + sample_plate_1,A2,35,65 + sample_plate_1,B2,58,42 + sample_plate_1,C2,42,58 + sample_plate_1,D2,92,8 + sample_plate_1,E2,88,12 + sample_plate_1,F2,26,74 + sample_plate_1,G2,31,69 + sample_plate_1,H2,96,4 + sample_plate_1,A3,87,13 + sample_plate_1,B3,82,18 + sample_plate_1,C3,36,64 + sample_plate_1,D3,78,22 + sample_plate_1,E3,26,74 + sample_plate_1,F3,34,66 + sample_plate_1,G3,63,37 + sample_plate_1,H3,20,80 + sample_plate_1,A4,84,16 + sample_plate_1,B4,59,41 + sample_plate_1,C4,58,42 + sample_plate_1,D4,84,16 + sample_plate_1,E4,47,53 + sample_plate_1,F4,67,33 + sample_plate_1,G4,52,48 + sample_plate_1,H4,79,21 + sample_plate_1,A5,80,20 + sample_plate_1,B5,86,14 + sample_plate_1,C5,41,59 + sample_plate_1,D5,48,52 + sample_plate_1,E5,96,4 + sample_plate_1,F5,72,28 + sample_plate_1,G5,45,55 + sample_plate_1,H5,99,1 + sample_plate_1,A6,41,59 + sample_plate_1,B6,20,80 + sample_plate_1,C6,98,2 + sample_plate_1,D6,54,46 + sample_plate_1,E6,30,70 + sample_plate_1,F6,42,58 + sample_plate_1,G6,21,79 + sample_plate_1,H6,48,52 + sample_plate_1,A7,73,27 + sample_plate_1,B7,84,16 + sample_plate_1,C7,40,60 + sample_plate_1,D7,74,26 + sample_plate_1,E7,80,20 + sample_plate_1,F7,44,56 + sample_plate_1,G7,26,74 + sample_plate_1,H7,45,55 + sample_plate_1,A8,99,1 + sample_plate_1,B8,98,2 + sample_plate_1,C8,34,66 + sample_plate_1,D8,89,11 + sample_plate_1,E8,46,54 + sample_plate_1,F8,37,63 + sample_plate_1,G8,58,42 + sample_plate_1,H8,34,66 + sample_plate_1,A9,44,56 + sample_plate_1,B9,89,11 + sample_plate_1,C9,30,70 + sample_plate_1,D9,67,33 + sample_plate_1,E9,46,54 + sample_plate_1,F9,79,21 + sample_plate_1,G9,59,41 + sample_plate_1,H9,23,77 + sample_plate_1,A10,26,74 + sample_plate_1,B10,99,1 + sample_plate_1,C10,51,49 + sample_plate_1,D10,38,62 + sample_plate_1,E10,99,1 + sample_plate_1,F10,21,79 + sample_plate_1,G10,59,41 + sample_plate_1,H10,58,42 + sample_plate_1,A11,45,55 + sample_plate_1,B11,28,72 + sample_plate_1,C11,51,49 + sample_plate_1,D11,34,66 + sample_plate_1,E11,27,73 + sample_plate_1,F11,60,40 + sample_plate_1,G11,33,67 + sample_plate_1,H11,61,39 + sample_plate_1,A12,69,31 + sample_plate_1,B12,47,53 + sample_plate_1,C12,46,54 + sample_plate_1,D12,93,7 + sample_plate_1,E12,54,46 + sample_plate_1,F12,65,35 + sample_plate_1,G12,58,42 + sample_plate_1,H12,37,63 + """ + + data = [r.split(",") for r in sample_quant_csv.strip().splitlines() if r][1:] + + for X in range(2): + protocol.comment("==============================================") + protocol.comment("Adding Dye Sample Plate 1") + protocol.comment("==============================================") + + current = 0 + p1000.pick_up_tip() + while current < len(data): + CurrentWell = str(data[current][1]) + DyeVol = float(data[current][2]) + if DyeVol != 0: + p1000.transfer( + DyeVol, Dye_1.bottom(z=2), sample_plate_1.wells_by_name()[CurrentWell].top(z=1), new_tip="never" + ) + current += 1 + p1000.return_tip() + + protocol.comment("==============================================") + protocol.comment("Adding Diluent Sample Plate 1") + protocol.comment("==============================================") + + current = 0 + while current < len(data): + CurrentWell = str(data[current][1]) + DilutionVol = float(data[current][2]) + if DilutionVol != 0: + p1000.pick_up_tip() + p1000.aspirate(DilutionVol, Diluent_1.bottom(z=2)) + p1000.dispense(DilutionVol, sample_plate_1.wells_by_name()[CurrentWell].top(z=0.2)) + p1000.return_tip() + current += 1 + + protocol.comment("==============================================") + protocol.comment("Adding Dye Sample Plate 2") + protocol.comment("==============================================") + + current = 0 + p1000.pick_up_tip() + while current < len(data): + CurrentWell = str(data[current][1]) + DyeVol = float(data[current][2]) + if DyeVol != 0: + p1000.transfer( + DyeVol, Dye_2.bottom(z=2), sample_plate_2.wells_by_name()[CurrentWell].top(z=1), new_tip="never" + ) + current += 1 + p1000.return_tip() + + protocol.comment("==============================================") + protocol.comment("Adding Diluent Sample Plate 2") + protocol.comment("==============================================") + + current = 0 + while current < len(data): + CurrentWell = str(data[current][1]) + DilutionVol = float(data[current][2]) + if DilutionVol != 0: + p1000.pick_up_tip() + p1000.aspirate(DilutionVol, Diluent_2.bottom(z=2)) + p1000.dispense(DilutionVol, sample_plate_2.wells_by_name()[CurrentWell].top(z=0.2)) + p1000.return_tip() + current += 1 + + protocol.comment("==============================================") + protocol.comment("Adding Dye Sample Plate 3") + protocol.comment("==============================================") + + current = 0 + p1000.pick_up_tip() + while current < len(data): + CurrentWell = str(data[current][1]) + DyeVol = float(data[current][2]) + if DyeVol != 0: + p1000.transfer( + DyeVol, Dye_3.bottom(z=2), sample_plate_3.wells_by_name()[CurrentWell].top(z=1), new_tip="never" + ) + current += 1 + p1000.return_tip() + + protocol.comment("==============================================") + protocol.comment("Adding Diluent Sample Plate 3") + protocol.comment("==============================================") + + current = 0 + while current < len(data): + CurrentWell = str(data[current][1]) + DilutionVol = float(data[current][2]) + if DilutionVol != 0: + p1000.pick_up_tip() + p1000.aspirate(DilutionVol, Diluent_3.bottom(z=2)) + p1000.dispense(DilutionVol, sample_plate_3.wells_by_name()[CurrentWell].top(z=0.2)) + p1000.return_tip() + current += 1 diff --git a/app-testing/files/protocols/py/OT3_P1000_96_HS_TM_MM_2_15_ABR5_6_HDQ_Bacteria_ParkTips_96_channel.py b/app-testing/files/protocols/py/OT3_P1000_96_HS_TM_MM_2_15_ABR5_6_HDQ_Bacteria_ParkTips_96_channel.py new file mode 100644 index 000000000000..efe8473686f7 --- /dev/null +++ b/app-testing/files/protocols/py/OT3_P1000_96_HS_TM_MM_2_15_ABR5_6_HDQ_Bacteria_ParkTips_96_channel.py @@ -0,0 +1,497 @@ +from opentrons.types import Point +import json +import os +import math +from time import sleep +from opentrons import types +import numpy as np + +""" +Setup: + +Slot 1 = H-S with Nest DW (empty plate) +Slot 2 = Nest DW (350 ul each well) +Slot 3 = Temp Mod with Armadillo PCR plate (100ul each well) +Slot 4 = Magblock (empty) +Slot 5 = Nest DW (300 ul each well) +Slot 6 = Nest DW (empty plate) +Slot 7 = Nest DW (1300 ul each well) +Slot 8 = Nest DW (700 ul each well) +Slot 9 = Nest DW (500 ul each well) +Slot 10 = 1000ul tips +Slot 11 = 1000ul tips (only used during elution steps) + +""" + +metadata = { + "protocolName": "Omega HDQ DNA Extraction: Bacteria 96 FOR ABR TESTING", + "author": "Zach Galluzzo ", + "apiLevel": "2.15", +} + +requirements = { + "robotType": "OT-3", + "apiLevel": "2.15", +} + +dry_run = True +HS_SLOT = 1 +USE_GRIPPER = True + + +# Start protocol +def run(ctx): + """ + Here is where you can change the locations of your labware and modules + (note that this is the recommended configuration) + """ + + # *****If drying beads does not produce same results- can eliminate waste in slot 12 and add extra elution reservoir*** + + # Same for all HDQ Extractions + deepwell_type = "nest_96_wellplate_2ml_deep" + wash_vol = 600 + settling_time = 2 + num_washes = 3 + + h_s = ctx.load_module("heaterShakerModuleV1", HS_SLOT) + TL_plate = h_s.load_labware(deepwell_type) # can be whatever plate type + TL_samples = TL_plate.wells()[0] + sample_plate = ctx.load_labware(deepwell_type, "6") + samples_m = sample_plate.wells()[0] + + temp = ctx.load_module("temperature module gen2", "3") + elutionplate = temp.load_labware("armadillo_96_wellplate_200ul_pcr_full_skirt") + elution_res = elutionplate.wells()[0] + MAG_PLATE_SLOT = ctx.load_module("magneticBlockV1", "4") + # elution_two = ctx.load_labware(deepwell_type, '12').wells()[0] + + TL_res = ctx.load_labware(deepwell_type, "2").wells()[0] + AL_res = ctx.load_labware(deepwell_type, "5").wells()[0] + wash1_res = ctx.load_labware(deepwell_type, "7").wells()[0] + wash2_res = ctx.load_labware(deepwell_type, "8").wells()[0] + bind_res = ctx.load_labware(deepwell_type, "9").wells()[0] + + # Load tips + tips = ctx.load_labware("opentrons_ot3_96_tiprack_1000ul_rss", "10").wells()[0] + tips1 = ctx.load_labware("opentrons_ot3_96_tiprack_1000ul_rss", "11").wells()[0] + + # Differences between sample types + AL_vol = 230 + TL_vol = 270 + sample_vol = 200 + inc_temp = 55 + starting_vol = AL_vol + sample_vol + binding_buffer_vol = 340 + elution_two_vol = 350 + elution_vol = 100 + + # load 96 channel pipette + pip = ctx.load_instrument("p1000_96", mount="left") + + pip.flow_rate.aspirate = 50 + pip.flow_rate.dispense = 150 + pip.flow_rate.blow_out = 300 + + def resuspend_pellet(vol, plate, reps=3): + pip.flow_rate.aspirate = 150 + pip.flow_rate.dispense = 200 + + loc1 = plate.bottom().move(types.Point(x=1, y=0, z=1)) + loc2 = plate.bottom().move(types.Point(x=0.75, y=0.75, z=1)) + loc3 = plate.bottom().move(types.Point(x=0, y=1, z=1)) + loc4 = plate.bottom().move(types.Point(x=-0.75, y=0.75, z=1)) + loc5 = plate.bottom().move(types.Point(x=-1, y=0, z=1)) + loc6 = plate.bottom().move(types.Point(x=-0.75, y=0 - 0.75, z=1)) + loc7 = plate.bottom().move(types.Point(x=0, y=-1, z=1)) + loc8 = plate.bottom().move(types.Point(x=0.75, y=-0.75, z=1)) + + if vol > 1000: + vol = 1000 + + mixvol = vol * 0.9 + + for _ in range(reps): + pip.aspirate(mixvol, loc1) + pip.dispense(mixvol, loc1) + pip.aspirate(mixvol, loc2) + pip.dispense(mixvol, loc2) + pip.aspirate(mixvol, loc3) + pip.dispense(mixvol, loc3) + pip.aspirate(mixvol, loc4) + pip.dispense(mixvol, loc4) + pip.aspirate(mixvol, loc5) + pip.dispense(mixvol, loc5) + pip.aspirate(mixvol, loc6) + pip.dispense(mixvol, loc6) + pip.aspirate(mixvol, loc7) + pip.dispense(mixvol, loc7) + pip.aspirate(mixvol, loc8) + pip.dispense(mixvol, loc8) + if _ == reps - 1: + pip.flow_rate.aspirate = 50 + pip.flow_rate.dispense = 30 + pip.aspirate(mixvol, loc8) + pip.dispense(mixvol, loc8) + + pip.flow_rate.aspirate = 50 + pip.flow_rate.dispense = 150 + + def bead_mix(vol, plate, reps=5): + pip.flow_rate.aspirate = 150 + pip.flow_rate.dispense = 200 + + loc1 = plate.bottom().move(types.Point(x=0, y=0, z=1)) + loc2 = plate.bottom().move(types.Point(x=0, y=0, z=8)) + loc3 = plate.bottom().move(types.Point(x=0, y=0, z=16)) + loc4 = plate.bottom().move(types.Point(x=0, y=0, z=24)) + + if vol > 1000: + vol = 1000 + + mixvol = vol * 0.9 + + for _ in range(reps): + pip.aspirate(mixvol, loc1) + pip.dispense(mixvol, loc1) + pip.aspirate(mixvol, loc1) + pip.dispense(mixvol, loc2) + pip.aspirate(mixvol, loc1) + pip.dispense(mixvol, loc3) + pip.aspirate(mixvol, loc1) + pip.dispense(mixvol, loc4) + if _ == reps - 1: + pip.flow_rate.aspirate = 50 + pip.flow_rate.dispense = 30 + pip.aspirate(mixvol, loc1) + pip.dispense(mixvol, loc1) + + pip.flow_rate.aspirate = 50 + pip.flow_rate.dispense = 150 + + def reset_protocol(): + ctx.comment("Move TL Sample Plate Back to Heater-Shaker") + h_s.open_labware_latch() + ctx.move_labware( + TL_plate, + h_s, + use_gripper=USE_GRIPPER, + pick_up_offset=grip_offset("pick-up", "deck"), + drop_offset=grip_offset("drop", "heater-shaker", slot=HS_SLOT), + ) + h_s.close_labware_latch() + ctx.comment("Move Sample Plate back to Original Deck Slot") + ctx.move_labware( + sample_plate, + 6, + use_gripper=USE_GRIPPER, + pick_up_offset=grip_offset("pick-up", "mag-plate"), + drop_offset=grip_offset("drop", "deck"), + ) + + pip.pick_up_tip(tips) + # Return Washes 1 and 2 from TL res to Wash res + for r in range(2): + if r == 0: + pip.aspirate(wash_vol, TL_res.top(-20)) + else: + pip.aspirate(wash_vol, TL_res.bottom(1)) + pip.dispense(wash_vol, wash1_res) + pip.air_gap(5) + + # Return sample TL from Bind to TL + pip.aspirate(200, bind_res.top(-19)) + pip.dispense(200, TL_res) + pip.air_gap(5) + + # Return sample TL from TL sample plate to TL res + pip.aspirate(70, TL_samples.bottom()) + pip.dispense(70, TL_res) + pip.air_gap(5) + + # Return AL from Bind to AL + pip.aspirate(AL_vol, bind_res.top(-25)) + pip.dispense(AL_vol, AL_res) + pip.air_gap(5) + + # Return W3 from Bind to W3 + pip.aspirate(wash_vol, bind_res.bottom()) + pip.dispense(wash_vol, wash2_res) + pip.air_gap(5) + + pip.return_tip() + + def grip_offset(action, item, slot=None): + from opentrons.types import Point + + # EDIT these values + # NOTE: we are still testing to determine our software's defaults + # but we also expect users will want to edit these + _pick_up_offsets = { + "deck": Point(), + "mag-plate": Point(), + "heater-shaker": Point(), + "temp-module": Point(), + "thermo-cycler": Point(), + } + # EDIT these values + # NOTE: we are still testing to determine our software's defaults + # but we also expect users will want to edit these + _drop_offsets = { + "deck": Point(z=0.5), + "mag-plate": Point(z=0.5), + "heater-shaker": Point(z=0.5), + "temp-module": Point(z=0.5), + "thermo-cycler": Point(z=0.5), + } + # do NOT edit these values + # NOTE: these values will eventually be in our software + # and will not need to be inside a protocol + _hw_offsets = { + "deck": Point(), + "mag-plate": Point(), + "heater-shaker": Point(z=2.5), + "temp-module": Point(z=5), + "thermo-cycler": Point(z=2.5), + } + # make sure arguments are correct + action_options = ["pick-up", "drop"] + item_options = list(_hw_offsets.keys()) + + if action not in action_options: + raise ValueError(f'"{action}" not recognized, available options: {action_options}') + if item not in item_options: + raise ValueError(f'"{item}" not recognized, available options: {item_options}') + hw_offset = _hw_offsets[item] + if action == "pick-up": + offset = hw_offset + _pick_up_offsets[item] + else: + offset = hw_offset + _drop_offsets[item] + # convert from Point() to dict() + return {"x": offset.x, "y": offset.y, "z": offset.z} + + # Just in case + h_s.close_labware_latch() + for loop in range(3): + # Start Protocol + pip.pick_up_tip(tips) + # Mix PK and TL buffers + ctx.comment("----- Mixing TL buffer and PK -----") + for m in range(3): + pip.aspirate(TL_vol, TL_res) + pip.dispense(TL_vol, TL_res.bottom(30)) + # Transfer TL to plate + ctx.comment("----- Transferring TL and PK to samples -----") + pip.aspirate(TL_vol, TL_res) + pip.air_gap(10) + pip.dispense(pip.current_volume, TL_samples) + h_s.set_target_temperature(55) + ctx.comment("----- Mixing TL buffer with samples -----") + resuspend_pellet(TL_vol, TL_samples, reps=4) + pip.return_tip() + + ctx.comment("----- Mixing and incubating for 30 minutes on Heater-Shaker -----") + h_s.set_and_wait_for_shake_speed(2000) + ctx.delay(minutes=30 if not dry_run else 0.25, msg="Shake at 2000 rpm for 30 minutes to allow lysis.") + h_s.deactivate_shaker() + + # Transfer 200ul of sample + TL buffer to sample plate + ctx.comment("----- Mixing, then transferring 200 ul of sample to new deep well plate -----") + pip.pick_up_tip(tips) + pip.aspirate(sample_vol, TL_samples) + pip.air_gap(20) + pip.dispense(pip.current_volume, samples_m) + pip.blow_out() + pip.return_tip() + + # Move TL samples off H-S into deck slot and sample plate onto H-S + ctx.comment("------- Transferring TL and Sample plates -------") + # Transfer TL samples from H-S to Magnet + h_s.open_labware_latch() + ctx.move_labware( + TL_plate, + MAG_PLATE_SLOT, + use_gripper=USE_GRIPPER, + pick_up_offset=grip_offset("pick-up", "heater-shaker", slot=HS_SLOT), + drop_offset=grip_offset("drop", "mag-plate"), + ) + # Move sample plate onto H-S from deck + ctx.move_labware( + sample_plate, + h_s, + use_gripper=USE_GRIPPER, + pick_up_offset=grip_offset("pick-up", "deck"), + drop_offset=grip_offset("drop", "heater-shaker", slot=HS_SLOT), + ) + h_s.close_labware_latch() + # Move plate off magplate onto the deck + ctx.move_labware( + TL_plate, + 6, + use_gripper=USE_GRIPPER, + pick_up_offset=grip_offset("pick-up", "mag-plate"), + drop_offset=grip_offset("drop", "deck"), + ) + + # Transfer and mix AL_lysis + ctx.comment("------- Starting AL Lysis Steps -------") + pip.pick_up_tip(tips) + pip.aspirate(AL_vol, AL_res) + pip.air_gap(10) + pip.dispense(pip.current_volume, samples_m) + resuspend_pellet(starting_vol, samples_m, reps=4) + pip.drop_tip(tips) + + # Mix, then heat + h_s.set_and_wait_for_shake_speed(2000) + ctx.delay(minutes=4 if not dry_run else 0.25, msg="Please wait 4 minutes to allow for proper lysis mixing.") + + h_s.deactivate_shaker() + + # Transfer and mix bind&beads + ctx.comment("------- Mixing and Transferring Beads and Binding -------") + pip.pick_up_tip(tips) + bead_mix(binding_buffer_vol, bind_res, reps=3) + pip.aspirate(binding_buffer_vol, bind_res) + pip.dispense(binding_buffer_vol, samples_m) + bead_mix(binding_buffer_vol + starting_vol, samples_m, reps=3) + pip.return_tip() + pip.home() + + # Shake for binding incubation + h_s.set_and_wait_for_shake_speed(rpm=1800) + ctx.delay(minutes=10 if not dry_run else 0.25, msg="Please allow 10 minutes for the beads to bind the DNA.") + + h_s.deactivate_shaker() + + h_s.open_labware_latch() + # Transfer plate to magnet + ctx.move_labware( + sample_plate, + MAG_PLATE_SLOT, + use_gripper=USE_GRIPPER, + pick_up_offset=grip_offset("pick-up", "heater-shaker", slot=HS_SLOT), + drop_offset=grip_offset("drop", "mag-plate"), + ) + h_s.close_labware_latch() + + ctx.delay(minutes=settling_time, msg="Please wait " + str(settling_time) + " minute(s) for beads to pellet.") + + # Remove Supernatant and move off magnet + ctx.comment("------- Removing Supernatant -------") + pip.pick_up_tip(tips) + pip.aspirate(1000, samples_m.bottom(0.5)) + pip.dispense(1000, bind_res) + if starting_vol + binding_buffer_vol > 1000: + pip.aspirate(1000, samples_m.bottom(0.5)) + pip.dispense(1000, bind_res) + pip.return_tip() + + # Transfer plate from magnet to H/S + h_s.open_labware_latch() + ctx.move_labware( + sample_plate, + h_s, + use_gripper=USE_GRIPPER, + pick_up_offset=grip_offset("pick-up", "mag-plate"), + drop_offset=grip_offset("drop", "heater-shaker", slot=HS_SLOT), + ) + + h_s.close_labware_latch() + + # Washes + for i in range(num_washes): + if i == 0 or 1: + wash_res = wash1_res + waste_res = TL_res + if i == 2: + wash_res = wash2_res + waste_res = bind_res + ctx.comment("------- Starting Wash #" + str(i + 1) + " -------") + pip.pick_up_tip(tips) + pip.aspirate(wash_vol, wash_res) + pip.dispense(wash_vol, samples_m) + # resuspend_pellet(wash_vol,samples_m,reps=1) + pip.blow_out() + pip.air_gap(10) + pip.return_tip() + pip.home() + + h_s.set_and_wait_for_shake_speed(rpm=1800) + ctx.delay(minutes=5 if not dry_run else 0.25) + h_s.deactivate_shaker() + h_s.open_labware_latch() + + # Transfer plate to magnet + ctx.move_labware( + sample_plate, + MAG_PLATE_SLOT, + use_gripper=USE_GRIPPER, + pick_up_offset=grip_offset("pick-up", "heater-shaker", slot=HS_SLOT), + drop_offset=grip_offset("drop", "mag-plate"), + ) + + ctx.delay( + minutes=settling_time, msg="Please wait " + str(settling_time) + " minute(s) for beads to pellet." + ) + + # Remove Supernatant and move off magnet + ctx.comment("------- Removing Supernatant -------") + pip.pick_up_tip(tips) + pip.aspirate(1000, samples_m.bottom(0.5)) + pip.dispense(1000, waste_res.top()) + if wash_vol > 1000: + pip.aspirate(1000, samples_m.bottom(0.5)) + pip.dispense(1000, waste_res.top()) + pip.return_tip() + + # if i == 0 or 2 and not dry_run: + # Transfer plate from magnet to H/S after first two washes + ctx.move_labware( + sample_plate, + h_s, + use_gripper=USE_GRIPPER, + pick_up_offset=grip_offset("pick-up", "mag-plate"), + drop_offset=grip_offset("drop", "heater-shaker", slot=HS_SLOT), + ) + h_s.close_labware_latch() + + dry_beads = 10 + + for beaddry in np.arange(dry_beads, 0, -0.5): + ctx.delay(minutes=0.5, msg="There are " + str(beaddry) + " minutes left in the drying step.") + + # Elution + ctx.comment("------- Beginning Elution Steps -------") + + pip.pick_up_tip(tips1) + pip.aspirate(elution_vol, elution_res) + pip.dispense(elution_vol, samples_m) + resuspend_pellet(elution_vol, samples_m, reps=3) + pip.return_tip() + pip.home() + + h_s.set_and_wait_for_shake_speed(rpm=2000) + ctx.delay(minutes=5 if not dry_run else 0.25, msg="Please wait 5 minutes to allow dna to elute from beads.") + h_s.deactivate_shaker() + h_s.open_labware_latch() + + # Transfer plate to magnet + ctx.move_labware( + sample_plate, + MAG_PLATE_SLOT, + use_gripper=USE_GRIPPER, + pick_up_offset=grip_offset("pick-up", "heater-shaker", slot=HS_SLOT), + drop_offset=grip_offset("drop", "mag-plate"), + ) + + ctx.delay(minutes=settling_time, msg="Please wait " + str(settling_time) + " minute(s) for beads to pellet.") + + pip.pick_up_tip(tips1) + pip.aspirate(elution_vol, samples_m) + pip.dispense(elution_vol, elution_res) + pip.return_tip() + + pip.home() + + reset_protocol() diff --git a/app-testing/files/protocols/py/OT3_P1000_96_HS_TM_MM_2_15_MagMaxRNACells96Ch.py b/app-testing/files/protocols/py/OT3_P1000_96_HS_TM_MM_2_15_MagMaxRNACells96Ch.py new file mode 100644 index 000000000000..c940ecb4b2d8 --- /dev/null +++ b/app-testing/files/protocols/py/OT3_P1000_96_HS_TM_MM_2_15_MagMaxRNACells96Ch.py @@ -0,0 +1,534 @@ +from opentrons.types import Point +import json +import os +import math +import threading +from time import sleep +from opentrons import types +import numpy as np + +""" +Setup: + +Slot 1 = H-S with Nest DW (empty plate) +Slot 2 = Nest DW (800 ul each well) +Slot 3 = Temp Mod with Armadillo PCR plate (50ul each well) +Slot 4 = Magblock (empty) +Slot 5 = Nest DW (200 ul each well) +Slot 6 = Nest DW (200 ul each well) +Slot 7 = Armadillo PCR plate (50 ul each well) +Slot 8 = Nest DW (200 ul each well) +Slot 9 = Nest DW (550 ul each well) +Slot 10 = 200ul tips +Slot 11 = 200ul tips (only used during elution steps) + +""" + + +metadata = { + "protocolName": "MagMax RNA Extraction: Cells 96 ABR TESTING", + "author": "Opentrons Engineering ", + "source": "Software Testing Team", + "description": ("MagMax RNA Extraction: Cells 96 ABR TESTING"), +} + +requirements = { + "robotType": "OT-3", + "apiLevel": "2.15", +} + +HS_SLOT = 1 +dry_run = True +USE_GRIPPER = True +whichwash = 1 +EMPTY_SLOT = 9 + + +def run(ctx): + """ + Here is where you can change the locations of your labware and modules + (note that this is the recommended configuration) + """ + # Protocol Parameters + deepwell_type = "nest_96_wellplate_2ml_deep" + res_type = "nest_12_reservoir_15ml" + wash_vol = 150 + settling_time = 2 + sample_vol = 50 + lysis_vol = 140 + elution_vol = 50 + starting_vol = sample_vol + lysis_vol + + h_s = ctx.load_module("heaterShakerModuleV1", HS_SLOT) + cell_plate = h_s.load_labware(deepwell_type) + cells_m = cell_plate.wells()[0] + sample_plate = ctx.load_labware(deepwell_type, "2") # Plate with just beads + samples_m = sample_plate.wells()[0] + h_s.close_labware_latch() + + tempdeck = ctx.load_module("Temperature Module Gen2", "3") + MAG_PLATE_SLOT = ctx.load_module("magneticBlockV1", "4") + # Keep elution warm during protocol + elutionplate = tempdeck.load_labware("opentrons_96_aluminumblock_nest_wellplate_100ul") + + # Load Reagents + lysis_res = ctx.load_labware(deepwell_type, "5").wells()[0] + wash1 = ctx.load_labware(deepwell_type, "6").wells()[0] + wash2 = wash3 = wash4 = ctx.load_labware(deepwell_type, "9").wells()[0] + dnase_res = ctx.load_labware("armadillo_96_wellplate_200ul_pcr_full_skirt", "7").wells()[0] + stop_res = ctx.load_labware("armadillo_96_wellplate_200ul_pcr_full_skirt", "8").wells()[0] + elution_res = elutionplate.wells()[0] + + # Load tips + tips = ctx.load_labware("opentrons_ot3_96_tiprack_200ul_rss", "10").wells()[0] + tips1 = ctx.load_labware("opentrons_ot3_96_tiprack_200ul_rss", "11").wells()[0] + + # load 96 channel pipette + pip = ctx.load_instrument("p1000_96", mount="left") + + pip.flow_rate.aspirate = 50 + pip.flow_rate.dispense = 150 + pip.flow_rate.blow_out = 300 + + def grip_offset(action, item, slot=None): + from opentrons.types import Point + + # EDIT these values + # NOTE: we are still testing to determine our software's defaults + # but we also expect users will want to edit these + _pick_up_offsets = { + "deck": Point(), + "mag-plate": Point(), + "heater-shaker": Point(), + "temp-module": Point(), + "thermo-cycler": Point(), + } + # EDIT these values + # NOTE: we are still testing to determine our software's defaults + # but we also expect users will want to edit these + _drop_offsets = { + "deck": Point(z=0.5), + "mag-plate": Point(z=0.5), + "heater-shaker": Point(z=0.5), + "temp-module": Point(z=0.5), + "thermo-cycler": Point(z=0.5), + } + # do NOT edit these values + # NOTE: these values will eventually be in our software + # and will not need to be inside a protocol + _hw_offsets = { + "deck": Point(), + "mag-plate": Point(), + "heater-shaker": Point(z=2.5), + "temp-module": Point(z=5), + "thermo-cycler": Point(z=2.5), + } + # make sure arguments are correct + action_options = ["pick-up", "drop"] + item_options = list(_hw_offsets.keys()) + + if action not in action_options: + raise ValueError(f'"{action}" not recognized, available options: {action_options}') + if item not in item_options: + raise ValueError(f'"{item}" not recognized, available options: {item_options}') + hw_offset = _hw_offsets[item] + if action == "pick-up": + offset = hw_offset + _pick_up_offsets[item] + else: + offset = hw_offset + _drop_offsets[item] + # convert from Point() to dict() + return {"x": offset.x, "y": offset.y, "z": offset.z} + + def remove_supernatant(vol, waste): + pip.pick_up_tip(tips) + if vol > 1000: + x = 2 + else: + x = 1 + transfer_vol = vol + for i in range(x): + pip.aspirate(transfer_vol, samples_m.bottom(0.15)) + pip.dispense(transfer_vol, waste) + pip.return_tip() + + # Transfer plate from magnet to H/S + h_s.open_labware_latch() + ctx.move_labware( + sample_plate, + h_s, + use_gripper=USE_GRIPPER, + pick_up_offset=grip_offset("pick-up", "mag-plate"), + drop_offset=grip_offset("drop", "heater-shaker", slot=HS_SLOT), + ) + h_s.close_labware_latch() + + def resuspend_pellet(vol, plate, reps=3): + pip.flow_rate.aspirate = 150 + pip.flow_rate.dispense = 200 + + loc1 = plate.bottom().move(types.Point(x=1, y=0, z=1)) + loc2 = plate.bottom().move(types.Point(x=0.75, y=0.75, z=1)) + loc3 = plate.bottom().move(types.Point(x=0, y=1, z=1)) + loc4 = plate.bottom().move(types.Point(x=-0.75, y=0.75, z=1)) + loc5 = plate.bottom().move(types.Point(x=-1, y=0, z=1)) + loc6 = plate.bottom().move(types.Point(x=-0.75, y=0 - 0.75, z=1)) + loc7 = plate.bottom().move(types.Point(x=0, y=-1, z=1)) + loc8 = plate.bottom().move(types.Point(x=0.75, y=-0.75, z=1)) + + if vol > 1000: + vol = 1000 + + mixvol = vol * 0.9 + + for _ in range(reps): + pip.aspirate(mixvol, loc1) + pip.dispense(mixvol, loc1) + pip.aspirate(mixvol, loc2) + pip.dispense(mixvol, loc2) + pip.aspirate(mixvol, loc3) + pip.dispense(mixvol, loc3) + pip.aspirate(mixvol, loc4) + pip.dispense(mixvol, loc4) + pip.aspirate(mixvol, loc5) + pip.dispense(mixvol, loc5) + pip.aspirate(mixvol, loc6) + pip.dispense(mixvol, loc6) + pip.aspirate(mixvol, loc7) + pip.dispense(mixvol, loc7) + pip.aspirate(mixvol, loc8) + pip.dispense(mixvol, loc8) + if _ == reps - 1: + pip.flow_rate.aspirate = 50 + pip.flow_rate.dispense = 30 + pip.aspirate(mixvol, loc8) + pip.dispense(mixvol, loc8) + + pip.flow_rate.aspirate = 50 + pip.flow_rate.dispense = 150 + + def bead_mix(vol, plate, reps=5): + pip.flow_rate.aspirate = 150 + pip.flow_rate.dispense = 200 + + loc1 = plate.bottom().move(types.Point(x=0, y=0, z=1)) + loc2 = plate.bottom().move(types.Point(x=0, y=0, z=8)) + loc3 = plate.bottom().move(types.Point(x=0, y=0, z=16)) + loc4 = plate.bottom().move(types.Point(x=0, y=0, z=24)) + + if vol > 1000: + vol = 1000 + + mixvol = vol * 0.9 + + for _ in range(reps): + pip.aspirate(mixvol, loc1) + pip.dispense(mixvol, loc1) + pip.aspirate(mixvol, loc1) + pip.dispense(mixvol, loc2) + pip.aspirate(mixvol, loc1) + pip.dispense(mixvol, loc3) + pip.aspirate(mixvol, loc1) + pip.dispense(mixvol, loc4) + if _ == reps - 1: + pip.flow_rate.aspirate = 50 + pip.flow_rate.dispense = 30 + pip.aspirate(mixvol, loc1) + pip.dispense(mixvol, loc1) + + pip.flow_rate.aspirate = 50 + pip.flow_rate.dispense = 150 + + def reset_protocol(): + # Replace Cell and Sample Plates + h_s.open_labware_latch() + # Transfer cell plate back to H-S initial spot + ctx.move_labware( + cell_plate, + h_s, + use_gripper=USE_GRIPPER, + pick_up_offset=grip_offset("pick-up", "mag-plate"), + drop_offset=grip_offset("drop", "heater-shaker", slot=HS_SLOT), + ) + h_s.close_labware_latch() + + # Transfer sample plate back to original slot 2 + ctx.move_labware( + sample_plate, + 2, + use_gripper=USE_GRIPPER, + pick_up_offset=grip_offset("pick-up", "mag-plate"), + drop_offset=grip_offset("drop", "deck"), + ) + + pip.pick_up_tip(tips) + # Return Wash buffers from lysis res back to their own + deep = 20 + for w in range(4): + pip.aspirate(wash_vol, lysis_res.top(-deep)) + if w == 0: + pip.dispense(wash_vol, wash1) + else: + pip.dispense(wash_vol, wash2) + deep = deep + 5 + pip.air_gap(5) + + # Return Stop Solution to original res + pip.aspirate(100, lysis_res.top(-deep + 5)) + pip.dispense(100, stop_res) + pip.air_gap(5) + + # Return DNAse to original res + pip.aspirate(50, lysis_res.top(-deep + 5)) + pip.dispense(50, dnase_res) + pip.air_gap(5) + + pip.return_tip() + + def lysis(vol, source): + pip.pick_up_tip(tips) + pip.aspirate(vol, source) + pip.dispense(vol, cells_m) + resuspend_pellet(vol, cells_m, reps=5) + pip.return_tip() + + h_s.set_and_wait_for_shake_speed(rpm=2200) + ctx.delay( + minutes=1 if not dry_run else 0.25, msg="Please wait 1 minute while the lysis buffer mixes with the sample." + ) + h_s.deactivate_shaker() + + def bind(): + """ + `bind` will perform magnetic bead binding on each sample in the + deepwell plate. Each channel of binding beads will be mixed before + transfer, and the samples will be mixed with the binding beads after + the transfer. The magnetic deck activates after the addition to all + samples, and the supernatant is removed after bead bining. + :param vol (float): The amount of volume to aspirate from the elution + buffer source and dispense to each well containing + beads. + :param park (boolean): Whether to save sample-corresponding tips + between adding elution buffer and transferring + supernatant to the final clean elutions PCR + plate. + """ + pip.pick_up_tip(tips) + # Quick Mix then Transfer cells+lysis/bind to wells with beads + for i in range(3): + pip.aspirate(125, cells_m) + pip.dispense(125, cells_m.bottom(15)) + pip.aspirate(175, cells_m) + pip.air_gap(10) + pip.dispense(185, samples_m) + bead_mix(140, samples_m, reps=5) + pip.blow_out(samples_m.top(-3)) + pip.air_gap(10) + pip.return_tip() + + # Replace Cell Plate on H-S with Bead Plate (now has sample in it also) + h_s.open_labware_latch() + # Transfer empty cell plate to empty mag plate + ctx.move_labware( + cell_plate, + MAG_PLATE_SLOT, + use_gripper=USE_GRIPPER, + pick_up_offset=grip_offset("pick-up", "heater-shaker", slot=HS_SLOT), + drop_offset=grip_offset("drop", "mag-plate"), + ) + # Transfer Beads+Cells to H-S + ctx.move_labware( + sample_plate, + h_s, + use_gripper=USE_GRIPPER, + pick_up_offset=grip_offset("pick-up", "deck"), + drop_offset=grip_offset("drop", "heater-shaker", slot=HS_SLOT), + ) + h_s.close_labware_latch() + h_s.set_and_wait_for_shake_speed(rpm=2000) + + # Transfer empty cell plate to empty slot 2 + ctx.move_labware( + cell_plate, + 2, + use_gripper=USE_GRIPPER, + pick_up_offset=grip_offset("pick-up", "mag-plate"), + drop_offset=grip_offset("drop", "deck"), + ) + + # Incubate for beads to bind DNA + ctx.delay( + minutes=5 if not dry_run else 0.25, msg="Please wait 5 minutes while the sample binds with the beads." + ) + h_s.deactivate_shaker() + + # Transfer plate to magnet + h_s.open_labware_latch() + ctx.move_labware( + sample_plate, + MAG_PLATE_SLOT, + use_gripper=USE_GRIPPER, + pick_up_offset=grip_offset("pick-up", "heater-shaker", slot=HS_SLOT), + drop_offset=grip_offset("drop", "mag-plate"), + ) + h_s.close_labware_latch() + + for bindi in np.arange(settling_time, 0, -0.5): # Settling time delay with countdown timer + ctx.delay(minutes=0.5, msg="There are " + str(bindi) + " minutes left in the incubation.") + + # remove initial supernatant + remove_supernatant(175, lysis_res) + + def wash(vol, source, waste): + + global whichwash # Defines which wash the protocol is on to log on the app + """ + if source == wash1: + whichwash = 1 + if source == wash2: + whichwash = 2 + if source == wash3: + whichwash = 3 + if source == wash4: + whichwash = 4 + """ + + pip.pick_up_tip(tips) + pip.aspirate(vol, source) + pip.dispense(vol, samples_m) + pip.blow_out(samples_m.top(-3)) + pip.air_gap(10) + pip.return_tip() + + h_s.set_and_wait_for_shake_speed(2000) + ctx.delay(minutes=5 if not dry_run else 0.25, msg="Please allow 5 minutes for wash to mix on heater-shaker.") + h_s.deactivate_shaker() + + # Transfer plate to magnet + h_s.open_labware_latch() + ctx.move_labware( + sample_plate, + MAG_PLATE_SLOT, + use_gripper=USE_GRIPPER, + pick_up_offset=grip_offset("pick-up", "heater-shaker", slot=HS_SLOT), + drop_offset=grip_offset("drop", "mag-plate"), + ) + h_s.close_labware_latch() + + for washi in np.arange(settling_time, 0, -0.5): # settling time timer for washes + ctx.delay( + minutes=0.5, msg="There are " + str(washi) + " minutes left in wash " + str(whichwash) + " incubation." + ) + + remove_supernatant(vol, lysis_res) + + whichwash = whichwash + 1 + + def dnase(vol, source): + pip.flow_rate.aspirate = 20 + pip.flow_rate.dispense = 50 + + pip.pick_up_tip(tips) + pip.aspirate(vol, source) + pip.dispense(vol, samples_m) + resuspend_pellet(45, samples_m, reps=4) + pip.return_tip() + + h_s.set_and_wait_for_shake_speed(rpm=2000) + ctx.delay(minutes=10 if not dry_run else 0.25, msg="Please wait 10 minutes while the dnase incubates.") + h_s.deactivate_shaker() + + pip.flow_rate.aspirate = 50 + pip.flow_rate.dispense = 150 + + def stop_reaction(vol, source): + + pip.pick_up_tip(tips) + pip.aspirate(vol, source) + pip.dispense(vol, samples_m) + resuspend_pellet(vol, samples_m, reps=2) + pip.blow_out(samples_m.top(-3)) + pip.air_gap(10) + pip.return_tip() + + h_s.set_and_wait_for_shake_speed(rpm=2000) + ctx.delay( + minutes=3 if not dry_run else 0.25, + msg="Please wait 3 minutes while the stop solution inactivates the dnase.", + ) + h_s.deactivate_shaker() + + # Transfer plate to magnet + h_s.open_labware_latch() + ctx.move_labware( + sample_plate, + MAG_PLATE_SLOT, + use_gripper=USE_GRIPPER, + pick_up_offset=grip_offset("pick-up", "heater-shaker", slot=HS_SLOT), + drop_offset=grip_offset("drop", "mag-plate"), + ) + h_s.close_labware_latch() + + for stop in np.arange(settling_time, 0, -0.5): + ctx.delay(minutes=0.5, msg="There are " + str(stop) + " minutes left in this incubation.") + + remove_supernatant(vol + 50, lysis_res) + + def elute(vol, source): + pip.pick_up_tip(tips1) + # Transfer + pip.aspirate(vol, source) + pip.dispense(vol, samples_m) + # Mix + resuspend_pellet(vol, samples_m, reps=2) + pip.return_tip() + + # Elution Incubation + h_s.set_and_wait_for_shake_speed(rpm=2000) + tempdeck.set_temperature(4) + h_s.deactivate_shaker() + + # Transfer plate to magnet + h_s.open_labware_latch() + ctx.move_labware( + sample_plate, + MAG_PLATE_SLOT, + use_gripper=USE_GRIPPER, + pick_up_offset=grip_offset("pick-up", "heater-shaker", slot=HS_SLOT), + drop_offset=grip_offset("drop", "mag-plate"), + ) + h_s.close_labware_latch() + + for elutei in np.arange(settling_time, 0, -0.5): + ctx.delay(minutes=0.5, msg="Incubating on MagDeck for " + str(elutei) + " more minutes.") + + pip.flow_rate.aspirate = 25 + pip.flow_rate.dispense = 25 + + # Transfer From Sample Plate to Elution Plate + pip.pick_up_tip(tips1) + pip.aspirate(vol, samples_m) + pip.dispense(vol, source) + pip.return_tip() + + """ + Here is where you can call the methods defined above to fit your specific + protocol. The normal sequence is: + """ + for loop in range(3): + # Start Protocol + lysis(lysis_vol, lysis_res) + bind() + wash(wash_vol, wash1, lysis_res) + wash(wash_vol, wash2, lysis_res) + # dnase1 treatment + dnase(50, dnase_res) + stop_reaction(100, stop_res) + # Resume washes + wash(wash_vol, wash3, lysis_res) + wash(wash_vol, wash4, lysis_res) + tempdeck.set_temperature(55) + drybeads = 1 # Number of minutes you want to dry for + for beaddry in np.arange(drybeads, 0, -0.5): + ctx.delay(minutes=0.5, msg="There are " + str(beaddry) + " minutes left in the drying step.") + elute(elution_vol, elution_res) + reset_protocol() diff --git a/app-testing/files/protocols/py/OT3_P1000_96_HS_TM_TC_MM_2_15_ABR5_6_Illumina_DNA_Prep_96x_Head_PART_III.py b/app-testing/files/protocols/py/OT3_P1000_96_HS_TM_TC_MM_2_15_ABR5_6_Illumina_DNA_Prep_96x_Head_PART_III.py new file mode 100644 index 000000000000..e21ca09af33d --- /dev/null +++ b/app-testing/files/protocols/py/OT3_P1000_96_HS_TM_TC_MM_2_15_ABR5_6_Illumina_DNA_Prep_96x_Head_PART_III.py @@ -0,0 +1,357 @@ +from opentrons import protocol_api +from opentrons import types + +metadata = { + "protocolName": "Illumina DNA Prep 96x Head PART III", + "author": "Opentrons ", + "source": "Protocol Library", + "apiLevel": "2.15", +} + +requirements = { + "robotType": "OT-3", + "apiLevel": "2.15", +} + +# SCRIPT SETTINGS +DRYRUN = "YES" # YES or NO, DRYRUN = 'YES' will return tips, skip incubation times, shorten mix, for testing purposes +USE_GRIPPER = True +USE_8xMULTI = "NO" + +# PROTOCOL SETTINGS + +# PROTOCOL BLOCKS +STEP_CLEANUP = 1 + +############################################################################################################################################ +############################################################################################################################################ +############################################################################################################################################ + + +def run(protocol: protocol_api.ProtocolContext): + global DRYRUN + + protocol.comment("THIS IS A DRY RUN") if DRYRUN == "YES" else protocol.comment("THIS IS A REACTION RUN") + + # DECK SETUP AND LABWARE + # ========== FIRST ROW =========== + heatershaker = protocol.load_module("heaterShakerModuleV1", "1") + sample_plate_2 = heatershaker.load_labware("nest_96_wellplate_100ul_pcr_full_skirt") + tiprack_200_1 = protocol.load_labware("opentrons_ot3_96_tiprack_200ul_rss", "2") + temp_block = protocol.load_module("temperature module gen2", "3") + sample_plate_3 = temp_block.load_labware("nest_96_wellplate_100ul_pcr_full_skirt") + # ========== SECOND ROW ========== + MAG_PLATE_SLOT = protocol.load_module("magneticBlockV1", "4") + reservoir_1 = protocol.load_labware("nest_96_wellplate_2ml_deep", "5") + reservoir_2 = protocol.load_labware("nest_96_wellplate_2ml_deep", "5") + # ========== THIRD ROW =========== + thermocycler = protocol.load_module("thermocycler module gen2") + sample_plate_1 = thermocycler.load_labware("nest_96_wellplate_100ul_pcr_full_skirt") + tiprack_200_2 = protocol.load_labware("opentrons_ot3_96_tiprack_200ul_rss", "8") + reservoir_3 = protocol.load_labware("nest_96_wellplate_2ml_deep", "5") + # ========== FOURTH ROW ========== + reservoir_4 = protocol.load_labware("nest_96_wellplate_2ml_deep", "5") + + # =========== RESERVOIR ========== + EtOH = reservoir_1["A1"] + AMPure = reservoir_2["A1"] + RSB = reservoir_3["A1"] + Liquid_trash = reservoir_4["A1"] + # ========= REAGENT PLATE ========= + + # pipette + if USE_8xMULTI == "YES": + p1000 = protocol.load_instrument("p1000_multi_gen3", "right") + else: + p1000 = protocol.load_instrument("p1000_96", "left") + + def grip_offset(action, item, slot=None): + """Grip offset.""" + from opentrons.types import Point + + # EDIT these values + # NOTE: we are still testing to determine our software's defaults + # but we also expect users will want to edit these + _pick_up_offsets = { + "deck": Point(), + "mag-plate": Point(), + "heater-shaker": Point(z=1.0), + "temp-module": Point(), + "thermo-cycler": Point(), + } + # EDIT these values + # NOTE: we are still testing to determine our software's defaults + # but we also expect users will want to edit these + _drop_offsets = { + "deck": Point(), + "mag-plate": Point(z=0.5), + "heater-shaker": Point(), + "temp-module": Point(), + "thermo-cycler": Point(), + } + # do NOT edit these values + # NOTE: these values will eventually be in our software + # and will not need to be inside a protocol + _hw_offsets = { + "deck": Point(), + "mag-plate": Point(z=29.5), + "heater-shaker-right": Point(z=2.5), + "heater-shaker-left": Point(z=2.5), + "temp-module": Point(z=5.0), + "thermo-cycler": Point(z=2.5), + } + # make sure arguments are correct + action_options = ["pick-up", "drop"] + item_options = list(_hw_offsets.keys()) + item_options.remove("heater-shaker-left") + item_options.remove("heater-shaker-right") + item_options.append("heater-shaker") + if action not in action_options: + raise ValueError(f'"{action}" not recognized, available options: {action_options}') + if item not in item_options: + raise ValueError(f'"{item}" not recognized, available options: {item_options}') + if item == "heater-shaker": + assert slot, 'argument slot= is required when using "heater-shaker"' + if slot in [1, 4, 7, 10]: + side = "left" + elif slot in [3, 6, 9, 12]: + side = "right" + else: + raise ValueError("heater shaker must be on either left or right side") + hw_offset = _hw_offsets[f"{item}-{side}"] + else: + hw_offset = _hw_offsets[item] + if action == "pick-up": + offset = hw_offset + _pick_up_offsets[item] + else: + offset = hw_offset + _drop_offsets[item] + + # convert from Point() to dict() + return {"x": offset.x, "y": offset.y, "z": offset.z} + + ############################################################################################################################################ + ############################################################################################################################################ + ############################################################################################################################################ + # commands + heatershaker.open_labware_latch() + if DRYRUN == "NO": + protocol.comment("SETTING THERMO and TEMP BLOCK Temperature") + thermocycler.set_block_temperature(4) + thermocycler.set_lid_temperature(100) + temp_block.set_temperature(4) + thermocycler.open_lid() + protocol.pause("Ready") + heatershaker.close_labware_latch() + + if STEP_CLEANUP == 1: + protocol.comment("==============================================") + protocol.comment("--> Cleanup") + protocol.comment("==============================================") + + protocol.comment("--> Adding H20 and SAMPLE") + H20Vol = 40 # H20 ALREADY IN WELL + SampleVol = 45 + p1000.pick_up_tip(tiprack_200_1["A1"]) # <---------------- Tip Pickup + p1000.aspirate(SampleVol, sample_plate_1["A1"].bottom(z=1)) + p1000.dispense(SampleVol, sample_plate_2["A1"].bottom(z=1)) + p1000.return_tip() # <---------------- Tip Return + + protocol.comment("--> ADDING AMPure (0.8x)") + AMPureVol = 45 + SampleVol = 85 + AMPureMixRep = 5 * 60 if DRYRUN == "NO" else 0.1 * 60 + AMPurePremix = 3 if DRYRUN == "NO" else 1 + # ========NEW SINGLE TIP DISPENSE=========== + p1000.pick_up_tip(tiprack_200_2["A1"]) # <---------------- Tip Pickup + p1000.mix(AMPurePremix, AMPureVol + 10, AMPure.bottom(z=1)) + p1000.aspirate(AMPureVol, AMPure.bottom(z=1), rate=0.25) + p1000.dispense(AMPureVol, sample_plate_2["A1"].bottom(z=1), rate=0.25) + p1000.default_speed = 5 + p1000.move_to(sample_plate_2["A1"].bottom(z=5)) + for Mix in range(2): + p1000.aspirate(60, rate=0.5) + p1000.move_to(sample_plate_2["A1"].bottom(z=1)) + p1000.aspirate(60, rate=0.5) + p1000.dispense(60, rate=0.5) + p1000.move_to(sample_plate_2["A1"].bottom(z=5)) + p1000.dispense(30, rate=0.5) + Mix += 1 + p1000.blow_out(sample_plate_2["A1"].top(z=2)) + p1000.default_speed = 400 + p1000.move_to(sample_plate_2["A1"].top(z=5)) + p1000.move_to(sample_plate_2["A1"].top(z=0)) + p1000.move_to(sample_plate_2["A1"].top(z=5)) + p1000.return_tip() # <---------------- Tip Return + # ========NEW HS MIX========================= + heatershaker.set_and_wait_for_shake_speed(rpm=1800) + protocol.delay(AMPureMixRep) + heatershaker.deactivate_shaker() + + # ============================================================================================ + # GRIPPER MOVE PLATE FROM HEATER SHAKER TO MAG PLATE + heatershaker.open_labware_latch() + protocol.move_labware( + labware=sample_plate_2, + new_location=MAG_PLATE_SLOT, + use_gripper=USE_GRIPPER, + pick_up_offset=grip_offset("pick-up", "heater-shaker", 1), + drop_offset=grip_offset("drop", "mag-plate"), + ) + heatershaker.close_labware_latch() + # ============================================================================================ + + if DRYRUN == "NO": + protocol.delay(minutes=4) + + protocol.comment("--> Removing Supernatant") + RemoveSup = 200 + p1000.pick_up_tip(tiprack_200_2["A1"]) # <---------------- Tip Pickup + p1000.move_to(sample_plate_2["A1"].bottom(z=3.5)) + p1000.aspirate(RemoveSup - 100, rate=0.25) + protocol.delay(minutes=0.1) + p1000.move_to(sample_plate_2["A1"].bottom(z=0.5)) + p1000.aspirate(100, rate=0.25) + p1000.default_speed = 5 + p1000.move_to(sample_plate_2["A1"].top(z=2)) + p1000.default_speed = 200 + p1000.dispense(200, Liquid_trash.top(z=0)) + protocol.delay(minutes=0.1) + p1000.blow_out() + p1000.default_speed = 400 + p1000.move_to(Liquid_trash.top(z=-5)) + p1000.move_to(Liquid_trash.top(z=0)) + p1000.return_tip() # <---------------- Tip Return + + protocol.pause("RESET TIPRACKS") + del tiprack_200_1 + del tiprack_200_2 + + tiprack_200_3 = protocol.load_labware("opentrons_ot3_96_tiprack_200ul_rss", "2") + tiprack_20_1 = protocol.load_labware("opentrons_ot3_96_tiprack_50ul_rss", "8") + + for X in range(2): + protocol.comment("--> ETOH Wash") + ETOHMaxVol = 150 + p1000.pick_up_tip(tiprack_200_3["A1"]) + p1000.aspirate(ETOHMaxVol, EtOH.bottom(z=1)) + p1000.move_to(EtOH.top(z=0)) + p1000.move_to(EtOH.top(z=-5)) + p1000.move_to(EtOH.top(z=0)) + p1000.move_to(sample_plate_2["A1"].top(z=-2)) + p1000.dispense(ETOHMaxVol, rate=1) + protocol.delay(minutes=0.1) + p1000.blow_out() + p1000.move_to(sample_plate_2["A1"].top(z=5)) + p1000.move_to(sample_plate_2["A1"].top(z=0)) + p1000.move_to(sample_plate_2["A1"].top(z=5)) + p1000.return_tip() + + if DRYRUN == "NO": + protocol.delay(minutes=0.5) + + protocol.comment("--> Remove ETOH Wash") + p1000.pick_up_tip(tiprack_200_3["A1"]) # <---------------- Tip Pickup + p1000.move_to(sample_plate_2["A1"].bottom(z=3.5)) + p1000.aspirate(RemoveSup - 100, rate=0.25) + protocol.delay(minutes=0.1) + p1000.move_to(sample_plate_2["A1"].bottom(z=0.5)) + p1000.aspirate(100, rate=0.25) + p1000.default_speed = 5 + p1000.move_to(sample_plate_2["A1"].top(z=2)) + p1000.default_speed = 200 + p1000.dispense(200, Liquid_trash.top(z=0)) + protocol.delay(minutes=0.1) + p1000.blow_out() + p1000.default_speed = 400 + p1000.move_to(Liquid_trash.top(z=-5)) + p1000.move_to(Liquid_trash.top(z=0)) + p1000.return_tip() # <---------------- Tip Return + + if DRYRUN == "NO": + protocol.delay(minutes=2) + + protocol.comment("--> Removing Residual ETOH") + p1000.pick_up_tip(tiprack_200_3["A1"]) # <---------------- Tip Pickup + p1000.move_to(sample_plate_2["A1"].bottom(z=0)) + p1000.aspirate(50, rate=0.25) + p1000.default_speed = 200 + p1000.dispense(100, Liquid_trash.top(z=0)) + protocol.delay(minutes=0.1) + p1000.blow_out() + p1000.default_speed = 400 + p1000.move_to(Liquid_trash.top(z=-5)) + p1000.move_to(Liquid_trash.top(z=0)) + p1000.return_tip() # <---------------- Tip Return + + if DRYRUN == "NO": + protocol.delay(minutes=1) + + # ============================================================================================ + # GRIPPER MOVE PLATE FROM MAG PLATE TO HEATER SHAKER + heatershaker.open_labware_latch() + protocol.move_labware( + labware=sample_plate_2, + new_location=heatershaker, + use_gripper=USE_GRIPPER, + pick_up_offset=grip_offset("pick-up", "mag-plate"), + drop_offset=grip_offset("drop", "heater-shaker", 1), + ) + heatershaker.close_labware_latch() + # ============================================================================================ + + protocol.comment("--> Adding RSB") + RSBVol = 32 + RSBMixRep = 1 * 60 if DRYRUN == "NO" else 0.1 * 60 + p1000.pick_up_tip(tiprack_20_1["A1"]) # <---------------- Tip Pickup + p1000.aspirate(RSBVol, RSB.bottom(z=1)) + + p1000.move_to((sample_plate_2.wells_by_name()["A1"].center().move(types.Point(x=1.3 * 0.8, y=0, z=-4)))) + p1000.dispense(RSBVol, rate=1) + p1000.move_to(sample_plate_2.wells_by_name()["A1"].bottom(z=1)) + p1000.aspirate(RSBVol, rate=1) + p1000.move_to((sample_plate_2.wells_by_name()["A1"].center().move(types.Point(x=0, y=1.3 * 0.8, z=-4)))) + p1000.dispense(RSBVol, rate=1) + p1000.move_to(sample_plate_2.wells_by_name()["A1"].bottom(z=1)) + p1000.aspirate(RSBVol, rate=1) + p1000.move_to((sample_plate_2.wells_by_name()["A1"].center().move(types.Point(x=1.3 * -0.8, y=0, z=-4)))) + p1000.dispense(RSBVol, rate=1) + p1000.move_to(sample_plate_2.wells_by_name()["A1"].bottom(z=1)) + p1000.aspirate(RSBVol, rate=1) + p1000.move_to((sample_plate_2.wells_by_name()["A1"].center().move(types.Point(x=0, y=1.3 * -0.8, z=-4)))) + p1000.dispense(RSBVol, rate=1) + p1000.move_to(sample_plate_2.wells_by_name()["A1"].bottom(z=1)) + p1000.aspirate(RSBVol, rate=1) + p1000.dispense(RSBVol, rate=1) + + p1000.blow_out(sample_plate_2.wells_by_name()["A1"].center()) + p1000.move_to(sample_plate_2.wells_by_name()["A1"].top(z=5)) + p1000.move_to(sample_plate_2.wells_by_name()["A1"].top(z=0)) + p1000.move_to(sample_plate_2.wells_by_name()["A1"].top(z=5)) + p1000.return_tip() # <---------------- Tip Return + heatershaker.set_and_wait_for_shake_speed(rpm=1600) + protocol.delay(RSBMixRep) + heatershaker.deactivate_shaker() + + # ============================================================================================ + # GRIPPER MOVE PLATE FROM HEATER SHAKER TO MAG PLATE + heatershaker.open_labware_latch() + protocol.move_labware( + labware=sample_plate_2, + new_location=MAG_PLATE_SLOT, + use_gripper=USE_GRIPPER, + pick_up_offset=grip_offset("pick-up", "heater-shaker", 1), + drop_offset=grip_offset("drop", "mag-plate"), + ) + heatershaker.close_labware_latch() + # ============================================================================================ + + if DRYRUN == "NO": + protocol.delay(minutes=3) + + protocol.comment("--> Transferring Supernatant") + TransferSup = 30 + p1000.pick_up_tip(tiprack_20_1["A1"]) # <---------------- Tip Pickup + p1000.move_to(sample_plate_2["A1"].bottom(z=0.25)) + p1000.aspirate(TransferSup + 1, rate=0.25) + p1000.dispense(TransferSup + 5, sample_plate_3["A1"].bottom(z=1)) + p1000.return_tip() # <---------------- Tip Return diff --git a/app-testing/files/protocols/py/OT3_P1000_96_None_2_15_ABR5_6_IDT_xGen_EZ_96x_Head_PART_I_III_ABR.py b/app-testing/files/protocols/py/OT3_P1000_96_None_2_15_ABR5_6_IDT_xGen_EZ_96x_Head_PART_I_III_ABR.py new file mode 100644 index 000000000000..71d8aa6ab48d --- /dev/null +++ b/app-testing/files/protocols/py/OT3_P1000_96_None_2_15_ABR5_6_IDT_xGen_EZ_96x_Head_PART_I_III_ABR.py @@ -0,0 +1,888 @@ +from opentrons import protocol_api +from opentrons import types + +metadata = { + "protocolName": "IDT xGen EZ 96x Head PART I-III ABR", + "author": "Opentrons ", + "source": "Protocol Library", + "apiLevel": "2.15", +} + +requirements = { + "robotType": "OT-3", + "apiLevel": "2.15", +} + +# SCRIPT SETTINGS +DRYRUN = True # True = skip incubation times, shorten mix, for testing purposes +USE_GRIPPER = True # True = Uses Gripper, False = Manual Move +TIP_TRASH = False # True = Used tips go in Trash, False = Used tips go back into rack +MODULES = False # True = Use Modules, False - No Modules for testing purposes + +# PROTOCOL SETTINGS +FRAGTIME = 27 # Minutes, Duration of the Fragmentation Step +PCRCYCLES = 5 # Amount of Cycles + +# PROTOCOL BLOCKS +STEP_FRERAT = 1 +STEP_FRERATDECK = 1 +STEP_LIG = 1 +STEP_LIGDECK = 1 +STEP_CLEANUP = 1 +STEP_PCR = 1 +STEP_PCRDECK = 1 +STEP_POSTPCR = 1 + +############################################################################################################################################ +############################################################################################################################################ +############################################################################################################################################ + + +def run(protocol: protocol_api.ProtocolContext): + + protocol.comment("THIS IS A DRY RUN") if DRYRUN == True else protocol.comment("THIS IS A REACTION RUN") + protocol.comment("USED TIPS WILL GO IN TRASH") if TIP_TRASH == True else protocol.comment( + "USED TIPS WILL BE RE-RACKED" + ) + + # DECK SETUP AND LABWARE + # ========== FIRST ROW =========== + if MODULES == True: + heatershaker = protocol.load_module("heaterShakerModuleV1", "1") + reagent_plate_1 = heatershaker.load_labware("nest_96_wellplate_100ul_pcr_full_skirt") + else: + heatershaker = 1 + reagent_plate_1 = protocol.load_labware("nest_96_wellplate_100ul_pcr_full_skirt", "1") + reservoir_1 = protocol.load_labware("nest_96_wellplate_2ml_deep", "2") + if MODULES == True: + temp_block = protocol.load_module("temperature module gen2", "3") + reagent_plate_2 = temp_block.load_labware("nest_96_wellplate_100ul_pcr_full_skirt") + else: + reagent_plate_2 = protocol.load_labware("nest_96_wellplate_100ul_pcr_full_skirt", "3") + # ========== SECOND ROW ========== + MAG_PLATE_SLOT = protocol.load_module("magneticBlockV1", "4") + reservoir_2 = protocol.load_labware("nest_96_wellplate_2ml_deep", "5") + tiprack_20_1 = protocol.load_labware("opentrons_ot3_96_tiprack_50ul_rss", "6") + # ========== THIRD ROW =========== + if MODULES == True: + thermocycler = protocol.load_module("thermocycler module gen2") + sample_plate_1 = thermocycler.load_labware("nest_96_wellplate_100ul_pcr_full_skirt") + else: + thermocycler = 7 + sample_plate_1 = protocol.load_labware("nest_96_wellplate_100ul_pcr_full_skirt", "7") + reservoir_3 = protocol.load_labware("nest_96_wellplate_2ml_deep", "8") + tiprack_200_1 = protocol.load_labware("opentrons_ot3_96_tiprack_200ul_rss", "9") + # ========== FOURTH ROW ========== + reservoir_4 = protocol.load_labware("nest_96_wellplate_2ml_deep", "11") + + # ========= REAGENT PLATE ========== + FRERAT = reagent_plate_1.wells_by_name()["A1"] + LIG = reagent_plate_2.wells_by_name()["A1"] + PCR = reagent_plate_2.wells_by_name()["A1"] + sample_plate_2 = reagent_plate_1 + sample_plate_3 = reagent_plate_2 + + # =========== RESERVOIR ========== + EtOH_1 = reservoir_1["A1"] + AMPure = reservoir_2["A1"] + RSB = reservoir_3["A1"] + Liquid_trash = reservoir_4["A1"] + + # pipette + p1000 = protocol.load_instrument("p1000_96", "left") + + # tip and sample tracking + + def grip_offset(action, item, slot=None): + """Grip offset.""" + from opentrons.types import Point + + # EDIT these values + # NOTE: we are still testing to determine our software's defaults + # but we also expect users will want to edit these + _pick_up_offsets = { + "deck": Point(), + "mag-plate": Point(), + "heater-shaker": Point(z=1.0), + "temp-module": Point(), + "thermo-cycler": Point(), + } + # EDIT these values + # NOTE: we are still testing to determine our software's defaults + # but we also expect users will want to edit these + _drop_offsets = { + "deck": Point(), + "mag-plate": Point(z=0.5), + "heater-shaker": Point(), + "temp-module": Point(), + "thermo-cycler": Point(), + } + # do NOT edit these values + # NOTE: these values will eventually be in our software + # and will not need to be inside a protocol + _hw_offsets = { + "deck": Point(), + "mag-plate": Point(z=2.5), + "heater-shaker-right": Point(z=2.5), + "heater-shaker-left": Point(z=2.5), + "temp-module": Point(z=5.0), + "thermo-cycler": Point(z=2.5), + } + # make sure arguments are correct + action_options = ["pick-up", "drop"] + item_options = list(_hw_offsets.keys()) + item_options.remove("heater-shaker-left") + item_options.remove("heater-shaker-right") + item_options.append("heater-shaker") + if action not in action_options: + raise ValueError(f'"{action}" not recognized, available options: {action_options}') + if item not in item_options: + raise ValueError(f'"{item}" not recognized, available options: {item_options}') + if item == "heater-shaker": + assert slot, 'argument slot= is required when using "heater-shaker"' + if slot in [1, 4, 7, 10]: + side = "left" + elif slot in [3, 6, 9, 12]: + side = "right" + else: + raise ValueError("heater shaker must be on either left or right side") + hw_offset = _hw_offsets[f"{item}-{side}"] + else: + hw_offset = _hw_offsets[item] + if action == "pick-up": + offset = hw_offset + _pick_up_offsets[item] + else: + offset = hw_offset + _drop_offsets[item] + + # convert from Point() to dict() + return {"x": offset.x, "y": offset.y, "z": offset.z} + + ############################################################################################################################################ + ############################################################################################################################################ + ############################################################################################################################################ + # commands + if MODULES == True: + heatershaker.open_labware_latch() + if DRYRUN == "NO": + protocol.comment("SETTING THERMO and TEMP BLOCK Temperature") + if MODULES == True: + thermocycler.set_block_temperature(4) + thermocycler.set_lid_temperature(100) + temp_block.set_temperature(4) + if MODULES == True: + thermocycler.open_lid() + protocol.pause("Ready") + if MODULES == True: + heatershaker.close_labware_latch() + + if STEP_FRERAT == 1: + protocol.comment("==============================================") + protocol.comment("--> Fragmenting / End Repair / A-Tailing") + protocol.comment("==============================================") + + protocol.comment("--> Adding FRERAT") + FRERATVol = 10.5 + FRERATMixRep = 10 if DRYRUN == "NO" else 1 + FRERATMixVol = 20 + p1000.pick_up_tip(tiprack_20_1["A1"]) + p1000.aspirate(FRERATVol, FRERAT.bottom()) + p1000.dispense(FRERATVol, sample_plate_1["A1"].bottom()) + p1000.move_to(sample_plate_1["A1"].bottom()) + p1000.mix(FRERATMixRep, FRERATMixVol) + p1000.return_tip() + + if STEP_FRERATDECK == 1: + if MODULES == True: + ############################################################################################################################################ + protocol.comment("Seal, Run FRERAT (60min)") + thermocycler.close_lid() + if DRYRUN == "NO": + profile_FRERAT = [ + {"temperature": 32, "hold_time_minutes": FRAGTIME}, + {"temperature": 65, "hold_time_minutes": 30}, + ] + thermocycler.execute_profile(steps=profile_FRERAT, repetitions=1, block_max_volume=50) + thermocycler.set_block_temperature(4) + ############################################################################################################################################ + thermocycler.open_lid() + + if DRYRUN == "NO": + protocol.pause("RESET tiprack_20_1") + + if STEP_LIG == 1: + protocol.comment("==============================================") + protocol.comment("--> Adapter Ligation") + protocol.comment("==============================================") + + protocol.comment("--> Adding Lig") + LIGVol = 30 + LIGMixRep = 40 if DRYRUN == "NO" else 1 + LIGMixVol = 50 + p1000.pick_up_tip(tiprack_20_1["A1"]) + p1000.mix(3, LIGVol, LIG.bottom(z=1), rate=0.5) + p1000.aspirate(LIGVol, LIG.bottom(z=1), rate=0.2) + p1000.default_speed = 5 + p1000.move_to(LIG.top(5)) + protocol.delay(seconds=0.2) + p1000.default_speed = 400 + p1000.dispense(LIGVol, sample_plate_1["A1"].bottom(), rate=0.25) + p1000.move_to(sample_plate_1["A1"].bottom()) + p1000.mix(LIGMixRep, LIGMixVol, rate=0.5) + p1000.blow_out(sample_plate_1["A1"].top(z=-5)) + p1000.return_tip() + + if STEP_LIGDECK == 1: + if MODULES == True: + ############################################################################################################################################ + if DRYRUN == "NO": + profile_LIG = [{"temperature": 20, "hold_time_minutes": 20}] + thermocycler.execute_profile(steps=profile_LIG, repetitions=1, block_max_volume=50) + thermocycler.set_block_temperature(4) + ############################################################################################################################################ + + if DRYRUN == "NO": + protocol.pause("RESET tiprack_20_1") + + # ============================================================================================ + # GRIPPER MOVE reagent_plate_1 FROM HEATHERSHAKER TO MAG PLATE + if MODULES == True: + heatershaker.open_labware_latch() + if MODULES == True: + protocol.move_labware( + labware=reagent_plate_1, + new_location=MAG_PLATE_SLOT, + use_gripper=USE_GRIPPER, + pick_up_offset=grip_offset("pick-up", "heater-shaker", 1), + drop_offset=grip_offset("drop", "mag-plate"), + ) + else: + protocol.move_labware( + labware=reagent_plate_1, + new_location=MAG_PLATE_SLOT, + use_gripper=USE_GRIPPER, + pick_up_offset=grip_offset("pick-up", "deck"), + drop_offset=grip_offset("drop", "deck"), + ) + # ============================================================================================ + + # ============================================================================================ + # GRIPPER MOVE sample_plate_1 FROM THERMOCYCLER TO HEATHERSHAKER + if MODULES == True: + protocol.move_labware( + labware=sample_plate_1, + new_location=heatershaker, + use_gripper=USE_GRIPPER, + pick_up_offset=grip_offset("pick-up", "thermo-cycler"), + drop_offset=grip_offset("drop", "heater-shaker", 1), + ) + else: + protocol.move_labware( + labware=sample_plate_1, + new_location=heatershaker, + use_gripper=USE_GRIPPER, + pick_up_offset=grip_offset("pick-up", "deck"), + drop_offset=grip_offset("drop", "deck"), + ) + # ============================================================================================ + + # ============================================================================================ + # GRIPPER MOVE reagent_plate_1 FROM MAG PLATE TO THERMOCYCLER + if MODULES == True: + protocol.move_labware( + labware=reagent_plate_1, + new_location=thermocycler, + use_gripper=USE_GRIPPER, + pick_up_offset=grip_offset("pick-up", "mag-plate"), + drop_offset=grip_offset("drop", "thermo-cycler"), + ) + else: + protocol.move_labware( + labware=reagent_plate_1, + new_location=thermocycler, + use_gripper=USE_GRIPPER, + pick_up_offset=grip_offset("pick-up", "deck"), + drop_offset=grip_offset("drop", "deck"), + ) + if MODULES == True: + heatershaker.close_labware_latch() + # ============================================================================================ + + ############################################################################################################################################ + ############################################################################################################################################ + ############################################################################################################################################ + + if STEP_CLEANUP == 1: + protocol.comment("==============================================") + protocol.comment("--> Cleanup") + protocol.comment("==============================================") + + protocol.comment("--> ADDING AMPure (0.8x)") + AMPureVol = 48 + SampleVol = 75 + AMPureMixRep = 5 * 60 if DRYRUN == "NO" else 0.1 * 60 + AMPurePremix = 3 if DRYRUN == "NO" else 1 + # ========NEW SINGLE TIP DISPENSE=========== + p1000.pick_up_tip(tiprack_200_1["A1"]) # <---------------- Tip Pickup + p1000.mix(AMPurePremix, AMPureVol + 10, AMPure.bottom(z=1)) + p1000.aspirate(AMPureVol, AMPure.bottom(z=1), rate=0.25) + p1000.dispense(AMPureVol, sample_plate_1["A1"].bottom(z=1), rate=0.25) + p1000.default_speed = 5 + p1000.move_to(sample_plate_1["A1"].bottom(z=5)) + for Mix in range(2): + p1000.aspirate(70, rate=0.5) + p1000.move_to(sample_plate_1["A1"].bottom(z=1)) + p1000.aspirate(50, rate=0.5) + p1000.dispense(50, rate=0.5) + p1000.move_to(sample_plate_1["A1"].bottom(z=5)) + p1000.dispense(70, rate=0.5) + Mix += 1 + p1000.blow_out(sample_plate_1["A1"].top(z=2)) + p1000.default_speed = 400 + p1000.move_to(sample_plate_1["A1"].top(z=5)) + p1000.move_to(sample_plate_1["A1"].top(z=0)) + p1000.move_to(sample_plate_1["A1"].top(z=5)) + p1000.return_tip() # <---------------- Tip Return + # ========NEW HS MIX========================= + if MODULES == True: + heatershaker.set_and_wait_for_shake_speed(rpm=1800) + protocol.delay(AMPureMixRep) + heatershaker.deactivate_shaker() + + # ============================================================================================ + # GRIPPER MOVE PLATE FROM HEATER SHAKER TO MAG PLATE + if MODULES == True: + heatershaker.open_labware_latch() + if MODULES == True: + protocol.move_labware( + labware=sample_plate_1, + new_location=MAG_PLATE_SLOT, + use_gripper=USE_GRIPPER, + pick_up_offset=grip_offset("pick-up", "heater-shaker", 1), + drop_offset=grip_offset("drop", "mag-plate"), + ) + else: + protocol.move_labware( + labware=sample_plate_1, + new_location=MAG_PLATE_SLOT, + use_gripper=USE_GRIPPER, + pick_up_offset=grip_offset("pick-up", "deck"), + drop_offset=grip_offset("drop", "deck"), + ) + if MODULES == True: + heatershaker.close_labware_latch() + # ============================================================================================ + + if DRYRUN == "NO": + protocol.delay(minutes=4) + + protocol.comment("--> Removing Supernatant") + RemoveSup = 200 + p1000.pick_up_tip(tiprack_200_1["A1"]) # <---------------- Tip Pickup + p1000.move_to(sample_plate_1["A1"].bottom(z=3.5)) + p1000.aspirate(RemoveSup - 100, rate=0.25) + protocol.delay(minutes=0.1) + p1000.move_to(sample_plate_1["A1"].bottom(z=0.5)) + p1000.aspirate(100, rate=0.25) + p1000.default_speed = 5 + p1000.move_to(sample_plate_1["A1"].top(z=2)) + p1000.default_speed = 200 + p1000.dispense(200, Liquid_trash.top(z=0)) + protocol.delay(minutes=0.1) + p1000.blow_out() + p1000.default_speed = 400 + p1000.move_to(Liquid_trash.top(z=-5)) + p1000.move_to(Liquid_trash.top(z=0)) + p1000.return_tip() # <---------------- Tip Return + + if DRYRUN == "NO": + protocol.pause("RESET tiprack_200_1") + + for X in range(2): + protocol.comment("--> ETOH Wash") + ETOHMaxVol = 150 + p1000.pick_up_tip(tiprack_200_1["A1"]) + p1000.aspirate(ETOHMaxVol, EtOH_1.bottom(z=1)) + p1000.move_to(EtOH_1.top(z=0)) + p1000.move_to(EtOH_1.top(z=-5)) + p1000.move_to(EtOH_1.top(z=0)) + p1000.move_to(sample_plate_1["A1"].top(z=-2)) + p1000.dispense(ETOHMaxVol, rate=1) + protocol.delay(minutes=0.1) + p1000.blow_out() + p1000.move_to(sample_plate_1["A1"].top(z=5)) + p1000.move_to(sample_plate_1["A1"].top(z=0)) + p1000.move_to(sample_plate_1["A1"].top(z=5)) + p1000.return_tip() + + if DRYRUN == "NO": + protocol.delay(minutes=0.5) + + protocol.comment("--> Remove ETOH Wash") + p1000.pick_up_tip(tiprack_200_1["A1"]) # <---------------- Tip Pickup + p1000.move_to(sample_plate_1["A1"].bottom(z=3.5)) + p1000.aspirate(RemoveSup - 100, rate=0.25) + protocol.delay(minutes=0.1) + p1000.move_to(sample_plate_1["A1"].bottom(z=0.5)) + p1000.aspirate(100, rate=0.25) + p1000.default_speed = 5 + p1000.move_to(sample_plate_1["A1"].top(z=2)) + p1000.default_speed = 200 + p1000.dispense(200, Liquid_trash.top(z=0)) + protocol.delay(minutes=0.1) + p1000.blow_out() + p1000.default_speed = 400 + p1000.move_to(Liquid_trash.top(z=-5)) + p1000.move_to(Liquid_trash.top(z=0)) + p1000.return_tip() # <---------------- Tip Return + + if DRYRUN == "NO": + protocol.delay(minutes=2) + + protocol.comment("--> Removing Residual ETOH") + p1000.pick_up_tip(tiprack_200_1["A1"]) # <---------------- Tip Pickup + p1000.move_to(sample_plate_1["A1"].bottom(z=0)) + p1000.aspirate(50, rate=0.25) + p1000.default_speed = 200 + p1000.dispense(100, Liquid_trash.top(z=0)) + protocol.delay(minutes=0.1) + p1000.blow_out() + p1000.default_speed = 400 + p1000.move_to(Liquid_trash.top(z=-5)) + p1000.move_to(Liquid_trash.top(z=0)) + p1000.return_tip() # <---------------- Tip Return + + if DRYRUN == "NO": + protocol.delay(minutes=1) + + # ============================================================================================ + # GRIPPER MOVE PLATE FROM MAG PLATE TO HEATER SHAKER + if MODULES == True: + heatershaker.open_labware_latch() + if MODULES == True: + protocol.move_labware( + labware=sample_plate_1, + new_location=heatershaker, + use_gripper=USE_GRIPPER, + pick_up_offset=grip_offset("pick-up", "mag-plate"), + drop_offset=grip_offset("drop", "heater-shaker", 1), + ) + else: + protocol.move_labware( + labware=sample_plate_1, + new_location=heatershaker, + use_gripper=USE_GRIPPER, + pick_up_offset=grip_offset("pick-up", "deck"), + drop_offset=grip_offset("drop", "deck"), + ) + if MODULES == True: + heatershaker.close_labware_latch() + # ============================================================================================ + + if DRYRUN == "NO": + protocol.pause("RESET tiprack_20_1") + protocol.pause("SWAP reagent_plate_1: -reagent_plate_1 +sample_plate_2 with Barcodes") + protocol.pause("SWAP reagent_plate_2: -reagent_plate_2 +reagent_plate_3 with PCR") + + protocol.comment("--> Adding RSB") + RSBVol = 22 + RSBMixRep = 4 * 60 if DRYRUN == "NO" else 0.1 * 60 + p1000.pick_up_tip(tiprack_20_1["A1"]) # <---------------- Tip Pickup + p1000.aspirate(RSBVol, RSB.bottom(z=1)) + + p1000.move_to((sample_plate_1.wells_by_name()["A1"].center().move(types.Point(x=1.3 * 0.8, y=0, z=-4)))) + p1000.dispense(RSBVol, rate=1) + p1000.move_to(sample_plate_1.wells_by_name()["A1"].bottom(z=1)) + p1000.aspirate(RSBVol, rate=1) + p1000.move_to((sample_plate_1.wells_by_name()["A1"].center().move(types.Point(x=0, y=1.3 * 0.8, z=-4)))) + p1000.dispense(RSBVol, rate=1) + p1000.move_to(sample_plate_1.wells_by_name()["A1"].bottom(z=1)) + p1000.aspirate(RSBVol, rate=1) + p1000.move_to((sample_plate_1.wells_by_name()["A1"].center().move(types.Point(x=1.3 * -0.8, y=0, z=-4)))) + p1000.dispense(RSBVol, rate=1) + p1000.move_to(sample_plate_1.wells_by_name()["A1"].bottom(z=1)) + p1000.aspirate(RSBVol, rate=1) + p1000.move_to((sample_plate_1.wells_by_name()["A1"].center().move(types.Point(x=0, y=1.3 * -0.8, z=-4)))) + p1000.dispense(RSBVol, rate=1) + p1000.move_to(sample_plate_1.wells_by_name()["A1"].bottom(z=1)) + p1000.aspirate(RSBVol, rate=1) + p1000.dispense(RSBVol, rate=1) + + p1000.blow_out(sample_plate_1.wells_by_name()["A1"].center()) + p1000.move_to(sample_plate_1.wells_by_name()["A1"].top(z=5)) + p1000.move_to(sample_plate_1.wells_by_name()["A1"].top(z=0)) + p1000.move_to(sample_plate_1.wells_by_name()["A1"].top(z=5)) + p1000.return_tip() # <---------------- Tip Return + if MODULES == True: + heatershaker.set_and_wait_for_shake_speed(rpm=2000) + protocol.delay(RSBMixRep) + heatershaker.deactivate_shaker() + + # ============================================================================================ + # GRIPPER MOVE PLATE FROM HEATER SHAKER TO MAG PLATE + if MODULES == True: + heatershaker.open_labware_latch() + if MODULES == True: + protocol.move_labware( + labware=sample_plate_1, + new_location=MAG_PLATE_SLOT, + use_gripper=USE_GRIPPER, + pick_up_offset=grip_offset("pick-up", "heater-shaker", 1), + drop_offset=grip_offset("drop", "mag-plate"), + ) + else: + protocol.move_labware( + labware=sample_plate_1, + new_location=MAG_PLATE_SLOT, + use_gripper=USE_GRIPPER, + pick_up_offset=grip_offset("pick-up", "deck"), + drop_offset=grip_offset("drop", "deck"), + ) + if MODULES == True: + heatershaker.close_labware_latch() + # ============================================================================================ + + if DRYRUN == "NO": + protocol.delay(minutes=3) + + protocol.comment("--> Transferring Supernatant") + TransferSup = 20 + p1000.pick_up_tip(tiprack_20_1["A1"]) # <---------------- Tip Pickup + p1000.move_to(sample_plate_1["A1"].bottom(z=0.25)) + p1000.aspirate(TransferSup + 1, rate=0.25) + p1000.dispense(TransferSup + 5, sample_plate_2["A1"].bottom(z=1)) + p1000.return_tip() # <---------------- Tip Return + + if DRYRUN == "NO": + protocol.pause("RESET tiprack_20_1") + + if STEP_PCR == 1: + protocol.comment("==============================================") + protocol.comment("--> Amplification") + protocol.comment("==============================================") + + protocol.comment("--> Adding PCR") + PCRVol = 25 + PCRMixRep = 10 + PCRMixVol = 50 + p1000.pick_up_tip(tiprack_20_1["A1"]) + p1000.mix(2, PCRVol, PCR.bottom(z=1), rate=0.5) + p1000.aspirate(PCRVol, PCR.bottom(z=1), rate=0.25) + p1000.dispense(PCRVol, sample_plate_2["A1"].bottom(z=1), rate=0.25) + p1000.mix(PCRMixRep, PCRMixVol, rate=0.5) + p1000.move_to(sample_plate_2["A1"].bottom(z=1)) + protocol.delay(minutes=0.1) + p1000.blow_out(sample_plate_2["A1"].top(z=-5)) + p1000.return_tip() + + if STEP_PCRDECK == 1: + ############################################################################################################################################ + if MODULES == True: + thermocycler.close_lid() + if DRYRUN == "NO": + profile_PCR_1 = [{"temperature": 98, "hold_time_seconds": 45}] + thermocycler.execute_profile(steps=profile_PCR_1, repetitions=1, block_max_volume=50) + profile_PCR_2 = [ + {"temperature": 98, "hold_time_seconds": 15}, + {"temperature": 60, "hold_time_seconds": 30}, + {"temperature": 72, "hold_time_seconds": 30}, + ] + thermocycler.execute_profile(steps=profile_PCR_2, repetitions=PCRCYCLES, block_max_volume=50) + profile_PCR_3 = [{"temperature": 72, "hold_time_minutes": 1}] + thermocycler.execute_profile(steps=profile_PCR_3, repetitions=1, block_max_volume=50) + thermocycler.set_block_temperature(4) + thermocycler.open_lid() + ############################################################################################################################################ + + ############################################################################################################################################ + ############################################################################################################################################ + ############################################################################################################################################ + + # ============================================================================================ + # GRIPPER MOVE sample_plate_2 FROM THERMOCYCLER TO HEATER SHAKER + if MODULES == True: + heatershaker.open_labware_latch() + if MODULES == True: + protocol.move_labware( + labware=sample_plate_2, + new_location=heatershaker, + use_gripper=USE_GRIPPER, + pick_up_offset=grip_offset("pick-up", "thermo-cycler"), + drop_offset=grip_offset("drop", "heater-shaker", 1), + ) + else: + protocol.move_labware( + labware=sample_plate_2, + new_location=heatershaker, + use_gripper=USE_GRIPPER, + pick_up_offset=grip_offset("pick-up", "deck"), + drop_offset=grip_offset("drop", "deck"), + ) + if MODULES == True: + heatershaker.close_labware_latch() + # ============================================================================================ + + # ============================================================================================ + # GRIPPER MOVE sample_plate_1 FROM HMAG PLATE TO THERMOCYCLER + if MODULES == True: + heatershaker.open_labware_latch() + if MODULES == True: + protocol.move_labware( + labware=sample_plate_1, + new_location=thermocycler, + use_gripper=USE_GRIPPER, + pick_up_offset=grip_offset("pick-up", "mag-plate"), + drop_offset=grip_offset("drop", "thermo-cycler"), + ) + else: + protocol.move_labware( + labware=sample_plate_1, + new_location=thermocycler, + use_gripper=USE_GRIPPER, + pick_up_offset=grip_offset("pick-up", "mag-plate"), + drop_offset=grip_offset("drop", "thermo-cycler"), + ) + if MODULES == True: + heatershaker.close_labware_latch() + # ============================================================================================ + + if DRYRUN == "NO": + protocol.pause("RESET tiprack_200_1") + + if STEP_POSTPCR == 1: + protocol.comment("==============================================") + protocol.comment("--> Cleanup") + protocol.comment("==============================================") + + protocol.comment("--> ADDING AMPure (0.8x)") + AMPureVol = 32.5 + SampleVol = 50 + AMPureMixRep = 5 * 60 if DRYRUN == "NO" else 0.1 * 60 + AMPurePremix = 3 if DRYRUN == "NO" else 1 + # ========NEW SINGLE TIP DISPENSE=========== + p1000.pick_up_tip(tiprack_200_1["A1"]) # <---------------- Tip Pickup + p1000.mix(AMPurePremix, AMPureVol + 10, AMPure.bottom(z=1)) + p1000.aspirate(AMPureVol, AMPure.bottom(z=1), rate=0.25) + p1000.dispense(AMPureVol, sample_plate_2["A1"].bottom(z=1), rate=0.25) + p1000.default_speed = 5 + p1000.move_to(sample_plate_2["A1"].bottom(z=3.5)) + for Mix in range(2): + p1000.aspirate(50, rate=0.5) + p1000.move_to(sample_plate_2["A1"].bottom(z=1)) + p1000.aspirate(30, rate=0.5) + p1000.dispense(30, rate=0.5) + p1000.move_to(sample_plate_2["A1"].bottom(z=3.5)) + p1000.dispense(50, rate=0.5) + Mix += 1 + p1000.blow_out(sample_plate_2["A1"].top(z=2)) + p1000.default_speed = 400 + p1000.move_to(sample_plate_2["A1"].top(z=5)) + p1000.move_to(sample_plate_2["A1"].top(z=0)) + p1000.move_to(sample_plate_2["A1"].top(z=5)) + p1000.return_tip() # <---------------- Tip Return + # ========NEW HS MIX========================= + if MODULES == True: + heatershaker.set_and_wait_for_shake_speed(rpm=1800) + protocol.delay(AMPureMixRep) + heatershaker.deactivate_shaker() + + # ============================================================================================ + # GRIPPER MOVE PLATE FROM HEATER SHAKER TO MAG PLATE + if MODULES == True: + heatershaker.open_labware_latch() + if MODULES == True: + protocol.move_labware( + labware=sample_plate_2, + new_location=MAG_PLATE_SLOT, + use_gripper=USE_GRIPPER, + pick_up_offset=grip_offset("pick-up", "heater-shaker", 1), + drop_offset=grip_offset("drop", "mag-plate"), + ) + else: + protocol.move_labware( + labware=sample_plate_2, + new_location=MAG_PLATE_SLOT, + use_gripper=USE_GRIPPER, + pick_up_offset=grip_offset("pick-up", "deck"), + drop_offset=grip_offset("drop", "deck"), + ) + if MODULES == True: + heatershaker.close_labware_latch() + # ============================================================================================ + + if DRYRUN == "NO": + protocol.delay(minutes=4) + + protocol.comment("--> Removing Supernatant") + RemoveSup = 200 + p1000.pick_up_tip(tiprack_200_1["A1"]) # <---------------- Tip Pickup + p1000.move_to(sample_plate_2["A1"].bottom(z=3.5)) + p1000.aspirate(RemoveSup - 100, rate=0.25) + protocol.delay(minutes=0.1) + p1000.move_to(sample_plate_2["A1"].bottom(z=0.5)) + p1000.aspirate(100, rate=0.25) + p1000.default_speed = 5 + p1000.move_to(sample_plate_2["A1"].top(z=2)) + p1000.default_speed = 200 + p1000.dispense(200, Liquid_trash.top(z=0)) + protocol.delay(minutes=0.1) + p1000.blow_out() + p1000.default_speed = 400 + p1000.move_to(Liquid_trash.top(z=-5)) + p1000.move_to(Liquid_trash.top(z=0)) + p1000.return_tip() # <---------------- Tip Return + + if DRYRUN == "NO": + protocol.pause("RESET tiprack_200_1") + + for X in range(2): + protocol.comment("--> ETOH Wash") + ETOHMaxVol = 150 + p1000.pick_up_tip(tiprack_200_1["A1"]) + p1000.aspirate(ETOHMaxVol, EtOH_1.bottom(z=1)) + p1000.move_to(EtOH_1.top(z=0)) + p1000.move_to(EtOH_1.top(z=-5)) + p1000.move_to(EtOH_1.top(z=0)) + p1000.move_to(sample_plate_2["A1"].top(z=-2)) + p1000.dispense(ETOHMaxVol, rate=1) + protocol.delay(minutes=0.1) + p1000.blow_out() + p1000.move_to(sample_plate_2["A1"].top(z=5)) + p1000.move_to(sample_plate_2["A1"].top(z=0)) + p1000.move_to(sample_plate_2["A1"].top(z=5)) + p1000.return_tip() + + if DRYRUN == "NO": + protocol.delay(minutes=0.5) + + protocol.comment("--> Remove ETOH Wash") + p1000.pick_up_tip(tiprack_200_1["A1"]) # <---------------- Tip Pickup + p1000.move_to(sample_plate_2["A1"].bottom(z=3.5)) + p1000.aspirate(RemoveSup - 100, rate=0.25) + protocol.delay(minutes=0.1) + p1000.move_to(sample_plate_2["A1"].bottom(z=0.5)) + p1000.aspirate(100, rate=0.25) + p1000.default_speed = 5 + p1000.move_to(sample_plate_2["A1"].top(z=2)) + p1000.default_speed = 200 + p1000.dispense(200, Liquid_trash.top(z=0)) + protocol.delay(minutes=0.1) + p1000.blow_out() + p1000.default_speed = 400 + p1000.move_to(Liquid_trash.top(z=-5)) + p1000.move_to(Liquid_trash.top(z=0)) + p1000.return_tip() # <---------------- Tip Return + + if DRYRUN == "NO": + protocol.delay(minutes=2) + + protocol.comment("--> Removing Residual ETOH") + p1000.pick_up_tip(tiprack_200_1["A1"]) # <---------------- Tip Pickup + p1000.move_to(sample_plate_2["A1"].bottom(z=0)) + p1000.aspirate(50, rate=0.25) + p1000.default_speed = 200 + p1000.dispense(100, Liquid_trash.top(z=0)) + protocol.delay(minutes=0.1) + p1000.blow_out() + p1000.default_speed = 400 + p1000.move_to(Liquid_trash.top(z=-5)) + p1000.move_to(Liquid_trash.top(z=0)) + p1000.return_tip() # <---------------- Tip Return + + if DRYRUN == "NO": + protocol.delay(minutes=1) + + # ============================================================================================ + # GRIPPER MOVE PLATE FROM MAG PLATE TO HEATER SHAKER + if MODULES == True: + heatershaker.open_labware_latch() + if MODULES == True: + protocol.move_labware( + labware=sample_plate_2, + new_location=heatershaker, + use_gripper=USE_GRIPPER, + pick_up_offset=grip_offset("pick-up", "mag-plate"), + drop_offset=grip_offset("drop", "heater-shaker", 1), + ) + else: + protocol.move_labware( + labware=sample_plate_2, + new_location=heatershaker, + use_gripper=USE_GRIPPER, + pick_up_offset=grip_offset("pick-up", "deck"), + drop_offset=grip_offset("drop", "deck"), + ) + if MODULES == True: + heatershaker.close_labware_latch() + # ============================================================================================ + + if DRYRUN == "NO": + protocol.pause("RESET tiprack_20_1") + + protocol.comment("--> Adding RSB") + RSBVol = 22 + RSBMixRep = 4 * 60 if DRYRUN == "NO" else 0.1 * 60 + p1000.pick_up_tip(tiprack_20_1["A1"]) # <---------------- Tip Pickup + p1000.aspirate(RSBVol, RSB.bottom(z=1)) + + p1000.move_to((sample_plate_2.wells_by_name()["A1"].center().move(types.Point(x=1.3 * 0.8, y=0, z=-4)))) + p1000.dispense(RSBVol, rate=1) + p1000.move_to(sample_plate_2.wells_by_name()["A1"].bottom(z=1)) + p1000.aspirate(RSBVol, rate=1) + p1000.move_to((sample_plate_2.wells_by_name()["A1"].center().move(types.Point(x=0, y=1.3 * 0.8, z=-4)))) + p1000.dispense(RSBVol, rate=1) + p1000.move_to(sample_plate_2.wells_by_name()["A1"].bottom(z=1)) + p1000.aspirate(RSBVol, rate=1) + p1000.move_to((sample_plate_2.wells_by_name()["A1"].center().move(types.Point(x=1.3 * -0.8, y=0, z=-4)))) + p1000.dispense(RSBVol, rate=1) + p1000.move_to(sample_plate_2.wells_by_name()["A1"].bottom(z=1)) + p1000.aspirate(RSBVol, rate=1) + p1000.move_to((sample_plate_2.wells_by_name()["A1"].center().move(types.Point(x=0, y=1.3 * -0.8, z=-4)))) + p1000.dispense(RSBVol, rate=1) + p1000.move_to(sample_plate_2.wells_by_name()["A1"].bottom(z=1)) + p1000.aspirate(RSBVol, rate=1) + p1000.dispense(RSBVol, rate=1) + + p1000.blow_out(sample_plate_2.wells_by_name()["A1"].center()) + p1000.move_to(sample_plate_2.wells_by_name()["A1"].top(z=5)) + p1000.move_to(sample_plate_2.wells_by_name()["A1"].top(z=0)) + p1000.move_to(sample_plate_2.wells_by_name()["A1"].top(z=5)) + p1000.return_tip() # <---------------- Tip Return + if MODULES == True: + heatershaker.set_and_wait_for_shake_speed(rpm=2000) + protocol.delay(RSBMixRep) + heatershaker.deactivate_shaker() + + # ============================================================================================ + # GRIPPER MOVE PLATE FROM HEATER SHAKER TO MAG PLATE + if MODULES == True: + heatershaker.open_labware_latch() + if MODULES == True: + protocol.move_labware( + labware=sample_plate_2, + new_location=MAG_PLATE_SLOT, + use_gripper=USE_GRIPPER, + pick_up_offset=grip_offset("pick-up", "heater-shaker", 1), + drop_offset=grip_offset("drop", "mag-plate"), + ) + else: + protocol.move_labware( + labware=sample_plate_2, + new_location=MAG_PLATE_SLOT, + use_gripper=USE_GRIPPER, + pick_up_offset=grip_offset("pick-up", "deck"), + drop_offset=grip_offset("drop", "deck"), + ) + if MODULES == True: + heatershaker.close_labware_latch() + # ============================================================================================ + + if DRYRUN == "NO": + protocol.delay(minutes=3) + + protocol.comment("--> Transferring Supernatant") + TransferSup = 20 + p1000.pick_up_tip(tiprack_20_1["A1"]) # <---------------- Tip Pickup + p1000.move_to(sample_plate_2["A1"].bottom(z=0.25)) + p1000.aspirate(TransferSup + 1, rate=0.25) + p1000.dispense(TransferSup + 5, sample_plate_3["A1"].bottom(z=1)) + p1000.return_tip() # <---------------- Tip Return diff --git a/app-testing/files/protocols/py/OT3_P100_96_HS_TM_2_15_Quick_Zymo_RNA_Bacteria.py b/app-testing/files/protocols/py/OT3_P100_96_HS_TM_2_15_Quick_Zymo_RNA_Bacteria.py new file mode 100644 index 000000000000..8d0687075aa7 --- /dev/null +++ b/app-testing/files/protocols/py/OT3_P100_96_HS_TM_2_15_Quick_Zymo_RNA_Bacteria.py @@ -0,0 +1,524 @@ +from opentrons.types import Point +import json +import os +import math +import threading +from time import sleep +from opentrons import types +import numpy as np + +metadata = { + "protocolName": "Quick Zymo Magbead RNA Extraction with Lysis: Bacteria 96 Channel Deletion Test", + "author": "Zach Galluzzo ", +} + +requirements = { + "robotType": "OT-3", + "apiLevel": "2.15", +} + +""" + ********** + + Line 254 + + + NOTE: this accesses private members of the protocol engine and is not stable, + as soon as moving labware offDeck is supported from the top level `move_labware`, + this hack should be removed + + *********** + +""" + +HS_SLOT = 1 +dry_run = False +USE_GRIPPER = True +whichwash = 1 + + +def run(ctx): + """ + Here is where you can change the locations of your labware and modules + (note that this is the recommended configuration) + """ + # Protocol Parameters + deepwell_type = "nest_96_wellplate_2ml_deep" + if not dry_run: + settling_time = 3 + else: + settling_time = 0.25 + # Volumes Defined + lysis_vol = 100 + binding_buffer_vol = 215 # Beads+Binding + wash_vol = stop_vol = 250 + dnase_vol = 50 + elution_vol = 50 + starting_vol = 250 # This is sample volume (300 in shield) + lysis volume + + h_s = ctx.load_module("heaterShakerModuleV1", HS_SLOT) + sample_plate = h_s.load_labware("opentrons_96_deep_well_adapter_nest_wellplate_2ml_deep") + samples_m = sample_plate.wells()[0] + h_s.close_labware_latch() + MAG_PLATE_SLOT = ctx.load_module("magneticBlockV1", "4") + + tempdeck = ctx.load_module("Temperature Module Gen2", "3") + # Keep elution warm during protocol + elutionplate = tempdeck.load_labware("opentrons_96_pcr_adapter_armadillo_wellplate_200ul") + + # Load Reservoir Plates + wash2_reservoir = lysis_reservoir = ctx.load_labware( + deepwell_type, "2" + ) # deleted after use- replaced (by gripper) with wash2 res + bind_reservoir = ctx.load_labware(deepwell_type, "6") + wash1_reservoir = ctx.load_labware(deepwell_type, "5") + wash3_reservoir = wash4_reservoir = wash5_reservoir = ctx.load_labware(deepwell_type, "7") + dnase_reservoir = ctx.load_labware("armadillo_96_wellplate_200ul_pcr_full_skirt", "9") + stop_reservoir = ctx.load_labware(deepwell_type, "8") + + # Load Reagents + lysis_res = lysis_reservoir.wells()[0] # deleted after use- replaced (by gripper) with wash2 res + bind_res = bind_reservoir.wells()[0] + wash1 = wash1_reservoir.wells()[0] + wash2 = wash2_reservoir.wells()[0] # loaded on magplate- move to lysis location after lysis is used + wash3 = wash4 = wash5 = wash3_reservoir.wells()[0] + dnase_res = dnase_reservoir.wells()[0] + stop_res = stop_reservoir.wells()[0] + elution_res = elutionplate.wells()[0] + + # Load tips + tips = ctx.load_labware("opentrons_ot3_96_tiprack_1000ul_rss", "10").wells()[0] + tips1 = ctx.load_labware("opentrons_ot3_96_tiprack_1000ul_rss", "11").wells()[0] + + # load instruments + pip = ctx.load_instrument("p1000_96", mount="left") + + pip.flow_rate.aspirate = 50 + pip.flow_rate.dispense = 150 + pip.flow_rate.blow_out = 300 + + def grip_offset(action, item, slot=None): + from opentrons.types import Point + + # EDIT these values + # NOTE: we are still testing to determine our software's defaults + # but we also expect users will want to edit these + _pick_up_offsets = { + "deck": Point(), + "mag-plate": Point(), + "heater-shaker": Point(), + "temp-module": Point(), + "thermo-cycler": Point(), + } + # EDIT these values + # NOTE: we are still testing to determine our software's defaults + # but we also expect users will want to edit these + _drop_offsets = { + "deck": Point(z=0.5), + "mag-plate": Point(z=0.5), + "heater-shaker": Point(z=0.5), + "temp-module": Point(z=0.5), + "thermo-cycler": Point(z=0.5), + } + # do NOT edit these values + # NOTE: these values will eventually be in our software + # and will not need to be inside a protocol + _hw_offsets = { + "deck": Point(), + "mag-plate": Point(), + "heater-shaker": Point(z=2.5), + "temp-module": Point(z=5), + "thermo-cycler": Point(z=2.5), + } + # make sure arguments are correct + action_options = ["pick-up", "drop"] + item_options = list(_hw_offsets.keys()) + + if action not in action_options: + raise ValueError(f'"{action}" not recognized, available options: {action_options}') + if item not in item_options: + raise ValueError(f'"{item}" not recognized, available options: {item_options}') + hw_offset = _hw_offsets[item] + if action == "pick-up": + offset = hw_offset + _pick_up_offsets[item] + else: + offset = hw_offset + _drop_offsets[item] + # convert from Point() to dict() + return {"x": offset.x, "y": offset.y, "z": offset.z} + + def blink(): + for i in range(3): + ctx.set_rail_lights(True) + ctx.delay(minutes=0.01666667) + ctx.set_rail_lights(False) + ctx.delay(minutes=0.01666667) + + def remove_supernatant(vol, waste): + pip.pick_up_tip(tips) + if vol > 1000: + x = 2 + else: + x = 1 + transfer_vol = vol / x + for i in range(x): + pip.aspirate(transfer_vol, samples_m.bottom(0.15)) + pip.dispense(transfer_vol, waste) + pip.return_tip() + + # Transfer plate from magnet to H/S + h_s.open_labware_latch() + ctx.move_labware( + sample_plate, + h_s, + use_gripper=USE_GRIPPER, + pick_up_offset=grip_offset("pick-up", "mag-plate"), + drop_offset=grip_offset("drop", "heater-shaker", slot=HS_SLOT), + ) + h_s.close_labware_latch() + + def resuspend_pellet(vol, plate, reps=3): + pip.flow_rate.aspirate = 150 + pip.flow_rate.dispense = 200 + + loc1 = plate.bottom().move(types.Point(x=1, y=0, z=1)) + loc2 = plate.bottom().move(types.Point(x=0.75, y=0.75, z=1)) + loc3 = plate.bottom().move(types.Point(x=0, y=1, z=1)) + loc4 = plate.bottom().move(types.Point(x=-0.75, y=0.75, z=1)) + loc5 = plate.bottom().move(types.Point(x=-1, y=0, z=1)) + loc6 = plate.bottom().move(types.Point(x=-0.75, y=0 - 0.75, z=1)) + loc7 = plate.bottom().move(types.Point(x=0, y=-1, z=1)) + loc8 = plate.bottom().move(types.Point(x=0.75, y=-0.75, z=1)) + + if vol > 1000: + vol = 1000 + + mixvol = vol * 0.9 + + for _ in range(reps): + pip.aspirate(mixvol, loc1) + pip.dispense(mixvol, loc1) + pip.aspirate(mixvol, loc2) + pip.dispense(mixvol, loc2) + pip.aspirate(mixvol, loc3) + pip.dispense(mixvol, loc3) + pip.aspirate(mixvol, loc4) + pip.dispense(mixvol, loc4) + pip.aspirate(mixvol, loc5) + pip.dispense(mixvol, loc5) + pip.aspirate(mixvol, loc6) + pip.dispense(mixvol, loc6) + pip.aspirate(mixvol, loc7) + pip.dispense(mixvol, loc7) + pip.aspirate(mixvol, loc8) + pip.dispense(mixvol, loc8) + if _ == reps - 1: + pip.flow_rate.aspirate = 50 + pip.flow_rate.dispense = 30 + pip.aspirate(mixvol, loc8) + pip.dispense(mixvol, loc8) + + pip.flow_rate.aspirate = 50 + pip.flow_rate.dispense = 150 + + def bead_mix(vol, plate, reps=5): + pip.flow_rate.aspirate = 150 + pip.flow_rate.dispense = 200 + + loc1 = plate.bottom().move(types.Point(x=0, y=0, z=1)) + loc2 = plate.bottom().move(types.Point(x=0, y=0, z=8)) + loc3 = plate.bottom().move(types.Point(x=0, y=0, z=16)) + loc4 = plate.bottom().move(types.Point(x=0, y=0, z=24)) + + if vol > 1000: + vol = 1000 + + mixvol = vol * 0.9 + + for _ in range(reps): + pip.aspirate(mixvol, loc1) + pip.dispense(mixvol, loc1) + pip.aspirate(mixvol, loc1) + pip.dispense(mixvol, loc2) + pip.aspirate(mixvol, loc1) + pip.dispense(mixvol, loc3) + pip.aspirate(mixvol, loc1) + pip.dispense(mixvol, loc4) + if _ == reps - 1: + pip.flow_rate.aspirate = 50 + pip.flow_rate.dispense = 30 + pip.aspirate(mixvol, loc1) + pip.dispense(mixvol, loc1) + + pip.flow_rate.aspirate = 50 + pip.flow_rate.dispense = 150 + + def lysis(vol, source): + pip.pick_up_tip(tips) + pip.aspirate(vol, source) + pip.dispense(vol, samples_m) + resuspend_pellet(starting_vol, samples_m, reps=5 if not dry_run else 1) + pip.return_tip() + + h_s.set_and_wait_for_shake_speed(rpm=2000) + + # Delete Lysis reservoir from deck + """ + blink() + ctx.pause('Please remove lysis reservoir (slot 2 or D2) from the deck.') + del ctx.deck['2'] + + + ctx._core._engine_client.move_labware( + labware_id=lysis_reservoir._core.labware_id, + new_location="offDeck", + strategy="manualMoveWithPause", + use_pick_up_location_lpc_offset=False, + use_drop_location_lpc_offset=False, + pick_up_offset=None, + drop_offset=None + ) + + ********** + + + NOTE: this accesses private members of the protocol engine and is not stable, + as soon as moving labware offDeck is supported from the top level `move_labware`, + this hack should be removed + + *********** + + + + + #Transfer wash2 res from magnet to deck slot + ctx.move_labware( + wash2_reservoir, + 2, + use_gripper=USE_GRIPPER, + pick_up_offset=grip_offset("pick-up","mag-plate"), + drop_offset=grip_offset("drop","deck") + ) + """ + ctx.delay( + minutes=1 if not dry_run else 0.25, + msg="Please wait 2 minutes while the lysis buffer mixes with the sample.", + ) + h_s.deactivate_shaker() + + def bind(vol, source): + """ + `bind` will perform magnetic bead binding on each sample in the + deepwell plate. Each channel of binding beads will be mixed before + transfer, and the samples will be mixed with the binding beads after + the transfer. The magnetic deck activates after the addition to all + samples, and the supernatant is removed after bead bining. + :param vol (float): The amount of volume to aspirate from the elution + buffer source and dispense to each well containing + beads. + :param park (boolean): Whether to save sample-corresponding tips + between adding elution buffer and transferring + supernatant to the final clean elutions PCR + plate. + """ + pip.pick_up_tip(tips) + # Mix in reservoir + bead_mix(vol, source, reps=5 if not dry_run else 1) + # Transfer from reservoir + pip.aspirate(vol, source) + pip.dispense(vol, samples_m) + # Mix in plate + bead_mix(1000, samples_m, reps=8 if not dry_run else 1) + pip.return_tip() + + h_s.set_and_wait_for_shake_speed(rpm=1800) + ctx.delay( + minutes=20 if not dry_run else 0.25, msg="Please wait 20 minutes while the sample binds with the beads." + ) + h_s.deactivate_shaker() + + h_s.open_labware_latch() + # Transfer plate to magnet + ctx.move_labware( + sample_plate, + MAG_PLATE_SLOT, + use_gripper=USE_GRIPPER, + pick_up_offset=grip_offset("pick-up", "heater-shaker", slot=HS_SLOT), + drop_offset=grip_offset("drop", "mag-plate"), + ) + h_s.close_labware_latch() + + for bindi in np.arange(settling_time + 1, 0, -0.5): # Settling time delay with countdown timer + ctx.delay(minutes=0.5, msg="There are " + str(bindi) + " minutes left in the incubation.") + + # remove initial supernatant + remove_supernatant(vol + starting_vol, bind_res) + + def wash(vol, source): + + global whichwash # Defines which wash the protocol is on to log on the app + + if source == wash1: + whichwash = 1 + waste_res = bind_res + if source == wash2: + whichwash = 2 + waste_res = bind_res + if source == wash3: + whichwash = 3 + waste_res = wash2 + if source == wash4: + whichwash = 4 + waste_res = wash2 + if source == wash5: + whichwash = 5 + waste_res = wash2 + + pip.pick_up_tip(tips) + pip.aspirate(vol, source) + pip.dispense(vol, samples_m) + resuspend_pellet(vol, samples_m, reps=5 if not dry_run else 1) + pip.return_tip() + + h_s.set_and_wait_for_shake_speed(2000) + ctx.delay(minutes=2 if not dry_run else 0.25, msg="Please allow 2 minutes for wash to mix on heater-shaker.") + h_s.deactivate_shaker() + + h_s.open_labware_latch() + # Transfer plate to magnet + ctx.move_labware( + sample_plate, + MAG_PLATE_SLOT, + use_gripper=USE_GRIPPER, + pick_up_offset=grip_offset("pick-up", "heater-shaker", slot=HS_SLOT), + drop_offset=grip_offset("drop", "mag-plate"), + ) + h_s.close_labware_latch() + + for washi in np.arange(settling_time, 0, -0.5): # settling time timer for washes + ctx.delay( + minutes=0.5, msg="There are " + str(washi) + " minutes left in wash " + str(whichwash) + " incubation." + ) + + remove_supernatant(vol, waste_res) + + def dnase(vol, source): + pip.pick_up_tip(tips) + pip.aspirate(vol, source) + pip.dispense(vol, samples_m) + resuspend_pellet(vol, samples_m, reps=4 if not dry_run else 1) + pip.return_tip() + + h_s.set_and_wait_for_shake_speed(rpm=2000) + if not dry_run: + h_s.set_and_wait_for_temperature(65) + # minutes should equal 10 minus time it takes to reach 65 + ctx.delay(minutes=9 if not dry_run else 0.25, msg="Please wait 10 minutes while the dnase incubates.") + h_s.deactivate_shaker() + + def stop_reaction(vol, source): + + pip.pick_up_tip(tips) + pip.aspirate(vol, source) + pip.dispense(vol, samples_m) + resuspend_pellet(vol, samples_m, reps=2 if not dry_run else 1) + pip.return_tip() + + h_s.set_and_wait_for_shake_speed(rpm=1800) + ctx.delay( + minutes=10 if not dry_run else 0.1, + msg="Please wait 10 minutes while the stop solution inactivates the dnase.", + ) + h_s.deactivate_shaker() + + h_s.open_labware_latch() + # Transfer plate to magnet + ctx.move_labware( + sample_plate, + MAG_PLATE_SLOT, + use_gripper=USE_GRIPPER, + pick_up_offset=grip_offset("pick-up", "heater-shaker", slot=HS_SLOT), + drop_offset=grip_offset("drop", "mag-plate"), + ) + h_s.close_labware_latch() + + for stop in np.arange(settling_time, 0, -0.5): + ctx.delay(minutes=0.5, msg="There are " + str(stop) + " minutes left in this incubation.") + + remove_supernatant(vol + 50, wash1) + + def elute(vol, source): + pip.pick_up_tip(tips1) + # Transfer + pip.aspirate(vol, source) + pip.dispense(vol, samples_m) + # Mix + resuspend_pellet(vol, samples_m, reps=3 if not dry_run else 1) + pip.return_tip() + + # Elution Incubation + h_s.set_and_wait_for_shake_speed(rpm=2000) + if not dry_run: + tempdeck.set_temperature(4) + ctx.delay( + minutes=3 if not dry_run else 0.25, msg="Please wait 5 minutes while the sample elutes from the beads." + ) + h_s.deactivate_shaker() + + h_s.open_labware_latch() + # Transfer plate to magnet + ctx.move_labware( + sample_plate, + MAG_PLATE_SLOT, + use_gripper=USE_GRIPPER, + pick_up_offset=grip_offset("pick-up", "heater-shaker", slot=HS_SLOT), + drop_offset=grip_offset("drop", "mag-plate"), + ) + h_s.close_labware_latch() + + for elutei in np.arange(settling_time, 0, -0.5): + ctx.delay(minutes=0.5, msg="Incubating on MagDeck for " + str(elutei) + " more minutes.") + + pip.flow_rate.aspirate = 25 + + pip.pick_up_tip(tips1) + pip.aspirate(vol, samples_m) + pip.dispense(vol, source) + pip.return_tip() + + h_s.open_labware_latch() + # Transfer plate to magnet + ctx.move_labware( + sample_plate, + h_s, + use_gripper=USE_GRIPPER, + pick_up_offset=grip_offset("pick-up", "mag-plate"), + drop_offset=grip_offset("drop", "heater-shaker", slot=HS_SLOT), + ) + h_s.close_labware_latch() + + """ + Here is where you can call the methods defined above to fit your specific + protocol. The normal sequence is: + """ + # Start Protocol + for x in range(2): + lysis(lysis_vol, lysis_res) + bind(binding_buffer_vol, bind_res) + wash(wash_vol, wash1) + if not dry_run: + wash(wash_vol, wash2) + wash(wash_vol, wash3) + # dnase1 treatment + dnase(dnase_vol, dnase_res) + stop_reaction(stop_vol, stop_res) + # Resume washes + if not dry_run: + wash(wash_vol, wash4) + wash(wash_vol, wash5) + tempdeck.set_temperature(55) + drybeads = 9 # Number of minutes you want to dry for + else: + drybeads = 0.5 + for beaddry in np.arange(drybeads, 0, -0.5): + ctx.delay(minutes=0.5, msg="There are " + str(beaddry) + " minutes left in the drying step.") + elute(elution_vol, elution_res) diff --git a/app-testing/files/protocols/py/OT3_P50MLeft_P1000MRight_None_2_15_ABRKAPALibraryQuantLongv2.py b/app-testing/files/protocols/py/OT3_P50MLeft_P1000MRight_None_2_15_ABRKAPALibraryQuantLongv2.py new file mode 100644 index 000000000000..b3a15181a3c9 --- /dev/null +++ b/app-testing/files/protocols/py/OT3_P50MLeft_P1000MRight_None_2_15_ABRKAPALibraryQuantLongv2.py @@ -0,0 +1,1001 @@ +from opentrons import protocol_api + +from opentrons import types + +import inspect + +metadata = { + "protocolName": "OT3 ABR KAPA Library Quant v2", + "author": "Opentrons ", + "source": "Protocol Library", + "description": "OT3 ABR KAPA Library Quant v2", +} + +requirements = { + "robotType": "OT-3", + "apiLevel": "2.15", +} + + +def right(s, amount): + if s == None: + return None + elif amount == None: + return None # Or throw a missing argument error + s = str(s) + if amount > len(s): + return s + elif amount == 0: + return "" + else: + return s[-amount:] + + +# SCRIPT SETTINGS +DRYRUN = "YES" # YES or NO, DRYRUN = 'YES' will return tips, skip incubation times, shorten mix, for testing purposes +OFFSET = "YES" # YES or NO, Sets whether to use protocol specific z offsets for each tip and labware or no offsets aside from defaults + +# PROTOCOL SETTINGS +SAMPLES = "24x" # 8x, 16x, or 24x +FORMAT = "384" # 96 or 384 +INICOLUMN1 = "A1" +INICOLUMN2 = "A3" +INICOLUMN3 = "A5" + +# PROTOCOL BLOCKS +STEP_DILUTE = 1 +STEP_MIX = 1 +STEP_DISPENSE = 1 + +STEPS = {STEP_DILUTE, STEP_MIX, STEP_DISPENSE} + + +def run(protocol: protocol_api.ProtocolContext): + + if DRYRUN == "YES": + protocol.comment("THIS IS A DRY RUN") + else: + protocol.comment("THIS IS A REACTION RUN") + + # DECK SETUP AND LABWARE + protocol.comment("THIS IS A NO MODULE RUN") + source_plate = protocol.load_labware( + "nest_96_wellplate_100ul_pcr_full_skirt", "1" + ) # <--- Actually an Eppendorf 96 well, same dimensions + reservoir = protocol.load_labware("nest_12_reservoir_15ml", "2") + dilution_plate_1 = protocol.load_labware("opentrons_96_aluminumblock_biorad_wellplate_200ul", "3") + + tiprack_50_1 = protocol.load_labware("opentrons_ot3_96_tiprack_50ul", "4") + tiprack_200_1 = protocol.load_labware("opentrons_ot3_96_tiprack_200ul", "5") + tiprack_50_2 = protocol.load_labware("opentrons_ot3_96_tiprack_50ul", "6") + + reagent_plate = protocol.load_labware("nest_96_wellplate_100ul_pcr_full_skirt", "7") # <--- NEST Strip Tubes + dilution_plate_2 = protocol.load_labware("opentrons_96_aluminumblock_biorad_wellplate_200ul", "8") + if FORMAT == "96": + qpcrplate_1 = protocol.load_labware( + "nest_96_wellplate_100ul_pcr_full_skirt", "9" + ) # <--- Actually an Eppendorf 96 well, same dimensions + qpcrplate_2 = protocol.load_labware( + "nest_96_wellplate_100ul_pcr_full_skirt", "10" + ) # <--- Actually an Eppendorf 96 well, same dimensions + if FORMAT == "384": + qpcrplate_1 = protocol.load_labware( + "corning_384_wellplate_112ul_flat", "9" + ) # <--- Actually an Eppendorf 96 well, same dimensions + qpcrplate_2 = protocol.load_labware( + "corning_384_wellplate_112ul_flat", "10" + ) # <--- Actually an Eppendorf 96 well, same dimensions + + # REAGENT PLATE + STD_1 = reagent_plate["A1"] + STD_2 = reagent_plate["A2"] + PCR_1 = reagent_plate["A3"] + PCR_2 = reagent_plate["A4"] + + # RESERVOIR + DIL = reservoir["A5"] + + # pipette + p50 = protocol.load_instrument("p50_multi_gen3", "left", tip_racks=[tiprack_50_1, tiprack_50_2]) + p1000 = protocol.load_instrument("p1000_multi_gen3", "right", tip_racks=[tiprack_200_1]) + + # samples + src_file_path = inspect.getfile(lambda: None) + protocol.comment(src_file_path) + + # tip and sample tracking + if SAMPLES == "8x": + protocol.comment("There are 8 Samples") + samplecolumns = 1 + elif SAMPLES == "16x": + protocol.comment("There are 16 Samples") + samplecolumns = 2 + elif SAMPLES == "24x": + protocol.comment("There are 24 Samples") + samplecolumns = 3 + else: + protocol.pause("ERROR?") + + # offset + p1000_offset_Deck = 0 + p1000_offset_Res = 0 + p1000_offset_Tube = 0 + p1000_offset_Thermo = 0 + p1000_offset_Mag = 0 + p1000_offset_Temp = 0 + + p50_offset_Deck = 0 + p50_offset_Res = 0 + p50_offset_Tube = 0 + p50_offset_Thermo = 0 + p50_offset_Mag = 0 + p50_offset_Temp = 0 + + # commands + + if STEP_DILUTE == 1: + protocol.comment("==============================================") + protocol.comment("--> Dispensing Diluent Part 1 and Part 2") + protocol.comment("==============================================") + p1000.pick_up_tip() + if ( + samplecolumns >= 1 + ): # ----------------------------------------------------------------------------------------- + X = "A2" + Y = "A6" + p1000.move_to(DIL.bottom(z=p1000_offset_Res)) + p1000.mix(3, 200, rate=0.5) + p1000.move_to(DIL.top(z=+5)) + protocol.delay(seconds=2) + p1000.aspirate(200, DIL.bottom(z=p1000_offset_Res), rate=0.25) + p1000.dispense(98, dilution_plate_1[X].bottom(z=p1000_offset_Temp), rate=0.25) + p1000.default_speed = 5 + p1000.move_to(dilution_plate_1[X].top()) + protocol.delay(seconds=2) + p1000.default_speed = 400 + p1000.dispense(95, dilution_plate_1[Y].bottom(z=p1000_offset_Temp), rate=0.25) + p1000.default_speed = 5 + p1000.move_to(dilution_plate_1[Y].top()) + protocol.delay(seconds=2) + p1000.default_speed = 400 + p1000.move_to(DIL.top()) + p1000.blow_out() + if ( + samplecolumns >= 2 + ): # ----------------------------------------------------------------------------------------- + X = "A3" + Y = "A7" + p1000.move_to(DIL.bottom(z=p1000_offset_Res)) + p1000.mix(3, 200, rate=0.5) + p1000.move_to(DIL.top(z=+5)) + protocol.delay(seconds=2) + p1000.aspirate(200, DIL.bottom(z=p1000_offset_Res), rate=0.25) + p1000.dispense(98, dilution_plate_1[X].bottom(z=p1000_offset_Temp), rate=0.25) + p1000.default_speed = 5 + p1000.move_to(dilution_plate_1[X].top()) + protocol.delay(seconds=2) + p1000.default_speed = 400 + p1000.dispense(95, dilution_plate_1[Y].bottom(z=p1000_offset_Temp), rate=0.25) + p1000.default_speed = 5 + p1000.move_to(dilution_plate_1[Y].top()) + protocol.delay(seconds=2) + p1000.default_speed = 400 + p1000.move_to(DIL.top()) + p1000.blow_out() + if ( + samplecolumns >= 3 + ): # ----------------------------------------------------------------------------------------- + X = "A4" + Y = "A8" + p1000.move_to(DIL.bottom(z=p1000_offset_Res)) + p1000.mix(3, 200, rate=0.5) + p1000.move_to(DIL.top(z=+5)) + protocol.delay(seconds=2) + p1000.aspirate(200, DIL.bottom(z=p1000_offset_Res), rate=0.25) + p1000.dispense(98, dilution_plate_1[X].bottom(z=p1000_offset_Temp), rate=0.25) + p1000.default_speed = 5 + p1000.move_to(dilution_plate_1[X].top()) + protocol.delay(seconds=2) + p1000.default_speed = 400 + p1000.dispense(95, dilution_plate_1[Y].bottom(z=p1000_offset_Temp), rate=0.25) + p1000.default_speed = 5 + p1000.move_to(dilution_plate_1[Y].top()) + protocol.delay(seconds=2) + p1000.default_speed = 400 + p1000.move_to(DIL.top()) + p1000.blow_out() + p1000.drop_tip() if DRYRUN == "NO" else p1000.return_tip() + + protocol.comment("==============================================") + protocol.comment("--> Adding Sample to Diluent Part 1") + protocol.comment("==============================================") + if ( + samplecolumns >= 1 + ): # ----------------------------------------------------------------------------------------- + X = INICOLUMN1 + Y = "A2" + p50.pick_up_tip() + p50.aspirate(2, source_plate[X].bottom(z=p50_offset_Mag), rate=0.25) + p50.dispense(2, dilution_plate_1[Y].center(), rate=0.5) + p50.drop_tip() if DRYRUN == "NO" else p50.return_tip() + if ( + samplecolumns >= 2 + ): # ----------------------------------------------------------------------------------------- + X = INICOLUMN2 + Y = "A3" + p50.pick_up_tip() + p50.aspirate(2, source_plate[X].bottom(z=p50_offset_Mag), rate=0.25) + p50.dispense(2, dilution_plate_1[Y].center(), rate=0.5) + p50.drop_tip() if DRYRUN == "NO" else p50.return_tip() + if ( + samplecolumns >= 3 + ): # ----------------------------------------------------------------------------------------- + X = INICOLUMN3 + Y = "A4" + p50.pick_up_tip() + p50.aspirate(2, source_plate[X].bottom(z=p50_offset_Mag), rate=0.25) + p50.dispense(2, dilution_plate_1[Y].center(), rate=0.5) + p50.drop_tip() if DRYRUN == "NO" else p50.return_tip() + + protocol.comment("--> Mixing") + if ( + samplecolumns >= 1 + ): # ----------------------------------------------------------------------------------------- + X = "A2" + p1000.pick_up_tip() + p1000.move_to(dilution_plate_1[X].bottom(z=p1000_offset_Temp)) + p1000.mix(50, 80) + p1000.default_speed = 5 + p1000.move_to(dilution_plate_1[X].top()) + protocol.delay(seconds=2) + p1000.default_speed = 400 + p1000.drop_tip() if DRYRUN == "NO" else p1000.return_tip() + if ( + samplecolumns >= 2 + ): # ----------------------------------------------------------------------------------------- + X = "A3" + p1000.pick_up_tip() + p1000.move_to(dilution_plate_1[X].bottom(z=p1000_offset_Temp)) + + p1000.mix(50, 80) + p1000.default_speed = 5 + p1000.move_to(dilution_plate_1[X].top()) + protocol.delay(seconds=2) + p1000.default_speed = 400 + p1000.drop_tip() if DRYRUN == "NO" else p1000.return_tip() + if ( + samplecolumns >= 3 + ): # ----------------------------------------------------------------------------------------- + X = "A4" + p1000.pick_up_tip() + p1000.move_to(dilution_plate_1[X].bottom(z=p1000_offset_Temp)) + p1000.mix(50, 80) + p1000.default_speed = 5 + p1000.move_to(dilution_plate_1[X].top()) + protocol.delay(seconds=2) + p1000.default_speed = 400 + p1000.drop_tip() if DRYRUN == "NO" else p1000.return_tip() + + protocol.comment("==============================================") + protocol.comment("--> Adding Diluted Sample to Diluent Part 2") + protocol.comment("==============================================") + if ( + samplecolumns >= 1 + ): # ----------------------------------------------------------------------------------------- + X = "A2" + Y = "A6" + p50.pick_up_tip() + p50.aspirate(5, dilution_plate_1[X].center(), rate=0.5) + p50.dispense(5, dilution_plate_1[Y].center(), rate=0.5) + p50.drop_tip() if DRYRUN == "NO" else p50.return_tip() + if ( + samplecolumns >= 2 + ): # ----------------------------------------------------------------------------------------- + X = "A3" + Y = "A7" + p50.pick_up_tip() + p50.aspirate(5, dilution_plate_1[X].center(), rate=0.5) + p50.dispense(5, dilution_plate_1[Y].center(), rate=0.5) + p50.drop_tip() if DRYRUN == "NO" else p50.return_tip() + if ( + samplecolumns >= 3 + ): # ----------------------------------------------------------------------------------------- + X = "A4" + Y = "A8" + p50.pick_up_tip() + p50.aspirate(5, dilution_plate_1[X].center(), rate=0.5) + p50.dispense(5, dilution_plate_1[Y].center(), rate=0.5) + p50.drop_tip() if DRYRUN == "NO" else p50.return_tip() + + protocol.comment("--> Mixing") + if ( + samplecolumns >= 1 + ): # ----------------------------------------------------------------------------------------- + X = "A6" + p1000.pick_up_tip() + p1000.move_to(dilution_plate_1[X].bottom(z=p1000_offset_Temp)) + p1000.mix(50, 80) + p1000.default_speed = 5 + p1000.move_to(dilution_plate_1[X].top()) + protocol.delay(seconds=2) + p1000.default_speed = 400 + p1000.drop_tip() if DRYRUN == "NO" else p1000.return_tip() + if ( + samplecolumns >= 2 + ): # ----------------------------------------------------------------------------------------- + X = "A7" + p1000.pick_up_tip() + p1000.move_to(dilution_plate_1[X].bottom(z=p1000_offset_Temp)) + p1000.mix(50, 80) + p1000.default_speed = 5 + p1000.move_to(dilution_plate_1[X].top()) + protocol.delay(seconds=2) + p1000.default_speed = 400 + p1000.drop_tip() if DRYRUN == "NO" else p1000.return_tip() + if ( + samplecolumns >= 3 + ): # ----------------------------------------------------------------------------------------- + X = "A8" + p1000.pick_up_tip() + p1000.move_to(dilution_plate_1[X].bottom(z=p1000_offset_Temp)) + p1000.mix(50, 80) + p1000.default_speed = 5 + p1000.move_to(dilution_plate_1[X].top()) + protocol.delay(seconds=2) + p1000.default_speed = 400 + p1000.drop_tip() if DRYRUN == "NO" else p1000.return_tip() + + for repeat in range(6): + if STEP_MIX == 1: + protocol.comment("==============================================") + protocol.comment("--> Adding qPCR Mix") + protocol.comment("==============================================") + qPCRVol = 50 + p1000.pick_up_tip() + p1000.aspirate((qPCRVol), PCR_1.bottom(z=p1000_offset_Thermo), rate=0.25) + p1000.dispense(qPCRVol, dilution_plate_1["A9"].bottom(z=p1000_offset_Temp), rate=0.25) + if ( + samplecolumns >= 1 + ): # ----------------------------------------------------------------------------------------- + X = "A10" + p1000.aspirate((qPCRVol), PCR_1.bottom(z=p1000_offset_Thermo), rate=0.25) + p1000.dispense(qPCRVol, dilution_plate_1[X].bottom(z=p1000_offset_Temp), rate=0.25) + if ( + samplecolumns >= 2 + ): # ----------------------------------------------------------------------------------------- + X = "A11" + p1000.aspirate((qPCRVol), PCR_1.bottom(z=p1000_offset_Thermo), rate=0.25) + p1000.dispense(qPCRVol, dilution_plate_1[X].bottom(z=p1000_offset_Temp), rate=0.25) + if ( + samplecolumns >= 3 + ): # ----------------------------------------------------------------------------------------- + X = "A12" + p1000.aspirate((qPCRVol), PCR_1.bottom(z=p1000_offset_Thermo), rate=0.25) + p1000.dispense(qPCRVol, dilution_plate_1[X].bottom(z=p1000_offset_Temp), rate=0.25) + p1000.drop_tip() if DRYRUN == "NO" else p1000.return_tip() + + p1000.pick_up_tip() + p1000.aspirate((qPCRVol), PCR_2.bottom(z=p1000_offset_Thermo), rate=0.25) + p1000.dispense(qPCRVol, dilution_plate_2["A9"].bottom(z=p1000_offset_Deck), rate=0.25) + if ( + samplecolumns >= 1 + ): # ----------------------------------------------------------------------------------------- + X = "A10" + p1000.aspirate((qPCRVol), PCR_2.bottom(z=p1000_offset_Thermo), rate=0.25) + p1000.dispense(qPCRVol, dilution_plate_2[X].bottom(z=p1000_offset_Deck), rate=0.25) + if ( + samplecolumns >= 2 + ): # ----------------------------------------------------------------------------------------- + X = "A11" + p1000.aspirate((qPCRVol), PCR_2.bottom(z=p1000_offset_Thermo), rate=0.25) + p1000.dispense(qPCRVol, dilution_plate_2[X].bottom(z=p1000_offset_Deck), rate=0.25) + if ( + samplecolumns >= 3 + ): # ----------------------------------------------------------------------------------------- + X = "A12" + p1000.aspirate((qPCRVol), PCR_2.bottom(z=p1000_offset_Thermo), rate=0.25) + p1000.dispense(qPCRVol, dilution_plate_2[X].bottom(z=p1000_offset_Deck), rate=0.25) + p1000.drop_tip() if DRYRUN == "NO" else p1000.return_tip() + + protocol.comment("==============================================") + protocol.comment("--> Adding Standards to Mix") + protocol.comment("==============================================") + SampleVol = 12.5 + p50.pick_up_tip() + p50.aspirate(SampleVol, STD_1.bottom(z=p50_offset_Thermo), rate=0.5) + p50.dispense(SampleVol, dilution_plate_1["A9"].bottom(z=p50_offset_Temp), rate=0.5) + p50.default_speed = 2.5 + p50.move_to(dilution_plate_1["A9"].center()) + protocol.delay(seconds=2) + p50.default_speed = 400 + p50.drop_tip() if DRYRUN == "NO" else p50.return_tip() + + p50.pick_up_tip() + p50.aspirate(SampleVol, STD_2.bottom(z=p50_offset_Thermo), rate=0.5) + p50.dispense(SampleVol, dilution_plate_2["A9"].bottom(z=p50_offset_Deck), rate=0.5) + p50.default_speed = 2.5 + p50.move_to(dilution_plate_2["A9"].center()) + protocol.delay(seconds=2) + p50.default_speed = 400 + p50.drop_tip() if DRYRUN == "NO" else p50.return_tip() + + protocol.comment("==============================================") + protocol.comment("--> Adding Diluted Sample to Mix") + protocol.comment("==============================================") + if ( + samplecolumns >= 1 + ): # ----------------------------------------------------------------------------------------- + X = "A6" + Y = "A10" + p50.pick_up_tip() + p50.aspirate(SampleVol, dilution_plate_1[X].center(), rate=0.5) + p50.dispense(SampleVol, dilution_plate_1[Y].bottom(z=p50_offset_Temp), rate=0.5) + p50.default_speed = 2.5 + p50.move_to(dilution_plate_1[Y].center()) + protocol.delay(seconds=2) + p50.default_speed = 400 + p50.drop_tip() if DRYRUN == "NO" else p50.return_tip() + if ( + samplecolumns >= 2 + ): # ----------------------------------------------------------------------------------------- + X = "A7" + Y = "A11" + p50.pick_up_tip() + p50.aspirate(SampleVol, dilution_plate_1[X].center(), rate=0.5) + p50.dispense(SampleVol, dilution_plate_1[Y].bottom(z=p50_offset_Temp), rate=0.5) + p50.default_speed = 2.5 + p50.move_to(dilution_plate_1[Y].center()) + protocol.delay(seconds=2) + p50.default_speed = 400 + p50.drop_tip() if DRYRUN == "NO" else p50.return_tip() + if ( + samplecolumns >= 3 + ): # ----------------------------------------------------------------------------------------- + X = "A8" + Y = "A12" + p50.pick_up_tip() + p50.aspirate(SampleVol, dilution_plate_1[X].center(), rate=0.5) + p50.dispense(SampleVol, dilution_plate_1[Y].bottom(z=p50_offset_Temp), rate=0.5) + p50.default_speed = 2.5 + p50.move_to(dilution_plate_1[Y].center()) + protocol.delay(seconds=2) + p50.default_speed = 400 + p50.drop_tip() if DRYRUN == "NO" else p50.return_tip() + + if ( + samplecolumns >= 1 + ): # ----------------------------------------------------------------------------------------- + X = "A6" + Y = "A10" + p50.pick_up_tip() + p50.aspirate(SampleVol, dilution_plate_2[X].center(), rate=0.5) + p50.dispense(SampleVol, dilution_plate_2[Y].bottom(z=p50_offset_Deck), rate=0.5) + p50.default_speed = 2.5 + p50.move_to(dilution_plate_2[Y].center()) + protocol.delay(seconds=2) + p50.default_speed = 400 + p50.drop_tip() if DRYRUN == "NO" else p50.return_tip() + if ( + samplecolumns >= 2 + ): # ----------------------------------------------------------------------------------------- + X = "A7" + Y = "A11" + p50.pick_up_tip() + p50.aspirate(SampleVol, dilution_plate_2[X].center(), rate=0.5) + p50.dispense(SampleVol, dilution_plate_2[Y].bottom(z=p50_offset_Deck), rate=0.5) + p50.default_speed = 2.5 + p50.move_to(dilution_plate_2[Y].center()) + protocol.delay(seconds=2) + p50.default_speed = 400 + p50.drop_tip() if DRYRUN == "NO" else p50.return_tip() + if ( + samplecolumns >= 3 + ): # ----------------------------------------------------------------------------------------- + X = "A8" + Y = "A12" + p50.pick_up_tip() + p50.aspirate(SampleVol, dilution_plate_2[X].center(), rate=0.5) + p50.dispense(SampleVol, dilution_plate_2[Y].bottom(z=p50_offset_Deck), rate=0.5) + p50.default_speed = 2.5 + p50.move_to(dilution_plate_2[Y].center()) + protocol.delay(seconds=2) + p50.default_speed = 400 + p50.drop_tip() if DRYRUN == "NO" else p50.return_tip() + + if STEP_DISPENSE == 1: + if FORMAT == "96": + protocol.comment("==============================================") + protocol.comment("--> Dispensing 96 well") + protocol.comment("==============================================") + X = "A9" + Y1 = "A1" + Y2 = "A2" + Y3 = "A3" + p1000.pick_up_tip() + p1000.aspirate(60, dilution_plate_1[X].bottom(z=p1000_offset_Temp), rate=0.5) + p1000.dispense(20, qpcrplate_1[Y1].bottom(z=p1000_offset_Mag), rate=0.5) + p1000.dispense(20, qpcrplate_1[Y2].bottom(z=p1000_offset_Mag), rate=0.5) + p1000.dispense(20, qpcrplate_1[Y3].bottom(z=p1000_offset_Mag), rate=0.5) + p1000.drop_tip() if DRYRUN == "NO" else p1000.return_tip() + if ( + samplecolumns >= 1 + ): # ----------------------------------------------------------------------------------------- + X = "A10" + Y1 = "A4" + Y2 = "A5" + Y3 = "A6" + p1000.pick_up_tip() + p1000.aspirate(60, dilution_plate_1[X].bottom(z=p1000_offset_Temp), rate=0.5) + p1000.dispense(20, qpcrplate_1[Y1].bottom(z=p1000_offset_Mag), rate=0.5) + p1000.dispense(20, qpcrplate_1[Y2].bottom(z=p1000_offset_Mag), rate=0.5) + p1000.dispense(20, qpcrplate_1[Y3].bottom(z=p1000_offset_Mag), rate=0.5) + p1000.drop_tip() if DRYRUN == "NO" else p1000.return_tip() + if ( + samplecolumns >= 2 + ): # ----------------------------------------------------------------------------------------- + X = "A11" + Y1 = "A7" + Y2 = "A8" + Y3 = "A9" + p1000.pick_up_tip() + p1000.aspirate(60, dilution_plate_1[X].bottom(z=p1000_offset_Temp), rate=0.5) + p1000.dispense(20, qpcrplate_1[Y1].bottom(z=p1000_offset_Mag), rate=0.5) + p1000.dispense(20, qpcrplate_1[Y2].bottom(z=p1000_offset_Mag), rate=0.5) + p1000.dispense(20, qpcrplate_1[Y3].bottom(z=p1000_offset_Mag), rate=0.5) + p1000.drop_tip() if DRYRUN == "NO" else p1000.return_tip() + if ( + samplecolumns >= 3 + ): # ----------------------------------------------------------------------------------------- + X = "A12" + Y1 = "A10" + Y2 = "A11" + Y3 = "A12" + p1000.pick_up_tip() + p1000.aspirate(60, dilution_plate_1[X].bottom(z=p1000_offset_Temp), rate=0.5) + p1000.dispense(20, qpcrplate_1[Y1].bottom(z=p1000_offset_Mag), rate=0.5) + p1000.dispense(20, qpcrplate_1[Y2].bottom(z=p1000_offset_Mag), rate=0.5) + p1000.dispense(20, qpcrplate_1[Y3].bottom(z=p1000_offset_Mag), rate=0.5) + p1000.drop_tip() if DRYRUN == "NO" else p1000.return_tip() + if FORMAT == "384": + + p1000.reset_tipracks() + p50.reset_tipracks() + + protocol.comment("==============================================") + protocol.comment("--> Dispensing 384 well") + protocol.comment("==============================================") + X = "A9" + Y1 = "A1" + Y2 = "A2" + Y3 = "A3" + Y4 = "A4" + Y5 = "A5" + Y6 = "A6" + p1000.pick_up_tip() + p1000.move_to(dilution_plate_1[X].bottom(z=p1000_offset_Temp)) + p1000.mix(30, 58) + p1000.aspirate(62, dilution_plate_1[X].bottom(z=p1000_offset_Temp), rate=0.25) + protocol.delay(seconds=0.2) + p1000.move_to(qpcrplate_1[Y1].top(z=1.0)) + protocol.delay(seconds=0.2) + p1000.default_speed = 2.5 + p1000.dispense(10, qpcrplate_1[Y1].bottom(z=1.75), rate=0.25) + protocol.delay(seconds=0.2) + p1000.default_speed = 400 + p1000.move_to(qpcrplate_1[Y2].top(z=1.0)) + protocol.delay(seconds=0.2) + p1000.default_speed = 2.5 + p1000.dispense(10, qpcrplate_1[Y2].bottom(z=1.75), rate=0.25) + protocol.delay(seconds=0.2) + p1000.default_speed = 400 + p1000.move_to(qpcrplate_1[Y3].top(z=1.0)) + protocol.delay(seconds=0.2) + p1000.default_speed = 2.5 + p1000.dispense(10, qpcrplate_1[Y3].bottom(z=1.75), rate=0.25) + protocol.delay(seconds=0.2) + p1000.default_speed = 400 + + p1000.move_to(qpcrplate_1[Y4].top(z=1.0)) + protocol.delay(seconds=0.2) + p1000.default_speed = 2.5 + p1000.dispense(10, qpcrplate_1[Y4].bottom(z=1.75), rate=0.25) + protocol.delay(seconds=0.2) + p1000.default_speed = 400 + p1000.move_to(qpcrplate_1[Y5].top(z=1.0)) + protocol.delay(seconds=0.2) + p1000.default_speed = 2.5 + p1000.dispense(10, qpcrplate_1[Y5].bottom(z=1.75), rate=0.25) + protocol.delay(seconds=0.2) + p1000.default_speed = 400 + p1000.move_to(qpcrplate_1[Y6].top(z=1.0)) + protocol.delay(seconds=0.2) + p1000.default_speed = 2.5 + p1000.dispense(10, qpcrplate_1[Y6].bottom(z=1.75), rate=0.25) + protocol.delay(seconds=0.2) + p1000.default_speed = 400 + + p1000.drop_tip() if DRYRUN == "NO" else p1000.return_tip() + if ( + samplecolumns >= 1 + ): # ----------------------------------------------------------------------------------------- + X = "A10" + Y1 = "B1" + Y2 = "B2" + Y3 = "B3" + Y4 = "B4" + Y5 = "B5" + Y6 = "B6" + p1000.pick_up_tip() + p1000.move_to(dilution_plate_1[X].bottom(z=p1000_offset_Temp)) + p1000.mix(30, 58) + p1000.aspirate(62, dilution_plate_1[X].bottom(z=p1000_offset_Temp), rate=0.25) + protocol.delay(seconds=0.2) + p1000.move_to(qpcrplate_1[Y1].top(z=1.0)) + protocol.delay(seconds=0.2) + p1000.default_speed = 2.5 + p1000.dispense(10, qpcrplate_1[Y1].bottom(z=1.75), rate=0.25) + protocol.delay(seconds=0.2) + p1000.default_speed = 400 + p1000.move_to(qpcrplate_1[Y2].top(z=1.0)) + protocol.delay(seconds=0.2) + p1000.default_speed = 2.5 + p1000.dispense(10, qpcrplate_1[Y2].bottom(z=1.75), rate=0.25) + protocol.delay(seconds=0.2) + p1000.default_speed = 400 + p1000.move_to(qpcrplate_1[Y3].top(z=1.0)) + protocol.delay(seconds=0.2) + p1000.default_speed = 2.5 + p1000.dispense(10, qpcrplate_1[Y3].bottom(z=1.75), rate=0.25) + protocol.delay(seconds=0.2) + p1000.default_speed = 400 + + p1000.move_to(qpcrplate_1[Y4].top(z=1.0)) + protocol.delay(seconds=0.2) + p1000.default_speed = 2.5 + p1000.dispense(10, qpcrplate_1[Y4].bottom(z=1.75), rate=0.25) + protocol.delay(seconds=0.2) + p1000.default_speed = 400 + p1000.move_to(qpcrplate_1[Y5].top(z=1.0)) + protocol.delay(seconds=0.2) + p1000.default_speed = 2.5 + p1000.dispense(10, qpcrplate_1[Y5].bottom(z=1.75), rate=0.25) + protocol.delay(seconds=0.2) + p1000.default_speed = 400 + p1000.move_to(qpcrplate_1[Y6].top(z=1.0)) + protocol.delay(seconds=0.2) + p1000.default_speed = 2.5 + p1000.dispense(10, qpcrplate_1[Y6].bottom(z=1.75), rate=0.25) + protocol.delay(seconds=0.2) + p1000.default_speed = 400 + + p1000.drop_tip() if DRYRUN == "NO" else p1000.return_tip() + + if ( + samplecolumns >= 2 + ): # ----------------------------------------------------------------------------------------- + X = "A11" + Y1 = "A7" + Y2 = "A8" + Y3 = "A9" + Y4 = "A10" + Y5 = "A11" + Y6 = "A12" + p1000.pick_up_tip() + p1000.move_to(dilution_plate_1[X].bottom(z=p1000_offset_Temp)) + p1000.mix(30, 58) + p1000.aspirate(62, dilution_plate_1[X].bottom(z=p1000_offset_Temp), rate=0.25) + protocol.delay(seconds=0.2) + p1000.move_to(qpcrplate_1[Y1].top(z=1.0)) + protocol.delay(seconds=0.2) + p1000.default_speed = 2.5 + p1000.dispense(10, qpcrplate_1[Y1].bottom(z=1.75), rate=0.25) + protocol.delay(seconds=0.2) + p1000.default_speed = 400 + p1000.move_to(qpcrplate_1[Y2].top(z=1.0)) + protocol.delay(seconds=0.2) + p1000.default_speed = 2.5 + p1000.dispense(10, qpcrplate_1[Y2].bottom(z=1.75), rate=0.25) + protocol.delay(seconds=0.2) + p1000.default_speed = 400 + p1000.move_to(qpcrplate_1[Y3].top(z=1.0)) + protocol.delay(seconds=0.2) + p1000.default_speed = 2.5 + p1000.dispense(10, qpcrplate_1[Y3].bottom(z=1.75), rate=0.25) + protocol.delay(seconds=0.2) + p1000.default_speed = 400 + + p1000.move_to(qpcrplate_1[Y4].top(z=1.0)) + protocol.delay(seconds=0.2) + p1000.default_speed = 2.5 + p1000.dispense(10, qpcrplate_1[Y4].bottom(z=1.75), rate=0.25) + protocol.delay(seconds=0.2) + p1000.default_speed = 400 + p1000.move_to(qpcrplate_1[Y5].top(z=1.0)) + protocol.delay(seconds=0.2) + p1000.default_speed = 2.5 + p1000.dispense(10, qpcrplate_1[Y5].bottom(z=1.75), rate=0.25) + protocol.delay(seconds=0.2) + p1000.default_speed = 400 + p1000.move_to(qpcrplate_1[Y6].top(z=1.0)) + protocol.delay(seconds=0.2) + p1000.default_speed = 2.5 + p1000.dispense(10, qpcrplate_1[Y6].bottom(z=1.75), rate=0.25) + protocol.delay(seconds=0.2) + p1000.default_speed = 400 + + p1000.drop_tip() if DRYRUN == "NO" else p1000.return_tip() + + if ( + samplecolumns >= 3 + ): # ----------------------------------------------------------------------------------------- + X = "A12" + Y1 = "B7" + Y2 = "B8" + Y3 = "B9" + Y4 = "B10" + Y5 = "B11" + Y6 = "B12" + p1000.pick_up_tip() + p1000.move_to(dilution_plate_1[X].bottom(z=p1000_offset_Temp)) + p1000.mix(30, 58) + p1000.aspirate(62, dilution_plate_1[X].bottom(z=p1000_offset_Temp), rate=0.25) + protocol.delay(seconds=0.2) + p1000.move_to(qpcrplate_1[Y1].top(z=1.0)) + protocol.delay(seconds=0.2) + p1000.default_speed = 2.5 + p1000.dispense(10, qpcrplate_1[Y1].bottom(z=1.75), rate=0.25) + protocol.delay(seconds=0.2) + p1000.default_speed = 400 + p1000.move_to(qpcrplate_1[Y2].top(z=1.0)) + protocol.delay(seconds=0.2) + p1000.default_speed = 2.5 + p1000.dispense(10, qpcrplate_1[Y2].bottom(z=1.75), rate=0.25) + protocol.delay(seconds=0.2) + p1000.default_speed = 400 + p1000.move_to(qpcrplate_1[Y3].top(z=1.0)) + protocol.delay(seconds=0.2) + p1000.default_speed = 2.5 + p1000.dispense(10, qpcrplate_1[Y3].bottom(z=1.75), rate=0.25) + protocol.delay(seconds=0.2) + p1000.default_speed = 400 + + p1000.move_to(qpcrplate_1[Y4].top(z=1.0)) + protocol.delay(seconds=0.2) + p1000.default_speed = 2.5 + p1000.dispense(10, qpcrplate_1[Y4].bottom(z=1.75), rate=0.25) + protocol.delay(seconds=0.2) + p1000.default_speed = 400 + p1000.move_to(qpcrplate_1[Y5].top(z=1.0)) + protocol.delay(seconds=0.2) + p1000.default_speed = 2.5 + p1000.dispense(10, qpcrplate_1[Y5].bottom(z=1.75), rate=0.25) + protocol.delay(seconds=0.2) + p1000.default_speed = 400 + p1000.move_to(qpcrplate_1[Y6].top(z=1.0)) + protocol.delay(seconds=0.2) + p1000.default_speed = 2.5 + p1000.dispense(10, qpcrplate_1[Y6].bottom(z=1.75), rate=0.25) + protocol.delay(seconds=0.2) + p1000.default_speed = 400 + + p1000.drop_tip() if DRYRUN == "NO" else p1000.return_tip() + + X = "A9" + Y1 = "A1" + Y2 = "A2" + Y3 = "A3" + Y4 = "A4" + Y5 = "A5" + Y6 = "A6" + p1000.pick_up_tip() + p1000.move_to(dilution_plate_2[X].bottom(z=p1000_offset_Temp)) + p1000.mix(30, 58) + p1000.aspirate(62, dilution_plate_2[X].bottom(z=p1000_offset_Temp), rate=0.25) + protocol.delay(seconds=0.2) + p1000.move_to(qpcrplate_2[Y1].top(z=1.0)) + protocol.delay(seconds=0.2) + p1000.default_speed = 2.5 + p1000.dispense(10, qpcrplate_2[Y1].bottom(z=1.75), rate=0.25) + protocol.delay(seconds=0.2) + p1000.default_speed = 400 + p1000.move_to(qpcrplate_2[Y2].top(z=1.0)) + protocol.delay(seconds=0.2) + p1000.default_speed = 2.5 + p1000.dispense(10, qpcrplate_2[Y2].bottom(z=1.75), rate=0.25) + protocol.delay(seconds=0.2) + p1000.default_speed = 400 + p1000.move_to(qpcrplate_2[Y3].top(z=1.0)) + protocol.delay(seconds=0.2) + p1000.default_speed = 2.5 + p1000.dispense(10, qpcrplate_2[Y3].bottom(z=1.75), rate=0.25) + protocol.delay(seconds=0.2) + p1000.default_speed = 400 + + p1000.move_to(qpcrplate_2[Y4].top(z=1.0)) + protocol.delay(seconds=0.2) + p1000.default_speed = 2.5 + p1000.dispense(10, qpcrplate_2[Y4].bottom(z=1.75), rate=0.25) + protocol.delay(seconds=0.2) + p1000.default_speed = 400 + p1000.move_to(qpcrplate_2[Y5].top(z=1.0)) + protocol.delay(seconds=0.2) + p1000.default_speed = 2.5 + p1000.dispense(10, qpcrplate_2[Y5].bottom(z=1.75), rate=0.25) + protocol.delay(seconds=0.2) + p1000.default_speed = 400 + p1000.move_to(qpcrplate_2[Y6].top(z=1.0)) + protocol.delay(seconds=0.2) + p1000.default_speed = 2.5 + p1000.dispense(10, qpcrplate_2[Y6].bottom(z=1.75), rate=0.25) + protocol.delay(seconds=0.2) + p1000.default_speed = 400 + + p1000.drop_tip() if DRYRUN == "NO" else p1000.return_tip() + if ( + samplecolumns >= 1 + ): # ----------------------------------------------------------------------------------------- + X = "A10" + Y1 = "B1" + Y2 = "B2" + Y3 = "B3" + Y4 = "B4" + Y5 = "B5" + Y6 = "B6" + p1000.pick_up_tip() + p1000.move_to(dilution_plate_2[X].bottom(z=p1000_offset_Temp)) + p1000.mix(30, 58) + p1000.aspirate(62, dilution_plate_2[X].bottom(z=p1000_offset_Temp), rate=0.25) + protocol.delay(seconds=0.2) + p1000.move_to(qpcrplate_2[Y1].top(z=1.0)) + protocol.delay(seconds=0.2) + p1000.default_speed = 2.5 + p1000.dispense(10, qpcrplate_2[Y1].bottom(z=1.75), rate=0.25) + protocol.delay(seconds=0.2) + p1000.default_speed = 400 + p1000.move_to(qpcrplate_2[Y2].top(z=1.0)) + protocol.delay(seconds=0.2) + p1000.default_speed = 2.5 + p1000.dispense(10, qpcrplate_2[Y2].bottom(z=1.75), rate=0.25) + protocol.delay(seconds=0.2) + p1000.default_speed = 400 + p1000.move_to(qpcrplate_2[Y3].top(z=1.0)) + protocol.delay(seconds=0.2) + p1000.default_speed = 2.5 + p1000.dispense(10, qpcrplate_2[Y3].bottom(z=1.75), rate=0.25) + protocol.delay(seconds=0.2) + p1000.default_speed = 400 + + p1000.move_to(qpcrplate_2[Y4].top(z=1.0)) + protocol.delay(seconds=0.2) + p1000.default_speed = 2.5 + p1000.dispense(10, qpcrplate_2[Y4].bottom(z=1.75), rate=0.25) + protocol.delay(seconds=0.2) + p1000.default_speed = 400 + p1000.move_to(qpcrplate_2[Y5].top(z=1.0)) + protocol.delay(seconds=0.2) + p1000.default_speed = 2.5 + p1000.dispense(10, qpcrplate_2[Y5].bottom(z=1.75), rate=0.25) + protocol.delay(seconds=0.2) + p1000.default_speed = 400 + p1000.move_to(qpcrplate_2[Y6].top(z=1.0)) + protocol.delay(seconds=0.2) + p1000.default_speed = 2.5 + p1000.dispense(10, qpcrplate_2[Y6].bottom(z=1.75), rate=0.25) + protocol.delay(seconds=0.2) + p1000.default_speed = 400 + + p1000.drop_tip() if DRYRUN == "NO" else p1000.return_tip() + + if ( + samplecolumns >= 2 + ): # ----------------------------------------------------------------------------------------- + X = "A11" + Y1 = "A7" + Y2 = "A8" + Y3 = "A9" + Y4 = "A10" + Y5 = "A11" + Y6 = "A12" + p1000.pick_up_tip() + p1000.move_to(dilution_plate_2[X].bottom(z=p1000_offset_Temp)) + p1000.mix(30, 58) + p1000.aspirate(62, dilution_plate_2[X].bottom(z=p1000_offset_Temp), rate=0.25) + protocol.delay(seconds=0.2) + p1000.move_to(qpcrplate_2[Y1].top(z=1.0)) + protocol.delay(seconds=0.2) + p1000.default_speed = 2.5 + p1000.dispense(10, qpcrplate_2[Y1].bottom(z=1.75), rate=0.25) + protocol.delay(seconds=0.2) + p1000.default_speed = 400 + p1000.move_to(qpcrplate_2[Y2].top(z=1.0)) + protocol.delay(seconds=0.2) + p1000.default_speed = 2.5 + p1000.dispense(10, qpcrplate_2[Y2].bottom(z=1.75), rate=0.25) + protocol.delay(seconds=0.2) + p1000.default_speed = 400 + p1000.move_to(qpcrplate_2[Y3].top(z=1.0)) + protocol.delay(seconds=0.2) + p1000.default_speed = 2.5 + p1000.dispense(10, qpcrplate_2[Y3].bottom(z=1.75), rate=0.25) + protocol.delay(seconds=0.2) + p1000.default_speed = 400 + + p1000.move_to(qpcrplate_2[Y4].top(z=1.0)) + protocol.delay(seconds=0.2) + p1000.default_speed = 2.5 + p1000.dispense(10, qpcrplate_2[Y4].bottom(z=1.75), rate=0.25) + protocol.delay(seconds=0.2) + p1000.default_speed = 400 + p1000.move_to(qpcrplate_2[Y5].top(z=1.0)) + protocol.delay(seconds=0.2) + p1000.default_speed = 2.5 + p1000.dispense(10, qpcrplate_2[Y5].bottom(z=1.75), rate=0.25) + protocol.delay(seconds=0.2) + p1000.default_speed = 400 + p1000.move_to(qpcrplate_2[Y6].top(z=1.0)) + protocol.delay(seconds=0.2) + p1000.default_speed = 2.5 + p1000.dispense(10, qpcrplate_2[Y6].bottom(z=1.75), rate=0.25) + protocol.delay(seconds=0.2) + p1000.default_speed = 400 + + p1000.drop_tip() if DRYRUN == "NO" else p1000.return_tip() + + if ( + samplecolumns >= 3 + ): # ----------------------------------------------------------------------------------------- + X = "A12" + Y1 = "B7" + Y2 = "B8" + Y3 = "B9" + Y4 = "B10" + Y5 = "B11" + Y6 = "B12" + p1000.pick_up_tip() + p1000.move_to(dilution_plate_2[X].bottom(z=p1000_offset_Temp)) + p1000.mix(30, 58) + p1000.aspirate(62, dilution_plate_2[X].bottom(z=p1000_offset_Temp), rate=0.25) + protocol.delay(seconds=0.2) + p1000.move_to(qpcrplate_2[Y1].top(z=1.0)) + protocol.delay(seconds=0.2) + p1000.default_speed = 2.5 + p1000.dispense(10, qpcrplate_2[Y1].bottom(z=1.75), rate=0.25) + protocol.delay(seconds=0.2) + p1000.default_speed = 400 + p1000.move_to(qpcrplate_2[Y2].top(z=1.0)) + protocol.delay(seconds=0.2) + p1000.default_speed = 2.5 + p1000.dispense(10, qpcrplate_2[Y2].bottom(z=1.75), rate=0.25) + protocol.delay(seconds=0.2) + p1000.default_speed = 400 + p1000.move_to(qpcrplate_2[Y3].top(z=1.0)) + protocol.delay(seconds=0.2) + p1000.default_speed = 2.5 + p1000.dispense(10, qpcrplate_2[Y3].bottom(z=1.75), rate=0.25) + protocol.delay(seconds=0.2) + p1000.default_speed = 400 + + p1000.move_to(qpcrplate_2[Y4].top(z=1.0)) + protocol.delay(seconds=0.2) + p1000.default_speed = 2.5 + p1000.dispense(10, qpcrplate_2[Y4].bottom(z=1.75), rate=0.25) + protocol.delay(seconds=0.2) + p1000.default_speed = 400 + p1000.move_to(qpcrplate_2[Y5].top(z=1.0)) + protocol.delay(seconds=0.2) + p1000.default_speed = 2.5 + p1000.dispense(10, qpcrplate_2[Y5].bottom(z=1.75), rate=0.25) + protocol.delay(seconds=0.2) + p1000.default_speed = 400 + p1000.move_to(qpcrplate_2[Y6].top(z=1.0)) + protocol.delay(seconds=0.2) + p1000.default_speed = 2.5 + p1000.dispense(10, qpcrplate_2[Y6].bottom(z=1.75), rate=0.25) + protocol.delay(seconds=0.2) + p1000.default_speed = 400 + + p1000.drop_tip() if DRYRUN == "NO" else p1000.return_tip() + + p1000.reset_tipracks() + p50.reset_tipracks() + repeat += 1 diff --git a/app-testing/tests/protocol_analyze_test.py b/app-testing/tests/protocol_analyze_test.py index 9e44935eadbf..f513820e07c0 100644 --- a/app-testing/tests/protocol_analyze_test.py +++ b/app-testing/tests/protocol_analyze_test.py @@ -83,7 +83,12 @@ def test_analyses( assert error_details == protocol.app_analysis_error protocol_landing.click_popout_close() else: - assert protocol_landing.get_error_details_safe() is None, "Unexpected analysis error." + error_link = protocol_landing.get_error_details_safe() + + if error_link is not None: + protocol_landing.base.click_webelement(error_link) + error_details = protocol_landing.get_popout_error().text + raise AssertionError(f"Unexpected analysis error: {error_details}") # Verifying elements on Protocol Landing Page # todo fix next line needs to be safe and print name not found From 67fe75d55d788733c91a128f699f38ecf9c48d5e Mon Sep 17 00:00:00 2001 From: Andy Sigler Date: Mon, 31 Jul 2023 11:25:08 -0400 Subject: [PATCH 22/25] confirm all DUT serial numbers in production-qc tests (#13199) --- .../hardware_testing/data/csv_report.py | 27 +++++++--- .../hardware_testing/gravimetric/report.py | 3 ++ .../opentrons_api/helpers_ot3.py | 10 ++++ .../gripper_assembly_qc_ot3/__main__.py | 49 ++++++++++++------- .../gripper_assembly_qc_ot3/test_force.py | 4 +- .../ninety_six_assembly_qc_ot3/__main__.py | 48 +++++++++++------- .../pipette_current_speed_qc_ot3.py | 28 ++++++----- .../robot_assembly_qc_ot3/__main__.py | 24 +++++---- .../production_qc/tip_iqc_ot3.py | 34 ++++++++----- 9 files changed, 151 insertions(+), 76 deletions(-) diff --git a/hardware-testing/hardware_testing/data/csv_report.py b/hardware-testing/hardware_testing/data/csv_report.py index bc4032a95f78..ae05daf32c8d 100644 --- a/hardware-testing/hardware_testing/data/csv_report.py +++ b/hardware-testing/hardware_testing/data/csv_report.py @@ -41,6 +41,8 @@ def print_csv_result(test: str, result: CSVResult) -> None: META_DATA_TEST_NAME = "test_name" META_DATA_TEST_TAG = "test_tag" META_DATA_TEST_RUN_ID = "test_run_id" +META_DATA_TEST_DEVICE_ID = "test_device_id" +META_DATA_TEST_ROBOT_ID = "test_robot_id" META_DATA_TEST_TIME_UTC = "test_time_utc" META_DATA_TEST_OPERATOR = "test_operator" META_DATA_TEST_VERSION = "test_version" @@ -260,8 +262,10 @@ def _generate_meta_data_section() -> CSVSection: CSVLine(tag=META_DATA_TEST_NAME, data=[str]), CSVLine(tag=META_DATA_TEST_TAG, data=[str]), CSVLine(tag=META_DATA_TEST_RUN_ID, data=[str]), + CSVLine(tag=META_DATA_TEST_DEVICE_ID, data=[str, CSVResult]), + CSVLine(tag=META_DATA_TEST_ROBOT_ID, data=[str]), CSVLine(tag=META_DATA_TEST_TIME_UTC, data=[str]), - CSVLine(tag=META_DATA_TEST_OPERATOR, data=[str]), + CSVLine(tag=META_DATA_TEST_OPERATOR, data=[str, CSVResult]), CSVLine(tag=META_DATA_TEST_VERSION, data=[str]), CSVLine(tag=META_DATA_TEST_FIRMWARE, data=[str]), ], @@ -291,7 +295,7 @@ def __init__( self._tag: Optional[str] = None self._file_name: Optional[str] = None _section_meta = _generate_meta_data_section() - _section_titles = [s.title for s in sections] + _section_titles = [META_DATA_TITLE] + [s.title for s in sections] _section_results = _generate_results_overview_section(_section_titles) self._sections = [_section_meta, _section_results] + sections self._cache_start_time(start_time) # must happen before storing any data @@ -330,9 +334,11 @@ def __getitem__(self, item: str) -> CSVSection: raise ValueError(f"unexpected section title: {item}") def _refresh_results_overview_values(self) -> None: - for s in self._sections[2:]: - section = self[RESULTS_OVERVIEW_TITLE] - line = section[f"RESULT_{s.title}"] + results_section = self[RESULTS_OVERVIEW_TITLE] + for s in self._sections: + if s == results_section: + continue + line = results_section[f"RESULT_{s.title}"] assert isinstance(line, CSVLine) line.store(CSVResult.PASS, print_results=False) if s.result_passed: @@ -382,9 +388,18 @@ def set_tag(self, tag: str) -> None: ) self.save_to_disk() + def set_device_id(self, device_id: str, result: CSVResult) -> None: + """Store DUT serial number.""" + self(META_DATA_TITLE, META_DATA_TEST_DEVICE_ID, [device_id, result]) + + def set_robot_id(self, robot_id: str) -> None: + """Store robot serial number.""" + self(META_DATA_TITLE, META_DATA_TEST_ROBOT_ID, [robot_id]) + def set_operator(self, operator: str) -> None: """Set operator.""" - self(META_DATA_TITLE, META_DATA_TEST_OPERATOR, [operator]) + result = CSVResult.from_bool(bool(operator)) + self(META_DATA_TITLE, META_DATA_TEST_OPERATOR, [operator, result]) def set_version(self, version: str) -> None: """Set version.""" diff --git a/hardware-testing/hardware_testing/gravimetric/report.py b/hardware-testing/hardware_testing/gravimetric/report.py index 9ccf7443efab..bac7f7b32f9b 100644 --- a/hardware-testing/hardware_testing/gravimetric/report.py +++ b/hardware-testing/hardware_testing/gravimetric/report.py @@ -4,6 +4,7 @@ from typing import List, Tuple, Any from hardware_testing.data.csv_report import ( + CSVResult, CSVReport, CSVSection, CSVLine, @@ -310,6 +311,8 @@ def store_serial_numbers( liquid: str, ) -> None: """Report serial numbers.""" + report.set_robot_id(robot) + report.set_device_id(pipette, CSVResult.PASS) report("SERIAL-NUMBERS", "robot", [robot]) report("SERIAL-NUMBERS", "pipette", [pipette]) report("SERIAL-NUMBERS", "tips", [tips]) diff --git a/hardware-testing/hardware_testing/opentrons_api/helpers_ot3.py b/hardware-testing/hardware_testing/opentrons_api/helpers_ot3.py index 8250e8bb5d0e..4dd8715ff72e 100644 --- a/hardware-testing/hardware_testing/opentrons_api/helpers_ot3.py +++ b/hardware-testing/hardware_testing/opentrons_api/helpers_ot3.py @@ -976,6 +976,16 @@ def get_pipette_serial_ot3(pipette: Union[PipetteOT2, PipetteOT3]) -> str: return f"P{volume}{channels}V{version}{id}" +def get_robot_serial_ot3(api: OT3API) -> str: + """Get robot serial number.""" + if api.is_simulator: + return "FLXA1000000000000" + robot_id = api._backend.eeprom_data.serial_number + if not robot_id: + robot_id = "None" + return robot_id + + def clear_pipette_ul_per_mm(api: OT3API, mount: OT3Mount) -> None: """Clear pipette ul-per-mm.""" diff --git a/hardware-testing/hardware_testing/production_qc/gripper_assembly_qc_ot3/__main__.py b/hardware-testing/hardware_testing/production_qc/gripper_assembly_qc_ot3/__main__.py index daa9f5bb2369..c893ddc97d8f 100644 --- a/hardware-testing/hardware_testing/production_qc/gripper_assembly_qc_ot3/__main__.py +++ b/hardware-testing/hardware_testing/production_qc/gripper_assembly_qc_ot3/__main__.py @@ -4,7 +4,7 @@ from pathlib import Path from hardware_testing.data import ui, get_git_description -from hardware_testing.data.csv_report import RESULTS_OVERVIEW_TITLE +from hardware_testing.data.csv_report import RESULTS_OVERVIEW_TITLE, CSVResult from hardware_testing.opentrons_api import helpers_ot3 from hardware_testing.opentrons_api.types import OT3Mount, Axis @@ -12,6 +12,18 @@ async def _main(cfg: TestConfig) -> None: + # BUILD REPORT + test_name = Path(__file__).parent.name + ui.print_title(test_name.replace("_", " ").upper()) + report = build_report(test_name.replace("_", "-")) + version = get_git_description() + report.set_version(version) + print(f"version: {version}") + if not cfg.simulate: + report.set_operator(input("enter operator name: ")) + else: + report.set_operator("simulation") + # BUILD API api = await helpers_ot3.build_async_ot3_hardware_api( is_simulating=cfg.simulate, @@ -19,6 +31,25 @@ async def _main(cfg: TestConfig) -> None: pipette_right="p1000_single_v3.3", gripper="GRPV1120230323A01", ) + report.set_firmware(api.fw_version) + robot_id = helpers_ot3.get_robot_serial_ot3(api) + print(f"robot serial: {robot_id}") + report.set_robot_id(robot_id) + + # GRIPPER SERIAL NUMBER + gripper = api.attached_gripper + assert gripper + gripper_id = str(gripper["gripper_id"]) + report.set_tag(gripper_id) + if not api.is_simulator: + barcode = input("SCAN gripper serial number: ").strip() + else: + barcode = str(gripper_id) + barcode_pass = CSVResult.from_bool(barcode == gripper_id) + print(f"barcode: {barcode} ({barcode_pass})") + report.set_device_id(gripper_id, result=barcode_pass) + + # HOME and ATTACH await api.home_z(OT3Mount.GRIPPER) await api.home() home_pos = await api.gantry_position(OT3Mount.GRIPPER) @@ -30,22 +61,6 @@ async def _main(cfg: TestConfig) -> None: ui.get_user_ready("attach a gripper") await api.reset() - gripper = api.attached_gripper - assert gripper - gripper_id = str(gripper["gripper_id"]) - - # BUILD REPORT - test_name = Path(__file__).parent.name - ui.print_title(test_name.replace("_", " ").upper()) - report = build_report(test_name.replace("_", "-")) - report.set_tag(gripper_id) - if not cfg.simulate: - report.set_operator(input("enter operator name: ")) - else: - report.set_operator("simulation") - report.set_version(get_git_description()) - report.set_firmware(api.fw_version) - # RUN TESTS for section, test_run in cfg.tests.items(): ui.print_title(section.value) diff --git a/hardware-testing/hardware_testing/production_qc/gripper_assembly_qc_ot3/test_force.py b/hardware-testing/hardware_testing/production_qc/gripper_assembly_qc_ot3/test_force.py index b8f491ab8179..7da282fc77af 100644 --- a/hardware-testing/hardware_testing/production_qc/gripper_assembly_qc_ot3/test_force.py +++ b/hardware-testing/hardware_testing/production_qc/gripper_assembly_qc_ot3/test_force.py @@ -33,7 +33,7 @@ FORCE_GAUGE_TRIAL_SAMPLE_INTERVAL = 0.25 # seconds FORCE_GAUGE_TRIAL_SAMPLE_COUNT = 20 # 20 samples = 5 seconds @ 4Hz -GAUGE_OFFSET = Point(x=2, y=42, z=75) +GAUGE_OFFSET = Point(x=2, y=-42, z=75) def _get_test_tag( @@ -151,7 +151,7 @@ async def _setup(api: OT3API) -> Union[Mark10, SimMark10]: await helpers_ot3.move_to_arched_ot3(api, mount, target_pos + Point(z=15)) if not api.is_simulator: ui.get_user_ready("please make sure the gauge in the middle of the gripper") - await api.move_to(mount, target_pos) + await helpers_ot3.jog_mount_ot3(api, OT3Mount.GRIPPER) if not api.is_simulator: ui.get_user_ready("about to grip") await api.grip(20) 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 f05e1ce031f7..f48fcca9dba8 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 @@ -4,7 +4,7 @@ from pathlib import Path from hardware_testing.data import ui, get_git_description -from hardware_testing.data.csv_report import RESULTS_OVERVIEW_TITLE +from hardware_testing.data.csv_report import RESULTS_OVERVIEW_TITLE, CSVResult from hardware_testing.opentrons_api import helpers_ot3 from hardware_testing.opentrons_api.types import OT3Mount, Axis @@ -12,14 +12,39 @@ async def _main(cfg: TestConfig) -> None: + # BUILD REPORT + test_name = Path(__file__).parent.name + ui.print_title(test_name.replace("_", " ").upper()) + report = build_report(test_name.replace("_", "-")) + report.set_version(get_git_description()) + if not cfg.simulate: + report.set_operator(input("enter operator name: ")) + else: + report.set_operator("simulation") + # BUILD API api = await helpers_ot3.build_async_ot3_hardware_api( is_simulating=cfg.simulate, pipette_left="p1000_96_v3.4", ) - await api.home() - home_pos = await api.gantry_position(OT3Mount.LEFT) + report.set_robot_id(helpers_ot3.get_robot_serial_ot3(api)) + + # PIPETTE SERIAL NUMBER mount = OT3Mount.LEFT + pipette = api.hardware_pipettes[mount.to_mount()] + assert pipette + pipette_id = str(pipette.pipette_id) + report.set_tag(pipette_id) + if not api.is_simulator: + barcode = input("scan pipette barcode: ").strip() + barcode_result = CSVResult(barcode == pipette_id) + report.set_device_id(pipette_id, barcode_result) + else: + report.set_device_id(pipette_id, CSVResult.PASS) + + # HOME and ATTACH + await api.home() + home_pos = await api.gantry_position(mount) attach_pos = helpers_ot3.get_slot_calibration_square_position_ot3(5) attach_pos = attach_pos._replace(z=home_pos.z) if not api.hardware_pipettes[mount.to_mount()]: @@ -30,27 +55,12 @@ async def _main(cfg: TestConfig) -> None: while not api.hardware_pipettes[mount.to_mount()]: ui.get_user_ready("attach a 96ch pipette") await api.reset() - await api.home_z(OT3Mount.LEFT) - - pipette = api.hardware_pipettes[mount.to_mount()] - assert pipette - pipette_id = str(pipette.pipette_id) + await api.home_z(mount) # FIXME: remove this once the "'L' format requires 0 <= number <= 4294967295" bug is gone await api._backend.home([Axis.P_L], api.gantry_load) await api.refresh_positions() - # BUILD REPORT - test_name = Path(__file__).parent.name - ui.print_title(test_name.replace("_", " ").upper()) - report = build_report(test_name.replace("_", "-")) - report.set_tag(pipette_id) - if not cfg.simulate: - report.set_operator(input("enter operator name: ")) - else: - report.set_operator("simulation") - report.set_version(get_git_description()) - # RUN TESTS for section, test_run in cfg.tests.items(): ui.print_title(section.value) diff --git a/hardware-testing/hardware_testing/production_qc/pipette_current_speed_qc_ot3.py b/hardware-testing/hardware_testing/production_qc/pipette_current_speed_qc_ot3.py index ab5e77d0eb1d..8d050439dc6f 100644 --- a/hardware-testing/hardware_testing/production_qc/pipette_current_speed_qc_ot3.py +++ b/hardware-testing/hardware_testing/production_qc/pipette_current_speed_qc_ot3.py @@ -10,6 +10,7 @@ ) from opentrons_shared_data.errors.exceptions import StallOrCollisionDetectedError +from hardware_testing.data import get_git_description from hardware_testing.data.csv_report import ( CSVReport, CSVResult, @@ -42,17 +43,11 @@ MAX_SPEED = max(TEST_SPEEDS) -def _get_operator(is_simulating: bool) -> str: - if is_simulating: - return "simulating" - return input("enter OPERATOR name: ") - - def _get_test_tag(current: float, speed: float, direction: str, pos: str) -> str: return f"current-{current}-speed-{speed}-{direction}-{pos}" -def _build_csv_report(operator: str, pipette_sn: str) -> CSVReport: +def _build_csv_report() -> CSVReport: _report = CSVReport( test_name="pipette-current-speed-qc-ot3", sections=[ @@ -74,9 +69,6 @@ def _build_csv_report(operator: str, pipette_sn: str) -> CSVReport: ), ], ) - _report.set_tag(pipette_sn) - _report.set_version("unknown") - _report.set_operator(operator) return _report @@ -239,7 +231,10 @@ async def _main(is_simulating: bool) -> None: pipette_left="p1000_single_v3.4", pipette_right="p1000_multi_v3.4", ) - _operator = _get_operator(api.is_simulator) + if not api.is_simulator: + operator = input("enter OPERATOR name: ") + else: + operator = "simulation" # home and move to a safe position await _reset_gantry(api) @@ -252,7 +247,16 @@ async def _main(is_simulating: bool) -> None: ui.print_title(f"{pipette_sn} - {mount.name}") if not api.is_simulator and not ui.get_user_answer("QC this pipette"): continue - report = _build_csv_report(_operator, pipette_sn) + report = _build_csv_report() + report.set_version(get_git_description()) + report.set_operator(operator) + report.set_robot_id(helpers_ot3.get_robot_serial_ot3(api)) + report.set_tag(pipette_sn) + if not api.is_simulator: + barcode = input("scan pipette barcode: ") + else: + barcode = str(pipette_sn) + report.set_device_id(pipette_sn, CSVResult.from_bool(barcode == pipette_sn)) failing_current = await _test_plunger(api, mount, report) report( "OVERALL", diff --git a/hardware-testing/hardware_testing/production_qc/robot_assembly_qc_ot3/__main__.py b/hardware-testing/hardware_testing/production_qc/robot_assembly_qc_ot3/__main__.py index e626d6178752..618764bc42d1 100644 --- a/hardware-testing/hardware_testing/production_qc/robot_assembly_qc_ot3/__main__.py +++ b/hardware-testing/hardware_testing/production_qc/robot_assembly_qc_ot3/__main__.py @@ -4,7 +4,7 @@ from pathlib import Path from hardware_testing.data import ui, get_git_description -from hardware_testing.data.csv_report import RESULTS_OVERVIEW_TITLE +from hardware_testing.data.csv_report import RESULTS_OVERVIEW_TITLE, CSVResult from hardware_testing.opentrons_api import helpers_ot3 from .config import TestSection, TestConfig, build_report, TESTS @@ -15,17 +15,13 @@ async def _main(cfg: TestConfig) -> None: test_name = Path(__file__).parent.name report = build_report(test_name) ui.print_title(test_name.replace("_", " ").upper()) + report.set_version(get_git_description()) - # GET INFO + # GET OPERATOR if not cfg.simulate: - robot_id = input("enter robot serial number: ") - operator = input("enter operator name: ") + report.set_operator(input("enter operator name: ")) else: - robot_id = "ot3-simulated-A01" - operator = "simulation" - report.set_tag(robot_id) - report.set_operator(operator) - report.set_version(get_git_description()) + report.set_operator("simulation") # BUILD API api = await helpers_ot3.build_async_ot3_hardware_api( @@ -36,6 +32,16 @@ async def _main(cfg: TestConfig) -> None: gripper="GRPV102", ) + # GET ROBOT SERIAL NUMBER + robot_id = helpers_ot3.get_robot_serial_ot3(api) + report.set_tag(robot_id) + report.set_robot_id(robot_id) + if not api.is_simulator: + barcode = input("scan robot barcode: ").strip() + report.set_device_id(robot_id, CSVResult.from_bool(barcode == robot_id)) + else: + report.set_device_id(robot_id, CSVResult.PASS) + # RUN TESTS for section, test_run in cfg.tests.items(): ui.print_title(section.value) diff --git a/hardware-testing/hardware_testing/production_qc/tip_iqc_ot3.py b/hardware-testing/hardware_testing/production_qc/tip_iqc_ot3.py index d68ed43cb1b8..64cbe1955689 100644 --- a/hardware-testing/hardware_testing/production_qc/tip_iqc_ot3.py +++ b/hardware-testing/hardware_testing/production_qc/tip_iqc_ot3.py @@ -10,7 +10,7 @@ SimPressureFixture, ) -from hardware_testing.data.csv_report import CSVReport, CSVSection, CSVLine +from hardware_testing.data.csv_report import CSVReport, CSVSection, CSVLine, CSVResult from hardware_testing.data import ui, get_git_description from hardware_testing.opentrons_api import helpers_ot3 from hardware_testing.opentrons_api.types import Point, OT3Mount @@ -94,12 +94,6 @@ def _get_tip_offset_in_rack(tip_name: str) -> Point: async def _main(is_simulating: bool, volume: float) -> None: ui.print_title("TIP IQC") - # CONNECT - api = await helpers_ot3.build_async_ot3_hardware_api( - is_simulating=is_simulating, pipette_left="p50_single_v3.4" - ) - mount = OT3Mount.LEFT - # CREATE CSV REPORT report = CSVReport( test_name="tip-iqc-ot3", @@ -115,13 +109,31 @@ async def _main(is_simulating: bool, volume: float) -> None: for tip in WELL_NAMES ], ) - if api.is_simulator: + version = get_git_description() + report.set_version(version) + print(f"version: {version}") + if is_simulating: report.set_operator("simulation") report.set_tag("simulation") + report.set_device_id("simulation", CSVResult.PASS) else: - report.set_operator(input("enter OPERATOR: ")) - report.set_tag(input("enter TAG: ")) - report.set_version(get_git_description()) + report.set_operator(input("enter OPERATOR: ").strip()) + tag = input("enter TAG: ").strip() + report.set_tag(tag) + report.set_device_id(tag, CSVResult.from_bool(bool(tag))) + + # BUILD API + api = await helpers_ot3.build_async_ot3_hardware_api( + is_simulating=is_simulating, + pipette_left="p1000_single_v3.3", + pipette_right="p1000_single_v3.3", + gripper="GRPV1120230323A01", + ) + report.set_firmware(api.fw_version) + robot_id = helpers_ot3.get_robot_serial_ot3(api) + print(f"robot serial: {robot_id}") + report.set_robot_id(robot_id) + mount = OT3Mount.LEFT # SETUP DECK if not api.is_simulator: From 8a947ddb0aff873b01b3bb1c1000b748e483fe4c Mon Sep 17 00:00:00 2001 From: Max Marrone Date: Mon, 31 Jul 2023 18:07:01 -0400 Subject: [PATCH 23/25] docs(api,robot-server): Document errors list as having no more than 1 element (#13191) --- api/src/opentrons/cli/analyze.py | 5 ++++- api/src/opentrons/protocol_engine/state/state_summary.py | 2 ++ .../robot_server/maintenance_runs/maintenance_run_models.py | 6 +++++- robot-server/robot_server/protocols/analysis_models.py | 6 +++++- robot-server/robot_server/runs/run_models.py | 6 +++++- 5 files changed, 21 insertions(+), 4 deletions(-) diff --git a/api/src/opentrons/cli/analyze.py b/api/src/opentrons/cli/analyze.py index 931025e15d75..9f5af67c5844 100644 --- a/api/src/opentrons/cli/analyze.py +++ b/api/src/opentrons/cli/analyze.py @@ -140,7 +140,10 @@ class PythonConfig(BaseModel): class AnalyzeResults(BaseModel): - """Results of a protocol analysis.""" + """Results of a protocol analysis. + + See robot-server's analysis models for field documentation. + """ createdAt: datetime files: List[ProtocolFile] diff --git a/api/src/opentrons/protocol_engine/state/state_summary.py b/api/src/opentrons/protocol_engine/state/state_summary.py index 323a10af15af..c7185cc2c0d8 100644 --- a/api/src/opentrons/protocol_engine/state/state_summary.py +++ b/api/src/opentrons/protocol_engine/state/state_summary.py @@ -18,6 +18,8 @@ class StateSummary(BaseModel): """Data from a protocol run.""" status: EngineStatus + # errors is a list for historical reasons. (This model needs to stay compatible with + # robot-server's database.) It shouldn't have more than 1 element. errors: List[ErrorOccurrence] labware: List[LoadedLabware] pipettes: List[LoadedPipette] diff --git a/robot-server/robot_server/maintenance_runs/maintenance_run_models.py b/robot-server/robot_server/maintenance_runs/maintenance_run_models.py index 8cde12588c26..f4d1a19dc61f 100644 --- a/robot-server/robot_server/maintenance_runs/maintenance_run_models.py +++ b/robot-server/robot_server/maintenance_runs/maintenance_run_models.py @@ -76,7 +76,11 @@ class MaintenanceRun(ResourceModel): ) errors: List[ErrorOccurrence] = Field( ..., - description="Any errors that have occurred during the run.", + description=( + "The run's fatal error, if there was one." + " For historical reasons, this is an array," + " but it won't have more than one element." + ), ) pipettes: List[LoadedPipette] = Field( ..., diff --git a/robot-server/robot_server/protocols/analysis_models.py b/robot-server/robot_server/protocols/analysis_models.py index a8b188ff0a78..d31bb9f4eaa1 100644 --- a/robot-server/robot_server/protocols/analysis_models.py +++ b/robot-server/robot_server/protocols/analysis_models.py @@ -108,7 +108,11 @@ class CompletedAnalysis(BaseModel): ) errors: List[ErrorOccurrence] = Field( ..., - description="Any errors the protocol run produced", + description=( + "The protocol's fatal error, if there was one." + " For historical reasons, this is an array," + " but it won't have more than one element." + ), ) liquids: List[Liquid] = Field( default_factory=list, diff --git a/robot-server/robot_server/runs/run_models.py b/robot-server/robot_server/runs/run_models.py index 485392cc9bce..7f435e054f06 100644 --- a/robot-server/robot_server/runs/run_models.py +++ b/robot-server/robot_server/runs/run_models.py @@ -77,7 +77,11 @@ class Run(ResourceModel): ) errors: List[ErrorOccurrence] = Field( ..., - description="Any errors that have occurred during the run.", + description=( + "The run's fatal error, if there was one." + " For historical reasons, this is an array," + " but it won't have more than one element." + ), ) pipettes: List[LoadedPipette] = Field( ..., From 2254a0f8bae6fa46b46d5f7967d1d7bc5ea6ff08 Mon Sep 17 00:00:00 2001 From: Ed Cormany Date: Mon, 31 Jul 2023 22:40:31 -0400 Subject: [PATCH 24/25] refactor(api): clean up labware movement error strings (#13198) --- .../protocol_engine/execution/labware_movement.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/api/src/opentrons/protocol_engine/execution/labware_movement.py b/api/src/opentrons/protocol_engine/execution/labware_movement.py index fa592dae5f65..07a85a18f417 100644 --- a/api/src/opentrons/protocol_engine/execution/labware_movement.py +++ b/api/src/opentrons/protocol_engine/execution/labware_movement.py @@ -97,7 +97,7 @@ async def move_labware_with_gripper( return ot3api = ensure_ot3_hardware( hardware_api=self._hardware_api, - error_msg="Gripper is only available on the OT-3", + error_msg="Gripper is only available on Opentrons Flex", ) if not ot3api.has_gripper(): @@ -230,7 +230,7 @@ def ensure_valid_gripper_location( location, (DeckSlotLocation, ModuleLocation, OnLabwareLocation) ): raise LabwareMovementNotAllowedError( - "Off-deck labware movements are not supported using the gripper." + "Cannot perform off-deck labware movements with the gripper." ) return location @@ -260,10 +260,10 @@ async def ensure_movement_not_obstructed_by_module( ) except ThermocyclerNotOpenError: raise LabwareMovementNotAllowedError( - "Cannot move labware from/to a thermocycler with closed lid." + "Cannot move labware to or from a Thermocycler with its lid closed." ) except HeaterShakerLabwareLatchNotOpenError: raise LabwareMovementNotAllowedError( - "Cannot move labware from/to a heater-shaker" + "Cannot move labware to or from a Heater-Shaker" " with its labware latch closed." ) From 95eb30fa66d1fb9c59a25d42980ac966cd701d83 Mon Sep 17 00:00:00 2001 From: Jethary Rader <66035149+jerader@users.noreply.github.com> Date: Tue, 1 Aug 2023 08:30:54 -0400 Subject: [PATCH 25/25] refactor(shared-data): p1000 flex minimum volumes (#13200) closes RAUT-578 --- shared-data/pipette/definitions/1/pipetteNameSpecs.json | 6 +++--- .../definitions/2/liquid/ninety_six_channel/p1000/1_0.json | 2 +- .../definitions/2/liquid/ninety_six_channel/p1000/3_0.json | 2 +- .../definitions/2/liquid/ninety_six_channel/p1000/3_3.json | 2 +- .../definitions/2/liquid/ninety_six_channel/p1000/3_4.json | 2 +- .../definitions/2/liquid/ninety_six_channel/p1000/3_5.json | 2 +- .../definitions/2/liquid/single_channel/p1000/3_0.json | 2 +- .../definitions/2/liquid/single_channel/p1000/3_3.json | 2 +- .../definitions/2/liquid/single_channel/p1000/3_4.json | 2 +- .../definitions/2/liquid/single_channel/p1000/3_5.json | 2 +- 10 files changed, 12 insertions(+), 12 deletions(-) diff --git a/shared-data/pipette/definitions/1/pipetteNameSpecs.json b/shared-data/pipette/definitions/1/pipetteNameSpecs.json index 4127fdf03540..cc49a1e74dbc 100644 --- a/shared-data/pipette/definitions/1/pipetteNameSpecs.json +++ b/shared-data/pipette/definitions/1/pipetteNameSpecs.json @@ -529,7 +529,7 @@ } }, "channels": 1, - "minVolume": 100, + "minVolume": 5, "maxVolume": 1000, "smoothieConfigs": { "stepsPerMM": 2133.33, @@ -574,7 +574,7 @@ } }, "channels": 8, - "minVolume": 1, + "minVolume": 5, "maxVolume": 1000, "smoothieConfigs": { "stepsPerMM": 2133.33, @@ -660,7 +660,7 @@ } }, "channels": 96, - "minVolume": 1, + "minVolume": 5, "maxVolume": 1000, "smoothieConfigs": { "stepsPerMM": 2133.33, diff --git a/shared-data/pipette/definitions/2/liquid/ninety_six_channel/p1000/1_0.json b/shared-data/pipette/definitions/2/liquid/ninety_six_channel/p1000/1_0.json index 1f01e4ade69e..27b31bb65cc6 100644 --- a/shared-data/pipette/definitions/2/liquid/ninety_six_channel/p1000/1_0.json +++ b/shared-data/pipette/definitions/2/liquid/ninety_six_channel/p1000/1_0.json @@ -365,7 +365,7 @@ "opentrons/opentrons_flex_96_tiprack_200ul/1": 10.5 }, "maxVolume": 1000, - "minVolume": 1, + "minVolume": 5, "defaultTipracks": [ "opentrons/opentrons_flex_96_tiprack_1000ul/1", "opentrons/opentrons_flex_96_tiprack_200ul/1", diff --git a/shared-data/pipette/definitions/2/liquid/ninety_six_channel/p1000/3_0.json b/shared-data/pipette/definitions/2/liquid/ninety_six_channel/p1000/3_0.json index 7215346d5f1b..26d091d548b6 100644 --- a/shared-data/pipette/definitions/2/liquid/ninety_six_channel/p1000/3_0.json +++ b/shared-data/pipette/definitions/2/liquid/ninety_six_channel/p1000/3_0.json @@ -365,7 +365,7 @@ "opentrons/opentrons_flex_96_tiprack_200ul/1": 10.5 }, "maxVolume": 1000, - "minVolume": 1, + "minVolume": 5, "defaultTipracks": [ "opentrons/opentrons_flex_96_tiprack_1000ul/1", "opentrons/opentrons_flex_96_tiprack_200ul/1", diff --git a/shared-data/pipette/definitions/2/liquid/ninety_six_channel/p1000/3_3.json b/shared-data/pipette/definitions/2/liquid/ninety_six_channel/p1000/3_3.json index 7215346d5f1b..26d091d548b6 100644 --- a/shared-data/pipette/definitions/2/liquid/ninety_six_channel/p1000/3_3.json +++ b/shared-data/pipette/definitions/2/liquid/ninety_six_channel/p1000/3_3.json @@ -365,7 +365,7 @@ "opentrons/opentrons_flex_96_tiprack_200ul/1": 10.5 }, "maxVolume": 1000, - "minVolume": 1, + "minVolume": 5, "defaultTipracks": [ "opentrons/opentrons_flex_96_tiprack_1000ul/1", "opentrons/opentrons_flex_96_tiprack_200ul/1", diff --git a/shared-data/pipette/definitions/2/liquid/ninety_six_channel/p1000/3_4.json b/shared-data/pipette/definitions/2/liquid/ninety_six_channel/p1000/3_4.json index 242bdff0414a..63aa174d3141 100644 --- a/shared-data/pipette/definitions/2/liquid/ninety_six_channel/p1000/3_4.json +++ b/shared-data/pipette/definitions/2/liquid/ninety_six_channel/p1000/3_4.json @@ -365,7 +365,7 @@ "opentrons/opentrons_flex_96_tiprack_200ul/1": 10.5 }, "maxVolume": 1000, - "minVolume": 1, + "minVolume": 5, "defaultTipracks": [ "opentrons/opentrons_flex_96_tiprack_1000ul/1", "opentrons/opentrons_flex_96_tiprack_200ul/1", diff --git a/shared-data/pipette/definitions/2/liquid/ninety_six_channel/p1000/3_5.json b/shared-data/pipette/definitions/2/liquid/ninety_six_channel/p1000/3_5.json index 242bdff0414a..63aa174d3141 100644 --- a/shared-data/pipette/definitions/2/liquid/ninety_six_channel/p1000/3_5.json +++ b/shared-data/pipette/definitions/2/liquid/ninety_six_channel/p1000/3_5.json @@ -365,7 +365,7 @@ "opentrons/opentrons_flex_96_tiprack_200ul/1": 10.5 }, "maxVolume": 1000, - "minVolume": 1, + "minVolume": 5, "defaultTipracks": [ "opentrons/opentrons_flex_96_tiprack_1000ul/1", "opentrons/opentrons_flex_96_tiprack_200ul/1", diff --git a/shared-data/pipette/definitions/2/liquid/single_channel/p1000/3_0.json b/shared-data/pipette/definitions/2/liquid/single_channel/p1000/3_0.json index 3e427d3b325a..594f045d0725 100644 --- a/shared-data/pipette/definitions/2/liquid/single_channel/p1000/3_0.json +++ b/shared-data/pipette/definitions/2/liquid/single_channel/p1000/3_0.json @@ -365,7 +365,7 @@ "opentrons/opentrons_flex_96_tiprack_50ul/1": 10.5 }, "maxVolume": 1000, - "minVolume": 1, + "minVolume": 5, "defaultTipracks": [ "opentrons/opentrons_flex_96_tiprack_1000ul/1", "opentrons/opentrons_flex_96_tiprack_200ul/1", diff --git a/shared-data/pipette/definitions/2/liquid/single_channel/p1000/3_3.json b/shared-data/pipette/definitions/2/liquid/single_channel/p1000/3_3.json index 3e427d3b325a..594f045d0725 100644 --- a/shared-data/pipette/definitions/2/liquid/single_channel/p1000/3_3.json +++ b/shared-data/pipette/definitions/2/liquid/single_channel/p1000/3_3.json @@ -365,7 +365,7 @@ "opentrons/opentrons_flex_96_tiprack_50ul/1": 10.5 }, "maxVolume": 1000, - "minVolume": 1, + "minVolume": 5, "defaultTipracks": [ "opentrons/opentrons_flex_96_tiprack_1000ul/1", "opentrons/opentrons_flex_96_tiprack_200ul/1", diff --git a/shared-data/pipette/definitions/2/liquid/single_channel/p1000/3_4.json b/shared-data/pipette/definitions/2/liquid/single_channel/p1000/3_4.json index 0fa958345e2d..82c2200f983f 100644 --- a/shared-data/pipette/definitions/2/liquid/single_channel/p1000/3_4.json +++ b/shared-data/pipette/definitions/2/liquid/single_channel/p1000/3_4.json @@ -291,7 +291,7 @@ "opentrons/opentrons_flex_96_tiprack_50ul/1": 10.5 }, "maxVolume": 1000, - "minVolume": 1, + "minVolume": 5, "defaultTipracks": [ "opentrons/opentrons_flex_96_tiprack_1000ul/1", "opentrons/opentrons_flex_96_tiprack_200ul/1", diff --git a/shared-data/pipette/definitions/2/liquid/single_channel/p1000/3_5.json b/shared-data/pipette/definitions/2/liquid/single_channel/p1000/3_5.json index 0fa958345e2d..82c2200f983f 100644 --- a/shared-data/pipette/definitions/2/liquid/single_channel/p1000/3_5.json +++ b/shared-data/pipette/definitions/2/liquid/single_channel/p1000/3_5.json @@ -291,7 +291,7 @@ "opentrons/opentrons_flex_96_tiprack_50ul/1": 10.5 }, "maxVolume": 1000, - "minVolume": 1, + "minVolume": 5, "defaultTipracks": [ "opentrons/opentrons_flex_96_tiprack_1000ul/1", "opentrons/opentrons_flex_96_tiprack_200ul/1",