From 62962c599b8b71bccc4c6b18a59a798f8b8989a3 Mon Sep 17 00:00:00 2001 From: Laura Cox <31892318+Laura-Danielle@users.noreply.github.com> Date: Tue, 27 Dec 2022 12:05:40 -0500 Subject: [PATCH] feat(api): Support 96 channel in the hardware controller (#11866) --- api/src/opentrons/config/defaults_ot3.py | 16 +- .../opentrons/config/ot3_pipette_config.py | 206 +++++ .../opentrons/hardware_control/__init__.py | 7 +- .../backends/ot3controller.py | 86 +- .../hardware_control/backends/ot3simulator.py | 83 +- .../hardware_control/backends/ot3utils.py | 28 +- .../opentrons/hardware_control/dev_types.py | 8 +- .../hardware_control/instruments/__init__.py | 3 +- .../instruments/instrument_abc.py | 10 +- .../instruments/ot2/pipette.py | 85 +- .../instruments/ot2/pipette_handler.py | 44 +- .../instruments/ot3/gripper.py | 3 + .../instruments/ot3/pipette.py | 577 ++++++++++++ .../instruments/ot3/pipette_handler.py | 832 ++++++++++++++++++ api/src/opentrons/hardware_control/ot3api.py | 195 ++-- .../protocols/instrument_configurer.py | 4 +- api/src/opentrons/hardware_control/types.py | 2 + .../opentrons/protocols/api_support/util.py | 7 +- api/tests/opentrons/config/ot3_settings.py | 6 + .../config/test_ot3_pipette_config.py | 233 +++++ .../opentrons/config/test_pipette_config.py | 6 +- api/tests/opentrons/data/testosaur_v2.py | 8 +- .../backends/test_ot3_controller.py | 49 +- .../hardware_control/test_instruments.py | 79 +- .../hardware_control/test_ot3_api.py | 168 +++- .../hardware_control/test_pipette.py | 483 ++++++---- .../hardware_control/test_pipette_handler.py | 77 ++ .../core/simulator/test_instrument_context.py | 11 + .../core/simulator/test_protocol_context.py | 1 + .../protocol_api_old/test_context.py | 17 +- .../protocol_api_old/test_instrument.py | 11 + .../advanced_control/test_transfers.py | 10 + .../execution/test_execute_json_v3.py | 1 + .../execution/test_execute_json_v4.py | 1 + api/tests/opentrons/test_execute.py | 10 +- .../opentrons_api/helpers_ot3.py | 24 +- .../opentrons_api/p1000_gen3_ul_per_mm.py | 4 +- .../ot2_p300_single_channel_gravimetric.py | 4 +- .../hardware_testing/liquid/test_heights.py | 8 +- .../hardware_control/motion.py | 9 +- .../robot_server/robot/calibration/util.py | 2 +- .../js/__tests__/pipetteSchemaV2.test.ts | 113 +++ .../js/__tests__/pipetteSpecSchemas.test.ts | 8 +- shared-data/js/pipettes.ts | 4 +- shared-data/pipette/README.md | 37 +- .../{ => 1}/pipetteModelSpecs.json | 0 .../definitions/{ => 1}/pipetteNameSpecs.json | 0 .../2/general/eight_channel/p1000/1_0.json | 38 + .../2/general/eight_channel/p50/1_0.json | 38 + .../general/ninety_six_channel/p1000/1_0.json | 38 + .../2/general/single_channel/p1000/1_0.json | 37 + .../2/general/single_channel/p50/1_0.json | 37 + .../2/geometry/eight_channel/p1000/1_0.json | 5 + .../eight_channel/p1000/placeholder.gltf | 0 .../2/geometry/eight_channel/p50/1_0.json | 5 + .../eight_channel/p50/placeholder.gltf | 0 .../ninety_six_channel/p1000/1_0.json | 5 + .../ninety_six_channel/p1000/placeholder.gltf | 0 .../2/geometry/single_channel/p1000/1_0.json | 5 + .../single_channel/p1000/placeholder.gltf | 0 .../2/geometry/single_channel/p50/1_0.json | 5 + .../single_channel/p50/placeholder.gltf | 0 .../2/liquid/eight_channel/p1000/1_0.json | 308 +++++++ .../2/liquid/eight_channel/p50/1_0.json | 80 ++ .../liquid/ninety_six_channel/p1000/1_0.json | 308 +++++++ .../2/liquid/single_channel/p1000/1_0.json | 308 +++++++ .../2/liquid/single_channel/p50/1_0.json | 80 ++ .../{ => 1}/pipetteModelSpecsSchema.json | 0 .../{ => 1}/pipetteNameSpecsSchema.json | 0 .../schemas/2/pipetteGeometrySchema.json | 27 + .../2/pipetteLiquidPropertiesSchema.json | 109 +++ .../schemas/2/pipettePropertiesSchema.json | 178 ++++ .../opentrons_shared_data/pipette/__init__.py | 4 +- .../pipette/load_data.py | 90 ++ .../pipette/pipette_definition.py | 296 +++++++ .../python/tests/pipette/test_load_data.py | 24 + .../tests/pipette/test_pipette_definition.py | 43 + 77 files changed, 5172 insertions(+), 476 deletions(-) create mode 100644 api/src/opentrons/config/ot3_pipette_config.py create mode 100644 api/src/opentrons/hardware_control/instruments/ot3/pipette.py create mode 100644 api/src/opentrons/hardware_control/instruments/ot3/pipette_handler.py create mode 100644 api/tests/opentrons/config/test_ot3_pipette_config.py create mode 100644 shared-data/js/__tests__/pipetteSchemaV2.test.ts rename shared-data/pipette/definitions/{ => 1}/pipetteModelSpecs.json (100%) rename shared-data/pipette/definitions/{ => 1}/pipetteNameSpecs.json (100%) create mode 100644 shared-data/pipette/definitions/2/general/eight_channel/p1000/1_0.json create mode 100644 shared-data/pipette/definitions/2/general/eight_channel/p50/1_0.json create mode 100644 shared-data/pipette/definitions/2/general/ninety_six_channel/p1000/1_0.json create mode 100644 shared-data/pipette/definitions/2/general/single_channel/p1000/1_0.json create mode 100644 shared-data/pipette/definitions/2/general/single_channel/p50/1_0.json create mode 100644 shared-data/pipette/definitions/2/geometry/eight_channel/p1000/1_0.json create mode 100644 shared-data/pipette/definitions/2/geometry/eight_channel/p1000/placeholder.gltf create mode 100644 shared-data/pipette/definitions/2/geometry/eight_channel/p50/1_0.json create mode 100644 shared-data/pipette/definitions/2/geometry/eight_channel/p50/placeholder.gltf create mode 100644 shared-data/pipette/definitions/2/geometry/ninety_six_channel/p1000/1_0.json create mode 100644 shared-data/pipette/definitions/2/geometry/ninety_six_channel/p1000/placeholder.gltf create mode 100644 shared-data/pipette/definitions/2/geometry/single_channel/p1000/1_0.json create mode 100644 shared-data/pipette/definitions/2/geometry/single_channel/p1000/placeholder.gltf create mode 100644 shared-data/pipette/definitions/2/geometry/single_channel/p50/1_0.json create mode 100644 shared-data/pipette/definitions/2/geometry/single_channel/p50/placeholder.gltf create mode 100644 shared-data/pipette/definitions/2/liquid/eight_channel/p1000/1_0.json create mode 100644 shared-data/pipette/definitions/2/liquid/eight_channel/p50/1_0.json create mode 100644 shared-data/pipette/definitions/2/liquid/ninety_six_channel/p1000/1_0.json create mode 100644 shared-data/pipette/definitions/2/liquid/single_channel/p1000/1_0.json create mode 100644 shared-data/pipette/definitions/2/liquid/single_channel/p50/1_0.json rename shared-data/pipette/schemas/{ => 1}/pipetteModelSpecsSchema.json (100%) rename shared-data/pipette/schemas/{ => 1}/pipetteNameSpecsSchema.json (100%) create mode 100644 shared-data/pipette/schemas/2/pipetteGeometrySchema.json create mode 100644 shared-data/pipette/schemas/2/pipetteLiquidPropertiesSchema.json create mode 100644 shared-data/pipette/schemas/2/pipettePropertiesSchema.json create mode 100644 shared-data/python/opentrons_shared_data/pipette/load_data.py create mode 100644 shared-data/python/opentrons_shared_data/pipette/pipette_definition.py create mode 100644 shared-data/python/tests/pipette/test_load_data.py create mode 100644 shared-data/python/tests/pipette/test_pipette_definition.py diff --git a/api/src/opentrons/config/defaults_ot3.py b/api/src/opentrons/config/defaults_ot3.py index dc286c19123..11d02ce6198 100644 --- a/api/src/opentrons/config/defaults_ot3.py +++ b/api/src/opentrons/config/defaults_ot3.py @@ -73,7 +73,8 @@ OT3AxisKind.X: 500, OT3AxisKind.Y: 500, OT3AxisKind.Z: 35, - OT3AxisKind.P: 45, + OT3AxisKind.P: 5, + OT3AxisKind.Q: 40, }, low_throughput={ OT3AxisKind.X: 500, @@ -100,7 +101,8 @@ OT3AxisKind.X: 1000, OT3AxisKind.Y: 1000, OT3AxisKind.Z: 100, - OT3AxisKind.P: 50, + OT3AxisKind.P: 10, + OT3AxisKind.Q: 10, }, low_throughput={ OT3AxisKind.X: 1000, @@ -125,13 +127,14 @@ OT3AxisKind.Y: 10, OT3AxisKind.Z: 10, OT3AxisKind.Z_G: 15, - OT3AxisKind.P: 10, + OT3AxisKind.P: 5, }, high_throughput={ OT3AxisKind.X: 10, OT3AxisKind.Y: 10, OT3AxisKind.Z: 10, OT3AxisKind.P: 10, + OT3AxisKind.Q: 10, }, low_throughput={ OT3AxisKind.X: 10, @@ -163,6 +166,7 @@ OT3AxisKind.Y: 5, OT3AxisKind.Z: 5, OT3AxisKind.P: 5, + OT3AxisKind.Q: 5, }, low_throughput={ OT3AxisKind.X: 5, @@ -190,8 +194,9 @@ high_throughput={ OT3AxisKind.X: 0.5, OT3AxisKind.Y: 0.5, - OT3AxisKind.Z: 0.1, + OT3AxisKind.Z: 0.8, OT3AxisKind.P: 0.3, + OT3AxisKind.Q: 0.3, }, low_throughput={ OT3AxisKind.X: 0.5, @@ -220,7 +225,8 @@ OT3AxisKind.X: 1.4, OT3AxisKind.Y: 1.4, OT3AxisKind.Z: 1.4, - OT3AxisKind.P: 1.0, + OT3AxisKind.P: 2.0, + OT3AxisKind.Q: 2.0, }, low_throughput={ OT3AxisKind.X: 1.4, diff --git a/api/src/opentrons/config/ot3_pipette_config.py b/api/src/opentrons/config/ot3_pipette_config.py new file mode 100644 index 00000000000..c9867274b97 --- /dev/null +++ b/api/src/opentrons/config/ot3_pipette_config.py @@ -0,0 +1,206 @@ +import re +from typing import List, Optional, Union, cast +from dataclasses import dataclass +from opentrons_shared_data.pipette import load_data +from opentrons_shared_data.pipette.dev_types import PipetteModel, PipetteName +from opentrons_shared_data.pipette.pipette_definition import ( + PipetteChannelType, + PipetteModelType, + PipetteVersionType, + PipetteGenerationType, + PipetteConfigurations, + PIPETTE_AVAILABLE_TYPES, + PIPETTE_CHANNELS_INTS, + PipetteModelMajorVersionType, + PipetteModelMinorVersionType, +) + +DEFAULT_CALIBRATION_OFFSET = [0.0, 0.0, 0.0] +DEFAULT_MODEL = PipetteModelType.p1000 +DEFAULT_CHANNELS = PipetteChannelType.SINGLE_CHANNEL +DEFAULT_MODEL_VERSION = PipetteVersionType(major=1, minor=0) + + +# TODO (lc 12-5-2022) Ideally we can deprecate this +# at somepoint once we load pipettes by channels and type +@dataclass +class PipetteNameType: + pipette_type: PipetteModelType + pipette_channels: PipetteChannelType + pipette_generation: PipetteGenerationType + + def __repr__(self) -> str: + base_name = f"{self.pipette_type.name}_{self.pipette_channels}" + if self.pipette_generation == PipetteGenerationType.GEN1: + return base_name + elif self.pipette_channels == PipetteChannelType.NINETY_SIX_CHANNEL: + return base_name + else: + return f"{base_name}_{self.pipette_generation.name.lower()}" + + +@dataclass +class PipetteModelVersionType: + pipette_type: PipetteModelType + pipette_channels: PipetteChannelType + pipette_version: PipetteVersionType + + def __repr__(self) -> str: + base_name = f"{self.pipette_type.name}_{self.pipette_channels}" + + return f"{base_name}_v{self.pipette_version}" + + +def channels_from_string(channels: str) -> PipetteChannelType: + """Convert channels from a string. + + With both `py:data:PipetteName` and `py:data:PipetteObject`, we refer to channel types + as `single`, `multi` or `96`. + + Args: + channels (str): The channel string we wish to convert. + + Returns: + PipetteChannelType: A `py:obj:PipetteChannelType` + representing the number of channels on a pipette. + + """ + if channels == "96": + return PipetteChannelType.NINETY_SIX_CHANNEL + elif channels == "multi": + return PipetteChannelType.EIGHT_CHANNEL + else: + return PipetteChannelType.SINGLE_CHANNEL + + +def version_from_string(version: str) -> PipetteVersionType: + """Convert a version string to a py:obj:PipetteVersionType. + + The version string will either be in the format of `int.int` or `vint.int`. + + Args: + version (str): The string version we wish to convert. + + Returns: + PipetteVersionType: A pipette version object. + + """ + version_list = [v for v in re.split("\\.|[v]", version) if v] + major = cast(PipetteModelMajorVersionType, int(version_list[0])) + minor = cast(PipetteModelMinorVersionType, int(version_list[1])) + return PipetteVersionType(major, minor) + + +def version_from_generation(pipette_name_list: List[str]) -> PipetteVersionType: + """Convert a string generation name to a py:obj:PipetteVersionType. + + Pipette generations are strings in the format of "gen1" or "gen2", and + usually associated withe :py:data:PipetteName. + + Args: + pipette_name_list (List[str]): A list of strings from the separated by `_` + py:data:PipetteName. + + Returns: + PipetteVersionType: A pipette version object. + + """ + if "gen3" in pipette_name_list: + return PipetteVersionType(3, 0) + elif "gen2" in pipette_name_list: + return PipetteVersionType(2, 0) + else: + return PipetteVersionType(1, 0) + + +def convert_pipette_name( + name: PipetteName, provided_version: Optional[str] = None +) -> PipetteModelVersionType: + """Convert the py:data:PipetteName to a py:obj:PipetteModelVersionType. + + `PipetteNames` are in the format of "p300_single" or "p300_single_gen1". + + Args: + name (PipetteName): The pipette name we want to convert. + + Returns: + PipetteModelVersionType: An object representing a broken out PipetteName + string. + + """ + split_pipette_name = name.split("_") + channels = channels_from_string(split_pipette_name[1]) + if provided_version: + version = version_from_string(provided_version) + else: + version = version_from_generation(split_pipette_name) + + pipette_type = PipetteModelType[split_pipette_name[0]] + + return PipetteModelVersionType(pipette_type, channels, version) + + +def convert_pipette_model( + model: Optional[PipetteModel], provided_version: Optional[str] = "" +) -> PipetteModelVersionType: + """Convert the py:data:PipetteModel to a py:obj:PipetteModelVersionType. + + `PipetteModel` are in the format of "p300_single_v1.0" or "p300_single_v3.3". + + Sometimes, models may not have a version, in which case the `provided_version` arg + allows you to specify a version to search for. + + Args: + model (PipetteModel): The pipette model we want to convert. + provided_version (str, Optional): The provided version we'd like to look for. + + Returns: + PipetteModelVersionType: An object representing a broken out PipetteName + string. + + """ + # TODO (lc 12-5-2022) This helper function is needed + # until we stop using "name" and "model" to refer + # to attached pipettes. + # We need to figure out how to default the pipette model as well + # rather than returning a p1000 + if model and not provided_version: + pipette_type, parsed_channels, parsed_version = model.split("_") + channels = channels_from_string(parsed_channels) + version = version_from_string(parsed_version) + elif model and provided_version: + pipette_type, parsed_channels = model.split("_") + channels = channels_from_string(parsed_channels) + version = version_from_string(provided_version) + else: + pipette_type = DEFAULT_MODEL.value + channels = DEFAULT_CHANNELS + version = DEFAULT_MODEL_VERSION + return PipetteModelVersionType(PipetteModelType[pipette_type], channels, version) + + +def supported_pipette(model_or_name: Union[PipetteName, PipetteModel, None]) -> bool: + """Determine if a pipette type is supported. + + Args: + model_or_name (Union[PipetteName, PipetteModel, None]): The pipette we want to check. + + Returns: + bool: Whether or not the given pipette name or model is supported. + """ + if not model_or_name: + return False + split_model_or_name = model_or_name.split("_") + channels_as_int = channels_from_string(split_model_or_name[1]).as_int + if ( + split_model_or_name[0] in PIPETTE_AVAILABLE_TYPES + or channels_as_int in PIPETTE_CHANNELS_INTS + ): + return True + return False + + +def load_ot3_pipette(model_type: PipetteModelVersionType) -> PipetteConfigurations: + return load_data.load_definition( + model_type.pipette_type, model_type.pipette_channels, model_type.pipette_version + ) diff --git a/api/src/opentrons/hardware_control/__init__.py b/api/src/opentrons/hardware_control/__init__.py index 194837d5dd2..eb0b7718690 100644 --- a/api/src/opentrons/hardware_control/__init__.py +++ b/api/src/opentrons/hardware_control/__init__.py @@ -25,7 +25,12 @@ from .execution_manager import ExecutionManager from .threaded_async_lock import ThreadedAsyncLock, ThreadedAsyncForbidden from .protocols import HardwareControlAPI -from .instruments import AbstractInstrument, Pipette, Gripper +from .instruments import AbstractInstrument, Gripper + +# TODO (lc 12-05-2022) We should 1. figure out if we need +# to globally export a class that is strictly used in the hardware controller +# and 2. how to properly export an ot2 and ot3 pipette. +from .instruments.ot2.pipette import Pipette ThreadManagedHardware = ThreadManager[HardwareControlAPI] diff --git a/api/src/opentrons/hardware_control/backends/ot3controller.py b/api/src/opentrons/hardware_control/backends/ot3controller.py index af59c6d2c0b..849493f2b0f 100644 --- a/api/src/opentrons/hardware_control/backends/ot3controller.py +++ b/api/src/opentrons/hardware_control/backends/ot3controller.py @@ -20,7 +20,7 @@ Iterator, ) from opentrons.config.types import OT3Config, GantryLoad -from opentrons.config import pipette_config, gripper_config +from opentrons.config import ot3_pipette_config, gripper_config from .ot3utils import ( axis_convert, create_move_group, @@ -34,6 +34,8 @@ create_gripper_jaw_grip_group, create_gripper_jaw_home_group, create_gripper_jaw_hold_group, + create_tip_action_group, + PipetteAction, ) try: @@ -94,14 +96,13 @@ capacitive_pass, ) from opentrons_hardware.drivers.gpio import OT3GPIO +from opentrons_shared_data.pipette.dev_types import PipetteName if TYPE_CHECKING: - from opentrons_shared_data.pipette.dev_types import PipetteName, PipetteModel from ..dev_types import ( - AttachedPipette, + OT3AttachedPipette, AttachedGripper, OT3AttachedInstruments, - InstrumentHardwareConfigs, ) log = logging.getLogger(__name__) @@ -359,6 +360,15 @@ async def home(self, axes: Sequence[OT3Axis]) -> OT3AxisMap[float]: positions = await asyncio.gather(*coros) if OT3Axis.G in checked_axes: await self.gripper_home_jaw() + if OT3Axis.Q in checked_axes: + await self.tip_action( + [OT3Axis.Q], + self.axis_bounds[OT3Axis.Q][1] - self.axis_bounds[OT3Axis.Q][0], + -1 + * self._configuration.motion_settings.default_max_speed.high_throughput[ + OT3Axis.to_kind(OT3Axis.Q) + ], + ) for position in positions: self._handle_motor_status_response(position) return axis_convert(self._position, 0.0) @@ -389,6 +399,22 @@ async def fast_home( """ return await self.home(axes) + async def tip_action( + self, + axes: Sequence[OT3Axis], + distance: float, + speed: float, + tip_action: str = "drop", + ) -> None: + move_group = create_tip_action_group( + axes, distance, speed, cast(PipetteAction, tip_action) + ) + runner = MoveGroupRunner(move_groups=[move_group]) + positions = await runner.run(can_messenger=self._messenger) + for axis, point in positions.items(): + self._position.update({axis: point[0]}) + self._encoder_position.update({axis: point[1]}) + async def gripper_grip_jaw( self, duty_cycle: float, @@ -415,21 +441,43 @@ async def gripper_home_jaw(self) -> None: self._handle_motor_status_response(positions) @staticmethod - def _synthesize_model_name(name: FirmwarePipetteName, model: str) -> "PipetteModel": - return cast("PipetteModel", f"{name.name}_v{model}") + def _lookup_serial_key(pipette_name: FirmwarePipetteName) -> str: + lookup_name = { + FirmwarePipetteName.p1000_single: "P1KS", + FirmwarePipetteName.p1000_multi: "P1KM", + FirmwarePipetteName.p50_single: "P50S", + FirmwarePipetteName.p50_multi: "P50M", + FirmwarePipetteName.p1000_96: "P1KH", + FirmwarePipetteName.p50_96: "P50H", + } + return lookup_name[pipette_name] + + @staticmethod + def _combine_serial_number(pipette_info: ohc_tool_types.PipetteInformation) -> str: + serialized_name = OT3Controller._lookup_serial_key(pipette_info.name) + version = ot3_pipette_config.version_from_string(pipette_info.model) + return f"{serialized_name}V{version.major}{version.minor}{pipette_info.serial}" @staticmethod def _build_attached_pip( attached: ohc_tool_types.PipetteInformation, mount: OT3Mount - ) -> AttachedPipette: + ) -> OT3AttachedPipette: if attached.name == FirmwarePipetteName.unknown: raise InvalidPipetteName(name=attached.name_int, mount=mount) try: + # TODO (lc 12-8-2022) We should return model as an int rather than + # a string. + # TODO (lc 12-6-2022) We should also provide the full serial number + # for PipetteInformation.serial so we don't have to use + # helper methods to convert the serial back to what was flashed + # on the eeprom. return { - "config": pipette_config.load( - OT3Controller._synthesize_model_name(attached.name, attached.model) + "config": ot3_pipette_config.load_ot3_pipette( + ot3_pipette_config.convert_pipette_name( + cast(PipetteName, attached.name.name), attached.model + ) ), - "id": attached.serial, + "id": OT3Controller._combine_serial_number(attached), } except KeyError: raise InvalidPipetteModel( @@ -444,7 +492,7 @@ def _build_attached_gripper( serial = attached.serial return { "config": gripper_config.load(model, serial), - "id": serial, + "id": f"GRPV{attached.model}{serial}", } @staticmethod @@ -593,6 +641,7 @@ def axis_bounds(self) -> OT3AxisMap[Tuple[float, float]]: OT3Axis.X: phony_bounds, OT3Axis.Y: phony_bounds, OT3Axis.Z_G: phony_bounds, + OT3Axis.Q: phony_bounds, } def single_boundary(self, boundary: int) -> OT3AxisMap[float]: @@ -672,12 +721,6 @@ async def clean_up(self) -> None: self._event_watcher.close() return None - async def configure_mount( - self, mount: OT3Mount, config: InstrumentHardwareConfigs - ) -> None: - """Configure a mount.""" - return None - @staticmethod def _get_home_position() -> Dict[NodeId, float]: return { @@ -765,9 +808,13 @@ async def probe_network(self, timeout: float = 5.0) -> None: # when that method actually does canbus stuff instrs = await self.get_attached_instruments({}) expected = {NodeId.gantry_x, NodeId.gantry_y, NodeId.head} - if instrs.get(OT3Mount.LEFT, cast("AttachedPipette", {})).get("config", None): + if instrs.get(OT3Mount.LEFT, cast("OT3AttachedPipette", {})).get( + "config", None + ): expected.add(NodeId.pipette_left) - if instrs.get(OT3Mount.RIGHT, cast("AttachedPipette", {})).get("config", None): + if instrs.get(OT3Mount.RIGHT, cast("OT3AttachedPipette", {})).get( + "config", None + ): expected.add(NodeId.pipette_right) if instrs.get(OT3Mount.GRIPPER, cast("AttachedGripper", {})).get( "config", None @@ -777,6 +824,7 @@ async def probe_network(self, timeout: float = 5.0) -> None: self._present_nodes = self._replace_gripper_node( self._replace_head_node(present) ) + log.info(f"The present nodes are now {self._present_nodes}") def _axis_is_present(self, axis: OT3Axis) -> bool: try: diff --git a/api/src/opentrons/hardware_control/backends/ot3simulator.py b/api/src/opentrons/hardware_control/backends/ot3simulator.py index 6a5f1f41523..1740c1f552e 100644 --- a/api/src/opentrons/hardware_control/backends/ot3simulator.py +++ b/api/src/opentrons/hardware_control/backends/ot3simulator.py @@ -19,8 +19,7 @@ ) from opentrons.config.types import OT3Config, GantryLoad -from opentrons.config import pipette_config, gripper_config -from opentrons_shared_data.pipette import dummy_model_for_name +from opentrons.config import ot3_pipette_config, gripper_config from .ot3utils import ( axis_convert, create_move_group, @@ -30,6 +29,8 @@ create_gripper_jaw_hold_group, create_gripper_jaw_grip_group, create_gripper_jaw_home_group, + create_tip_action_group, + PipetteAction, ) from opentrons_hardware.firmware_bindings.constants import NodeId @@ -52,13 +53,13 @@ ) from opentrons_hardware.hardware_control.motion import MoveStopCondition -from opentrons_shared_data.pipette.dev_types import PipetteName +from opentrons_shared_data.pipette.dev_types import PipetteName, PipetteModel from opentrons_shared_data.gripper.dev_types import GripperModel from opentrons.hardware_control.dev_types import ( InstrumentHardwareConfigs, PipetteSpec, GripperSpec, - AttachedPipette, + OT3AttachedPipette, AttachedGripper, OT3AttachedInstruments, ) @@ -127,17 +128,20 @@ def _sanitize_attached_instrument( gripper_spec["id"] = passed_ai.get("id") return gripper_spec + # TODO (lc 12-5-2022) need to not always pass in defaults here + # but doing it to satisfy linter errors for now. pipette_spec: PipetteSpec = {"model": None, "id": None} if not passed_ai or not passed_ai.get("model"): return pipette_spec - if passed_ai["model"] in pipette_config.config_models: - return passed_ai # type: ignore - if passed_ai["model"] in pipette_config.config_names: - pipette_spec["model"] = dummy_model_for_name( - passed_ai["model"] # type: ignore - ) + + if ot3_pipette_config.supported_pipette( + cast(PipetteModel, passed_ai["model"]) + ): + pipette_spec["model"] = cast(PipetteModel, passed_ai.get("model")) pipette_spec["id"] = passed_ai.get("id") return pipette_spec + # TODO (lc 12-05-2022) When the time comes we should properly + # support backwards compatibility raise KeyError( "If you specify attached_instruments, the model " "should be pipette names or pipette models, but " @@ -282,12 +286,21 @@ async def gripper_hold_jaw( ) -> None: _ = create_gripper_jaw_hold_group(encoder_position_um) + async def tip_action( + self, + axes: Sequence[OT3Axis], + distance: float = 33, + speed: float = -5.5, + tip_action: str = "drop", + ) -> None: + _ = create_tip_action_group( + axes, distance, speed, cast(PipetteAction, tip_action) + ) + def _attached_to_mount( self, mount: OT3Mount, expected_instr: Optional[PipetteName] ) -> OT3AttachedInstruments: - init_instr = self._attached_instruments.get( - mount, {"model": None, "id": None} # type: ignore - ) + init_instr = self._attached_instruments.get(mount, {"model": None, "id": None}) # type: ignore if mount is OT3Mount.GRIPPER: return self._attached_gripper_to_mount(cast(GripperSpec, init_instr)) return self._attached_pipette_to_mount( @@ -309,19 +322,19 @@ def _attached_pipette_to_mount( mount: OT3Mount, init_instr: PipetteSpec, expected_instr: Optional[PipetteName], - ) -> AttachedPipette: + ) -> OT3AttachedPipette: found_model = init_instr["model"] - back_compat: List["PipetteName"] = [] - if found_model: - back_compat = pipette_config.configs[found_model].get("backCompatNames", []) - if ( - expected_instr - and found_model - and ( - pipette_config.configs[found_model]["name"] != expected_instr - and expected_instr not in back_compat - ) + + # TODO (lc 12-05-2022) When the time comes, we should think about supporting + # backwards compatability -- hopefully not relying on config keys only, + # but TBD. + if expected_instr and not ot3_pipette_config.supported_pipette( + cast(PipetteModel, expected_instr) ): + raise RuntimeError( + f"mount {mount.name} requested a {expected_instr} which is not supported on the OT3" + ) + if found_model and expected_instr and (expected_instr != found_model): if self._strict_attached: raise RuntimeError( "mount {}: expected instrument {} but got {}".format( @@ -330,27 +343,29 @@ def _attached_pipette_to_mount( ) else: return { - "config": pipette_config.load(dummy_model_for_name(expected_instr)), + "config": ot3_pipette_config.load_ot3_pipette( + ot3_pipette_config.convert_pipette_name(expected_instr) + ), "id": None, } - elif found_model and expected_instr: + if found_model and expected_instr or found_model: # Instrument detected matches instrument expected (note: # "instrument detected" means passed as an argument to the # constructor of this class) + + # OR Instrument detected and no expected instrument specified return { - "config": pipette_config.load(found_model, init_instr["id"]), - "id": init_instr["id"], - } - elif found_model: - # Instrument detected and no expected instrument specified - return { - "config": pipette_config.load(found_model, init_instr["id"]), + "config": ot3_pipette_config.load_ot3_pipette( + ot3_pipette_config.convert_pipette_model(found_model) + ), "id": init_instr["id"], } elif expected_instr: # Expected instrument specified and no instrument detected return { - "config": pipette_config.load(dummy_model_for_name(expected_instr)), + "config": ot3_pipette_config.load_ot3_pipette( + ot3_pipette_config.convert_pipette_name(expected_instr) + ), "id": None, } else: diff --git a/api/src/opentrons/hardware_control/backends/ot3utils.py b/api/src/opentrons/hardware_control/backends/ot3utils.py index d2c19c54a7f..3237e8aa9ef 100644 --- a/api/src/opentrons/hardware_control/backends/ot3utils.py +++ b/api/src/opentrons/hardware_control/backends/ot3utils.py @@ -1,5 +1,6 @@ """Shared utilities for ot3 hardware control.""" -from typing import Dict, Iterable, List, Tuple, TypeVar +from typing import Dict, Iterable, List, Tuple, TypeVar, Sequence +from typing_extensions import Literal from opentrons.config.types import OT3MotionSettings, OT3CurrentSettings, GantryLoad from opentrons.hardware_control.types import ( OT3Axis, @@ -15,6 +16,7 @@ from opentrons_hardware.firmware_bindings.constants import ( NodeId, SensorId, + PipetteTipActionType, ) from opentrons_hardware.hardware_control.motion_planning import ( AxisConstraints, @@ -35,12 +37,15 @@ MoveType, MoveStopCondition, create_gripper_jaw_step, + create_tip_action_step, ) GRIPPER_JAW_HOME_TIME: float = 120 GRIPPER_JAW_GRIP_TIME: float = 1 GRIPPER_JAW_HOME_DC: float = 100 +PipetteAction = Literal["pick_up", "drop"] + # TODO: These methods exist to defer uses of NodeId to inside # method bodies, which won't be evaluated until called. This is needed # because the robot server doesn't have opentrons_ot3_firmware as a dep @@ -153,13 +158,7 @@ def get_current_settings( ) -> OT3AxisMap[CurrentConfig]: conf_by_pip = config.by_gantry_load(gantry_load) currents = {} - for axis_kind in [ - OT3AxisKind.P, - OT3AxisKind.X, - OT3AxisKind.Y, - OT3AxisKind.Z, - OT3AxisKind.Z_G, - ]: + for axis_kind in conf_by_pip["hold_current"].keys(): for axis in OT3Axis.of_kind(axis_kind): currents[axis] = CurrentConfig( conf_by_pip["hold_current"][axis_kind], @@ -243,6 +242,19 @@ def create_home_group( return move_group +def create_tip_action_group( + axes: Sequence[OT3Axis], distance: float, velocity: float, action: PipetteAction +) -> MoveGroup: + current_nodes = [axis_to_node(ax) for ax in axes] + step = create_tip_action_step( + velocity={node_id: np.float64(velocity) for node_id in current_nodes}, + distance={node_id: np.float64(distance) for node_id in current_nodes}, + present_nodes=current_nodes, + action=PipetteTipActionType[action], + ) + return [step] + + def create_gripper_jaw_grip_group( duty_cycle: float, stop_condition: MoveStopCondition = MoveStopCondition.none, diff --git a/api/src/opentrons/hardware_control/dev_types.py b/api/src/opentrons/hardware_control/dev_types.py index fe0bef25e3b..197b918c290 100644 --- a/api/src/opentrons/hardware_control/dev_types.py +++ b/api/src/opentrons/hardware_control/dev_types.py @@ -12,6 +12,7 @@ PipetteName, ChannelCount, ) +from opentrons_shared_data.pipette.pipette_definition import PipetteConfigurations from opentrons_shared_data.gripper.dev_types import ( GripperModel, GripperName, @@ -41,6 +42,11 @@ class AttachedPipette(TypedDict): id: Optional[str] +class OT3AttachedPipette(TypedDict): + config: Optional[PipetteConfigurations] + id: Optional[str] + + class AttachedGripper(TypedDict): config: Optional[GripperConfig] id: Optional[str] @@ -48,7 +54,7 @@ class AttachedGripper(TypedDict): AttachedInstruments = Dict[Mount, AttachedPipette] -OT3AttachedInstruments = Union[AttachedPipette, AttachedGripper] +OT3AttachedInstruments = Union[OT3AttachedPipette, AttachedGripper] EIGHT_CHANNELS = Literal[8] ONE_CHANNEL = Literal[1] diff --git a/api/src/opentrons/hardware_control/instruments/__init__.py b/api/src/opentrons/hardware_control/instruments/__init__.py index 0a69034cc2e..673e89f20c7 100644 --- a/api/src/opentrons/hardware_control/instruments/__init__.py +++ b/api/src/opentrons/hardware_control/instruments/__init__.py @@ -1,6 +1,5 @@ from .instrument_abc import AbstractInstrument -from .ot2.pipette import Pipette from .ot3.gripper import Gripper -__all__ = ["AbstractInstrument", "Pipette", "Gripper"] +__all__ = ["AbstractInstrument", "Gripper"] diff --git a/api/src/opentrons/hardware_control/instruments/instrument_abc.py b/api/src/opentrons/hardware_control/instruments/instrument_abc.py index 9c9a0f40796..1bd4b790fb1 100644 --- a/api/src/opentrons/hardware_control/instruments/instrument_abc.py +++ b/api/src/opentrons/hardware_control/instruments/instrument_abc.py @@ -1,12 +1,11 @@ from abc import ABC, abstractmethod from typing import Any, Optional, Generic, TypeVar -from opentrons.types import Point, Mount -from opentrons.hardware_control.types import CriticalPoint, OT3Mount +from opentrons.types import Point +from opentrons.hardware_control.types import CriticalPoint InstrumentConfig = TypeVar("InstrumentConfig") -MountType = TypeVar("MountType", Mount, OT3Mount) class AbstractInstrument(ABC, Generic[InstrumentConfig]): @@ -27,6 +26,11 @@ def config(self) -> InstrumentConfig: """Instrument config in dataclass format.""" ... + @abstractmethod + def reload_configurations(self) -> None: + """Reset the instrument to default configurations.""" + ... + @abstractmethod def update_config_item(self, elem_name: str, elem_val: Any) -> None: """Update instrument config item.""" diff --git a/api/src/opentrons/hardware_control/instruments/ot2/pipette.py b/api/src/opentrons/hardware_control/instruments/ot2/pipette.py index 57b94935e37..f14084971bc 100644 --- a/api/src/opentrons/hardware_control/instruments/ot2/pipette.py +++ b/api/src/opentrons/hardware_control/instruments/ot2/pipette.py @@ -10,17 +10,11 @@ from opentrons_shared_data.pipette import name_config as pipette_name_config -from opentrons.types import Point -from opentrons.config import pipette_config, robot_configs, feature_flags -from opentrons.config.types import RobotConfig, OT3Config +from opentrons.types import Point, Mount +from opentrons.config import pipette_config, robot_configs +from opentrons.config.types import RobotConfig from opentrons.drivers.types import MoveSplit -from opentrons.hardware_control.types import OT3Mount -from ..instrument_abc import AbstractInstrument, MountType -from ..ot3.instrument_calibration import ( - save_pipette_offset_calibration, - load_pipette_offset as ot3_pipette_offset, - PipetteOffsetByPipetteMount as OT3PipetteOffsetByPipetteMount, -) +from ..instrument_abc import AbstractInstrument from .instrument_calibration import ( PipetteOffsetByPipetteMount, load_pipette_offset, @@ -28,7 +22,6 @@ from opentrons.hardware_control.types import ( CriticalPoint, BoardRevision, - OT3AxisKind, InvalidMoveError, ) @@ -52,9 +45,6 @@ # TODO (lc 11-1-2022) We need to separate out the pipette object # into a separate category for OT2 vs OT3 pipettes. At which point # this union will be unneccessary -OffsetCalibrationType = Union[ - PipetteOffsetByPipetteMount, OT3PipetteOffsetByPipetteMount -] class Pipette(AbstractInstrument[pipette_config.PipetteConfig]): @@ -70,7 +60,7 @@ class Pipette(AbstractInstrument[pipette_config.PipetteConfig]): def __init__( self, config: pipette_config.PipetteConfig, - pipette_offset_cal: OffsetCalibrationType, + pipette_offset_cal: PipetteOffsetByPipetteMount, pipette_id: Optional[str] = None, ) -> None: self._config = config @@ -134,7 +124,7 @@ def nozzle_offset(self) -> Tuple[float, float, float]: return self._nozzle_offset @property - def pipette_offset(self) -> OffsetCalibrationType: + def pipette_offset(self) -> PipetteOffsetByPipetteMount: return self._pipette_offset def update_config_item(self, elem_name: str, elem_val: Any) -> None: @@ -143,26 +133,36 @@ def update_config_item(self, elem_name: str, elem_val: Any) -> None: # Update the cached dict representation self._config_as_dict = asdict(self._config) - def reset_pipette_offset(self, mount: MountType, to_default: bool) -> None: + def reload_configurations(self) -> None: + self._config = pipette_config.load(self.model, self.pipette_id) + self._config_as_dict = asdict(self._config) + + def reset_state(self) -> None: + self._current_volume = 0.0 + self._working_volume = self._config.max_volume + self._current_tip_length = 0.0 + self._current_tiprack_diameter = 0.0 + self._fallback_tip_length = self._config.tip_length + self._tip_overlap_map = self._config.tip_overlap + self._has_tip = False + self.ready_to_aspirate = False + #: True if ready to aspirate + self._aspirate_flow_rate = self._config.default_aspirate_flow_rates["2.0"] + self._dispense_flow_rate = self._config.default_dispense_flow_rates["2.0"] + self._blow_out_flow_rate = self._config.default_blow_out_flow_rates["2.0"] + + def reset_pipette_offset(self, mount: Mount, to_default: bool) -> None: """Reset the pipette offset to system defaults.""" if to_default: self._pipette_offset = load_pipette_offset(pip_id=None, mount=mount) else: self._pipette_offset = load_pipette_offset(self._pipette_id, mount) - def save_pipette_offset(self, mount: MountType, offset: Point) -> None: + def save_pipette_offset(self, mount: Mount, offset: Point) -> None: """Update the pipette offset to a new value.""" # TODO (lc 10-31-2022) We should have this command be supported properly by # ot-3 and ot-2 when we split out the pipette class - if feature_flags.enable_ot3_hardware_controller(): - save_pipette_offset_calibration( - self._pipette_id, OT3Mount.from_mount(mount), offset - ) - self._pipette_offset = ot3_pipette_offset( - self._pipette_id, OT3Mount.from_mount(mount) - ) - else: - self._pipette_offset = load_pipette_offset(self._pipette_id, mount) + self._pipette_offset = load_pipette_offset(self._pipette_id, mount) @property def name(self) -> PipetteName: @@ -392,7 +392,7 @@ def as_dict(self) -> "Pipette.DictType": def _reload_and_check_skip( new_config: pipette_config.PipetteConfig, attached_instr: Pipette, - pipette_offset: OffsetCalibrationType, + pipette_offset: PipetteOffsetByPipetteMount, ) -> Tuple[Pipette, bool]: # Once we have determined that the new and attached pipettes # are similar enough that we might skip, see if the configs @@ -425,7 +425,7 @@ def load_from_config_and_check_skip( attached: Optional[Pipette], requested: Optional[PipetteName], serial: Optional[str], - pipette_offset: OffsetCalibrationType, + pipette_offset: PipetteOffsetByPipetteMount, ) -> Tuple[Optional[Pipette], bool]: """ Given the pipette config for an attached pipette (if any) freshly read @@ -506,30 +506,3 @@ def generate_hardware_configs( )["B"], "splits": None, } - - -def generate_hardware_configs_ot3( - pipette: Optional[Pipette], robot_config: OT3Config, revision: BoardRevision -) -> InstrumentHardwareConfigs: - """ - Fuse robot and pipette configuration to generate commands to send to - the motor driver if required - """ - if pipette: - return { - "steps_per_mm": 0, - "home_pos": 0, - "max_travel": pipette.config.max_travel, - "idle_current": pipette.config.idle_current, - "splits": _build_splits(pipette), - } - else: - return { - "steps_per_mm": 0, - "home_pos": 0, - "max_travel": 0, - "idle_current": robot_config.current_settings.hold_current.none[ - OT3AxisKind.P - ], - "splits": None, - } diff --git a/api/src/opentrons/hardware_control/instruments/ot2/pipette_handler.py b/api/src/opentrons/hardware_control/instruments/ot2/pipette_handler.py index fa4c7cd88ed..c06a0fbb5b2 100644 --- a/api/src/opentrons/hardware_control/instruments/ot2/pipette_handler.py +++ b/api/src/opentrons/hardware_control/instruments/ot2/pipette_handler.py @@ -37,9 +37,11 @@ from opentrons.hardware_control.dev_types import PipetteDict from .pipette import Pipette -from .instrument_calibration import load_pipette_offset -from ..instrument_abc import MountType +# TODO both pipette handlers should be combined once the pipette configurations +# are unified AND we separate out the concept of changing pipette state versus static state + +MountType = TypeVar("MountType", top_types.Mount, OT3Mount) InstrumentsByMount = Dict[MountType, Optional[Pipette]] PipetteHandlingData = Tuple[Pipette, MountType] @@ -116,11 +118,15 @@ def _reset(m: MountType) -> None: p = self._attached_instruments[m] if not p: return - new_p = Pipette( - p._config, load_pipette_offset(p.pipette_id, m), p.pipette_id - ) - new_p.act_as(p.acting_as) - self._attached_instruments[m] = new_p + if isinstance(m, OT3Mount): + # This is to satisfy lint. Code will be cleaner once + # we can combine the pipette handler for OT2 and OT3 + # pipettes again. + p.reset_pipette_offset(m.to_mount(), to_default=False) + else: + p.reset_pipette_offset(m, to_default=False) + p.reload_configurations() + p.reset_state() if not mount: for m in type(list(self._attached_instruments.keys())[0]): @@ -133,8 +139,15 @@ def reset_instrument_offset(self, mount: MountType, to_default: bool) -> None: Temporarily reset the pipette offset to default values. :param mount: Modify the given mount. """ - pipette = self.get_pipette(mount) - pipette.reset_pipette_offset(mount, to_default) + if isinstance(mount, OT3Mount): + # This is to satisfy lint. Code will be cleaner once + # we can combine the pipette handler for OT2 and OT3 + # pipettes again. + pipette = self.get_pipette(mount) + pipette.reset_pipette_offset(mount.to_mount(), to_default) + else: + pipette = self.get_pipette(mount) + pipette.reset_pipette_offset(mount, to_default) def save_instrument_offset(self, mount: MountType, delta: top_types.Point) -> None: """ @@ -142,8 +155,15 @@ def save_instrument_offset(self, mount: MountType, delta: top_types.Point) -> No :param mount: Modify the given mount. :param delta: The offset to set for the pipette. """ - pipette = self.get_pipette(mount) - pipette.save_pipette_offset(mount, delta) + if isinstance(mount, OT3Mount): + # This is to satisfy lint. Code will be cleaner once + # we can combine the pipette handler for OT2 and OT3 + # pipettes again. + pipette = self.get_pipette(mount) + pipette.save_pipette_offset(mount.to_mount(), delta) + else: + pipette = self.get_pipette(mount) + pipette.save_pipette_offset(mount, delta) # TODO(mc, 2022-01-11): change returned map value type to `Optional[PipetteDict]` # instead of potentially returning an empty dict @@ -213,6 +233,8 @@ def get_attached_instrument(self, mount: MountType) -> PipetteDict: instr, instr.blow_out_flow_rate, "dispense" ) result["ready_to_aspirate"] = instr.ready_to_aspirate + # TODO (12-5-2022) figure out why this is using default aspirate flow rate + # rather than default dispense flow rate. result["default_blow_out_speeds"] = { alvl: self.plunger_speed(instr, fr, "dispense") for alvl, fr in instr.config.default_aspirate_flow_rates.items() diff --git a/api/src/opentrons/hardware_control/instruments/ot3/gripper.py b/api/src/opentrons/hardware_control/instruments/ot3/gripper.py index 73d45bc6c46..2a119f209eb 100644 --- a/api/src/opentrons/hardware_control/instruments/ot3/gripper.py +++ b/api/src/opentrons/hardware_control/instruments/ot3/gripper.py @@ -132,6 +132,9 @@ def model(self) -> GripperModel: def gripper_id(self) -> str: return self._gripper_id + def reload_configurations(self) -> None: + return None + def reset_offset(self, to_default: bool) -> None: """Tempoarily reset the gripper offsets to default values.""" if to_default: diff --git a/api/src/opentrons/hardware_control/instruments/ot3/pipette.py b/api/src/opentrons/hardware_control/instruments/ot3/pipette.py new file mode 100644 index 00000000000..b6a41f4c5e2 --- /dev/null +++ b/api/src/opentrons/hardware_control/instruments/ot3/pipette.py @@ -0,0 +1,577 @@ +import logging +import functools + +from typing import Any, List, Dict, Optional, Set, Tuple, Union, cast +from typing_extensions import Final + +from opentrons.types import Point + +from opentrons.config import ot3_pipette_config +from opentrons_shared_data.pipette.pipette_definition import ( + PipetteConfigurations, + PipetteTipType, + PlungerPositions, + MotorConfigurations, + PickUpTipConfigurations, + SupportedTipsDefinition, + TipHandlingConfigurations, + PipetteModelType, + PipetteChannelType, +) +from ..instrument_abc import AbstractInstrument +from .instrument_calibration import ( + save_pipette_offset_calibration, + load_pipette_offset, + PipetteOffsetByPipetteMount, +) +from opentrons_shared_data.pipette.dev_types import ( + UlPerMmAction, + PipetteName, + PipetteModel, +) +from opentrons.hardware_control.types import CriticalPoint, OT3Mount, InvalidMoveError + +mod_log = logging.getLogger(__name__) + +# TODO (lc 12-2-2022) We should move this to the geometry configurations +INTERNOZZLE_SPACING_MM: Final[float] = 9 + + +def piecewise_volume_conversion( + ul: float, sequence: List[Tuple[float, float, float]] +) -> float: + """ + Takes a volume in microliters and a sequence representing a piecewise + function for the slope and y-intercept of a ul/mm function, where each + sub-list in the sequence contains: + + - the max volume for the piece of the function (minimum implied from the + max of the previous item or 0 + - the slope of the segment + - the y-intercept of the segment + + :return: the ul/mm value for the specified volume + """ + # pick the first item from the seq for which the target is less than + # the bracketing element + for x in sequence: + if ul <= x[0]: + # use that element to calculate the movement distance in mm + return x[1] * ul + x[2] + + # Compatibility with previous implementation of search. + # list(filter(lambda x: ul <= x[0], sequence))[0] + raise IndexError() + + +class Pipette(AbstractInstrument[PipetteConfigurations]): + """A class to gather and track pipette state and configs. + + This class should not touch hardware or call back out to the hardware + control API. Its only purpose is to gather state. + """ + + DictType = Dict[str, Union[str, float, bool]] + #: The type of this data class as a dict + + def __init__( + self, + config: PipetteConfigurations, + pipette_offset_cal: PipetteOffsetByPipetteMount, + pipette_id: Optional[str] = None, + ) -> None: + self._config = config + self._config_as_dict = config.dict() + self._plunger_positions = config.plunger_positions_configurations + self._plunger_motor_current = config.plunger_motor_configurations + self._pick_up_configurations = config.pick_up_tip_configurations + self._drop_configurations = config.drop_tip_configurations + self._pipette_offset = pipette_offset_cal + self._pipette_type = self._config.pipette_type + self._pipette_version = self._config.version + self._max_channels = self._config.channels + + # TODO (lc 12-05-2022) figure out how we can safely deprecate "name" and "model" + self._pipette_name = ot3_pipette_config.PipetteNameType( + pipette_type=config.pipette_type, + pipette_channels=config.channels, + pipette_generation=config.display_category, + ) + self._acting_as = self._pipette_name + self._pipette_model = ot3_pipette_config.PipetteModelVersionType( + pipette_type=config.pipette_type, + pipette_channels=config.channels, + pipette_version=config.version, + ) + self._nozzle_offset = self._config.nozzle_offset + self._current_volume = 0.0 + self._working_volume = float(self._config.max_volume) + self._current_tip_length = 0.0 + self._current_tiprack_diameter = 0.0 + self._has_tip = False + self._pipette_id = pipette_id + self._log = mod_log.getChild( + self._pipette_id if self._pipette_id else "" + ) + self._log.info( + "loaded: {}, pipette offset: {}".format( + self._pipette_model, self._pipette_offset.offset + ) + ) + self.ready_to_aspirate = False + #: True if ready to aspirate + self._active_tip_settings = self._config.supported_tips[ + PipetteTipType(self._working_volume) + ] + self._fallback_tip_length = self._active_tip_settings.default_tip_length + self._aspirate_flow_rate = self._active_tip_settings.default_aspirate_flowrate + self._dispense_flow_rate = self._active_tip_settings.default_dispense_flowrate + self._blow_out_flow_rate = self._active_tip_settings.default_blowout_flowrate + + # TODO (lc 12-6-2022) When we switch over to sending pipette state, we + # we should also try to make sure the python api isn't reaching into + # Pipette interals. For now, we want to make sure the shape of + # tip overlap matches the shape of OT2 pipettes. We'll also need + # to revisit some liquid configurations for tiprack types. + self._tip_overlap = {"default": self._active_tip_settings.default_tip_overlap} + + @property + def config(self) -> PipetteConfigurations: + return self._config + + @property + def channels(self) -> PipetteChannelType: + return self._max_channels + + @property + def tip_overlap(self) -> Dict[str, float]: + return self._tip_overlap + + @property + def nozzle_offset(self) -> List[float]: + return self._nozzle_offset + + @property + def pipette_offset(self) -> PipetteOffsetByPipetteMount: + return self._pipette_offset + + @property + def plunger_positions(self) -> PlungerPositions: + return self._plunger_positions + + @property + def plunger_motor_current(self) -> MotorConfigurations: + return self._plunger_motor_current + + @property + def pick_up_configurations(self) -> PickUpTipConfigurations: + return self._pick_up_configurations + + @pick_up_configurations.setter + def pick_up_configurations(self, pick_up_configs: PickUpTipConfigurations) -> None: + self._pick_up_configurations = pick_up_configs + + @property + def drop_configurations(self) -> TipHandlingConfigurations: + return self._drop_configurations + + @property + def active_tip_settings(self) -> SupportedTipsDefinition: + return self._active_tip_settings + + def act_as(self, name: PipetteName) -> None: + """Reconfigure to act as ``name``. ``name`` must be either the + actual name of the pipette, or a name in its back-compatibility + config. + """ + raise NotImplementedError( + "Backwards compatibility is not supported at this time." + ) + + def update_config_item(self, elem_name: str, elem_val: Any) -> None: + raise NotImplementedError("Update config is not supported at this time.") + + @property + def acting_as(self) -> PipetteName: + return cast(PipetteName, f"{self._acting_as}") + + def reload_configurations(self) -> None: + self._config = ot3_pipette_config.load_ot3_pipette(self._pipette_model) + self._config_as_dict = self._config.dict() + + def reset_state(self) -> None: + self._current_volume = 0.0 + self._working_volume = float(self._config.max_volume) + self._current_tip_length = 0.0 + self._current_tiprack_diameter = 0.0 + self._has_tip = False + self.ready_to_aspirate = False + #: True if ready to aspirate + self._active_tip_settings = self._config.supported_tips[ + PipetteTipType(self._working_volume) + ] + self._fallback_tip_length = self._active_tip_settings.default_tip_length + self._aspirate_flow_rate = self._active_tip_settings.default_aspirate_flowrate + self._dispense_flow_rate = self._active_tip_settings.default_dispense_flowrate + self._blow_out_flow_rate = self._active_tip_settings.default_blowout_flowrate + + self._tip_overlap = {"default": self._active_tip_settings.default_tip_overlap} + + def reset_pipette_offset(self, mount: OT3Mount, to_default: bool) -> None: + """Reset the pipette offset to system defaults.""" + if to_default: + self._pipette_offset = load_pipette_offset(pip_id=None, mount=mount) + else: + self._pipette_offset = load_pipette_offset(self._pipette_id, mount) + + def save_pipette_offset(self, mount: OT3Mount, offset: Point) -> None: + """Update the pipette offset to a new value.""" + save_pipette_offset_calibration(self._pipette_id, mount, offset) + self._pipette_offset = load_pipette_offset(self._pipette_id, mount) + + @property + def name(self) -> PipetteName: + return cast(PipetteName, f"{self._pipette_name}") + + @property + def model(self) -> PipetteModel: + return cast(PipetteModel, f"{self._pipette_model}") + + @property + def pipette_type(self) -> PipetteModelType: + return self._pipette_type + + @property + def pipette_id(self) -> Optional[str]: + return self._pipette_id + + def critical_point(self, cp_override: Optional[CriticalPoint] = None) -> Point: + """ + The vector from the pipette's origin to its critical point. The + critical point for a pipette is the end of the nozzle if no tip is + attached, or the end of the tip if a tip is attached. + + If `cp_override` is specified and valid - so is either + :py:attr:`CriticalPoint.NOZZLE` or :py:attr:`CriticalPoint.TIP` when + we have a tip, or :py:attr:`CriticalPoint.XY_CENTER` - the specified + critical point will be used. + """ + instr = Point(*self._pipette_offset.offset) + offsets = self.nozzle_offset + # Temporary solution for the 96 channel critical point locations. + # We should instead record every channel "critical point" in + # the pipette configurations. + X_DIRECTION_VALUE = 1 + Y_DIVISION = 2 + if self.channels.value == 96: + NUM_ROWS = 12 + NUM_COLS = 8 + X_DIRECTION_VALUE = -1 + Y_DIVISION = 3 + elif self.channels.value == 8: + NUM_ROWS = 1 + NUM_COLS = 8 + else: + NUM_ROWS = 1 + NUM_COLS = 1 + + x_offset_to_right_nozzle = ( + X_DIRECTION_VALUE * INTERNOZZLE_SPACING_MM * (NUM_ROWS - 1) + ) + y_offset_to_front_nozzle = INTERNOZZLE_SPACING_MM * (NUM_COLS - 1) + + if cp_override in [ + CriticalPoint.GRIPPER_JAW_CENTER, + CriticalPoint.GRIPPER_FRONT_CALIBRATION_PIN, + CriticalPoint.GRIPPER_REAR_CALIBRATION_PIN, + ]: + raise InvalidMoveError( + f"Critical point {cp_override.name} is not valid for a pipette" + ) + + if not self.has_tip or cp_override == CriticalPoint.NOZZLE: + cp_type = CriticalPoint.NOZZLE + tip_length = 0.0 + else: + cp_type = CriticalPoint.TIP + tip_length = self.current_tip_length + if cp_override == CriticalPoint.XY_CENTER: + mod_offset_xy = [ + offsets[0] - x_offset_to_right_nozzle / 2, + offsets[1] - y_offset_to_front_nozzle / Y_DIVISION, + offsets[2], + ] + cp_type = CriticalPoint.XY_CENTER + elif cp_override == CriticalPoint.FRONT_NOZZLE: + # front left nozzle of the 96 channel and + # front nozzle of the 8 channel + mod_offset_xy = [ + offsets[0], + offsets[1] - y_offset_to_front_nozzle, + offsets[2], + ] + cp_type = CriticalPoint.FRONT_NOZZLE + else: + mod_offset_xy = list(offsets) + mod_and_tip = Point( + mod_offset_xy[0], mod_offset_xy[1], mod_offset_xy[2] - tip_length + ) + + cp = mod_and_tip + instr + + if self._log.isEnabledFor(logging.DEBUG): + info_str = "cp: {}{}: {} (from: ".format( + cp_type, " (from override)" if cp_override else "", cp + ) + info_str += "model offset: {} + instrument offset: {}".format( + mod_offset_xy, instr + ) + info_str += " - tip_length: {}".format(tip_length) + info_str += ")" + self._log.debug(info_str) + + return cp + + @property + def current_volume(self) -> float: + """The amount of liquid currently aspirated""" + return self._current_volume + + @property + def current_tip_length(self) -> float: + """The length of the current tip attached (0.0 if no tip)""" + return self._current_tip_length + + @current_tip_length.setter + def current_tip_length(self, tip_length: float) -> None: + self._current_tip_length = tip_length + + @property + def current_tiprack_diameter(self) -> float: + """The diameter of the current tip rack (0.0 if no tip)""" + return self._current_tiprack_diameter + + @current_tiprack_diameter.setter + def current_tiprack_diameter(self, diameter: float) -> None: + self._current_tiprack_diameter = diameter + + @property + def aspirate_flow_rate(self) -> float: + """Current active flow rate (not config value)""" + return self._aspirate_flow_rate + + @aspirate_flow_rate.setter + def aspirate_flow_rate(self, new_flow_rate: float) -> None: + assert new_flow_rate > 0 + self._aspirate_flow_rate = new_flow_rate + + @property + def dispense_flow_rate(self) -> float: + """Current active flow rate (not config value)""" + return self._dispense_flow_rate + + @dispense_flow_rate.setter + def dispense_flow_rate(self, new_flow_rate: float) -> None: + assert new_flow_rate > 0 + self._dispense_flow_rate = new_flow_rate + + @property + def blow_out_flow_rate(self) -> float: + """Current active flow rate (not config value)""" + return self._blow_out_flow_rate + + @blow_out_flow_rate.setter + 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 working_volume(self) -> float: + """The working volume of the pipette""" + return self._working_volume + + @working_volume.setter + def working_volume(self, tip_volume: float) -> None: + """The working volume is the current tip max volume""" + self._working_volume = min(self.config.max_volume, tip_volume) + self._active_tip_settings = self._config.supported_tips[ + PipetteTipType(int(self._working_volume)) + ] + self._fallback_tip_length = self._active_tip_settings.default_tip_length + self._tip_overlap = {"default": self._active_tip_settings.default_tip_overlap} + + @property + def available_volume(self) -> float: + """The amount of liquid possible to aspirate""" + return self.working_volume - self.current_volume + + def set_current_volume(self, new_volume: float) -> None: + assert new_volume >= 0 + assert new_volume <= self.working_volume + self._current_volume = new_volume + + def add_current_volume(self, volume_incr: float) -> None: + assert self.ok_to_add_volume(volume_incr) + self._current_volume += volume_incr + + def remove_current_volume(self, volume_incr: float) -> None: + assert self._current_volume >= volume_incr + self._current_volume -= volume_incr + + def ok_to_add_volume(self, volume_incr: float) -> bool: + return self.current_volume + volume_incr <= self.working_volume + + def add_tip(self, tip_length: float) -> None: + """ + Add a tip to the pipette for position tracking and validation + (effectively updates the pipette's critical point) + + :param tip_length: a positive, non-zero float presenting the distance + in Z from the end of the pipette nozzle to the end of the tip + :return: + """ + assert tip_length > 0.0, "tip_length must be greater than 0" + assert not self.has_tip + self._has_tip = True + self._current_tip_length = tip_length + + def remove_tip(self) -> None: + """ + Remove the tip from the pipette (effectively updates the pipette's + critical point) + """ + assert self.has_tip + self._has_tip = False + self._current_tip_length = 0.0 + + @property + def has_tip(self) -> bool: + return self._has_tip + + # Cache max is chosen somewhat arbitrarily. With a float is input we don't + # want this to unbounded. + @functools.lru_cache(maxsize=100) + def ul_per_mm( + self, ul: float, action: UlPerMmAction, specific_tip: str = "default" + ) -> float: + if action == "aspirate": + sequence = self._active_tip_settings.aspirate[specific_tip] + else: + sequence = self._active_tip_settings.dispense[specific_tip] + return piecewise_volume_conversion(ul, sequence) + + def __str__(self) -> str: + return "{} current volume {}ul critical point: {} at {}".format( + self._config.display_name, + self.current_volume, + "tip end" if self.has_tip else "nozzle end", + 0, + ) + + def __repr__(self) -> str: + return "<{}: {} {}>".format( + self.__class__.__name__, self._config.display_name, id(self) + ) + + def as_dict(self) -> "Pipette.DictType": + # TODO (lc 12-05-2022) Kill this code ASAP + self._config_as_dict.update( + { + "current_volume": self.current_volume, + "available_volume": self.available_volume, + "name": self.name, + "model": self.model, + "pipette_id": self.pipette_id, + "has_tip": self.has_tip, + "working_volume": self.working_volume, + "aspirate_flow_rate": self.aspirate_flow_rate, + "dispense_flow_rate": self.dispense_flow_rate, + "blow_out_flow_rate": self.blow_out_flow_rate, + "default_aspirate_flow_rates": self.active_tip_settings.default_aspirate_flowrate, + "default_blow_out_flow_rates": self.active_tip_settings.default_dispense_flowrate, + "default_dispense_flow_rates": self.active_tip_settings.default_blowout_flowrate, + "tip_length": self.current_tip_length, + "return_tip_height": self.active_tip_settings.default_return_tip_height, + "tip_overlap": self.tip_overlap, + } + ) + return self._config_as_dict + + +def _reload_and_check_skip( + new_config: PipetteConfigurations, + attached_instr: Pipette, + pipette_offset: PipetteOffsetByPipetteMount, +) -> Tuple[Pipette, bool]: + # Once we have determined that the new and attached pipettes + # are similar enough that we might skip, see if the configs + # match closely enough. + # Returns a pipette object and True if we may skip hw reconfig + # TODO this can potentially be removed in a follow-up refactor. + if ( + new_config == attached_instr.config + and pipette_offset == attached_instr._pipette_offset + ): + # Same config, good enough + return attached_instr, True + else: + newdict = new_config.dict() + olddict = attached_instr.config.dict() + changed: Set[str] = set() + for k in newdict.keys(): + if newdict[k] != olddict[k]: + changed.add(k) + if changed.intersection("quirks"): + # Something has changed that requires reconfig + p = Pipette(new_config, pipette_offset, attached_instr._pipette_id) + p.act_as(attached_instr.acting_as) + return p, False + # Good to skip + return attached_instr, True + + +def load_from_config_and_check_skip( + config: Optional[PipetteConfigurations], + attached: Optional[Pipette], + requested: Optional[PipetteName], + serial: Optional[str], + pipette_offset: PipetteOffsetByPipetteMount, +) -> Tuple[Optional[Pipette], bool]: + """ + Given the pipette config for an attached pipette (if any) freshly read + from disk, and any attached instruments, + + - Compare the new and configured pipette configs + - Load the new configs if they differ + - Return a bool indicating whether hardware reconfiguration may be + skipped + """ + + if not config and not attached: + # nothing attached now, nothing used to be attached, nothing + # to reconfigure + return attached, True + + if config and attached: + # something was attached and something is attached. are they + # the same? we can tell by comparing serials + if serial == attached.pipette_id: + if requested: + # if there is an explicit instrument request, in addition + # to checking if the old and new responses are the same + # we also have to make sure the old pipette is properly + # configured to the request + if requested == attached.acting_as: + # similar enough to check + return _reload_and_check_skip(config, attached, pipette_offset) + else: + # if there is no request, make sure that the old pipette + # did not have backcompat applied + if attached.acting_as == attached.name: + # similar enough to check + return _reload_and_check_skip(config, attached, pipette_offset) + + if config: + return Pipette(config, pipette_offset, serial), False + else: + return None, False diff --git a/api/src/opentrons/hardware_control/instruments/ot3/pipette_handler.py b/api/src/opentrons/hardware_control/instruments/ot3/pipette_handler.py new file mode 100644 index 00000000000..04a692fe9b6 --- /dev/null +++ b/api/src/opentrons/hardware_control/instruments/ot3/pipette_handler.py @@ -0,0 +1,832 @@ +"""Shared code for managing pipette configuration and storage.""" +from dataclasses import dataclass +import logging +from typing import ( + Callable, + Dict, + Optional, + Tuple, + Any, + cast, + List, + Sequence, + Iterator, + TypeVar, +) +from typing_extensions import Final +from opentrons_shared_data.pipette.dev_types import UlPerMmAction + +from opentrons import types as top_types +from opentrons.hardware_control.types import ( + CriticalPoint, + HardwareAction, + TipAttachedError, + NoTipAttachedError, + OT3Axis, + OT3Mount, +) +from opentrons.hardware_control.constants import ( + SHAKE_OFF_TIPS_SPEED, + SHAKE_OFF_TIPS_PICKUP_DISTANCE, + DROP_TIP_RELEASE_DISTANCE, + SHAKE_OFF_TIPS_DROP_DISTANCE, +) + +from opentrons.hardware_control.dev_types import PipetteDict +from .pipette import Pipette + +MOD_LOG = logging.getLogger(__name__) + +# TODO both pipette handlers should be combined once the pipette configurations +# are unified AND we separate out the concept of changing pipette state versus static state +HOME_POSITION: Final[float] = 230.15 + +MountType = TypeVar("MountType", top_types.Mount, OT3Mount) +InstrumentsByMount = Dict[MountType, Optional[Pipette]] +PipetteHandlingData = Tuple[Pipette, OT3Mount] + + +@dataclass(frozen=True) +class LiquidActionSpec: + axis: OT3Axis + volume: float + plunger_distance: float + speed: float + instr: Pipette + current: float + + +# TODO during refactor we should figure out if +# we still need these dataclasses + + +@dataclass(frozen=True) +class PickUpTipPressSpec: + relative_down: top_types.Point + relative_up: top_types.Point + current: Dict[OT3Axis, float] + speed: float + + +@dataclass(frozen=True) +class TipMotorPickUpTipSpec: + tiprack_down: top_types.Point + tiprack_up: top_types.Point + pick_up_distance: float + speed: float + currents: Dict[OT3Axis, float] + + +@dataclass(frozen=True) +class PickUpTipSpec: + plunger_prep_pos: float + plunger_currents: Dict[OT3Axis, float] + presses: List[PickUpTipPressSpec] + shake_off_list: List[Tuple[top_types.Point, Optional[float]]] + retract_target: float + pick_up_motor_actions: Optional[TipMotorPickUpTipSpec] + + +@dataclass(frozen=True) +class DropTipMove: + target_position: float + current: Dict[OT3Axis, float] + speed: Optional[float] + home_after: bool = False + home_after_safety_margin: float = 0 + home_axes: Sequence[OT3Axis] = tuple() + is_ht_tip_action: bool = False + + +@dataclass(frozen=True) +class DropTipSpec: + drop_moves: List[DropTipMove] + shake_moves: List[Tuple[top_types.Point, Optional[float]]] + ending_current: Dict[OT3Axis, float] + + +class PipetteHandlerProvider: + IHP_LOG = MOD_LOG.getChild("InstrumentHandler") + + def __init__(self, attached_instruments: InstrumentsByMount[OT3Mount]): + assert attached_instruments + self._attached_instruments: InstrumentsByMount[OT3Mount] = attached_instruments + self._ihp_log = PipetteHandlerProvider.IHP_LOG.getChild(str(id(self))) + + def reset_instrument(self, mount: Optional[OT3Mount] = None) -> None: + """ + Reset the internal state of a pipette by its mount, without doing + any lower level reconfiguration. This is useful to make sure that no + settings changes from a protocol persist. + + :param mount: If specified, reset that mount. If not specified, + reset both + """ + # need to have a reset function on the pipette + def _reset(m: OT3Mount) -> None: + self._ihp_log.info(f"Resetting configuration for {m}") + p = self._attached_instruments[m] + if not p: + return + p.reset_pipette_offset(OT3Mount.from_mount(m), to_default=False) + p.reload_configurations() + p.reset_state() + + if not mount: + for m in type(list(self._attached_instruments.keys())[0]): + _reset(m) + else: + _reset(mount) + + def reset_instrument_offset(self, mount: OT3Mount, to_default: bool) -> None: + """ + Temporarily reset the pipette offset to default values. + :param mount: Modify the given mount. + """ + pipette = self.get_pipette(mount) + pipette.reset_pipette_offset(mount, to_default) + + def save_instrument_offset(self, mount: OT3Mount, delta: top_types.Point) -> None: + """ + Save a new instrument offset the pipette offset to a particular value. + :param mount: Modify the given mount. + :param delta: The offset to set for the pipette. + """ + pipette = self.get_pipette(mount) + pipette.save_pipette_offset(mount, delta) + + # TODO(mc, 2022-01-11): change returned map value type to `Optional[PipetteDict]` + # instead of potentially returning an empty dict + # For compatibility purposes only right now. We should change this + # as soon as we can modify the /pipettes endpoint. + def get_attached_instruments(self) -> Dict[OT3Mount, PipetteDict]: + """Get the status dicts of the cached attached instruments. + + Also available as :py:meth:`get_attached_instruments`. + + This returns a dictified version of the + :py:class:`hardware_control.instruments.pipette.Pipette` as a dict keyed by + the :py:class:`top_types.Mount` to which the pipette is attached. + If no pipette is attached on a given mount, the mount key will + still be present but will have the value ``None``. + + Note that this is only a query of a cached value; to actively scan + for changes, use :py:meth:`cache_instruments`. This process deactivates + the motors and should be used sparingly. + """ + return { + m: self.get_attached_instrument(m) + for m in self._attached_instruments.keys() + } + + # TODO(mc, 2022-01-11): change return type to `Optional[PipetteDict]` instead + # of potentially returning an empty dict + def get_attached_instrument(self, mount: OT3Mount) -> PipetteDict: + # TODO (lc 12-05-2022) Kill this code ASAP + instr = self._attached_instruments[mount] + result: Dict[str, Any] = {} + if instr: + configs = [ + "name", + "min_volume", + "max_volume", + "channels", + "aspirate_flow_rate", + "dispense_flow_rate", + "pipette_id", + "current_volume", + "display_name", + "tip_length", + "model", + "blow_out_flow_rate", + "working_volume", + "tip_overlap", + "available_volume", + "return_tip_height", + "default_aspirate_flow_rates", + "default_blow_out_flow_rates", + "default_dispense_flow_rates", + ] + + instr_dict = instr.as_dict() + # TODO (spp, 2021-08-27): Revisit this logic. Why do we need to build + # this dict newly every time? Any why only a few items are being updated? + for key in configs: + result[key] = instr_dict[key] + result["channels"] = instr._max_channels.as_int + result["has_tip"] = instr.has_tip + result["tip_length"] = instr.current_tip_length + result["aspirate_speed"] = self.plunger_speed( + instr, instr.aspirate_flow_rate, "aspirate" + ) + result["dispense_speed"] = self.plunger_speed( + instr, instr.dispense_flow_rate, "dispense" + ) + result["blow_out_speed"] = self.plunger_speed( + instr, instr.blow_out_flow_rate, "dispense" + ) + result["ready_to_aspirate"] = instr.ready_to_aspirate + # TODO (12-5-2022) Not really sure what this is supposed to + # be for.... revisit when we separate out static configs and + # stateful configs. + result["default_blow_out_speeds"] = { + "2.0": self.plunger_speed( + instr, + instr.active_tip_settings.default_dispense_flowrate, + "dispense", + ) + } + result["default_dispense_speeds"] = { + "2.0": self.plunger_speed( + instr, + instr.active_tip_settings.default_dispense_flowrate, + "dispense", + ) + } + result["default_aspirate_speeds"] = { + "2.0": self.plunger_speed( + instr, + instr._active_tip_settings.default_aspirate_flowrate, + "aspirate", + ) + } + return cast(PipetteDict, result) + + @property + def attached_instruments(self) -> Dict[OT3Mount, PipetteDict]: + return self.get_attached_instruments() + + @property + def hardware_instruments(self) -> InstrumentsByMount[OT3Mount]: + """Do not write new code that uses this.""" + return self._attached_instruments + + def set_current_tiprack_diameter( + self, mount: OT3Mount, tiprack_diameter: float + ) -> None: + instr = self.get_pipette(mount) + self._ihp_log.info( + "Updating tip rack diameter on pipette mount: " + f"{mount}, tip diameter: {tiprack_diameter} mm" + ) + instr.current_tiprack_diameter = tiprack_diameter + + def set_working_volume(self, mount: OT3Mount, tip_volume: int) -> None: + instr = self.get_pipette(mount) + if not instr: + raise top_types.PipetteNotAttachedError( + "No pipette attached to {} mount".format(mount.name) + ) + self._ihp_log.info( + "Updating working volume on pipette mount:" + f"{mount}, tip volume: {tip_volume} ul" + ) + instr.working_volume = tip_volume + + def calibrate_plunger( + self, + mount: OT3Mount, + top: Optional[float] = None, + bottom: Optional[float] = None, + blow_out: Optional[float] = None, + drop_tip: Optional[float] = None, + ) -> None: + """ + Set calibration values for the pipette plunger. + This can be called multiple times as the user sets each value, + or you can set them all at once. + :param top: Touching but not engaging the plunger. + :param bottom: Must be above the pipette's physical hard-stop, while + still leaving enough room for 'blow_out' + :param blow_out: Plunger is pushed down enough to expel all liquids. + :param drop_tip: Position that causes the tip to be released from the + pipette + """ + instr = self.get_pipette(mount) + pos_dict: Dict[str, float] = { + "top": instr.plunger_positions.top, + "bottom": instr.plunger_positions.bottom, + "blow_out": instr.plunger_positions.blow_out, + "drop_tip": instr.plunger_positions.drop_tip, + } + if top is not None: + pos_dict["top"] = top + if bottom is not None: + pos_dict["bottom"] = bottom + if blow_out is not None: + pos_dict["blow_out"] = blow_out + if drop_tip is not None: + pos_dict["drop_tip"] = drop_tip + for key in pos_dict.keys(): + instr.update_config_item(key, pos_dict[key]) + + def set_flow_rate( + self, + mount: OT3Mount, + aspirate: Optional[float] = None, + dispense: Optional[float] = None, + blow_out: Optional[float] = None, + ) -> None: + this_pipette = self.get_pipette(mount) + if aspirate: + this_pipette.aspirate_flow_rate = aspirate + if dispense: + this_pipette.dispense_flow_rate = dispense + if blow_out: + this_pipette.blow_out_flow_rate = blow_out + + def set_pipette_speed( + self, + mount: OT3Mount, + aspirate: Optional[float] = None, + dispense: Optional[float] = None, + blow_out: Optional[float] = None, + ) -> None: + this_pipette = self.get_pipette(mount) + if aspirate: + this_pipette.aspirate_flow_rate = self.plunger_flowrate( + this_pipette, aspirate, "aspirate" + ) + if dispense: + this_pipette.dispense_flow_rate = self.plunger_flowrate( + this_pipette, dispense, "dispense" + ) + if blow_out: + this_pipette.blow_out_flow_rate = self.plunger_flowrate( + this_pipette, blow_out, "dispense" + ) + + def instrument_max_height( + self, + mount: OT3Mount, + retract_distance: float, + critical_point: Optional[CriticalPoint], + ) -> float: + """Return max achievable height of the attached instrument + based on the current critical point + """ + cp = self.critical_point_for(mount, critical_point) + + max_height = HOME_POSITION - retract_distance + cp.z + + return max_height + + async def reset(self) -> None: + self._attached_instruments = { + k: None for k in self._attached_instruments.keys() + } + + async def add_tip(self, mount: OT3Mount, tip_length: float) -> None: + instr = self._attached_instruments[mount] + attached = self.attached_instruments + instr_dict = attached[mount] + if instr and not instr.has_tip: + instr.add_tip(tip_length=tip_length) + # TODO (spp, 2021-08-27): These items are being updated in a local copy + # of the PipetteDict, which gets thrown away. Fix this. + instr_dict["has_tip"] = True + instr_dict["tip_length"] = tip_length + else: + self._ihp_log.warning( + "attach tip called while tip already attached to {instr}" + ) + + async def remove_tip(self, mount: OT3Mount) -> None: + instr = self._attached_instruments[mount] + attached = self.attached_instruments + instr_dict = attached[mount] + if instr and instr.has_tip: + instr.remove_tip() + # TODO (spp, 2021-08-27): These items are being updated in a local copy + # of the PipetteDict, which gets thrown away. Fix this. + instr_dict["has_tip"] = False + instr_dict["tip_length"] = 0.0 + else: + self._ihp_log.warning("detach tip called with no tip") + + def critical_point_for( + self, mount: OT3Mount, cp_override: Optional[CriticalPoint] = None + ) -> top_types.Point: + """Return the current critical point of the specified mount. + + The mount's critical point is the position of the mount itself, if no + pipette is attached, or the pipette's critical point (which depends on + tip status). + + If `cp_override` is specified, and that critical point actually exists, + it will be used instead. Invalid `cp_override`s are ignored. + """ + pip = self._attached_instruments[mount] + if pip is not None and cp_override != CriticalPoint.MOUNT: + return pip.critical_point(cp_override) + else: + # This offset is required because the motor driver coordinate system is + # configured such that the end of a p300 single gen1's tip is 0. + return top_types.Point(0, 0, 30) + + def ready_for_tip_action(self, target: Pipette, action: HardwareAction) -> None: + if not target.has_tip: + raise NoTipAttachedError(f"Cannot perform {action} without a tip attached") + if ( + action == HardwareAction.ASPIRATE + and target.current_volume == 0 + and not target.ready_to_aspirate + ): + raise RuntimeError("Pipette not ready to aspirate") + self._ihp_log.debug(f"{action} on {target.name}") + + def plunger_position( + self, instr: Pipette, ul: float, action: "UlPerMmAction" + ) -> float: + mm = ul / instr.ul_per_mm(ul, action) + position = mm + instr.plunger_positions.bottom + return round(position, 6) + + def plunger_speed( + self, instr: Pipette, ul_per_s: float, action: "UlPerMmAction" + ) -> float: + mm_per_s = ul_per_s / instr.ul_per_mm(instr.config.max_volume, action) + return round(mm_per_s, 6) + + def plunger_flowrate( + self, instr: Pipette, mm_per_s: float, action: "UlPerMmAction" + ) -> float: + ul_per_s = mm_per_s * instr.ul_per_mm(instr.config.max_volume, action) + return round(ul_per_s, 6) + + def plan_check_aspirate( + self, + mount: OT3Mount, + volume: Optional[float], + rate: float, + ) -> Optional[LiquidActionSpec]: + """Check preconditions for aspirate, parse args, and calculate positions. + + While the mechanics of issuing an aspirate move itself are left to child + classes, determining things like aspiration volume from the allowed argument + types is invariant between machines, and this method gathers that functionality. + + Coalesce + - Optional volumes + + Check + - Aspiration volumes compared to max and remaining + + Calculate + - Plunger distances (possibly calling an overridden plunger_volume) + """ + instrument = self.get_pipette(mount) + self.ready_for_tip_action(instrument, HardwareAction.ASPIRATE) + if volume is None: + self._ihp_log.debug( + "No aspirate volume defined. Aspirating up to " + "max_volume for the pipette" + ) + asp_vol = instrument.available_volume + else: + asp_vol = volume + + if asp_vol == 0: + return None + + assert instrument.ok_to_add_volume( + asp_vol + ), "Cannot aspirate more than pipette max volume" + + dist = self.plunger_position( + instrument, instrument.current_volume + asp_vol, "aspirate" + ) + speed = self.plunger_speed( + instrument, instrument.aspirate_flow_rate * rate, "aspirate" + ) + + return LiquidActionSpec( + axis=OT3Axis.of_main_tool_actuator(mount), + volume=asp_vol, + plunger_distance=dist, + speed=speed, + instr=instrument, + current=instrument.plunger_motor_current.run, + ) + + def plan_check_dispense( + self, + mount: OT3Mount, + volume: Optional[float], + rate: float, + ) -> Optional[LiquidActionSpec]: + """Check preconditions for dispense, parse args, and calculate positions. + + While the mechanics of issuing a dispense move itself are left to child + classes, determining things like dispense volume from the allowed argument + types is invariant between machines, and this method gathers that functionality. + + Coalesce + - Optional volumes + + Check + - Dispense volumes compared to max and remaining + + Calculate + - Plunger distances (possibly calling an overridden plunger_volume) + """ + + instrument = self.get_pipette(mount) + self.ready_for_tip_action(instrument, HardwareAction.DISPENSE) + + if volume is None: + disp_vol = instrument.current_volume + self._ihp_log.debug( + "No dispense volume specified. Dispensing all " + "remaining liquid ({}uL) from pipette".format(disp_vol) + ) + else: + disp_vol = volume + + # Ensure we don't dispense more than the current volume + disp_vol = min(instrument.current_volume, disp_vol) + + if disp_vol == 0: + return None + + dist = self.plunger_position( + instrument, instrument.current_volume - disp_vol, "dispense" + ) + speed = self.plunger_speed( + instrument, instrument.dispense_flow_rate * rate, "dispense" + ) + return LiquidActionSpec( + axis=OT3Axis.of_main_tool_actuator(mount), + volume=disp_vol, + plunger_distance=dist, + speed=speed, + instr=instrument, + current=instrument.plunger_motor_current.run, + ) + + def plan_check_blow_out(self, mount: OT3Mount) -> LiquidActionSpec: + """Check preconditions and calculate values for blowout.""" + instrument = self.get_pipette(mount) + self.ready_for_tip_action(instrument, HardwareAction.BLOWOUT) + speed = self.plunger_speed( + instrument, instrument.blow_out_flow_rate, "dispense" + ) + + return LiquidActionSpec( + axis=OT3Axis.of_main_tool_actuator(mount), + volume=0, + plunger_distance=instrument.plunger_positions.blow_out, + speed=speed, + instr=instrument, + current=instrument.plunger_motor_current.run, + ) + + @staticmethod + def _build_pickup_shakes( + instrument: Pipette, + ) -> List[Tuple[top_types.Point, Optional[float]]]: + def build_one_shake() -> List[Tuple[top_types.Point, Optional[float]]]: + shake_dist = float(SHAKE_OFF_TIPS_PICKUP_DISTANCE) + shake_speed = float(SHAKE_OFF_TIPS_SPEED) + return [ + (top_types.Point(-shake_dist, 0, 0), shake_speed), # left + (top_types.Point(2 * shake_dist, 0, 0), shake_speed), # right + (top_types.Point(-shake_dist, 0, 0), shake_speed), # center + (top_types.Point(0, -shake_dist, 0), shake_speed), # front + (top_types.Point(0, 2 * shake_dist, 0), shake_speed), # back + (top_types.Point(0, -shake_dist, 0), shake_speed), # center + (top_types.Point(0, 0, DROP_TIP_RELEASE_DISTANCE), None), # up + ] + + return [] + + def plan_check_pick_up_tip( + self, + mount: OT3Mount, + tip_length: float, + presses: Optional[int], + increment: Optional[float], + ) -> Tuple[PickUpTipSpec, Callable[[], None]]: + + # Prechecks: ready for pickup tip and press/increment are valid + instrument = self.get_pipette(mount) + if instrument.has_tip: + raise TipAttachedError("Cannot pick up tip with a tip attached") + self._ihp_log.debug(f"Picking up tip on {mount.name}") + + def add_tip_to_instr() -> None: + instrument.add_tip(tip_length=tip_length) + instrument.set_current_volume(0) + + if presses is None or presses < 0: + checked_presses = instrument.pick_up_configurations.presses + else: + checked_presses = presses + + if not increment or increment < 0: + check_incr = instrument.pick_up_configurations.increment + else: + check_incr = increment + + pick_up_speed = instrument.pick_up_configurations.speed + + def build_presses() -> Iterator[Tuple[float, float]]: + # Press the nozzle into the tip number of times, + # moving further by mm after each press + for i in range(checked_presses): + # move nozzle down into the tip + press_dist = ( + -1.0 * instrument.pick_up_configurations.distance + + -1.0 * check_incr * i + ) + # move nozzle back up + backup_dist = -press_dist + yield (press_dist, backup_dist) + + if instrument.channels.value == 96: + return ( + PickUpTipSpec( + plunger_prep_pos=instrument.plunger_positions.bottom, + plunger_currents={ + OT3Axis.of_main_tool_actuator( + mount + ): instrument.plunger_motor_current.run, + }, + presses=[], + shake_off_list=[], + retract_target=instrument.pick_up_configurations.distance, + pick_up_motor_actions=TipMotorPickUpTipSpec( + # Move onto the posts + tiprack_down=top_types.Point(0, 0, -5), + tiprack_up=top_types.Point(0, 0, 7), + pick_up_distance=instrument.pick_up_configurations.distance, + speed=instrument.pick_up_configurations.speed, + currents={OT3Axis.Q: instrument.pick_up_configurations.current}, + ), + ), + add_tip_to_instr, + ) + return ( + PickUpTipSpec( + plunger_prep_pos=instrument.plunger_positions.bottom, + plunger_currents={ + OT3Axis.of_main_tool_actuator( + mount + ): instrument.plunger_motor_current.run + }, + presses=[ + PickUpTipPressSpec( + current={ + OT3Axis.by_mount( + mount + ): instrument.pick_up_configurations.current + }, + speed=pick_up_speed, + relative_down=top_types.Point(0, 0, press_dist), + relative_up=top_types.Point(0, 0, backup_dist), + ) + for press_dist, backup_dist in build_presses() + ], + shake_off_list=self._build_pickup_shakes(instrument), + retract_target=instrument.pick_up_configurations.distance + + check_incr * checked_presses + + 2, + pick_up_motor_actions=None, + ), + add_tip_to_instr, + ) + + @staticmethod + def _shake_off_tips_drop( + tiprack_diameter: float, + ) -> List[Tuple[top_types.Point, Optional[float]]]: + # tips don't always fall off, especially if resting against + # tiprack or other tips below it. To ensure the tip has fallen + # first, shake the pipette to dislodge partially-sealed tips, + # then second, raise the pipette so loosened tips have room to fall + shake_off_dist = SHAKE_OFF_TIPS_DROP_DISTANCE + if tiprack_diameter > 0.0: + shake_off_dist = min(shake_off_dist, tiprack_diameter / 4) + shake_off_dist = max(shake_off_dist, 1.0) + speed = SHAKE_OFF_TIPS_SPEED + return [ + (top_types.Point(-shake_off_dist, 0, 0), speed), # left + (top_types.Point(2 * shake_off_dist, 0, 0), speed), # right + (top_types.Point(-shake_off_dist, 0, 0), speed), # center + (top_types.Point(0, 0, DROP_TIP_RELEASE_DISTANCE), None), # top + ] + + def _droptip_sequence_builder( + self, + bottom_pos: float, + droptip_pos: float, + plunger_currents: Dict[OT3Axis, float], + drop_tip_currents: Dict[OT3Axis, float], + speed: float, + home_after: bool, + home_axes: Sequence[OT3Axis], + is_ht_pipette: bool = False, + ) -> Callable[[], List[DropTipMove]]: + def build() -> List[DropTipMove]: + base = [ + DropTipMove( + target_position=bottom_pos, current=plunger_currents, speed=None + ), + DropTipMove( + target_position=droptip_pos, + current=drop_tip_currents, + speed=speed, + home_after=home_after, + home_after_safety_margin=abs(bottom_pos - droptip_pos), + home_axes=home_axes, + is_ht_tip_action=is_ht_pipette, + ), + DropTipMove( # always finish drop-tip at a known safe plunger position + target_position=bottom_pos, current=plunger_currents, speed=None + ), + ] + return base + + return build + + def plan_check_drop_tip( + self, + mount: OT3Mount, + home_after: bool, + ) -> Tuple[DropTipSpec, Callable[[], None]]: + instrument = self.get_pipette(mount) + self.ready_for_tip_action(instrument, HardwareAction.DROPTIP) + + bottom = instrument.plunger_positions.bottom + droptip = instrument.plunger_positions.drop_tip + speed = instrument.drop_configurations.speed + shakes: List[Tuple[top_types.Point, Optional[float]]] = [] + + def _remove_tips() -> None: + instrument.set_current_volume(0) + instrument.current_tiprack_diameter = 0.0 + instrument.remove_tip() + + seq_builder_ot3 = self._droptip_sequence_builder( + bottom, + droptip, + { + OT3Axis.of_main_tool_actuator( + mount + ): instrument.plunger_motor_current.run + }, + { + OT3Axis.of_main_tool_actuator( + mount + ): instrument.drop_configurations.current + }, + speed, + home_after, + (OT3Axis.of_main_tool_actuator(mount),), + instrument.channels.value == 96, + ) + + seq_ot3 = seq_builder_ot3() + return ( + DropTipSpec( + drop_moves=seq_ot3, + shake_moves=shakes, + ending_current={ + OT3Axis.of_main_tool_actuator( + mount + ): instrument.plunger_motor_current.run + }, + ), + _remove_tips, + ) + + def has_pipette(self, mount: OT3Mount) -> bool: + return bool(self._attached_instruments[mount]) + + def get_pipette(self, mount: OT3Mount) -> Pipette: + pip = self._attached_instruments[mount] + if not pip: + raise top_types.PipetteNotAttachedError( + f"No pipette attached to {mount.name} mount" + ) + return pip + + +class OT3PipetteHandler(PipetteHandlerProvider): + """Override for correct plunger_position.""" + + def plunger_position( + self, instr: Pipette, ul: float, action: "UlPerMmAction" + ) -> float: + mm = ul / instr.ul_per_mm(ul, action) + position = instr.plunger_positions.bottom - mm + return round(position, 6) + + def critical_point_for( + self, mount: OT3Mount, cp_override: Optional[CriticalPoint] = None + ) -> top_types.Point: + pip = self._attached_instruments[OT3Mount.from_mount(mount)] + if pip is not None and cp_override != CriticalPoint.MOUNT: + return pip.critical_point(cp_override) + else: + return top_types.Point(0, 0, 0) diff --git a/api/src/opentrons/hardware_control/ot3api.py b/api/src/opentrons/hardware_control/ot3api.py index 138ae7cb8da..83c16f32c00 100644 --- a/api/src/opentrons/hardware_control/ot3api.py +++ b/api/src/opentrons/hardware_control/ot3api.py @@ -5,7 +5,6 @@ import logging from collections import OrderedDict from typing import ( - Mapping, cast, Callable, Dict, @@ -18,14 +17,13 @@ TypeVar, ) -from opentrons_shared_data.pipette import name_config from opentrons_shared_data.pipette.dev_types import ( PipetteName, ) from opentrons_shared_data.gripper.constants import IDLE_STATE_GRIP_FORCE from opentrons import types as top_types -from opentrons.config import robot_configs +from opentrons.config import robot_configs, ot3_pipette_config from opentrons.config.types import ( RobotConfig, OT3Config, @@ -42,10 +40,7 @@ from .util import use_or_initialize_loop, check_motion_bounds -# TODO (lc 09-23-2022) Pull in correct OT3 instrument once -# instrument model re-working is complete. -from .instruments.ot2.pipette import ( - generate_hardware_configs_ot3, +from .instruments.ot3.pipette import ( load_from_config_and_check_skip, ) from .instruments.ot3.gripper import compare_gripper_config_and_check_skip @@ -87,7 +82,12 @@ # TODO (lc 09/15/2022) We should update our pipette handler to reflect OT-3 properties # in a follow-up PR. -from .instruments.ot2.pipette_handler import OT3PipetteHandler, InstrumentsByMount +from .instruments.ot3.pipette_handler import ( + OT3PipetteHandler, + InstrumentsByMount, + PickUpTipSpec, + TipMotorPickUpTipSpec, +) from .instruments.ot3.instrument_calibration import load_pipette_offset from .instruments.ot3.gripper_handler import GripperHandler from .instruments.ot3.instrument_calibration import ( @@ -106,7 +106,7 @@ from .dev_types import ( AttachedGripper, - AttachedPipette, + OT3AttachedPipette, PipetteDict, InstrumentDict, GripperDict, @@ -404,25 +404,23 @@ async def update_firmware( """Update the firmware on the hardware.""" await self._backend.update_firmware(firmware_file, target) - @staticmethod - def _gantry_load_from_instruments( - instruments: Mapping[OT3Mount, Optional[InstrumentDict]] - ) -> GantryLoad: + def _gantry_load_from_instruments(self) -> GantryLoad: """Compute the gantry load based on attached instruments.""" - left = cast(PipetteDict, instruments.get(OT3Mount.LEFT)) - right = cast(PipetteDict, instruments.get(OT3Mount.RIGHT)) - gripper = cast(GripperDict, instruments.get(OT3Mount.GRIPPER)) + left = self._pipette_handler.has_pipette(OT3Mount.LEFT) + right = self._pipette_handler.has_pipette(OT3Mount.RIGHT) + gripper = self._gripper_handler.has_gripper() if left and right: # Only low-throughputs can have the two-instrument case return GantryLoad.TWO_LOW_THROUGHPUT - if left: - # only a low-throughput pipette can be on the left mount - return GantryLoad.LOW_THROUGHPUT if right: + # only a low-throughput pipette can be on the right mount + return GantryLoad.LOW_THROUGHPUT + if left: # as good a measure as any to define low vs high throughput, though # we'll want to touch this up as we get pipette definitions for HT # pipettes - if right["channels"] <= 8: + left_hw_pipette = self._pipette_handler.get_pipette(OT3Mount.LEFT) + if left_hw_pipette.config.channels.as_int <= 8: return GantryLoad.LOW_THROUGHPUT else: return GantryLoad.HIGH_THROUGHPUT @@ -434,14 +432,14 @@ def _gantry_load_from_instruments( async def cache_pipette( self, mount: OT3Mount, - instrument_data: AttachedPipette, + instrument_data: OT3AttachedPipette, req_instr: Optional[PipetteName], ) -> None: """Set up pipette based on scanned information.""" config = instrument_data.get("config") pip_id = instrument_data.get("id") pip_offset_cal = load_pipette_offset(pip_id, mount) - p, may_skip = load_from_config_and_check_skip( + p, _ = load_from_config_and_check_skip( config, self._pipette_handler.hardware_instruments[mount], req_instr, @@ -449,16 +447,8 @@ async def cache_pipette( pip_offset_cal, ) self._pipette_handler.hardware_instruments[mount] = p - if req_instr and p: - p.act_as(req_instr) - if not may_skip: - self._log.info(f"Doing full configuration on {mount.name}") - hw_config = generate_hardware_configs_ot3( - p, self._config, self._backend.board_revision - ) - await self._backend.configure_mount(mount, hw_config) - else: - self._log.info(f"Skipping configuration on {mount.name}") + # TODO (lc 12-5-2022) Properly support backwards compatibility + # when applicable async def cache_gripper(self, instrument_data: AttachedGripper) -> None: """Set up gripper based on scanned information.""" @@ -489,25 +479,28 @@ async def cache_instruments( OT3Mount.from_mount(m): v for m, v in (require or {}).items() } for mount, name in checked_require.items(): - if name not in name_config(): + # TODO (lc 12-5-2022) cache instruments should be receiving + # a pipette type / channels rather than the named config. + # We should also check version here once we're comfortable. + if not ot3_pipette_config.supported_pipette(name): raise RuntimeError(f"{name} is not a valid pipette name") async with self._motion_lock: + # we're not actually checking the required instrument except in the context + # of simulation and it feels like a lot of work for this function + # actually be doing. found = await self._backend.get_attached_instruments(checked_require) - for mount, instrument_data in found.items(): if mount == OT3Mount.GRIPPER: await self.cache_gripper(cast(AttachedGripper, instrument_data)) else: req_instr_name = checked_require.get(mount, None) await self.cache_pipette( - mount, cast(AttachedPipette, instrument_data), req_instr_name + mount, cast(OT3AttachedPipette, instrument_data), req_instr_name ) await self._backend.probe_network() await self._backend.update_motor_status() - await self.set_gantry_load( - self._gantry_load_from_instruments(self.get_all_attached_instr()) - ) + await self.set_gantry_load(self._gantry_load_from_instruments()) # Global actions API def pause(self, pause_type: PauseType) -> None: @@ -610,8 +603,9 @@ async def home_plunger(self, mount: Union[top_types.Mount, OT3Mount]) -> None: instr = self._pipette_handler.hardware_instruments[checked_mount] if instr: target_pos = target_position_from_plunger( - checked_mount, instr.config.bottom, self._current_position + checked_mount, instr.plunger_positions.bottom, self._current_position ) + self._log.info("Attempting to move the plunger to bottom.") await self._move(target_pos, acquire_lock=False, home_flagged_axes=False) await self.current_position_ot3(mount=checked_mount, refresh=True) @@ -957,7 +951,10 @@ async def home( if axes: checked_axes = [OT3Axis.from_axis(ax) for ax in axes] else: - checked_axes = [ax for ax in OT3Axis] + checked_axes = [ax for ax in OT3Axis if ax != OT3Axis.Q] + if self.gantry_load == GantryLoad.HIGH_THROUGHPUT: + checked_axes.append(OT3Axis.Q) + self._log.info(f"Homing {axes}") async with self._motion_lock: try: await self._backend.home(checked_axes) @@ -1173,7 +1170,7 @@ async def prepare_for_aspirate( speed = self._pipette_handler.plunger_speed( instrument, instrument.blow_out_flow_rate, "aspirate" ) - bottom = instrument.config.bottom + bottom = instrument.plunger_positions.bottom target_pos = target_position_from_plunger( OT3Mount.from_mount(mount), bottom, self._current_position ) @@ -1286,6 +1283,52 @@ async def blow_out(self, mount: Union[top_types.Mount, OT3Mount]) -> None: blowout_spec.instr.set_current_volume(0) blowout_spec.instr.ready_to_aspirate = False + async def _force_pick_up_tip( + self, mount: OT3Mount, pipette_spec: PickUpTipSpec + ) -> None: + for press in pipette_spec.presses: + async with self._backend.restore_current(): + await self._backend.set_active_current( + {axis: current for axis, current in press.current.items()} + ) + target_down = target_position_from_relative( + mount, press.relative_down, self._current_position + ) + await self._move(target_down, speed=press.speed) + target_up = target_position_from_relative( + mount, press.relative_up, self._current_position + ) + await self._move(target_up) + + async def _motor_pick_up_tip( + self, mount: OT3Mount, pipette_spec: TipMotorPickUpTipSpec + ) -> None: + async with self._backend.restore_current(): + await self._backend.set_active_current( + {axis: current for axis, current in pipette_spec.currents.items()} + ) + # Move to pick up position + target_down = target_position_from_relative( + mount, + pipette_spec.tiprack_down, + self._current_position, + ) + await self._move(target_down) + # perform pick up tip + await self._backend.tip_action( + [OT3Axis.of_main_tool_actuator(mount)], + pipette_spec.pick_up_distance, + pipette_spec.speed, + "pick_up", + ) + # Move to pick up position + target_up = target_position_from_relative( + mount, + pipette_spec.tiprack_up, + self._current_position, + ) + await self._move(target_up) + async def pick_up_tip( self, mount: Union[top_types.Mount, OT3Mount], @@ -1299,32 +1342,23 @@ async def pick_up_tip( spec, _add_tip_to_instrs = self._pipette_handler.plan_check_pick_up_tip( realmount, tip_length, presses, increment ) - await self._backend.set_active_current( - {axis: current for axis, current in spec.plunger_currents.items()} - ) + target_absolute = target_position_from_plunger( realmount, spec.plunger_prep_pos, self._current_position ) - await self._move( - target_absolute, - home_flagged_axes=False, - ) - - for press in spec.presses: - async with self._backend.restore_current(): - await self._backend.set_active_current( - {axis: current for axis, current in press.current.items()} - ) - target_down = target_position_from_relative( - realmount, press.relative_down, self._current_position - ) - await self._move(target_down, speed=press.speed) - target_up = target_position_from_relative( - realmount, press.relative_up, self._current_position + async with self._backend.restore_current(): + await self._backend.set_active_current( + {axis: current for axis, current in spec.plunger_currents.items()} + ) + await self._move( + target_absolute, + home_flagged_axes=False, ) - await self._move(target_up) - _add_tip_to_instrs() + if spec.pick_up_motor_actions: + await self._motor_pick_up_tip(realmount, spec.pick_up_motor_actions) + else: + await self._force_pick_up_tip(realmount, spec) # neighboring tips tend to get stuck in the space between # the volume chamber and the drop-tip sleeve on p1000. @@ -1334,6 +1368,9 @@ async def pick_up_tip( # Here we add in the debounce distance for the switch as # a safety precaution await self.retract(realmount, spec.retract_target) + + _add_tip_to_instrs() + if prep_after: await self.prepare_for_aspirate(realmount) @@ -1370,14 +1407,25 @@ async def drop_tip( for axis, current in move.current.items() } ) - target_pos = target_position_from_plunger( - realmount, move.target_position, self._current_position - ) - await self._move( - target_pos, - speed=move.speed, - home_flagged_axes=False, - ) + + if move.is_ht_tip_action and move.speed: + # The speed check is needed because speed can sometimes be None. + # Not sure why + await self._backend.tip_action( + [OT3Axis.of_main_tool_actuator(mount)], + move.target_position, + move.speed, + "drop", + ) + else: + target_pos = target_position_from_plunger( + realmount, move.target_position, self._current_position + ) + await self._move( + target_pos, + speed=move.speed, + home_flagged_axes=False, + ) if move.home_after: machine_pos = await self._backend.fast_home( [OT3Axis.from_axis(ax) for ax in move.home_axes], @@ -1418,7 +1466,8 @@ def critical_point_for( @property def hardware_pipettes(self) -> InstrumentsByMount[top_types.Mount]: - # override required for type matching + # TODO (lc 12-5-2022) We should have ONE entry point into knowing + # what pipettes are attached from the hardware controller. return { m.to_mount(): i for m, i in self._pipette_handler.hardware_instruments.items() @@ -1426,7 +1475,9 @@ def hardware_pipettes(self) -> InstrumentsByMount[top_types.Mount]: } @property - def hardware_instruments(self) -> InstrumentsByMount[top_types.Mount]: + def hardware_instruments(self) -> InstrumentsByMount[top_types.Mount]: # type: ignore + # see comment in `protocols.instrument_configurer` + # override required for type matching # Warning: don't use this in new code, used `hardware_pipettes` instead return self.hardware_pipettes diff --git a/api/src/opentrons/hardware_control/protocols/instrument_configurer.py b/api/src/opentrons/hardware_control/protocols/instrument_configurer.py index 04180d202b0..2fc5d9dfa45 100644 --- a/api/src/opentrons/hardware_control/protocols/instrument_configurer.py +++ b/api/src/opentrons/hardware_control/protocols/instrument_configurer.py @@ -4,7 +4,9 @@ from opentrons_shared_data.pipette.dev_types import PipetteName from opentrons.types import Mount -from opentrons.hardware_control.instruments import Pipette +# TODO (lc 12-05-2022) This protocol has deviated from the OT3 api. We +# need to figure out how to combine them again in follow-up refactors. +from opentrons.hardware_control.instruments.ot2.pipette import Pipette from ..dev_types import PipetteDict from ..types import CriticalPoint diff --git a/api/src/opentrons/hardware_control/types.py b/api/src/opentrons/hardware_control/types.py index 3fb4aec2497..7d007cff544 100644 --- a/api/src/opentrons/hardware_control/types.py +++ b/api/src/opentrons/hardware_control/types.py @@ -111,6 +111,8 @@ class OT3AxisKind(enum.Enum): #: Plunger axis (of the left and right pipettes) Z_G = 4 #: Gripper Z axis + Q = 5 + #: High-throughput tip grabbing axis OTHER = 5 #: The internal axes of high throughput pipettes, for instance diff --git a/api/src/opentrons/protocols/api_support/util.py b/api/src/opentrons/protocols/api_support/util.py index 758329f0fae..8db5f631922 100644 --- a/api/src/opentrons/protocols/api_support/util.py +++ b/api/src/opentrons/protocols/api_support/util.py @@ -194,12 +194,15 @@ def _find_value_for_api_version( for_version: APIVersion, values: Dict[str, float] ) -> float: """ - Parse a dict that looks like + Either parse a dict that looks like {"2.0": 5, "2.5": 4} (aka the flow rate values from pipette config) and return the value for - the highest api level that is at or underneath ``for_version`` + the highest api level that is at or underneath ``for_version``, + or return the value passed in, if it's only a float. """ + if isinstance(values, float): + return values sorted_versions = sorted({APIVersion.from_string(k): v for k, v in values.items()}) last = values[str(sorted_versions[0])] for version in sorted_versions: diff --git a/api/tests/opentrons/config/ot3_settings.py b/api/tests/opentrons/config/ot3_settings.py index bdc6bdbfda6..e460f8ce961 100644 --- a/api/tests/opentrons/config/ot3_settings.py +++ b/api/tests/opentrons/config/ot3_settings.py @@ -22,6 +22,7 @@ "Y": 2, "Z": 15, "P": 15, + "Q": 5, }, "two_low_throughput": {"X": 1.1, "Y": 2.2}, "gripper": { @@ -48,6 +49,7 @@ "Y": 2, "Z": 3, "P": 4, + "Q": 5, }, "two_low_throughput": { "X": 4, @@ -76,6 +78,7 @@ "Y": 2, "Z": 3, "P": 6, + "Q": 5, }, "two_low_throughput": { "X": 1, @@ -104,6 +107,7 @@ "Y": 2, "Z": 3, "P": 6, + "Q": 5, }, "two_low_throughput": { "X": 0.5, @@ -134,6 +138,7 @@ "Y": 0.7, "Z": 0.7, "P": 0.8, + "Q": 0.3, }, "two_low_throughput": {"X": 0.7, "Y": 0.7, "Z": 0.6}, "gripper": { @@ -159,6 +164,7 @@ "Y": 0.5, "Z": 0.4, "P": 2.0, + "Q": 0.3, }, "two_low_throughput": {"X": 9, "Y": 0.1, "Z": 0.6}, "gripper": { diff --git a/api/tests/opentrons/config/test_ot3_pipette_config.py b/api/tests/opentrons/config/test_ot3_pipette_config.py new file mode 100644 index 00000000000..0f02bb2cc31 --- /dev/null +++ b/api/tests/opentrons/config/test_ot3_pipette_config.py @@ -0,0 +1,233 @@ +import pytest + +from typing import Tuple, cast +from opentrons_shared_data.pipette.pipette_definition import ( + SupportedTipsDefinition, + PipetteTipType, +) +from opentrons_shared_data.pipette.pipette_definition import ( + PipetteChannelType, + PipetteModelType, + PipetteVersionType, + PipetteGenerationType, + PipetteModelMajorVersionType, + PipetteModelMinorVersionType, +) +from opentrons_shared_data.pipette.dev_types import PipetteModel, PipetteName +from opentrons.config import ot3_pipette_config as pc + + +def test_multiple_tip_configurations() -> None: + model_version = pc.PipetteModelVersionType( + PipetteModelType.p1000, + PipetteChannelType.EIGHT_CHANNEL, + PipetteVersionType(1, 0), + ) + loaded_configuration = pc.load_ot3_pipette(model_version) + assert list(loaded_configuration.supported_tips.keys()) == list(PipetteTipType) + assert isinstance( + loaded_configuration.supported_tips[PipetteTipType.t50], + SupportedTipsDefinition, + ) + + +@pytest.mark.parametrize( + argnames=["model", "channels", "version"], + argvalues=[["p50", 8, (1, 0)], ["p1000", 96, (1, 0)], ["p50", 1, (1, 0)]], +) +def test_load_full_pipette_configurations( + model: str, channels: int, version: Tuple[int, int] +) -> None: + model_version = pc.PipetteModelVersionType( + PipetteModelType(model), + PipetteChannelType(channels), + PipetteVersionType( + cast(PipetteModelMajorVersionType, version[0]), + cast(PipetteModelMinorVersionType, version[1]), + ), + ) + loaded_configuration = pc.load_ot3_pipette(model_version) + assert loaded_configuration.pipette_type.value == model + assert loaded_configuration.channels.as_int == channels + assert loaded_configuration.version.as_tuple == version + + +@pytest.mark.parametrize( + argnames=["model", "output"], + argvalues=[ + [ + "p50_single_v2.0", + pc.PipetteModelVersionType( + PipetteModelType.p50, + PipetteChannelType.SINGLE_CHANNEL, + PipetteVersionType(2, 0), + ), + ], + [ + "p1000_multi_v1.0", + pc.PipetteModelVersionType( + PipetteModelType.p1000, + PipetteChannelType.EIGHT_CHANNEL, + PipetteVersionType(1, 0), + ), + ], + [ + "p1000_96_v1.0", + pc.PipetteModelVersionType( + PipetteModelType.p1000, + PipetteChannelType.NINETY_SIX_CHANNEL, + PipetteVersionType(1, 0), + ), + ], + ], +) +def test_convert_pipette_model( + model: PipetteModel, output: pc.PipetteModelVersionType +) -> None: + assert output == pc.convert_pipette_model(model) + + +@pytest.mark.parametrize( + argnames=["model", "version", "output"], + argvalues=[ + [ + "p50_single", + "2.0", + pc.PipetteModelVersionType( + PipetteModelType.p50, + PipetteChannelType.SINGLE_CHANNEL, + PipetteVersionType(2, 0), + ), + ], + [ + "p1000_multi", + "3.3", + pc.PipetteModelVersionType( + PipetteModelType.p1000, + PipetteChannelType.EIGHT_CHANNEL, + PipetteVersionType(3, 3), + ), + ], + [ + "p1000_96", + "1.1", + pc.PipetteModelVersionType( + PipetteModelType.p1000, + PipetteChannelType.NINETY_SIX_CHANNEL, + PipetteVersionType(1, 1), + ), + ], + ], +) +def test_convert_pipette_model_provided_version( + model: PipetteModel, version: str, output: pc.PipetteModelVersionType +) -> None: + assert output == pc.convert_pipette_model(model, version) + + +@pytest.mark.parametrize( + argnames=["name", "output"], + argvalues=[ + [ + "p50_single_gen2", + pc.PipetteModelVersionType( + PipetteModelType.p50, + PipetteChannelType.SINGLE_CHANNEL, + PipetteVersionType(2, 0), + ), + ], + [ + "p1000_multi_gen3", + pc.PipetteModelVersionType( + PipetteModelType.p1000, + PipetteChannelType.EIGHT_CHANNEL, + PipetteVersionType(3, 0), + ), + ], + [ + "p1000_96", + pc.PipetteModelVersionType( + PipetteModelType.p1000, + PipetteChannelType.NINETY_SIX_CHANNEL, + PipetteVersionType(1, 0), + ), + ], + ], +) +def test_convert_pipette_name( + name: PipetteName, output: pc.PipetteModelVersionType +) -> None: + assert output == pc.convert_pipette_name(name) + + +@pytest.mark.parametrize( + argnames=["model_type", "channels", "generation", "output"], + argvalues=[ + [ + PipetteModelType.p50, + PipetteChannelType.SINGLE_CHANNEL, + PipetteGenerationType.GEN2, + "p50_single_gen2", + ], + [ + PipetteModelType.p1000, + PipetteChannelType.EIGHT_CHANNEL, + PipetteGenerationType.GEN2, + "p1000_multi_gen2", + ], + [ + # 96 channel has a unique "name" right now + PipetteModelType.p1000, + PipetteChannelType.NINETY_SIX_CHANNEL, + PipetteGenerationType.GEN3, + "p1000_96", + ], + ], +) +def test_model_version_type_string_version( + model_type: PipetteModelType, + channels: PipetteChannelType, + generation: PipetteGenerationType, + output: PipetteName, +) -> None: + data = pc.PipetteNameType( + pipette_type=model_type, + pipette_channels=channels, + pipette_generation=generation, + ) + assert output == str(data) + + +@pytest.mark.parametrize( + argnames=["model_type", "channels", "version", "output"], + argvalues=[ + [ + PipetteModelType.p50, + PipetteChannelType.SINGLE_CHANNEL, + PipetteVersionType(1, 0), + "p50_single_v1.0", + ], + [ + PipetteModelType.p1000, + PipetteChannelType.EIGHT_CHANNEL, + PipetteVersionType(2, 1), + "p1000_multi_v2.1", + ], + [ + PipetteModelType.p1000, + PipetteChannelType.NINETY_SIX_CHANNEL, + PipetteVersionType(3, 3), + "p1000_96_v3.3", + ], + ], +) +def test_name_type_string_generation( + model_type: PipetteModelType, + channels: PipetteChannelType, + version: PipetteVersionType, + output: PipetteModel, +) -> None: + data = pc.PipetteModelVersionType( + pipette_type=model_type, pipette_channels=channels, pipette_version=version + ) + assert output == str(data) diff --git a/api/tests/opentrons/config/test_pipette_config.py b/api/tests/opentrons/config/test_pipette_config.py index 29a9140a03d..a06845a4719 100644 --- a/api/tests/opentrons/config/test_pipette_config.py +++ b/api/tests/opentrons/config/test_pipette_config.py @@ -13,7 +13,7 @@ from opentrons_shared_data import load_shared_data from opentrons_shared_data.pipette.dev_types import PipetteModel -defs = json.loads(load_shared_data("pipette/definitions/pipetteModelSpecs.json")) +defs = json.loads(load_shared_data("pipette/definitions/1/pipetteModelSpecs.json")) def check_sequences_close( @@ -275,6 +275,8 @@ def test_validate_overrides_pass( # TODO(mc, 2022-06-10): this fixture reaches into internals of the HardwareAPI # that are only present in the simulator, not the actual controller. It is not # an effective test of whether anything actually works +# TODO (lc, 12-05-2022): Re-write these tests when the OT2 pipette +# configurations are ported over to the new format. @pytest.fixture async def attached_pipettes( hardware: HardwareControlAPI, @@ -317,6 +319,7 @@ def marker_with_default(marker: str, default: str) -> str: (CONFIG["pipette_config_overrides_dir"] / "abcd123.json").unlink() +@pytest.mark.ot2_only async def test_override(attached_pipettes: Dict[str, PipetteSpec]) -> None: # This test will check that setting modified pipette configs # works as expected @@ -350,6 +353,7 @@ async def test_override(attached_pipettes: Dict[str, PipetteSpec]) -> None: ) +@pytest.mark.ot2_only async def test_incorrect_modify_pipette_settings( attached_pipettes: Dict[str, PipetteSpec] ) -> None: diff --git a/api/tests/opentrons/data/testosaur_v2.py b/api/tests/opentrons/data/testosaur_v2.py index 2a30fb3dab0..be59fc04339 100644 --- a/api/tests/opentrons/data/testosaur_v2.py +++ b/api/tests/opentrons/data/testosaur_v2.py @@ -11,10 +11,10 @@ def run(ctx: protocol_api.ProtocolContext) -> None: ctx.home() - tr = ctx.load_labware("opentrons_96_tiprack_300ul", 1) - right = ctx.load_instrument("p300_single", types.Mount.RIGHT, [tr]) + tr = ctx.load_labware("opentrons_96_tiprack_1000ul", 1) + right = ctx.load_instrument("p1000_single", types.Mount.RIGHT, [tr]) lw = ctx.load_labware("corning_96_wellplate_360ul_flat", 2) right.pick_up_tip() - right.aspirate(10, lw.wells()[0].bottom()) - right.dispense(10, lw.wells()[1].bottom()) + right.aspirate(100, lw.wells()[0].bottom()) + right.dispense(100, lw.wells()[1].bottom()) right.drop_tip(tr.wells()[-1].top()) diff --git a/api/tests/opentrons/hardware_control/backends/test_ot3_controller.py b/api/tests/opentrons/hardware_control/backends/test_ot3_controller.py index eee007a8cb3..cc6fde44950 100644 --- a/api/tests/opentrons/hardware_control/backends/test_ot3_controller.py +++ b/api/tests/opentrons/hardware_control/backends/test_ot3_controller.py @@ -1,5 +1,4 @@ import pytest -from typing import TYPE_CHECKING from itertools import chain from mock import AsyncMock, patch from opentrons.hardware_control.backends.ot3controller import OT3Controller @@ -36,9 +35,6 @@ GripperInformation, ) -if TYPE_CHECKING: - from opentrons_shared_data.pipette.dev_types import PipetteName, PipetteModel - @pytest.fixture def mock_config() -> OT3Config: @@ -322,23 +318,21 @@ async def fake_gai(expected): @pytest.mark.parametrize( - "tool_summary,pipette_id,pipette_name,pipette_model,gripper_id,gripper_name", + "tool_summary,pipette_id,gripper_id,gripper_name", [ ( ToolSummary( left=PipetteInformation( name=FirmwarePipetteName.p1000_single, name_int=FirmwarePipetteName.p1000_single.value, - model="3.0", + model="3.3", serial="hello", ), right=None, gripper=GripperInformation(model="0", serial="fake_serial"), ), - "hello", - "p1000_single_gen3", - "p1000_single_v3.0", - "fake_serial", + "P1KSV33hello", + "GRPV0fake_serial", "gripper", ), ], @@ -348,8 +342,6 @@ async def test_get_attached_instruments( mock_tool_detector: OneshotToolDetector, tool_summary: ToolSummary, pipette_id: str, - pipette_name: "PipetteName", - pipette_model: "PipetteModel", gripper_id: str, gripper_name: str, ): @@ -365,8 +357,6 @@ async def fake_probe(can_messenger, expected, timeout): detected = await controller.get_attached_instruments({}) assert list(detected.keys()) == [OT3Mount.LEFT, OT3Mount.GRIPPER] assert detected[OT3Mount.LEFT]["id"] == pipette_id - assert detected[OT3Mount.LEFT]["config"].name == pipette_name - assert detected[OT3Mount.LEFT]["config"].model == pipette_model assert detected[OT3Mount.GRIPPER]["id"] == gripper_id assert detected[OT3Mount.GRIPPER]["config"].name == gripper_name @@ -405,7 +395,10 @@ async def fake_probe(can_messenger, expected, timeout): tool_summary = ToolSummary( left=PipetteInformation( - name=FirmwarePipetteName.p1000_single, name_int=0, model=41, serial="hello" + name=FirmwarePipetteName.p1000_single, + name_int=0, + model="4.1", + serial="hello", ), right=None, gripper=GripperInformation(model=0, serial="fake_serial"), @@ -548,3 +541,29 @@ async def test_ready_for_movement( axes = [OT3Axis.X, OT3Axis.Y, OT3Axis.Z_L] assert controller.check_ready_for_movement(axes) == ready + + +async def test_tip_action(controller: OT3Controller, mock_move_group_run) -> None: + await controller.tip_action([OT3Axis.P_L], 33, -5.5, tip_action="pick_up") + for call in mock_move_group_run.call_args_list: + move_group_runner = call[0][0] + for move_group in move_group_runner._move_groups: + assert move_group # don't pass in empty groups + assert len(move_group) == 1 + # we should be sending this command to the pipette axes to process. + assert list(move_group[0].keys()) == [NodeId.pipette_left] + step = move_group[0][NodeId.pipette_left] + assert step.stop_condition == MoveStopCondition.none + + mock_move_group_run.reset_mock() + + await controller.tip_action([OT3Axis.P_L], 33, -5.5, tip_action="drop") + for call in mock_move_group_run.call_args_list: + move_group_runner = call[0][0] + for move_group in move_group_runner._move_groups: + assert move_group # don't pass in empty groups + assert len(move_group) == 1 + # we should be sending this command to the pipette axes to process. + assert list(move_group[0].keys()) == [NodeId.pipette_left] + step = move_group[0][NodeId.pipette_left] + assert step.stop_condition == MoveStopCondition.limit_switch diff --git a/api/tests/opentrons/hardware_control/test_instruments.py b/api/tests/opentrons/hardware_control/test_instruments.py index e995f35970f..cc1d8ce1e82 100644 --- a/api/tests/opentrons/hardware_control/test_instruments.py +++ b/api/tests/opentrons/hardware_control/test_instruments.py @@ -130,8 +130,12 @@ async def test_cache_instruments(sim_and_instr): attached_instruments=dummy_instruments, loop=asyncio.get_running_loop() ) await hw_api.cache_instruments() - attached = hw_api.attached_instruments - typeguard.check_type("left mount dict", attached[types.Mount.LEFT], PipetteDict) + + with pytest.raises(RuntimeError): + await hw_api.cache_instruments({types.Mount.LEFT: "p400_single_1.0"}) + # TODO (lc 12-5-2022) This is no longer true. We should modify this + # typecheck once we have static and stateful pipette configurations. + # typeguard.check_type("left mount dict", attached[types.Mount.LEFT], PipetteDict) async def test_mismatch_fails(sim_and_instr): @@ -147,6 +151,7 @@ async def test_mismatch_fails(sim_and_instr): await hw_api.cache_instruments(requested_instr) +@pytest.mark.ot2_only async def test_backwards_compatibility(dummy_backwards_compatibility, sim_and_instr): sim_builder, _ = sim_and_instr hw_api = await sim_builder( @@ -381,7 +386,7 @@ async def test_aspirate_ot3(dummy_instruments_ot3, ot3_api_obj): aspirate_rate = 2 await hw_api.prepare_for_aspirate(mount) await hw_api.aspirate(mount, aspirate_ul, aspirate_rate) - new_plunger_pos = 59.212208 + new_plunger_pos = 71.212208 pos = await hw_api.current_position(mount) assert pos[Axis.B] == new_plunger_pos @@ -430,13 +435,13 @@ async def test_dispense_ot3(dummy_instruments_ot3, ot3_api_obj): dispense_1 = 3.0 await hw_api.dispense(mount, dispense_1) - plunger_pos_1 = 58.92099 + plunger_pos_1 = 70.92099 assert (await hw_api.current_position(mount))[Axis.B] == pytest.approx( plunger_pos_1 ) await hw_api.dispense(mount, rate=2) - plunger_pos_2 = 59.5 + plunger_pos_2 = 71.5 assert (await hw_api.current_position(mount))[Axis.B] == pytest.approx( plunger_pos_2 ) @@ -530,7 +535,7 @@ async def test_aspirate_flow_rate(sim_and_instr): await hw_api.aspirate(types.Mount.LEFT, 2) assert_move_called( mock_move, - get_plunger_speed(hw_api)(pip, pip.config.aspirate_flow_rate, "aspirate"), + get_plunger_speed(hw_api)(pip, pip.aspirate_flow_rate, "aspirate"), ) with mock.patch.object(hw_api, "_move") as mock_move: @@ -538,9 +543,7 @@ async def test_aspirate_flow_rate(sim_and_instr): await hw_api.aspirate(types.Mount.LEFT, 2, rate=0.5) assert_move_called( mock_move, - get_plunger_speed(hw_api)( - pip, pip.config.aspirate_flow_rate * 0.5, "aspirate" - ), + get_plunger_speed(hw_api)(pip, pip.aspirate_flow_rate * 0.5, "aspirate"), ) hw_api.set_flow_rate(mount, aspirate=1) @@ -592,16 +595,14 @@ async def test_dispense_flow_rate(sim_and_instr): await hw_api.dispense(types.Mount.LEFT, 2) assert_move_called( mock_move, - get_plunger_speed(hw_api)(pip, pip.config.dispense_flow_rate, "dispense"), + get_plunger_speed(hw_api)(pip, pip.dispense_flow_rate, "dispense"), ) with mock.patch.object(hw_api, "_move") as mock_move: await hw_api.dispense(types.Mount.LEFT, 2, rate=0.5) assert_move_called( mock_move, - get_plunger_speed(hw_api)( - pip, pip.config.dispense_flow_rate * 0.5, "dispense" - ), + get_plunger_speed(hw_api)(pip, pip.dispense_flow_rate * 0.5, "dispense"), ) hw_api.set_flow_rate(mount, dispense=3) @@ -648,7 +649,7 @@ async def test_blowout_flow_rate(sim_and_instr): await hw_api.blow_out(mount) assert_move_called( mock_move, - get_plunger_speed(hw_api)(pip, pip.config.blow_out_flow_rate, "dispense"), + get_plunger_speed(hw_api)(pip, pip.blow_out_flow_rate, "dispense"), ) hw_api.set_flow_rate(mount, blow_out=2) @@ -670,26 +671,50 @@ async def test_blowout_flow_rate(sim_and_instr): async def test_reset_instruments(monkeypatch, sim_and_instr): - sim_builder, dummy_instruments = sim_and_instr + instruments = { + types.Mount.LEFT: { + "model": "p1000_single_v3.0", + "id": "testy", + "name": "p1000_single_gen3", + }, + types.Mount.RIGHT: { + "model": "p1000_single_v3.0", + "id": "testy", + "name": "p1000_single_gen3", + }, + } + sim_builder, _ = sim_and_instr hw_api = await sim_builder( - attached_instruments=dummy_instruments, loop=asyncio.get_running_loop() + attached_instruments=instruments, loop=asyncio.get_running_loop() ) - hw_api.set_flow_rate(types.Mount.LEFT, 20) + hw_api.set_flow_rate(types.Mount.LEFT, 15) + hw_api.set_flow_rate(types.Mount.RIGHT, 50) # gut check - assert hw_api.attached_instruments[types.Mount.LEFT]["aspirate_flow_rate"] == 20 + assert hw_api.attached_instruments[types.Mount.LEFT]["aspirate_flow_rate"] == 15 + assert hw_api.attached_instruments[types.Mount.RIGHT]["aspirate_flow_rate"] == 50 old_l = hw_api.hardware_instruments[types.Mount.LEFT] old_r = hw_api.hardware_instruments[types.Mount.RIGHT] + + assert old_l.aspirate_flow_rate == 15 + assert old_r.aspirate_flow_rate == 50 hw_api.reset_instrument(types.Mount.LEFT) - # left should have been reset, right should not - assert not (old_l is hw_api.hardware_instruments[types.Mount.LEFT]) - assert old_r is hw_api.hardware_instruments[types.Mount.RIGHT] + # after the reset, the left should be more or less the same assert old_l.pipette_id == hw_api.hardware_instruments[types.Mount.LEFT].pipette_id + assert hw_api.hardware_instruments[types.Mount.LEFT].aspirate_flow_rate != 15 + assert hw_api.hardware_instruments[types.Mount.RIGHT].aspirate_flow_rate == 50 # but non-default configs should be changed - assert hw_api.attached_instruments[types.Mount.LEFT]["aspirate_flow_rate"] != 20 - old_l = hw_api.hardware_instruments[types.Mount.LEFT] - old_r = hw_api.hardware_instruments[types.Mount.RIGHT] - + assert hw_api.attached_instruments[types.Mount.LEFT]["aspirate_flow_rate"] != 15 + # and the right pipette remains the same + assert hw_api.attached_instruments[types.Mount.RIGHT]["aspirate_flow_rate"] == 50 + + # set the flowrate on the left again + hw_api.set_flow_rate(types.Mount.LEFT, 50) + assert hw_api.attached_instruments[types.Mount.LEFT]["aspirate_flow_rate"] == 50 + # reset the configurations of both pipettes hw_api.reset_instrument() - assert not (old_l is hw_api.hardware_instruments[types.Mount.LEFT]) - assert not (old_r is hw_api.hardware_instruments[types.Mount.LEFT]) + assert hw_api.attached_instruments[types.Mount.LEFT]["aspirate_flow_rate"] != 15 + assert hw_api.attached_instruments[types.Mount.RIGHT]["aspirate_flow_rate"] != 50 + + assert hw_api.hardware_instruments[types.Mount.LEFT].aspirate_flow_rate != 15 + assert hw_api.hardware_instruments[types.Mount.LEFT].aspirate_flow_rate != 50 diff --git a/api/tests/opentrons/hardware_control/test_ot3_api.py b/api/tests/opentrons/hardware_control/test_ot3_api.py index fa72fcde84b..8517ffdaad2 100644 --- a/api/tests/opentrons/hardware_control/test_ot3_api.py +++ b/api/tests/opentrons/hardware_control/test_ot3_api.py @@ -1,20 +1,23 @@ """ Tests for behaviors specific to the OT3 hardware controller. """ -from typing import cast, Iterator, Union, Dict, Tuple, List +from typing import Iterator, Union, Dict, Tuple, List from typing_extensions import Literal from math import copysign import pytest from mock import AsyncMock, patch, Mock from opentrons.config.types import GantryLoad, CapacitivePassSettings -from opentrons.hardware_control.dev_types import ( - InstrumentDict, - AttachedGripper, -) +from opentrons.hardware_control.dev_types import AttachedGripper, OT3AttachedPipette from opentrons.hardware_control.instruments.ot3.gripper_handler import ( GripError, GripperHandler, ) -from opentrons.hardware_control.instruments.ot2.pipette_handler import OT3PipetteHandler +from opentrons.hardware_control.instruments.ot3.pipette_handler import ( + OT3PipetteHandler, + PickUpTipSpec, + TipMotorPickUpTipSpec, + DropTipMove, + DropTipSpec, +) from opentrons.hardware_control.types import ( OT3Mount, OT3Axis, @@ -30,8 +33,13 @@ from opentrons.types import Point, Mount -from opentrons.config import gripper_config as gc +from opentrons.config import gripper_config as gc, ot3_pipette_config from opentrons_shared_data.gripper.dev_types import GripperModel +from opentrons_shared_data.pipette.pipette_definition import ( + PipetteModelType, + PipetteChannelType, + PipetteVersionType, +) @pytest.fixture @@ -147,38 +155,77 @@ async def mock_instrument_handlers( @pytest.mark.parametrize( - "attached,load", + "load_configs,load", ( ( - {OT3Mount.RIGHT: {"channels": 8}, OT3Mount.LEFT: {"channels": 1}}, + { + OT3Mount.RIGHT: {"channels": 8, "version": (1, 0), "model": "p50"}, + OT3Mount.LEFT: {"channels": 1, "version": (1, 0), "model": "p1000"}, + }, GantryLoad.TWO_LOW_THROUGHPUT, ), ({}, GantryLoad.NONE), - ({OT3Mount.GRIPPER: {"name": "gripper"}}, GantryLoad.GRIPPER), - ({OT3Mount.LEFT: {"channels": 1}}, GantryLoad.LOW_THROUGHPUT), - ({OT3Mount.RIGHT: {"channels": 8}}, GantryLoad.LOW_THROUGHPUT), - ({OT3Mount.RIGHT: {"channels": 96}}, GantryLoad.HIGH_THROUGHPUT), ( - {OT3Mount.LEFT: {"channels": 1}, OT3Mount.GRIPPER: {"name": "gripper"}}, + {OT3Mount.GRIPPER: {"model": GripperModel.V1, "id": "g12345"}}, + GantryLoad.GRIPPER, + ), + ( + {OT3Mount.LEFT: {"channels": 8, "version": (1, 0), "model": "p1000"}}, GantryLoad.LOW_THROUGHPUT, ), ( - {OT3Mount.RIGHT: {"channels": 8}, OT3Mount.GRIPPER: {"name": "gripper"}}, + {OT3Mount.RIGHT: {"channels": 8, "version": (1, 0), "model": "p1000"}}, GantryLoad.LOW_THROUGHPUT, ), ( - {OT3Mount.RIGHT: {"channels": 96}, OT3Mount.GRIPPER: {"name": "gripper"}}, + {OT3Mount.LEFT: {"channels": 96, "model": "p1000", "version": (1, 0)}}, + GantryLoad.HIGH_THROUGHPUT, + ), + ( + { + OT3Mount.LEFT: {"channels": 1, "version": (1, 0), "model": "p1000"}, + OT3Mount.GRIPPER: {"model": GripperModel.V1, "id": "g12345"}, + }, + GantryLoad.LOW_THROUGHPUT, + ), + ( + { + OT3Mount.RIGHT: {"channels": 8, "version": (1, 0), "model": "p1000"}, + OT3Mount.GRIPPER: {"model": GripperModel.V1, "id": "g12345"}, + }, + GantryLoad.LOW_THROUGHPUT, + ), + ( + { + OT3Mount.LEFT: {"channels": 96, "model": "p1000", "version": (1, 0)}, + OT3Mount.GRIPPER: {"model": GripperModel.V1, "id": "g12345"}, + }, GantryLoad.HIGH_THROUGHPUT, ), ), ) -def test_gantry_load_transform(attached, load): - assert ( - OT3API._gantry_load_from_instruments( - cast(Dict[OT3Mount, InstrumentDict], attached) - ) - == load - ) +async def test_gantry_load_transform( + ot3_hardware: ThreadManager[OT3API], + load_configs: Dict[str, Union[int, str, Tuple[int, int]]], + load: GantryLoad, +) -> None: + + for mount, configs in load_configs.items(): + if mount == OT3Mount.GRIPPER: + gripper_config = gc.load(configs["model"], configs["id"]) + instr_data = AttachedGripper(config=gripper_config, id="g12345") + await ot3_hardware.cache_gripper(instr_data) + else: + pipette_config = ot3_pipette_config.load_ot3_pipette( + ot3_pipette_config.PipetteModelVersionType( + PipetteModelType(configs["model"]), + PipetteChannelType(configs["channels"]), + PipetteVersionType(*configs["version"]), + ) + ) + instr_data = OT3AttachedPipette(config=pipette_config, id="fakepip") + await ot3_hardware.cache_pipette(mount, instr_data, None) + assert ot3_hardware._gantry_load_from_instruments() == load @pytest.fixture @@ -682,3 +729,78 @@ async def test_save_instrument_offset( pipette_handler.save_instrument_offset.assert_called_once_with( converted_mount, Point(1, 1, 1) ) + + +async def test_pick_up_tip_full_tiprack( + ot3_hardware: ThreadManager[OT3API], + mock_instrument_handlers: Tuple[Mock], +) -> None: + await ot3_hardware.home() + _, pipette_handler = mock_instrument_handlers + backend = ot3_hardware.managed_obj._backend + + def _fake_function(): + return None + + with patch.object( + backend, "tip_action", AsyncMock(spec=backend.tip_action) + ) as tip_action: + + pipette_handler.plan_check_pick_up_tip.return_value = ( + PickUpTipSpec( + plunger_prep_pos=0, + plunger_currents={ + OT3Axis.of_main_tool_actuator(Mount.LEFT): 0, + }, + presses=[], + shake_off_list=[], + retract_target=0, + pick_up_motor_actions=TipMotorPickUpTipSpec( + # Move onto the posts + tiprack_down=Point(0, 0, 0), + tiprack_up=Point(0, 0, 0), + pick_up_distance=0, + speed=0, + currents={OT3Axis.Q: 0}, + ), + ), + _fake_function, + ) + await ot3_hardware.pick_up_tip(Mount.LEFT, 40.0) + pipette_handler.plan_check_pick_up_tip.assert_called_once_with( + OT3Mount.LEFT, 40.0, None, None + ) + tip_action.assert_called_once_with([OT3Axis.P_L], 0, 0, "pick_up") + + +async def test_drop_tip_full_tiprack( + ot3_hardware: ThreadManager[OT3API], + mock_instrument_handlers: Tuple[Mock], +) -> None: + _, pipette_handler = mock_instrument_handlers + backend = ot3_hardware.managed_obj._backend + + def _fake_function(): + return None + + with patch.object( + backend, "tip_action", AsyncMock(spec=backend.tip_action) + ) as tip_action: + pipette_handler.plan_check_drop_tip.return_value = ( + DropTipSpec( + drop_moves=[ + DropTipMove( + target_position=1, + current={OT3Axis.P_L: 1.0}, + speed=1, + is_ht_tip_action=True, + ) + ], + shake_moves=[], + ending_current={OT3Axis.P_L: 1.0}, + ), + _fake_function, + ) + await ot3_hardware.drop_tip(Mount.LEFT) + pipette_handler.plan_check_drop_tip.assert_called_once_with(OT3Mount.LEFT, True) + tip_action.assert_called_once_with([OT3Axis.P_L], 1, 1, "drop") diff --git a/api/tests/opentrons/hardware_control/test_pipette.py b/api/tests/opentrons/hardware_control/test_pipette.py index 6303a72745c..0fee0ebdcc9 100644 --- a/api/tests/opentrons/hardware_control/test_pipette.py +++ b/api/tests/opentrons/hardware_control/test_pipette.py @@ -1,197 +1,384 @@ import pytest +from mock import patch +from typing import Union, Callable from opentrons.calibration_storage import types as cal_types from opentrons.types import Point, Mount -from opentrons.hardware_control.instruments.ot2 import pipette, instrument_calibration +from pytest_lazyfixture import lazy_fixture # type: ignore[import] +from opentrons.hardware_control.instruments.ot2 import ( + pipette as ot2_pipette, + instrument_calibration, +) +from opentrons.hardware_control.instruments.ot3 import ( + pipette as ot3_pipette, + instrument_calibration as ot3_calibration, +) from opentrons.hardware_control import types -from opentrons.config import pipette_config +from opentrons.config import pipette_config, ot3_pipette_config + +OT2_PIP_CAL = instrument_calibration.PipetteOffsetByPipetteMount( + offset=Point(0, 0, 0), + source=cal_types.SourceType.user, + status=cal_types.CalibrationStatus(), +) -PIP_CAL = instrument_calibration.PipetteOffsetByPipetteMount( +OT3_PIP_CAL = ot3_calibration.PipetteOffsetByPipetteMount( offset=Point(0, 0, 0), source=cal_types.SourceType.user, status=cal_types.CalibrationStatus(), ) -def test_tip_tracking(): - pip = pipette.Pipette(pipette_config.load("p10_single_v1"), PIP_CAL, "testID") +@pytest.fixture +def hardware_pipette_ot2() -> Callable: + def _create_pipette( + model: str, + calibration: instrument_calibration.PipetteOffsetByPipetteMount = OT2_PIP_CAL, + id: str = "testID", + ): + return ot2_pipette.Pipette(pipette_config.load(model), calibration, id) + + return _create_pipette + + +@pytest.fixture +def hardware_pipette_ot3() -> Callable: + def _create_pipette( + model: ot3_pipette_config.PipetteModelVersionType, + calibration: ot3_calibration.PipetteOffsetByPipetteMount = OT3_PIP_CAL, + id: str = "testID", + ): + return ot3_pipette.Pipette( + ot3_pipette_config.load_ot3_pipette(model), calibration, id + ) + + return _create_pipette + + +@pytest.mark.parametrize( + argnames=["pipette_builder", "model"], + argvalues=[ + [lazy_fixture("hardware_pipette_ot2"), "p10_single_v1"], + [ + lazy_fixture("hardware_pipette_ot3"), + ot3_pipette_config.convert_pipette_model("p1000_single_v1.0"), + ], + ], +) +def test_tip_tracking( + pipette_builder: Callable, + model: Union[str, ot3_pipette_config.PipetteModelVersionType], +) -> None: + hw_pipette = pipette_builder(model) with pytest.raises(AssertionError): - pip.remove_tip() - assert not pip.has_tip + hw_pipette.remove_tip() + assert not hw_pipette.has_tip tip_length = 25.0 - pip.add_tip(tip_length) - assert pip.has_tip + hw_pipette.add_tip(tip_length) + assert hw_pipette.has_tip with pytest.raises(AssertionError): - pip.add_tip(tip_length) - pip.remove_tip() - assert not pip.has_tip + hw_pipette.add_tip(tip_length) + hw_pipette.remove_tip() + assert not hw_pipette.has_tip with pytest.raises(AssertionError): - pip.remove_tip() + hw_pipette.remove_tip() -@pytest.mark.parametrize("model", pipette_config.config_models) -def test_critical_points_nozzle_offset(model): - loaded = pipette_config.load(model) - pip = pipette.Pipette(loaded, PIP_CAL, "testID") +@pytest.mark.parametrize( + argnames=["pipette_builder", "model", "nozzle_offset"], + argvalues=[ + [lazy_fixture("hardware_pipette_ot2"), "p10_single_v1", Point(0, 0, 12.0)], + [ + lazy_fixture("hardware_pipette_ot3"), + ot3_pipette_config.convert_pipette_model("p1000_single_v1.0"), + Point(-8.0, -22.0, -259.15), + ], + ], +) +def test_tip_nozzle_position_tracking( + pipette_builder: Callable, + model: Union[str, ot3_pipette_config.PipetteModelVersionType], + nozzle_offset: Point, +) -> None: + hw_pipette = pipette_builder(model) # default pipette offset is[0, 0, 0], only nozzle offset would be used # to determine critical point - nozzle_offset = Point(*loaded.nozzle_offset) - assert pip.critical_point() == nozzle_offset - assert pip.critical_point(types.CriticalPoint.NOZZLE) == nozzle_offset - assert pip.critical_point(types.CriticalPoint.TIP) == nozzle_offset + assert hw_pipette.critical_point() == nozzle_offset + assert hw_pipette.critical_point(types.CriticalPoint.NOZZLE) == nozzle_offset + assert hw_pipette.critical_point(types.CriticalPoint.TIP) == nozzle_offset tip_length = 25.0 - pip.add_tip(tip_length) + hw_pipette.add_tip(tip_length) new = nozzle_offset._replace(z=nozzle_offset.z - tip_length) - assert pip.critical_point() == new - assert pip.critical_point(types.CriticalPoint.NOZZLE) == nozzle_offset - assert pip.critical_point(types.CriticalPoint.TIP) == new - pip.remove_tip() - assert pip.critical_point() == nozzle_offset - assert pip.critical_point(types.CriticalPoint.NOZZLE) == nozzle_offset - assert pip.critical_point(types.CriticalPoint.TIP) == nozzle_offset - - -@pytest.mark.parametrize("model", pipette_config.config_models) -def test_critical_points_pipette_offset(model): - loaded = pipette_config.load(model) - # set pipette offset calibration - pip_cal = instrument_calibration.PipetteOffsetByPipetteMount( - offset=[10, 10, 10], - source=cal_types.SourceType.user, - status=cal_types.CalibrationStatus(), - ) - pip = pipette.Pipette(loaded, pip_cal, "testID") + assert hw_pipette.critical_point() == new + assert hw_pipette.critical_point(types.CriticalPoint.NOZZLE) == nozzle_offset + assert hw_pipette.critical_point(types.CriticalPoint.TIP) == new + hw_pipette.remove_tip() + assert hw_pipette.critical_point() == nozzle_offset + assert hw_pipette.critical_point(types.CriticalPoint.NOZZLE) == nozzle_offset + assert hw_pipette.critical_point(types.CriticalPoint.TIP) == nozzle_offset + + +@pytest.mark.parametrize( + argnames=["pipette_builder", "model", "calibration"], + argvalues=[ + [ + lazy_fixture("hardware_pipette_ot2"), + "p10_single_v1", + instrument_calibration.PipetteOffsetByPipetteMount( + offset=Point(10, 10, 10), + source=cal_types.SourceType.user, + status=cal_types.CalibrationStatus(), + ), + ], + [ + lazy_fixture("hardware_pipette_ot3"), + ot3_pipette_config.convert_pipette_model("p1000_single_v1.0"), + ot3_calibration.PipetteOffsetByPipetteMount( + offset=Point(10, 10, 10), + source=cal_types.SourceType.user, + status=cal_types.CalibrationStatus(), + ), + ], + ], +) +def test_critical_points_pipette_offset( + pipette_builder: Callable, + model: Union[str, ot3_pipette_config.PipetteModelVersionType], + calibration: Union[ + instrument_calibration.PipetteOffsetByPipetteMount, + ot3_calibration.PipetteOffsetByPipetteMount, + ], +) -> None: + + hw_pipette = pipette_builder(model, calibration) # pipette offset + nozzle offset to determine critical point - offsets = Point(*pip_cal.offset) + Point(*pip.nozzle_offset) - assert pip.critical_point() == offsets - assert pip.critical_point(types.CriticalPoint.NOZZLE) == offsets - assert pip.critical_point(types.CriticalPoint.TIP) == offsets + offsets = calibration.offset + Point(*hw_pipette.nozzle_offset) + assert hw_pipette.critical_point() == offsets + assert hw_pipette.critical_point(types.CriticalPoint.NOZZLE) == offsets + assert hw_pipette.critical_point(types.CriticalPoint.TIP) == offsets tip_length = 25.0 - pip.add_tip(tip_length) + hw_pipette.add_tip(tip_length) new = offsets._replace(z=offsets.z - tip_length) - assert pip.critical_point() == new - assert pip.critical_point(types.CriticalPoint.NOZZLE) == offsets - assert pip.critical_point(types.CriticalPoint.TIP) == new - pip.remove_tip() - assert pip.critical_point() == offsets - assert pip.critical_point(types.CriticalPoint.NOZZLE) == offsets - assert pip.critical_point(types.CriticalPoint.TIP) == offsets - - -@pytest.mark.parametrize("config_model", pipette_config.config_models) -def test_volume_tracking(config_model): - loaded = pipette_config.load(config_model) - pip = pipette.Pipette(loaded, PIP_CAL, "testID") - assert pip.current_volume == 0.0 - assert pip.available_volume == loaded.max_volume - assert pip.ok_to_add_volume(loaded.max_volume - 0.1) - pip.set_current_volume(0.1) + assert hw_pipette.critical_point() == new + assert hw_pipette.critical_point(types.CriticalPoint.NOZZLE) == offsets + assert hw_pipette.critical_point(types.CriticalPoint.TIP) == new + hw_pipette.remove_tip() + assert hw_pipette.critical_point() == offsets + assert hw_pipette.critical_point(types.CriticalPoint.NOZZLE) == offsets + assert hw_pipette.critical_point(types.CriticalPoint.TIP) == offsets + + +@pytest.mark.parametrize( + argnames=["pipette_builder", "model", "max_volume"], + argvalues=[ + [lazy_fixture("hardware_pipette_ot2"), "p10_single_v1", 10.0], + [ + lazy_fixture("hardware_pipette_ot3"), + ot3_pipette_config.convert_pipette_model("p1000_single_v1.0"), + 1000.0, + ], + ], +) +def test_volume_tracking( + pipette_builder: Callable, + model: Union[str, ot3_pipette_config.PipetteModelVersionType], + max_volume: float, +) -> None: + hw_pipette = pipette_builder(model) + assert hw_pipette.current_volume == 0.0 + assert hw_pipette.available_volume == max_volume + assert hw_pipette.ok_to_add_volume(max_volume - 0.1) + hw_pipette.set_current_volume(0.1) with pytest.raises(AssertionError): - pip.set_current_volume(loaded.max_volume + 0.1) + hw_pipette.set_current_volume(max_volume + 0.1) with pytest.raises(AssertionError): - pip.set_current_volume(-1) - assert pip.current_volume == 0.1 - pip.remove_current_volume(0.1) + hw_pipette.set_current_volume(-1) + assert hw_pipette.current_volume == 0.1 + hw_pipette.remove_current_volume(0.1) with pytest.raises(AssertionError): - pip.remove_current_volume(0.1) - assert pip.current_volume == 0.0 - pip.set_current_volume(loaded.max_volume) - assert not pip.ok_to_add_volume(0.1) + hw_pipette.remove_current_volume(0.1) + assert hw_pipette.current_volume == 0.0 + hw_pipette.set_current_volume(max_volume) + assert not hw_pipette.ok_to_add_volume(0.1) with pytest.raises(AssertionError): - pip.add_current_volume(0.1) - assert pip.current_volume == loaded.max_volume + hw_pipette.add_current_volume(0.1) + assert hw_pipette.current_volume == max_volume -@pytest.mark.parametrize("config_model", pipette_config.config_models) -def test_config_update(config_model): - loaded = pipette_config.load(config_model) - pip = pipette.Pipette(loaded, PIP_CAL, "testID") +@pytest.mark.ot2_only +def test_config_update(hardware_pipette_ot2: Callable): + hw_pipette = hardware_pipette_ot2("p10_single_v1") sample_plunger_pos = {"top": 19.5} - pip.update_config_item("top", sample_plunger_pos.get("top")) - assert pip.config.top == sample_plunger_pos.get("top") + hw_pipette.update_config_item("top", sample_plunger_pos.get("top")) + assert hw_pipette.config.top == sample_plunger_pos.get("top") -def test_smoothie_config_update(monkeypatch): - for config in pipette_config.config_models: - assert config == config - - -@pytest.mark.parametrize("config_model", pipette_config.config_models) -def test_tip_overlap(config_model): - # TODO (lc 10-31-2022) We really shouldn't need to paramaterize over all of the - # config models to check that the config is loaded in properly. - loaded = pipette_config.load(config_model) - pip = pipette.Pipette(loaded, PIP_CAL, "testId") - assert pip.config.tip_overlap == pipette_config.configs[config_model]["tipOverlap"] - - -def test_flow_rate_setting(): - pip = pipette.Pipette(pipette_config.load("p300_single_v2.0"), PIP_CAL, "testId") +@pytest.mark.ot2_only +def test_flow_rate_setting( + hardware_pipette_ot2: Callable, +) -> None: + hw_pipette = hardware_pipette_ot2("p10_single_v1") # pipettes should load settings from config at init time - assert pip.aspirate_flow_rate == pip.config.default_aspirate_flow_rates["2.0"] - assert pip.dispense_flow_rate == pip.config.default_dispense_flow_rates["2.0"] - assert pip.blow_out_flow_rate == pip.config.default_blow_out_flow_rates["2.0"] + assert ( + hw_pipette.aspirate_flow_rate + == hw_pipette.config.default_aspirate_flow_rates["2.0"] + ) + assert ( + hw_pipette.dispense_flow_rate + == hw_pipette.config.default_dispense_flow_rates["2.0"] + ) + assert ( + hw_pipette.blow_out_flow_rate + == hw_pipette.config.default_blow_out_flow_rates["2.0"] + ) # changing flow rates with normal property access shouldn't touch # config or other flow rates - config = pip.config - pip.aspirate_flow_rate = 2 - assert pip.aspirate_flow_rate == 2 - assert pip.dispense_flow_rate == pip.config.default_dispense_flow_rates["2.0"] - assert pip.blow_out_flow_rate == pip.config.default_blow_out_flow_rates["2.0"] - assert pip.config is config - pip.dispense_flow_rate = 3 - assert pip.aspirate_flow_rate == 2 - assert pip.dispense_flow_rate == 3 - assert pip.blow_out_flow_rate == pip.config.default_blow_out_flow_rates["2.0"] - assert pip.config is config - pip.blow_out_flow_rate = 4 - assert pip.aspirate_flow_rate == 2 - assert pip.dispense_flow_rate == 3 - assert pip.blow_out_flow_rate == 4 - assert pip.config is config - - -@pytest.mark.parametrize("config_model", pipette_config.config_models) -def test_xy_center(config_model): - loaded = pipette_config.load(config_model) - pip = pipette.Pipette(loaded, PIP_CAL, "testId") - if loaded.channels == 8: - cp_y_offset = 9 * 3.5 - else: - cp_y_offset = 0 - assert pip.critical_point(types.CriticalPoint.XY_CENTER) == Point( - loaded.nozzle_offset[0], - loaded.nozzle_offset[1] - cp_y_offset, - loaded.nozzle_offset[2], + hw_pipette.aspirate_flow_rate = 2 + assert hw_pipette.aspirate_flow_rate == 2 + assert ( + hw_pipette.dispense_flow_rate + == hw_pipette.config.default_dispense_flow_rates["2.0"] + ) + assert ( + hw_pipette.blow_out_flow_rate + == hw_pipette.config.default_blow_out_flow_rates["2.0"] + ) + hw_pipette.dispense_flow_rate = 3 + assert hw_pipette.aspirate_flow_rate == 2 + assert hw_pipette.dispense_flow_rate == 3 + assert ( + hw_pipette.blow_out_flow_rate + == hw_pipette.config.default_blow_out_flow_rates["2.0"] + ) + hw_pipette.blow_out_flow_rate = 4 + assert hw_pipette.aspirate_flow_rate == 2 + assert hw_pipette.dispense_flow_rate == 3 + assert hw_pipette.blow_out_flow_rate == 4 + + +@pytest.mark.parametrize( + argnames=[ + "pipette_builder", + "model", + "expected_xy_critical_point", + "expected_front_critical_point", + ], + argvalues=[ + [ + lazy_fixture("hardware_pipette_ot2"), + "p10_single_v1", + Point(0, 0, 12.0), + Point(0, 0, 12.0), + ], + [ + lazy_fixture("hardware_pipette_ot2"), + "p300_multi_v2.0", + Point(0.0, 0.0, 35.52), + Point(0.0, -31.5, 35.52), + ], + [ + lazy_fixture("hardware_pipette_ot3"), + ot3_pipette_config.convert_pipette_model("p1000_single_v1.0"), + Point(-8.0, -22.0, -259.15), + Point(-8.0, -22.0, -259.15), + ], + [ + lazy_fixture("hardware_pipette_ot3"), + ot3_pipette_config.convert_pipette_model("p1000_multi_v1.0"), + Point(-8.0, -47.5, -259.15), + Point(-8.0, -79.0, -259.15), + ], + [ + lazy_fixture("hardware_pipette_ot3"), + ot3_pipette_config.convert_pipette_model("p1000_96", "1.0"), + Point(13.5, -46.5, -259.15), + Point(-36.0, -88.5, -259.15), + ], + ], +) +def test_alternative_critical_points( + pipette_builder: Callable, + model: Union[str, ot3_pipette_config.PipetteModelVersionType], + expected_xy_critical_point: Point, + expected_front_critical_point: Point, +) -> None: + hw_pipette = pipette_builder(model) + assert ( + hw_pipette.critical_point(types.CriticalPoint.XY_CENTER) + == expected_xy_critical_point + ) + assert ( + hw_pipette.critical_point(types.CriticalPoint.FRONT_NOZZLE) + == expected_front_critical_point ) -def test_reset_instrument_offset(): +@pytest.mark.parametrize( + argnames=["pipette_builder", "model", "calibration"], + argvalues=[ + [ + lazy_fixture("hardware_pipette_ot2"), + "p10_single_v1", + instrument_calibration.PipetteOffsetByPipetteMount( + offset=Point(1, 1, 1), + source=cal_types.SourceType.user, + status=cal_types.CalibrationStatus(), + ), + ], + [ + lazy_fixture("hardware_pipette_ot3"), + ot3_pipette_config.convert_pipette_model("p1000_single_v1.0"), + ot3_calibration.PipetteOffsetByPipetteMount( + offset=Point(1, 1, 1), + source=cal_types.SourceType.user, + status=cal_types.CalibrationStatus(), + ), + ], + ], +) +def test_reset_instrument_offset( + pipette_builder: Callable, + model: Union[str, ot3_pipette_config.PipetteModelVersionType], + calibration: Union[ + instrument_calibration.PipetteOffsetByPipetteMount, + ot3_calibration.PipetteOffsetByPipetteMount, + ], +) -> None: + + hw_pipette = pipette_builder(model, calibration) + assert hw_pipette.pipette_offset.offset == Point(1, 1, 1) + hw_pipette.reset_pipette_offset(Mount.LEFT, to_default=True) + assert hw_pipette.pipette_offset.offset == Point(0, 0, 0) + + +def test_save_instrument_offset_ot3(hardware_pipette_ot2: Callable) -> None: # TODO (lc 10-31-2022) These tests would be much cleaner/easier to mock with # an InstrumentCalibrationProvider class (like robot calibration provider) # which should be done in a follow-up refactor. - temporary_calibration = instrument_calibration.PipetteOffsetByPipetteMount( - offset=Point(1, 1, 1), - source=cal_types.SourceType.user, - status=cal_types.CalibrationStatus(), - ) + path_to_calibrations = "opentrons.hardware_control.instruments.ot2.pipette" + hw_pipette = hardware_pipette_ot2("p10_single_v1") - pip = pipette.Pipette( - pipette_config.load("p10_single_v1"), temporary_calibration, "testID" - ) - assert pip.pipette_offset.offset == Point(1, 1, 1) - pip.reset_pipette_offset(Mount.LEFT, to_default=True) - assert pip.pipette_offset.offset == Point(0, 0, 0) + assert hw_pipette.pipette_offset.offset == Point(0, 0, 0) + with patch(f"{path_to_calibrations}.load_pipette_offset") as load_cal: + hw_pipette.save_pipette_offset(Mount.LEFT, Point(1.0, 2.0, 3.0)) + load_cal.assert_called_once_with("testID", Mount.LEFT) -@pytest.mark.xfail -def test_save_instrument_offset(): +def test_save_instrument_offset_ot2(hardware_pipette_ot3: Callable) -> None: # TODO (lc 10-31-2022) These tests would be much cleaner/easier to mock with # an InstrumentCalibrationProvider class (like robot calibration provider) # which should be done in a follow-up refactor. - pip = pipette.Pipette(pipette_config.load("p10_single_v1"), PIP_CAL, "testID") + path_to_calibrations = "opentrons.hardware_control.instruments.ot3.pipette" + hw_pipette = hardware_pipette_ot3( + ot3_pipette_config.convert_pipette_model("p1000_single_v1.0") + ) + + assert hw_pipette.pipette_offset.offset == Point(0, 0, 0) + with patch( + f"{path_to_calibrations}.save_pipette_offset_calibration" + ) as save_cal, patch(f"{path_to_calibrations}.load_pipette_offset") as load_cal: + hw_pipette.save_pipette_offset(Mount.LEFT, Point(1.0, 2.0, 3.0)) - assert pip.pipette_offset.offset == Point(0, 0, 0) - pip.save_pipette_offset(Mount.LEFT, Point(1.0, 2.0, 3.0)) - # TODO (lc 10-31-2022) This assert should be easier to handle once we - # have the correct exports from calibration_storage - assert pip.pipette_offset.offset == Point(1.0, 2.0, 3.0) + save_cal.assert_called_once_with( + "testID", Mount.LEFT, Point(x=1.0, y=2.0, z=3.0) + ) + load_cal.assert_called_once_with("testID", Mount.LEFT) diff --git a/api/tests/opentrons/hardware_control/test_pipette_handler.py b/api/tests/opentrons/hardware_control/test_pipette_handler.py index 1dc16ef45b3..fcb4ab2a60c 100644 --- a/api/tests/opentrons/hardware_control/test_pipette_handler.py +++ b/api/tests/opentrons/hardware_control/test_pipette_handler.py @@ -2,12 +2,19 @@ import pytest from decoy import Decoy +from typing import Optional from opentrons import types +from opentrons.hardware_control.types import OT3Axis from opentrons.hardware_control.instruments.ot2.pipette import Pipette from opentrons.hardware_control.instruments.ot2.pipette_handler import ( PipetteHandlerProvider, ) +from opentrons.hardware_control.instruments.ot3.pipette import Pipette as OT3Pipette +from opentrons.hardware_control.instruments.ot3.pipette_handler import ( + PipetteHandlerProvider as OT3PipetteHandlerProvider, + TipMotorPickUpTipSpec, +) @pytest.fixture @@ -15,6 +22,11 @@ def mock_pipette(decoy: Decoy) -> Pipette: return decoy.mock(cls=Pipette) +@pytest.fixture +def mock_pipette_ot3(decoy: Decoy) -> OT3Pipette: + return decoy.mock(cls=OT3Pipette) + + @pytest.fixture def subject(decoy: Decoy, mock_pipette: Pipette) -> PipetteHandlerProvider: inst_by_mount = {types.Mount.LEFT: mock_pipette, types.Mount.RIGHT: None} @@ -22,6 +34,15 @@ def subject(decoy: Decoy, mock_pipette: Pipette) -> PipetteHandlerProvider: return subject +@pytest.fixture +def subject_ot3( + decoy: Decoy, mock_pipette_ot3: OT3Pipette +) -> OT3PipetteHandlerProvider: + inst_by_mount = {types.Mount.LEFT: mock_pipette_ot3, types.Mount.RIGHT: None} + subject = OT3PipetteHandlerProvider(attached_instruments=inst_by_mount) + return subject + + @pytest.mark.parametrize( "presses_input, expected_array_length", [(0, 0), (None, 3), (3, 3)] ) @@ -55,6 +76,62 @@ def test_plan_check_pick_up_tip_with_presses_argument( assert len(spec.presses) == expected_array_length +@pytest.mark.parametrize( + "presses_input, expected_array_length, channels, expected_pick_up_motor_actions", + [ + ( + 0, + 0, + 96, + TipMotorPickUpTipSpec( + tiprack_down=types.Point(0, 0, -5), + tiprack_up=types.Point(0, 0, 7), + pick_up_distance=0, + speed=10, + currents={OT3Axis.Q: 1}, + ), + ), + (None, 3, 8, None), + (3, 3, 1, None), + ], +) +def test_plan_check_pick_up_tip_with_presses_argument_ot3( + decoy: Decoy, + subject_ot3: PipetteHandlerProvider, + mock_pipette_ot3: OT3Pipette, + presses_input: int, + expected_array_length: int, + channels: int, + expected_pick_up_motor_actions: Optional[TipMotorPickUpTipSpec], +) -> None: + """Should return an array with expected length.""" + tip_length = 25.0 + mount = types.Mount.LEFT + presses = presses_input + increment = 1 + + decoy.when(mock_pipette_ot3.has_tip).then_return(False) + decoy.when(mock_pipette_ot3.pick_up_configurations.presses).then_return(3) + decoy.when(mock_pipette_ot3.pick_up_configurations.increment).then_return(increment) + decoy.when(mock_pipette_ot3.pick_up_configurations.speed).then_return(10) + decoy.when(mock_pipette_ot3.pick_up_configurations.distance).then_return(0) + decoy.when(mock_pipette_ot3.pick_up_configurations.current).then_return(1) + decoy.when(mock_pipette_ot3.config.quirks).then_return([]) + decoy.when(mock_pipette_ot3.channels.value).then_return(channels) + + if presses_input is None: + decoy.when(mock_pipette_ot3.config.pick_up_presses).then_return( + expected_array_length + ) + + spec, _add_tip_to_instrs = subject_ot3.plan_check_pick_up_tip( + mount, tip_length, presses, increment + ) + + assert len(spec.presses) == expected_array_length + assert spec.pick_up_motor_actions == expected_pick_up_motor_actions + + def test_get_pipette_fails(decoy: Decoy, subject: PipetteHandlerProvider): with pytest.raises(types.PipetteNotAttachedError): subject.get_pipette(types.Mount.RIGHT) diff --git a/api/tests/opentrons/protocol_api_old/core/simulator/test_instrument_context.py b/api/tests/opentrons/protocol_api_old/core/simulator/test_instrument_context.py index 3364edbcd87..a5084ff6b73 100644 --- a/api/tests/opentrons/protocol_api_old/core/simulator/test_instrument_context.py +++ b/api/tests/opentrons/protocol_api_old/core/simulator/test_instrument_context.py @@ -9,6 +9,17 @@ from opentrons.protocol_api.core.common import InstrumentCore, LabwareCore from opentrons.types import Location, Point +try: + import opentrons_hardware # noqa: F401 + + # TODO (lc 12-8-2022) Not sure if we plan to keep these tests, but if we do + # we should re-write them to be agnostic to the underlying hardware. Otherwise + # I wouldn't really consider these to be proper unit tests. + pytest.skip("These tests are only valid on the OT-2.", allow_module_level=True) +except ImportError: + # If we don't have opentrons_hardware, we can safely run these tests. + pass + @pytest.fixture( params=[ diff --git a/api/tests/opentrons/protocol_api_old/core/simulator/test_protocol_context.py b/api/tests/opentrons/protocol_api_old/core/simulator/test_protocol_context.py index 546557c0cbf..7aa26a3b601 100644 --- a/api/tests/opentrons/protocol_api_old/core/simulator/test_protocol_context.py +++ b/api/tests/opentrons/protocol_api_old/core/simulator/test_protocol_context.py @@ -30,6 +30,7 @@ def subject(request: pytest.FixtureRequest) -> ProtocolCore: return request.param # type: ignore[attr-defined, no-any-return] +@pytest.mark.ot2_only def test_replacing_instrument_tip_state( subject: ProtocolCore, tip_rack: LabwareCore ) -> None: diff --git a/api/tests/opentrons/protocol_api_old/test_context.py b/api/tests/opentrons/protocol_api_old/test_context.py index b44fea42666..c7a7118ac4e 100644 --- a/api/tests/opentrons/protocol_api_old/test_context.py +++ b/api/tests/opentrons/protocol_api_old/test_context.py @@ -17,7 +17,7 @@ ) from opentrons.types import Mount, Point, Location, TransferTipPolicy from opentrons.hardware_control import API, NoTipAttachedError, ThreadManagedHardware -from opentrons.hardware_control.instruments import Pipette +from opentrons.hardware_control.instruments.ot2.pipette import Pipette from opentrons.hardware_control.types import Axis from opentrons.protocols.advanced_control import transfers as tf from opentrons.protocols.api_support import instrument as instrument_support @@ -27,6 +27,17 @@ import pytest +try: + import opentrons_hardware # noqa: F401 + + # TODO (lc 12-8-2022) Not sure if we plan to keep these tests, but if we do + # we should re-write them to be agnostic to the underlying hardware. Otherwise + # I wouldn't really consider these to be proper unit tests. + pytest.skip("These tests are only valid on the OT-2.", allow_module_level=True) +except ImportError: + # If we don't have opentrons_hardware, we can safely run these tests. + pass + def set_version_added(attr, mp, version): """helper to mock versionadded for an attr @@ -418,7 +429,6 @@ def test_pick_up_tip_no_location(ctx, get_labware_def, pipette_model, tiprack_ki assert not tiprack2.wells()[0].has_tip -@pytest.mark.ot2_only def test_instrument_trash(ctx, get_labware_def): ctx.home() @@ -438,7 +448,7 @@ def test_instrument_trash_ot3(ctx, get_labware_def): ctx.home() mount = Mount.LEFT - instr = ctx.load_instrument("p300_single", mount) + instr = ctx.load_instrument("p1000_single_gen3", mount) assert instr.trash_container.name == "opentrons_1_trash_3200ml_fixed" @@ -995,7 +1005,6 @@ def test_order_of_module_load(): assert id(async_temp2) == id(hw_temp2) -@pytest.mark.ot2_only def test_tip_length_for_caldata(ctx, decoy, monkeypatch): # TODO (lc 10-27-2022) We need to investigate why the pipette id is # being reported as none for this test (and probably all the others) diff --git a/api/tests/opentrons/protocol_api_old/test_instrument.py b/api/tests/opentrons/protocol_api_old/test_instrument.py index 3af05703e11..79a68567c8a 100644 --- a/api/tests/opentrons/protocol_api_old/test_instrument.py +++ b/api/tests/opentrons/protocol_api_old/test_instrument.py @@ -8,6 +8,17 @@ import opentrons.protocol_api as papi +try: + import opentrons_hardware # noqa: F401 + + # TODO (lc 12-8-2022) Not sure if we plan to keep these tests, but if we do + # we should re-write them to be agnostic to the underlying hardware. Otherwise + # I wouldn't really consider these to be proper unit tests. + pytest.skip("These tests are only valid on the OT-2.", allow_module_level=True) +except ImportError: + # If we don't have opentrons_hardware, we can safely run these tests. + pass + @pytest.fixture def make_context_and_labware(hardware): diff --git a/api/tests/opentrons/protocols/advanced_control/test_transfers.py b/api/tests/opentrons/protocols/advanced_control/test_transfers.py index 547da1e33c8..5c9d25c8e42 100644 --- a/api/tests/opentrons/protocols/advanced_control/test_transfers.py +++ b/api/tests/opentrons/protocols/advanced_control/test_transfers.py @@ -7,6 +7,16 @@ import opentrons.protocol_api as papi +try: + import opentrons_hardware # noqa: F401 + + # TODO (lc 12-8-2022) We need to re-write these transfer tests so that + # they are agnostic to the underlying hardware. + pytest.skip("These tests are only valid on the OT-2.", allow_module_level=True) +except ImportError: + # If we don't have opentrons_hardware, we can safely run these tests. + pass + @pytest.fixture def _instr_labware(ctx): diff --git a/api/tests/opentrons/protocols/execution/test_execute_json_v3.py b/api/tests/opentrons/protocols/execution/test_execute_json_v3.py index 07aa5380f47..56c5f5cfb08 100644 --- a/api/tests/opentrons/protocols/execution/test_execute_json_v3.py +++ b/api/tests/opentrons/protocols/execution/test_execute_json_v3.py @@ -459,6 +459,7 @@ def test_dispatch_json_invalid_command(): ) +@pytest.mark.ot2_only def test_papi_execute_json_v3(monkeypatch, ctx, get_json_protocol_fixture): protocol_data = get_json_protocol_fixture("3", "testAllAtomicSingleV3", False) protocol = parse(protocol_data, None) diff --git a/api/tests/opentrons/protocols/execution/test_execute_json_v4.py b/api/tests/opentrons/protocols/execution/test_execute_json_v4.py index d0c88d3a1a6..39d0d4647ab 100644 --- a/api/tests/opentrons/protocols/execution/test_execute_json_v4.py +++ b/api/tests/opentrons/protocols/execution/test_execute_json_v4.py @@ -471,6 +471,7 @@ def test_dispatch_json_invalid_command( ) +@pytest.mark.ot2_only def test_papi_execute_json_v4(monkeypatch, ctx, get_json_protocol_fixture): protocol_data = get_json_protocol_fixture("4", "testModulesProtocol", False) protocol = parse(protocol_data, None) diff --git a/api/tests/opentrons/test_execute.py b/api/tests/opentrons/test_execute.py index 8bdf05c64ab..050f3d54f53 100644 --- a/api/tests/opentrons/test_execute.py +++ b/api/tests/opentrons/test_execute.py @@ -53,7 +53,7 @@ def test_execute_function_apiv2( "id": "testid", } mock_get_attached_instr.return_value[types.Mount.RIGHT] = { - "config": load(PipetteModel("p300_single_v1.5")), + "config": load(PipetteModel("p1000_single_v1")), "id": "testid2", } entries = [] @@ -64,10 +64,10 @@ def emit_runlog(entry: Any) -> None: execute.execute(protocol.filelike, "testosaur_v2.py", 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 300 µL on 1", - "Aspirating 10.0 uL from A1 of Corning 96 Well Plate 360 µL Flat on 2 at 150.0 uL/sec", - "Dispensing 10.0 uL into B1 of Corning 96 Well Plate 360 µL Flat on 2 at 300.0 uL/sec", - "Dropping tip into H12 of Opentrons 96 Tip Rack 300 µL on 1", + "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", ] diff --git a/hardware-testing/hardware_testing/opentrons_api/helpers_ot3.py b/hardware-testing/hardware_testing/opentrons_api/helpers_ot3.py index 08b32a96bf6..13c5fcf0c0f 100644 --- a/hardware-testing/hardware_testing/opentrons_api/helpers_ot3.py +++ b/hardware-testing/hardware_testing/opentrons_api/helpers_ot3.py @@ -1,5 +1,5 @@ """Opentrons helper methods.""" -from dataclasses import dataclass, replace +from dataclasses import dataclass from datetime import datetime from subprocess import run from time import time @@ -15,7 +15,7 @@ # TODO (lc 10-27-2022) This should be changed to an ot3 pipette object once we # have that well defined. -from opentrons.hardware_control.instruments.ot2.pipette import Pipette +from opentrons.hardware_control.instruments.ot3.pipette import Pipette from opentrons.hardware_control.motion_utilities import deck_from_machine from opentrons.hardware_control.ot3api import OT3API @@ -289,24 +289,32 @@ def get_plunger_positions_ot3( ) -> Tuple[float, float, float, float]: """Update plunger current.""" pipette = _get_pipette_from_mount(api, mount) - cfg = pipette.config - return cfg.top, cfg.bottom, cfg.blow_out, cfg.drop_tip + return ( + pipette.plunger_positions.top, + pipette.plunger_positions.bottom, + pipette.plunger_positions.blow_out, + pipette.plunger_positions.drop_tip, + ) async def update_pick_up_current( - api: OT3API, mount: OT3Mount, current: Optional[float] = 0.125 + api: OT3API, mount: OT3Mount, current: float = 0.125 ) -> None: """Update pick-up-tip current.""" pipette = _get_pipette_from_mount(api, mount) - pipette._config = replace(pipette.config, pick_up_current=current) + config_model = pipette.pick_up_configurations + config_model.current = current + pipette.pick_up_configurations = config_model async def update_pick_up_distance( - api: OT3API, mount: OT3Mount, distance: Optional[float] = 17.0 + api: OT3API, mount: OT3Mount, distance: float = 17.0 ) -> None: """Update pick-up-tip current.""" pipette = _get_pipette_from_mount(api, mount) - pipette._config = replace(pipette.config, pick_up_distance=distance) + config_model = pipette.pick_up_configurations + config_model.distance = distance + pipette.pick_up_configurations = config_model async def move_plunger_absolute_ot3( diff --git a/hardware-testing/hardware_testing/opentrons_api/p1000_gen3_ul_per_mm.py b/hardware-testing/hardware_testing/opentrons_api/p1000_gen3_ul_per_mm.py index 673acfe411f..66df1dae6d5 100644 --- a/hardware-testing/hardware_testing/opentrons_api/p1000_gen3_ul_per_mm.py +++ b/hardware-testing/hardware_testing/opentrons_api/p1000_gen3_ul_per_mm.py @@ -4,9 +4,7 @@ from opentrons.hardware_control.ot3api import OT3API -# TODO (lc 10-27-2022) This should be changed to an ot3 pipette object once we -# have that well defined. -from opentrons.hardware_control.instruments.ot2.pipette import Pipette +from opentrons.hardware_control.instruments.ot3.pipette import Pipette from opentrons_shared_data.pipette.dev_types import UlPerMm diff --git a/hardware-testing/protocols/ot2_p300_single_channel_gravimetric.py b/hardware-testing/protocols/ot2_p300_single_channel_gravimetric.py index 83d4e0c30b2..16610fa7e16 100644 --- a/hardware-testing/protocols/ot2_p300_single_channel_gravimetric.py +++ b/hardware-testing/protocols/ot2_p300_single_channel_gravimetric.py @@ -54,6 +54,8 @@ def _run(protocol: ProtocolContext) -> None: ) args = parser.parse_args() TEST_VIAL_LIQUID = args.test_vial_liquid - _ctx = helpers.get_api_context(metadata["apiLevel"], is_simulating=args.simulate) + _ctx = helpers.get_api_context( + metadata["apiLevel"], is_simulating=args.simulate, machine="ot2" + ) _ctx.home() _run(_ctx) diff --git a/hardware-testing/tests/hardware_testing/liquid/test_heights.py b/hardware-testing/tests/hardware_testing/liquid/test_heights.py index 8b1b8171ea4..e65749e56a1 100644 --- a/hardware-testing/tests/hardware_testing/liquid/test_heights.py +++ b/hardware-testing/tests/hardware_testing/liquid/test_heights.py @@ -178,8 +178,8 @@ def test_before_and_after_heights() -> None: # the Corning plate assumes a perfect cylinder without a lookup table tiprack, trough, plate, _ = _load_labware(ctx) # load a pipette - single = ctx.load_instrument("p300_single_gen2", mount="left", tip_racks=[tiprack]) - multi = ctx.load_instrument("p300_multi_gen2", mount="right", tip_racks=[tiprack]) + single = ctx.load_instrument("p1000_single_gen3", mount="left", tip_racks=[tiprack]) + multi = ctx.load_instrument("p1000_multi_gen3", mount="right", tip_racks=[tiprack]) # initialize liquid tracker tracker = LiquidTracker() tracker.initialize_from_deck(ctx) @@ -234,8 +234,8 @@ def test_update_affected_wells() -> None: # the Corning plate assumes a perfect cylinder without a lookup table tiprack, trough, plate, _ = _load_labware(ctx) # load a pipette - single = ctx.load_instrument("p300_single_gen2", mount="left", tip_racks=[tiprack]) - multi = ctx.load_instrument("p300_multi_gen2", mount="right", tip_racks=[tiprack]) + single = ctx.load_instrument("p1000_single_gen3", mount="left", tip_racks=[tiprack]) + multi = ctx.load_instrument("p1000_multi_gen3", mount="right", tip_racks=[tiprack]) # initialize liquid tracker tracker = LiquidTracker() tracker.initialize_from_deck(ctx) diff --git a/hardware/opentrons_hardware/hardware_control/motion.py b/hardware/opentrons_hardware/hardware_control/motion.py index ec3c7cbd1cc..202e73e4883 100644 --- a/hardware/opentrons_hardware/hardware_control/motion.py +++ b/hardware/opentrons_hardware/hardware_control/motion.py @@ -159,22 +159,21 @@ def create_home_step( def create_tip_action_step( velocity: Dict[NodeId, np.float64], - duration: np.float64, + distance: Dict[NodeId, np.float64], present_nodes: Iterable[NodeId], action: PipetteTipActionType, ) -> MoveGroupStep: """Creates a step for tip handling actions that require motor movement.""" - ordered_nodes = sorted(present_nodes, key=lambda node: node.value) step: MoveGroupStep = {} stop_condition = ( MoveStopCondition.limit_switch if action == PipetteTipActionType.drop else MoveStopCondition.none ) - for axis_node in ordered_nodes: + for axis_node in present_nodes: step[axis_node] = MoveGroupTipActionStep( - velocity_mm_sec=velocity.get(axis_node, np.float64(0)), - duration_sec=duration, + velocity_mm_sec=velocity[axis_node], + duration_sec=abs(distance[axis_node] / velocity[axis_node]), stop_condition=stop_condition, action=action, ) diff --git a/robot-server/robot_server/robot/calibration/util.py b/robot-server/robot_server/robot/calibration/util.py index b1fbe1022fe..d6a77747904 100644 --- a/robot-server/robot_server/robot/calibration/util.py +++ b/robot-server/robot_server/robot/calibration/util.py @@ -2,7 +2,7 @@ import contextlib from typing import Set, Dict, Any, Union, List, Optional, TYPE_CHECKING -from opentrons.hardware_control.instruments import Pipette +from opentrons.hardware_control.instruments.ot2.pipette import Pipette from opentrons.hardware_control.util import plan_arc from opentrons.hardware_control.types import CriticalPoint from opentrons.protocol_api import labware diff --git a/shared-data/js/__tests__/pipetteSchemaV2.test.ts b/shared-data/js/__tests__/pipetteSchemaV2.test.ts new file mode 100644 index 00000000000..25332eb7104 --- /dev/null +++ b/shared-data/js/__tests__/pipetteSchemaV2.test.ts @@ -0,0 +1,113 @@ +import Ajv from 'ajv' +import glob from 'glob' +import path from 'path' + +import liquidSpecsSchema from '../../pipette/schemas/2/pipetteLiquidPropertiesSchema.json' +import geometrySpecsSchema from '../../pipette/schemas/2/pipetteGeometrySchema.json' +import generalSpecsSchema from '../../pipette/schemas/2/pipettePropertiesSchema.json' + +const allGeometryDefinitions = path.join( + __dirname, + '../../pipette/definitions/2/geometry/**/**/*.json' +) + +const allGeneralDefinitions = path.join( + __dirname, + '../../labware/definitions/2/general/**/**/*.json' +) + +const allLiquidDefinitions = path.join( + __dirname, + '../../labware/definitions/2/liquid/**/**/*.json' +) + +const ajv = new Ajv({ allErrors: true, jsonPointers: true }) + +const validateLiquidSpecs = ajv.compile(liquidSpecsSchema) +const validateGeometrySpecs = ajv.compile(geometrySpecsSchema) +const validateGeneralSpecs = ajv.compile(generalSpecsSchema) + +describe('test schema against all liquid specs definitions', () => { + const liquidPaths = glob.sync(allLiquidDefinitions) + + beforeAll(() => { + // Make sure definitions path didn't break, which would give you false positives + expect(liquidPaths.length).toBeGreaterThan(0) + }) + + liquidPaths.forEach(liquidPath => { + const liquidDef = require(liquidPath) + + it(`${liquidPath} validates against schema`, () => { + const valid = validateLiquidSpecs(liquidDef) + const validationErrors = validateLiquidSpecs.errors + expect(validationErrors).toBe(null) + expect(valid).toBe(true) + }) + + it(`parent dir matches pipette model: ${liquidPath}`, () => { + expect(['p50', 'p1000']).toContain( + path.basename(path.dirname(liquidPath)) + ) + }) + }) +}) + +describe('test schema against all geometry specs definitions', () => { + const geometryPaths = glob.sync(allGeometryDefinitions) + + beforeAll(() => { + // Make sure definitions path didn't break, which would give you false positives + expect(geometryPaths.length).toBeGreaterThan(0) + }) + + geometryPaths.forEach(geometryPath => { + const geometryDef = require(geometryPath) + const geometryParentDir = path.dirname(geometryPath) + + it(`${geometryPath} validates against schema`, () => { + const valid = validateGeometrySpecs(geometryDef) + const validationErrors = validateGeometrySpecs.errors + expect(validationErrors).toBe(null) + expect(valid).toBe(true) + }) + + it(`parent dir matches pipette model: ${geometryPath}`, () => { + expect(['p50', 'p1000']).toContain( + path.basename(path.dirname(geometryPath)) + ) + }) + + it(`parent directory contains a gltf file: ${geometryPath}`, () => { + const gltf_file = glob.sync(path.join(geometryParentDir, '*.gltf')) + expect(gltf_file.length).toBeGreaterThan(0) + expect(gltf_file).toBeDefined() + }) + }) +}) + +describe('test schema against all general specs definitions', () => { + const generalPaths = glob.sync(allGeneralDefinitions) + + beforeAll(() => { + // Make sure definitions path didn't break, which would give you false positives + expect(generalPaths.length).toBeGreaterThan(0) + }) + + generalPaths.forEach(generalPath => { + const generalDef = require(generalPath) + + it(`${generalPath} validates against schema`, () => { + const valid = validateGeneralSpecs(generalDef) + const validationErrors = validateGeneralSpecs.errors + expect(validationErrors).toBe(null) + expect(valid).toBe(true) + }) + + it(`parent dir matches pipette model: ${generalPath}`, () => { + expect(['p50', 'p1000']).toContain( + path.basename(path.dirname(generalPath)) + ) + }) + }) +}) diff --git a/shared-data/js/__tests__/pipetteSpecSchemas.test.ts b/shared-data/js/__tests__/pipetteSpecSchemas.test.ts index df096876252..9368088c7ab 100644 --- a/shared-data/js/__tests__/pipetteSpecSchemas.test.ts +++ b/shared-data/js/__tests__/pipetteSpecSchemas.test.ts @@ -1,8 +1,8 @@ import Ajv from 'ajv' -import nameSpecsSchema from '../../pipette/schemas/pipetteNameSpecsSchema.json' -import modelSpecsSchema from '../../pipette/schemas/pipetteModelSpecsSchema.json' -import pipetteNameSpecs from '../../pipette/definitions/pipetteNameSpecs.json' -import pipetteModelSpecs from '../../pipette/definitions/pipetteModelSpecs.json' +import nameSpecsSchema from '../../pipette/schemas/1/pipetteNameSpecsSchema.json' +import modelSpecsSchema from '../../pipette/schemas/1/pipetteModelSpecsSchema.json' +import pipetteNameSpecs from '../../pipette/definitions/1/pipetteNameSpecs.json' +import pipetteModelSpecs from '../../pipette/definitions/1/pipetteModelSpecs.json' const ajv = new Ajv({ allErrors: true, jsonPointers: true }) diff --git a/shared-data/js/pipettes.ts b/shared-data/js/pipettes.ts index b6551782ac6..c237bd26c47 100644 --- a/shared-data/js/pipettes.ts +++ b/shared-data/js/pipettes.ts @@ -1,5 +1,5 @@ -import pipetteNameSpecs from '../pipette/definitions/pipetteNameSpecs.json' -import pipetteModelSpecs from '../pipette/definitions/pipetteModelSpecs.json' +import pipetteNameSpecs from '../pipette/definitions/1/pipetteNameSpecs.json' +import pipetteModelSpecs from '../pipette/definitions/1/pipetteModelSpecs.json' import { OT3_PIPETTES } from './constants' import type { PipetteNameSpecs, PipetteModelSpecs } from './types' diff --git a/shared-data/pipette/README.md b/shared-data/pipette/README.md index 9fc94d3fcc4..d6b198e3224 100644 --- a/shared-data/pipette/README.md +++ b/shared-data/pipette/README.md @@ -1,8 +1,37 @@ -# Pipette Spec Data +# Pipette Configurations + +## Schema Version 2 + +Information about our pipettes is now split into 3 different categories of data. Each data file is organized into `///`. + +- `configuration_type` is the top level category of data (i.e. `geometry` or `liquid`) +- `pipette_type` is the type of pipette generally referred to by the channel size (i.e. `single_channel` or `eight_channel`) +- `pipette_model` is the max volume of the pipette (i.e. `p50` or `p1000`) +- `pipette_version` is the version number flashed to the pipette (i.e. `v1` or `v1.2`) + +This organization is subject to change based on the model name changes that product might make. + +### Geometry Configurations: `shared-data/pipette/schemas/2/pipetteGeometrySchema.json` + +Pipette geometry configurations includes physical properties that map the pipette end effector in space. In this section of data, we would also like to store 3D model descriptor files that are compatible with typescript and other 3D modeling visualization software for future applications. + +We are planning to use [gltf](https://www.khronos.org/gltf/) formatted files as you can choose your 3D model anchors inside solidworks and export the file. + +### Liquid Configurations: `shared-data/pipette/schemas/2/pipetteLiquidPropertiesSchema.json` + +Pipette liquid configurations include all pipette properties that may affect liquid handling. This includes pipetting function and default flow rates based on tip size. + +We have now added in the ability to categorize liquid handling properties by tip type (which can also vary by brand). Eventually, we may need this to be more complex than just a look up dictionary of `tip_type` : `brand+liquid` but we can decide to make that change at a different time. + +### General Properties Configurations: `shared-data/pipette/schemas/2/pipettePropertiesSchema.json` + +Pipette general properties should be similar to schema version 1 name specs that are shared across pipette type + model. + +## Schema Version 1 Information about our pipettes is split into 2 different files. -## Name Level: `shared-data/pipette/definitions/pipetteNameSpecs.json` +### Name Level: `shared-data/pipette/schemas/1/pipetteNameSpecs.json` A pipette name is what is communicated with customers, what is listed in the store, etc. Name-level information does not vary across pipettes with the same "name", it includes: min and max volume, display name, number of channels, and default aspirate/dispense flow rates. @@ -10,12 +39,12 @@ The "name" is all that is communicated to the average user about a pipette. Both `"p10_single"` is an example of a name. -## Model Level: `shared-data/pipette/definitions/pipetteModelSpecs.json` +### Model Level: `shared-data/pipette/schemas/1/pipetteModelSpecs.json` A "model" is synonymous with a part number. Our models / part numbers look like `"p10_single_v1.3"`. Although the name is a substring of the model string, it isn't a good idea to infer name by parsing it out of the model. The model level contains information specific to particular pipette models. The model can be read off of a pipette's EEPROM at runtime. This information is required for protocol execution on the robot, but is not used directly in the code of JSON or Python protocols. -# JSON Schemas +## JSON Schemas In `shared-data/pipette/schemas/` there are JSON schemas for these files, which ensure data integrity. Further descriptions about the individual fields are written into the schemas. diff --git a/shared-data/pipette/definitions/pipetteModelSpecs.json b/shared-data/pipette/definitions/1/pipetteModelSpecs.json similarity index 100% rename from shared-data/pipette/definitions/pipetteModelSpecs.json rename to shared-data/pipette/definitions/1/pipetteModelSpecs.json diff --git a/shared-data/pipette/definitions/pipetteNameSpecs.json b/shared-data/pipette/definitions/1/pipetteNameSpecs.json similarity index 100% rename from shared-data/pipette/definitions/pipetteNameSpecs.json rename to shared-data/pipette/definitions/1/pipetteNameSpecs.json diff --git a/shared-data/pipette/definitions/2/general/eight_channel/p1000/1_0.json b/shared-data/pipette/definitions/2/general/eight_channel/p1000/1_0.json new file mode 100644 index 00000000000..1eb2478cf83 --- /dev/null +++ b/shared-data/pipette/definitions/2/general/eight_channel/p1000/1_0.json @@ -0,0 +1,38 @@ +{ + "$otSharedSchema": "#/pipette/schemas/2/pipettePropertiesSchema.json", + "displayName": "P1000 Eight Channel GEN3", + "model": "p1000", + "displayCategory": "GEN3", + "pickUpTipConfigurations": { + "current": 0.5, + "presses": 1, + "speed": 10, + "increment": 0.0, + "distance": 13.0 + }, + "dropTipConfigurations": { + "current": 1.0, + "speed": 10 + }, + "plungerMotorConfigurations": { + "idle": 0.3, + "run": 1.0 + }, + "plungerPositionsConfigurations": { + "top": 0.5, + "bottom": 71.5, + "blowout": 76.5, + "drop": 92.5 + }, + "availableSensors": { + "sensors": ["pressure", "capacitive", "environment"], + "pressure": { "count": 2 }, + "capacitive": { "count": 2 }, + "environment": { "count": 1 } + }, + "partialTipConfigurations": { + "partialTipSupported": true, + "availableConfigurations": [1, 2, 3, 4, 5, 6, 7, 8] + }, + "channels": 8 +} diff --git a/shared-data/pipette/definitions/2/general/eight_channel/p50/1_0.json b/shared-data/pipette/definitions/2/general/eight_channel/p50/1_0.json new file mode 100644 index 00000000000..50d177c5619 --- /dev/null +++ b/shared-data/pipette/definitions/2/general/eight_channel/p50/1_0.json @@ -0,0 +1,38 @@ +{ + "$otSharedSchema": "#/pipette/schemas/2/pipettePropertiesSchema.json", + "displayName": "P50 Eight Channel GEN3", + "model": "p50", + "displayCategory": "GEN3", + "pickUpTipConfigurations": { + "current": 0.5, + "presses": 1, + "speed": 10, + "increment": 0.0, + "distance": 13.0 + }, + "dropTipConfigurations": { + "current": 1.0, + "speed": 10 + }, + "plungerMotorConfigurations": { + "idle": 0.3, + "run": 1.0 + }, + "plungerPositionsConfigurations": { + "top": 0.5, + "bottom": 71.5, + "blowout": 76.5, + "drop": 92.5 + }, + "availableSensors": { + "sensors": ["pressure", "capacitive", "environment"], + "pressure": { "count": 2 }, + "capacitive": { "count": 2 }, + "environment": { "count": 1 } + }, + "partialTipConfigurations": { + "partialTipSupported": true, + "availableConfigurations": [1, 2, 3, 4, 5, 6, 7, 8] + }, + "channels": 8 +} diff --git a/shared-data/pipette/definitions/2/general/ninety_six_channel/p1000/1_0.json b/shared-data/pipette/definitions/2/general/ninety_six_channel/p1000/1_0.json new file mode 100644 index 00000000000..31797450a45 --- /dev/null +++ b/shared-data/pipette/definitions/2/general/ninety_six_channel/p1000/1_0.json @@ -0,0 +1,38 @@ +{ + "$otSharedSchema": "#/pipette/schemas/2/pipettePropertiesSchema.json", + "displayName": "P1000 96 Channel GEN3", + "model": "p1000", + "displayCategory": "GEN3", + "pickUpTipConfigurations": { + "current": 1.5, + "presses": 0.0, + "speed": 5.5, + "increment": 0.0, + "distance": 13.75 + }, + "dropTipConfigurations": { + "current": 1.5, + "speed": 10 + }, + "plungerMotorConfigurations": { + "idle": 0.3, + "run": 2.0 + }, + "plungerPositionsConfigurations": { + "top": 0, + "bottom": 66, + "blowout": 71, + "drop": 80 + }, + "availableSensors": { + "sensors": ["pressure", "capacitive", "environment"], + "pressure": { "count": 2 }, + "capacitive": { "count": 2 }, + "environment": { "count": 1 } + }, + "partialTipConfigurations": { + "partialTipSupported": true, + "availableConfigurations": [1, 8, 12, 96] + }, + "channels": 96 +} diff --git a/shared-data/pipette/definitions/2/general/single_channel/p1000/1_0.json b/shared-data/pipette/definitions/2/general/single_channel/p1000/1_0.json new file mode 100644 index 00000000000..222db4dd1bc --- /dev/null +++ b/shared-data/pipette/definitions/2/general/single_channel/p1000/1_0.json @@ -0,0 +1,37 @@ +{ + "$otSharedSchema": "#/pipette/schemas/2/pipettePropertiesSchema.json", + "displayName": "P1000 One Channel GEN3", + "model": "p1000", + "displayCategory": "GEN3", + "pickUpTipConfigurations": { + "current": 0.15, + "presses": 1, + "speed": 10, + "increment": 0.0, + "distance": 13.0 + }, + "dropTipConfigurations": { + "current": 1.0, + "speed": 10 + }, + "plungerMotorConfigurations": { + "idle": 0.3, + "run": 1.0 + }, + "plungerPositionsConfigurations": { + "top": 0.5, + "bottom": 71.5, + "blowout": 76.5, + "drop": 90.5 + }, + "availableSensors": { + "sensors": ["pressure", "capacitive", "environment"], + "pressure": { "count": 1 }, + "capacitive": { "count": 1 }, + "environment": { "count": 1 } + }, + "partialTipConfigurations": { + "partialTipSupported": false + }, + "channels": 1 +} diff --git a/shared-data/pipette/definitions/2/general/single_channel/p50/1_0.json b/shared-data/pipette/definitions/2/general/single_channel/p50/1_0.json new file mode 100644 index 00000000000..4ef9e60a2a3 --- /dev/null +++ b/shared-data/pipette/definitions/2/general/single_channel/p50/1_0.json @@ -0,0 +1,37 @@ +{ + "$otSharedSchema": "#/pipette/schemas/2/pipettePropertiesSchema.json", + "displayName": "P50 One Channel GEN3", + "model": "p50", + "displayCategory": "GEN3", + "pickUpTipConfigurations": { + "current": 0.15, + "presses": 1, + "speed": 10, + "increment": 0.0, + "distance": 13.0 + }, + "dropTipConfigurations": { + "current": 1.0, + "speed": 10 + }, + "plungerMotorConfigurations": { + "idle": 0.3, + "run": 1.0 + }, + "plungerPositionsConfigurations": { + "top": 0.5, + "bottom": 71.5, + "blowout": 76.5, + "drop": 90.5 + }, + "availableSensors": { + "sensors": ["pressure", "capacitive", "environment"], + "pressure": { "count": 1 }, + "capacitive": { "count": 1 }, + "environment": { "count": 1 } + }, + "partialTipConfigurations": { + "partialTipSupported": false + }, + "channels": 1 +} diff --git a/shared-data/pipette/definitions/2/geometry/eight_channel/p1000/1_0.json b/shared-data/pipette/definitions/2/geometry/eight_channel/p1000/1_0.json new file mode 100644 index 00000000000..b0f29297cc0 --- /dev/null +++ b/shared-data/pipette/definitions/2/geometry/eight_channel/p1000/1_0.json @@ -0,0 +1,5 @@ +{ + "$otSharedSchema": "#/pipette/schemas/2/pipetteGeometrySchema.json", + "pathTo3D": "pipette/definitions/2/eight_channel/p50/placeholder.gltf", + "nozzleOffset": [-8.0, -16.0, -259.15] +} diff --git a/shared-data/pipette/definitions/2/geometry/eight_channel/p1000/placeholder.gltf b/shared-data/pipette/definitions/2/geometry/eight_channel/p1000/placeholder.gltf new file mode 100644 index 00000000000..e69de29bb2d diff --git a/shared-data/pipette/definitions/2/geometry/eight_channel/p50/1_0.json b/shared-data/pipette/definitions/2/geometry/eight_channel/p50/1_0.json new file mode 100644 index 00000000000..8769a00ffc2 --- /dev/null +++ b/shared-data/pipette/definitions/2/geometry/eight_channel/p50/1_0.json @@ -0,0 +1,5 @@ +{ + "$otSharedSchema": "#/pipette/schemas/2/pipetteGeometrySchema.json", + "pathTo3D": "pipette/definitions/2/eight_channel/p1000/placeholder.gltf", + "nozzleOffset": [-8.0, -16.0, -259.15] +} diff --git a/shared-data/pipette/definitions/2/geometry/eight_channel/p50/placeholder.gltf b/shared-data/pipette/definitions/2/geometry/eight_channel/p50/placeholder.gltf new file mode 100644 index 00000000000..e69de29bb2d diff --git a/shared-data/pipette/definitions/2/geometry/ninety_six_channel/p1000/1_0.json b/shared-data/pipette/definitions/2/geometry/ninety_six_channel/p1000/1_0.json new file mode 100644 index 00000000000..090c7a22699 --- /dev/null +++ b/shared-data/pipette/definitions/2/geometry/ninety_six_channel/p1000/1_0.json @@ -0,0 +1,5 @@ +{ + "$otSharedSchema": "#/pipette/schemas/2/pipetteGeometrySchema.json", + "pathTo3D": "pipette/definitions/2/ninety_six_channel/p1000/placeholder.gltf", + "nozzleOffset": [-36.0, -25.5, -259.15] +} diff --git a/shared-data/pipette/definitions/2/geometry/ninety_six_channel/p1000/placeholder.gltf b/shared-data/pipette/definitions/2/geometry/ninety_six_channel/p1000/placeholder.gltf new file mode 100644 index 00000000000..e69de29bb2d diff --git a/shared-data/pipette/definitions/2/geometry/single_channel/p1000/1_0.json b/shared-data/pipette/definitions/2/geometry/single_channel/p1000/1_0.json new file mode 100644 index 00000000000..3598874a13e --- /dev/null +++ b/shared-data/pipette/definitions/2/geometry/single_channel/p1000/1_0.json @@ -0,0 +1,5 @@ +{ + "$otSharedSchema": "#/pipette/schemas/2/pipetteGeometrySchema.json", + "pathTo3D": "pipette/definitions/2/single_channel/p1000/placeholder.gltf", + "nozzleOffset": [-8.0, -22.0, -259.15] +} diff --git a/shared-data/pipette/definitions/2/geometry/single_channel/p1000/placeholder.gltf b/shared-data/pipette/definitions/2/geometry/single_channel/p1000/placeholder.gltf new file mode 100644 index 00000000000..e69de29bb2d diff --git a/shared-data/pipette/definitions/2/geometry/single_channel/p50/1_0.json b/shared-data/pipette/definitions/2/geometry/single_channel/p50/1_0.json new file mode 100644 index 00000000000..c723f77f5bc --- /dev/null +++ b/shared-data/pipette/definitions/2/geometry/single_channel/p50/1_0.json @@ -0,0 +1,5 @@ +{ + "$otSharedSchema": "#/pipette/schemas/2/pipetteGeometrySchema.json", + "pathTo3D": "pipette/definitions/2/single_channel/p50/placeholder.gltf", + "nozzleOffset": [-8.0, -22.0, -259.15] +} diff --git a/shared-data/pipette/definitions/2/geometry/single_channel/p50/placeholder.gltf b/shared-data/pipette/definitions/2/geometry/single_channel/p50/placeholder.gltf new file mode 100644 index 00000000000..e69de29bb2d 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 new file mode 100644 index 00000000000..ee1b9451aac --- /dev/null +++ b/shared-data/pipette/definitions/2/liquid/eight_channel/p1000/1_0.json @@ -0,0 +1,308 @@ +{ + "$otSharedSchema": "#/pipette/schemas/2/pipetteLiquidPropertiesSchema.json", + "supportedTips": { + "t50": { + "defaultAspirateFlowRate": 6, + "defaultDispenseFlowRate": 6, + "defaultBlowOutFlowRate": 80, + "defaultTipLength": 57.9, + "defaultTipOverlap": 10.5, + "defaultReturnTipHeight": 0.83, + "aspirate": { + "default": [ + [0.4148, -1705.1015, 20.5455], + [0.4476, -80.633, 47.2788], + [0.5512, -1.5936, 11.9026], + [0.6027, -18.9998, 21.4972], + [0.6503, -15.8781, 19.6156], + [0.7733, 3.0612, 7.2993], + [0.8391, -5.2227, 13.7056], + [0.9736, 3.0706, 6.7467], + [1.16, -0.374, 10.1005], + [1.3964, 1.3004, 8.1582], + [1.5815, -0.4837, 10.6494], + [1.8306, 1.1464, 8.0714], + [2.0345, 0.0132, 10.1459], + [2.6221, 0.5374, 9.0794], + [2.9655, -1.7582, 15.0986], + [3.5124, 0.2754, 9.0681], + [4.6591, 1.406, 5.097], + [5.367, 0.394, 9.8123], + [6.0839, 0.3365, 10.1205], + [6.8312, 0.3379, 10.1121], + [7.5676, 0.2611, 10.637], + [8.2397, 0.095, 11.8939], + [8.9776, 0.2015, 11.0165], + [10.413, 0.1332, 11.6294], + [11.8539, 0.1074, 11.8979], + [13.3655, 0.1286, 11.6464], + [14.8236, 0.0758, 12.3519], + [16.3203, 0.083, 12.2457], + [17.7915, 0.0581, 12.6515], + [19.2145, 0.0273, 13.1995], + [20.6718, 0.0388, 12.9792], + [22.1333, 0.0357, 13.044], + [25.0761, 0.0332, 13.0977], + [28.0339, 0.029, 13.2035], + [30.967, 0.0201, 13.4538], + [33.8727, 0.013, 13.6737], + [36.8273, 0.0172, 13.5324], + [39.7594, 0.0121, 13.7191], + [42.6721, 0.0083, 13.8687], + [45.5964, 0.0085, 13.8618], + [48.5297, 0.0084, 13.8668], + [51.4512, 0.0064, 13.9651] + ] + }, + "dispense": { + "default": [ + [0.4148, -1705.1015, 20.5455], + [0.4476, -80.633, 47.2788], + [0.5512, -1.5936, 11.9026], + [0.6027, -18.9998, 21.4972], + [0.6503, -15.8781, 19.6156], + [0.7733, 3.0612, 7.2993], + [0.8391, -5.2227, 13.7056], + [0.9736, 3.0706, 6.7467], + [1.16, -0.374, 10.1005], + [1.3964, 1.3004, 8.1582], + [1.5815, -0.4837, 10.6494], + [1.8306, 1.1464, 8.0714], + [2.0345, 0.0132, 10.1459], + [2.6221, 0.5374, 9.0794], + [2.9655, -1.7582, 15.0986], + [3.5124, 0.2754, 9.0681], + [4.6591, 1.406, 5.097], + [5.367, 0.394, 9.8123], + [6.0839, 0.3365, 10.1205], + [6.8312, 0.3379, 10.1121], + [7.5676, 0.2611, 10.637], + [8.2397, 0.095, 11.8939], + [8.9776, 0.2015, 11.0165], + [10.413, 0.1332, 11.6294], + [11.8539, 0.1074, 11.8979], + [13.3655, 0.1286, 11.6464], + [14.8236, 0.0758, 12.3519], + [16.3203, 0.083, 12.2457], + [17.7915, 0.0581, 12.6515], + [19.2145, 0.0273, 13.1995], + [20.6718, 0.0388, 12.9792], + [22.1333, 0.0357, 13.044], + [25.0761, 0.0332, 13.0977], + [28.0339, 0.029, 13.2035], + [30.967, 0.0201, 13.4538], + [33.8727, 0.013, 13.6737], + [36.8273, 0.0172, 13.5324], + [39.7594, 0.0121, 13.7191], + [42.6721, 0.0083, 13.8687], + [45.5964, 0.0085, 13.8618], + [48.5297, 0.0084, 13.8668], + [51.4512, 0.0064, 13.9651] + ] + } + }, + "t200": { + "defaultAspirateFlowRate": 80, + "defaultDispenseFlowRate": 80, + "defaultBlowOutFlowRate": 80, + "defaultTipLength": 58.35, + "defaultTipOverlap": 10.5, + "defaultReturnTipHeight": 0.83, + "aspirate": { + "default": [ + [0.8314, -2.9322, 24.0741], + [0.8853, -30.0996, 48.7784], + [0.9778, -4.3627, 25.9941], + [0.975, 802.2301, -762.6744], + [1.1272, -4.6837, 24.0666], + [1.2747, -3.91, 23.1945], + [1.5656, -2.8032, 21.7836], + [1.6667, -7.2039, 28.6731], + [2.4403, -0.5147, 17.5244], + [3.0564, -1.6013, 20.1761], + [3.6444, -1.1974, 18.9418], + [4.1189, -1.7877, 21.0928], + [4.6467, -0.8591, 17.2684], + [5.2597, -0.207, 14.2379], + [5.8581, -0.2196, 14.3044], + [6.4772, -0.1025, 13.6183], + [7.8158, 0.0537, 12.6063], + [9.1664, 0.0507, 12.6302], + [10.5064, 0.0285, 12.8339], + [14.8361, 0.0818, 12.273], + [19.3933, 0.0801, 12.2991], + [23.9242, 0.0487, 12.9079], + [28.4922, 0.0379, 13.1666], + [36.145, 0.0277, 13.4572], + [43.7972, 0.0184, 13.7916], + [51.5125, 0.0154, 13.9248], + [59.2467, 0.0121, 14.0931], + [66.9428, 0.0084, 14.3151], + [74.6853, 0.0079, 14.3498], + [82.3722, 0.0052, 14.5512], + [90.1106, 0.0054, 14.5333], + [97.8369, 0.0043, 14.6288], + [105.6153, 0.0046, 14.5983], + [113.3686, 0.0036, 14.7076], + [121.1108, 0.003, 14.7785], + [136.61, 0.0026, 14.826], + [152.0708, 0.0018, 14.9298], + [167.6433, 0.0021, 14.8827], + [183.1011, 0.0012, 15.0438], + [198.5845, 0.0011, 15.0538], + [214.0264, 0.0008, 15.123] + ] + }, + "dispense": { + "default": [ + [0.8314, -2.9322, 24.0741], + [0.8853, -30.0996, 48.7784], + [0.9778, -4.3627, 25.9941], + [0.975, 802.2301, -762.6744], + [1.1272, -4.6837, 24.0666], + [1.2747, -3.91, 23.1945], + [1.5656, -2.8032, 21.7836], + [1.6667, -7.2039, 28.6731], + [2.4403, -0.5147, 17.5244], + [3.0564, -1.6013, 20.1761], + [3.6444, -1.1974, 18.9418], + [4.1189, -1.7877, 21.0928], + [4.6467, -0.8591, 17.2684], + [5.2597, -0.207, 14.2379], + [5.8581, -0.2196, 14.3044], + [6.4772, -0.1025, 13.6183], + [7.8158, 0.0537, 12.6063], + [9.1664, 0.0507, 12.6302], + [10.5064, 0.0285, 12.8339], + [14.8361, 0.0818, 12.273], + [19.3933, 0.0801, 12.2991], + [23.9242, 0.0487, 12.9079], + [28.4922, 0.0379, 13.1666], + [36.145, 0.0277, 13.4572], + [43.7972, 0.0184, 13.7916], + [51.5125, 0.0154, 13.9248], + [59.2467, 0.0121, 14.0931], + [66.9428, 0.0084, 14.3151], + [74.6853, 0.0079, 14.3498], + [82.3722, 0.0052, 14.5512], + [90.1106, 0.0054, 14.5333], + [97.8369, 0.0043, 14.6288], + [105.6153, 0.0046, 14.5983], + [113.3686, 0.0036, 14.7076], + [121.1108, 0.003, 14.7785], + [136.61, 0.0026, 14.826], + [152.0708, 0.0018, 14.9298], + [167.6433, 0.0021, 14.8827], + [183.1011, 0.0012, 15.0438], + [198.5845, 0.0011, 15.0538], + [214.0264, 0.0008, 15.123] + ] + } + }, + "t1000": { + "defaultAspirateFlowRate": 160, + "defaultDispenseFlowRate": 160, + "defaultBlowOutFlowRate": 80, + "defaultTipLength": 95.6, + "defaultTipOverlap": 10.5, + "defaultReturnTipHeight": 0.83, + "aspirate": { + "default": [ + [0.7511, 3.9556, 6.455], + [1.3075, 2.1664, 5.8839], + [1.8737, 1.1513, 7.2111], + [3.177, 0.9374, 7.612], + [4.5368, 0.5531, 8.8328], + [7.3103, 0.3035, 9.9651], + [10.0825, 0.1513, 11.0781], + [12.9776, 0.1293, 11.2991], + [15.9173, 0.0976, 11.7115], + [18.8243, 0.0624, 12.2706], + [21.8529, 0.07, 12.1275], + [24.8068, 0.0418, 12.7442], + [27.7744, 0.0356, 12.8984], + [35.2873, 0.0303, 13.0454], + [42.7989, 0.0202, 13.4038], + [50.4562, 0.0196, 13.4293], + [58.1081, 0.0145, 13.6843], + [65.7267, 0.0104, 13.9252], + [73.2857, 0.0068, 14.1606], + [81.0016, 0.0091, 13.9883], + [88.6617, 0.0064, 14.2052], + [103.9829, 0.0051, 14.3271], + [119.4408, 0.0049, 14.3475], + [134.889, 0.0037, 14.485], + [150.273, 0.0026, 14.6402], + [181.2798, 0.0026, 14.6427], + [212.4724, 0.0022, 14.7002], + [243.577, 0.0015, 14.8558], + [274.7216, 0.0012, 14.9205], + [305.8132, 0.0009, 15.0118], + [368.0697, 0.0007, 15.0668], + [430.2513, 0.0005, 15.1594], + [492.3487, 0.0003, 15.2291], + [554.5713, 0.0003, 15.2367], + [616.6825, 0.0002, 15.2949], + [694.4168, 0.0002, 15.3027], + [772.0327, 0.0001, 15.3494], + [849.617, 0.0001, 15.3717], + [927.2556, 0.0001, 15.3745], + [1004.87, 0.0001, 15.3912], + [1051.4648, 0.0001, 15.391] + ] + }, + "dispense": { + "default": [ + [0.7511, 3.9556, 6.455], + [1.3075, 2.1664, 5.8839], + [1.8737, 1.1513, 7.2111], + [3.177, 0.9374, 7.612], + [4.5368, 0.5531, 8.8328], + [7.3103, 0.3035, 9.9651], + [10.0825, 0.1513, 11.0781], + [12.9776, 0.1293, 11.2991], + [15.9173, 0.0976, 11.7115], + [18.8243, 0.0624, 12.2706], + [21.8529, 0.07, 12.1275], + [24.8068, 0.0418, 12.7442], + [27.7744, 0.0356, 12.8984], + [35.2873, 0.0303, 13.0454], + [42.7989, 0.0202, 13.4038], + [50.4562, 0.0196, 13.4293], + [58.1081, 0.0145, 13.6843], + [65.7267, 0.0104, 13.9252], + [73.2857, 0.0068, 14.1606], + [81.0016, 0.0091, 13.9883], + [88.6617, 0.0064, 14.2052], + [103.9829, 0.0051, 14.3271], + [119.4408, 0.0049, 14.3475], + [134.889, 0.0037, 14.485], + [150.273, 0.0026, 14.6402], + [181.2798, 0.0026, 14.6427], + [212.4724, 0.0022, 14.7002], + [243.577, 0.0015, 14.8558], + [274.7216, 0.0012, 14.9205], + [305.8132, 0.0009, 15.0118], + [368.0697, 0.0007, 15.0668], + [430.2513, 0.0005, 15.1594], + [492.3487, 0.0003, 15.2291], + [554.5713, 0.0003, 15.2367], + [616.6825, 0.0002, 15.2949], + [694.4168, 0.0002, 15.3027], + [772.0327, 0.0001, 15.3494], + [849.617, 0.0001, 15.3717], + [927.2556, 0.0001, 15.3745], + [1004.87, 0.0001, 15.3912], + [1051.4648, 0.0001, 15.391] + ] + } + } + }, + "maxVolume": 1000, + "minVolume": 5, + "defaultTipracks": [ + "opentrons/opentrons_ot3_96_tiprack_1000ul/1", + "opentrons/opentrons_ot3_96_tiprack_200ul/1", + "opentrons/opentrons_ot3_96_tiprack_50ul/1" + ] +} diff --git a/shared-data/pipette/definitions/2/liquid/eight_channel/p50/1_0.json b/shared-data/pipette/definitions/2/liquid/eight_channel/p50/1_0.json new file mode 100644 index 00000000000..92e6be23beb --- /dev/null +++ b/shared-data/pipette/definitions/2/liquid/eight_channel/p50/1_0.json @@ -0,0 +1,80 @@ +{ + "$otSharedSchema": "#/pipette/schemas/2/pipetteLiquidPropertiesSchema.json", + "supportedTips": { + "t50": { + "defaultAspirateFlowRate": 8, + "defaultDispenseFlowRate": 8, + "defaultBlowOutFlowRate": 4, + "defaultTipLength": 57.9, + "defaultTipOverlap": 10.5, + "defaultReturnTipHeight": 0.83, + "aspirate": { + "default": [ + [0.6464, 0.4817, 0.0427], + [1.0889, 0.2539, 0.1591], + [1.5136, 0.1624, 0.2587], + [1.9108, 0.1042, 0.3467], + [2.2941, 0.0719, 0.4085], + [2.9978, 0.037, 0.4886], + [3.7731, 0.0378, 0.4863], + [4.7575, 0.0516, 0.4342], + [5.5024, 0.011, 0.6275], + [6.2686, 0.0114, 0.6253], + [7.005, 0.0054, 0.6625], + [8.5207, 0.0063, 0.6563], + [10.0034, 0.003, 0.6844], + [11.5075, 0.0031, 0.6833], + [13.0327, 0.0032, 0.6829], + [14.5356, 0.0018, 0.7003], + [17.5447, 0.0014, 0.7063], + [20.5576, 0.0011, 0.7126], + [23.5624, 0.0007, 0.7197], + [26.5785, 0.0007, 0.721], + [29.593, 0.0005, 0.7248], + [32.6109, 0.0004, 0.7268], + [35.6384, 0.0004, 0.727], + [38.6439, 0.0002, 0.7343], + [41.6815, 0.0004, 0.7284], + [44.6895, 0.0002, 0.7372], + [47.6926, 0.0001, 0.7393], + [51.4567, 0.0001, 0.7382] + ] + }, + "dispense": { + "default": [ + [0.6464, 0.4817, 0.0427], + [1.0889, 0.2539, 0.1591], + [1.5136, 0.1624, 0.2587], + [1.9108, 0.1042, 0.3467], + [2.2941, 0.0719, 0.4085], + [2.9978, 0.037, 0.4886], + [3.7731, 0.0378, 0.4863], + [4.7575, 0.0516, 0.4342], + [5.5024, 0.011, 0.6275], + [6.2686, 0.0114, 0.6253], + [7.005, 0.0054, 0.6625], + [8.5207, 0.0063, 0.6563], + [10.0034, 0.003, 0.6844], + [11.5075, 0.0031, 0.6833], + [13.0327, 0.0032, 0.6829], + [14.5356, 0.0018, 0.7003], + [17.5447, 0.0014, 0.7063], + [20.5576, 0.0011, 0.7126], + [23.5624, 0.0007, 0.7197], + [26.5785, 0.0007, 0.721], + [29.593, 0.0005, 0.7248], + [32.6109, 0.0004, 0.7268], + [35.6384, 0.0004, 0.727], + [38.6439, 0.0002, 0.7343], + [41.6815, 0.0004, 0.7284], + [44.6895, 0.0002, 0.7372], + [47.6926, 0.0001, 0.7393], + [51.4567, 0.0001, 0.7382] + ] + } + } + }, + "maxVolume": 50, + "minVolume": 0.5, + "defaultTipracks": ["opentrons/opentrons_ot3_96_tiprack_50ul/1"] +} 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 new file mode 100644 index 00000000000..74cbc028bc7 --- /dev/null +++ b/shared-data/pipette/definitions/2/liquid/ninety_six_channel/p1000/1_0.json @@ -0,0 +1,308 @@ +{ + "$otSharedSchema": "#/pipette/schemas/2/pipetteLiquidPropertiesSchema.json", + "supportedTips": { + "t50": { + "defaultAspirateFlowRate": 6, + "defaultDispenseFlowRate": 6, + "defaultBlowOutFlowRate": 80, + "defaultTipLength": 57.9, + "defaultTipOverlap": 10.5, + "defaultReturnTipHeight": 0.83, + "aspirate": { + "default": [ + [0.4148, -1705.1015, 20.5455], + [0.4476, -80.633, 47.2788], + [0.5512, -1.5936, 11.9026], + [0.6027, -18.9998, 21.4972], + [0.6503, -15.8781, 19.6156], + [0.7733, 3.0612, 7.2993], + [0.8391, -5.2227, 13.7056], + [0.9736, 3.0706, 6.7467], + [1.16, -0.374, 10.1005], + [1.3964, 1.3004, 8.1582], + [1.5815, -0.4837, 10.6494], + [1.8306, 1.1464, 8.0714], + [2.0345, 0.0132, 10.1459], + [2.6221, 0.5374, 9.0794], + [2.9655, -1.7582, 15.0986], + [3.5124, 0.2754, 9.0681], + [4.6591, 1.406, 5.097], + [5.367, 0.394, 9.8123], + [6.0839, 0.3365, 10.1205], + [6.8312, 0.3379, 10.1121], + [7.5676, 0.2611, 10.637], + [8.2397, 0.095, 11.8939], + [8.9776, 0.2015, 11.0165], + [10.413, 0.1332, 11.6294], + [11.8539, 0.1074, 11.8979], + [13.3655, 0.1286, 11.6464], + [14.8236, 0.0758, 12.3519], + [16.3203, 0.083, 12.2457], + [17.7915, 0.0581, 12.6515], + [19.2145, 0.0273, 13.1995], + [20.6718, 0.0388, 12.9792], + [22.1333, 0.0357, 13.044], + [25.0761, 0.0332, 13.0977], + [28.0339, 0.029, 13.2035], + [30.967, 0.0201, 13.4538], + [33.8727, 0.013, 13.6737], + [36.8273, 0.0172, 13.5324], + [39.7594, 0.0121, 13.7191], + [42.6721, 0.0083, 13.8687], + [45.5964, 0.0085, 13.8618], + [48.5297, 0.0084, 13.8668], + [51.4512, 0.0064, 13.9651] + ] + }, + "dispense": { + "default": [ + [0.4148, -1705.1015, 20.5455], + [0.4476, -80.633, 47.2788], + [0.5512, -1.5936, 11.9026], + [0.6027, -18.9998, 21.4972], + [0.6503, -15.8781, 19.6156], + [0.7733, 3.0612, 7.2993], + [0.8391, -5.2227, 13.7056], + [0.9736, 3.0706, 6.7467], + [1.16, -0.374, 10.1005], + [1.3964, 1.3004, 8.1582], + [1.5815, -0.4837, 10.6494], + [1.8306, 1.1464, 8.0714], + [2.0345, 0.0132, 10.1459], + [2.6221, 0.5374, 9.0794], + [2.9655, -1.7582, 15.0986], + [3.5124, 0.2754, 9.0681], + [4.6591, 1.406, 5.097], + [5.367, 0.394, 9.8123], + [6.0839, 0.3365, 10.1205], + [6.8312, 0.3379, 10.1121], + [7.5676, 0.2611, 10.637], + [8.2397, 0.095, 11.8939], + [8.9776, 0.2015, 11.0165], + [10.413, 0.1332, 11.6294], + [11.8539, 0.1074, 11.8979], + [13.3655, 0.1286, 11.6464], + [14.8236, 0.0758, 12.3519], + [16.3203, 0.083, 12.2457], + [17.7915, 0.0581, 12.6515], + [19.2145, 0.0273, 13.1995], + [20.6718, 0.0388, 12.9792], + [22.1333, 0.0357, 13.044], + [25.0761, 0.0332, 13.0977], + [28.0339, 0.029, 13.2035], + [30.967, 0.0201, 13.4538], + [33.8727, 0.013, 13.6737], + [36.8273, 0.0172, 13.5324], + [39.7594, 0.0121, 13.7191], + [42.6721, 0.0083, 13.8687], + [45.5964, 0.0085, 13.8618], + [48.5297, 0.0084, 13.8668], + [51.4512, 0.0064, 13.9651] + ] + } + }, + "t200": { + "defaultAspirateFlowRate": 80, + "defaultDispenseFlowRate": 80, + "defaultBlowOutFlowRate": 80, + "defaultTipLength": 58.35, + "defaultTipOverlap": 10.5, + "defaultReturnTipHeight": 0.83, + "aspirate": { + "default": [ + [0.8314, -2.9322, 24.0741], + [0.8853, -30.0996, 48.7784], + [0.9778, -4.3627, 25.9941], + [0.975, 802.2301, -762.6744], + [1.1272, -4.6837, 24.0666], + [1.2747, -3.91, 23.1945], + [1.5656, -2.8032, 21.7836], + [1.6667, -7.2039, 28.6731], + [2.4403, -0.5147, 17.5244], + [3.0564, -1.6013, 20.1761], + [3.6444, -1.1974, 18.9418], + [4.1189, -1.7877, 21.0928], + [4.6467, -0.8591, 17.2684], + [5.2597, -0.207, 14.2379], + [5.8581, -0.2196, 14.3044], + [6.4772, -0.1025, 13.6183], + [7.8158, 0.0537, 12.6063], + [9.1664, 0.0507, 12.6302], + [10.5064, 0.0285, 12.8339], + [14.8361, 0.0818, 12.273], + [19.3933, 0.0801, 12.2991], + [23.9242, 0.0487, 12.9079], + [28.4922, 0.0379, 13.1666], + [36.145, 0.0277, 13.4572], + [43.7972, 0.0184, 13.7916], + [51.5125, 0.0154, 13.9248], + [59.2467, 0.0121, 14.0931], + [66.9428, 0.0084, 14.3151], + [74.6853, 0.0079, 14.3498], + [82.3722, 0.0052, 14.5512], + [90.1106, 0.0054, 14.5333], + [97.8369, 0.0043, 14.6288], + [105.6153, 0.0046, 14.5983], + [113.3686, 0.0036, 14.7076], + [121.1108, 0.003, 14.7785], + [136.61, 0.0026, 14.826], + [152.0708, 0.0018, 14.9298], + [167.6433, 0.0021, 14.8827], + [183.1011, 0.0012, 15.0438], + [198.5845, 0.0011, 15.0538], + [214.0264, 0.0008, 15.123] + ] + }, + "dispense": { + "default": [ + [0.8314, -2.9322, 24.0741], + [0.8853, -30.0996, 48.7784], + [0.9778, -4.3627, 25.9941], + [0.975, 802.2301, -762.6744], + [1.1272, -4.6837, 24.0666], + [1.2747, -3.91, 23.1945], + [1.5656, -2.8032, 21.7836], + [1.6667, -7.2039, 28.6731], + [2.4403, -0.5147, 17.5244], + [3.0564, -1.6013, 20.1761], + [3.6444, -1.1974, 18.9418], + [4.1189, -1.7877, 21.0928], + [4.6467, -0.8591, 17.2684], + [5.2597, -0.207, 14.2379], + [5.8581, -0.2196, 14.3044], + [6.4772, -0.1025, 13.6183], + [7.8158, 0.0537, 12.6063], + [9.1664, 0.0507, 12.6302], + [10.5064, 0.0285, 12.8339], + [14.8361, 0.0818, 12.273], + [19.3933, 0.0801, 12.2991], + [23.9242, 0.0487, 12.9079], + [28.4922, 0.0379, 13.1666], + [36.145, 0.0277, 13.4572], + [43.7972, 0.0184, 13.7916], + [51.5125, 0.0154, 13.9248], + [59.2467, 0.0121, 14.0931], + [66.9428, 0.0084, 14.3151], + [74.6853, 0.0079, 14.3498], + [82.3722, 0.0052, 14.5512], + [90.1106, 0.0054, 14.5333], + [97.8369, 0.0043, 14.6288], + [105.6153, 0.0046, 14.5983], + [113.3686, 0.0036, 14.7076], + [121.1108, 0.003, 14.7785], + [136.61, 0.0026, 14.826], + [152.0708, 0.0018, 14.9298], + [167.6433, 0.0021, 14.8827], + [183.1011, 0.0012, 15.0438], + [198.5845, 0.0011, 15.0538], + [214.0264, 0.0008, 15.123] + ] + } + }, + "t1000": { + "defaultAspirateFlowRate": 160, + "defaultDispenseFlowRate": 160, + "defaultBlowOutFlowRate": 80, + "defaultTipLength": 95.6, + "defaultTipOverlap": 10.5, + "defaultReturnTipHeight": 0.83, + "aspirate": { + "default": [ + [0.7511, 3.9556, 6.455], + [1.3075, 2.1664, 5.8839], + [1.8737, 1.1513, 7.2111], + [3.177, 0.9374, 7.612], + [4.5368, 0.5531, 8.8328], + [7.3103, 0.3035, 9.9651], + [10.0825, 0.1513, 11.0781], + [12.9776, 0.1293, 11.2991], + [15.9173, 0.0976, 11.7115], + [18.8243, 0.0624, 12.2706], + [21.8529, 0.07, 12.1275], + [24.8068, 0.0418, 12.7442], + [27.7744, 0.0356, 12.8984], + [35.2873, 0.0303, 13.0454], + [42.7989, 0.0202, 13.4038], + [50.4562, 0.0196, 13.4293], + [58.1081, 0.0145, 13.6843], + [65.7267, 0.0104, 13.9252], + [73.2857, 0.0068, 14.1606], + [81.0016, 0.0091, 13.9883], + [88.6617, 0.0064, 14.2052], + [103.9829, 0.0051, 14.3271], + [119.4408, 0.0049, 14.3475], + [134.889, 0.0037, 14.485], + [150.273, 0.0026, 14.6402], + [181.2798, 0.0026, 14.6427], + [212.4724, 0.0022, 14.7002], + [243.577, 0.0015, 14.8558], + [274.7216, 0.0012, 14.9205], + [305.8132, 0.0009, 15.0118], + [368.0697, 0.0007, 15.0668], + [430.2513, 0.0005, 15.1594], + [492.3487, 0.0003, 15.2291], + [554.5713, 0.0003, 15.2367], + [616.6825, 0.0002, 15.2949], + [694.4168, 0.0002, 15.3027], + [772.0327, 0.0001, 15.3494], + [849.617, 0.0001, 15.3717], + [927.2556, 0.0001, 15.3745], + [1004.87, 0.0001, 15.3912], + [1051.4648, 0.0001, 15.391] + ] + }, + "dispense": { + "default": [ + [0.7511, 3.9556, 6.455], + [1.3075, 2.1664, 5.8839], + [1.8737, 1.1513, 7.2111], + [3.177, 0.9374, 7.612], + [4.5368, 0.5531, 8.8328], + [7.3103, 0.3035, 9.9651], + [10.0825, 0.1513, 11.0781], + [12.9776, 0.1293, 11.2991], + [15.9173, 0.0976, 11.7115], + [18.8243, 0.0624, 12.2706], + [21.8529, 0.07, 12.1275], + [24.8068, 0.0418, 12.7442], + [27.7744, 0.0356, 12.8984], + [35.2873, 0.0303, 13.0454], + [42.7989, 0.0202, 13.4038], + [50.4562, 0.0196, 13.4293], + [58.1081, 0.0145, 13.6843], + [65.7267, 0.0104, 13.9252], + [73.2857, 0.0068, 14.1606], + [81.0016, 0.0091, 13.9883], + [88.6617, 0.0064, 14.2052], + [103.9829, 0.0051, 14.3271], + [119.4408, 0.0049, 14.3475], + [134.889, 0.0037, 14.485], + [150.273, 0.0026, 14.6402], + [181.2798, 0.0026, 14.6427], + [212.4724, 0.0022, 14.7002], + [243.577, 0.0015, 14.8558], + [274.7216, 0.0012, 14.9205], + [305.8132, 0.0009, 15.0118], + [368.0697, 0.0007, 15.0668], + [430.2513, 0.0005, 15.1594], + [492.3487, 0.0003, 15.2291], + [554.5713, 0.0003, 15.2367], + [616.6825, 0.0002, 15.2949], + [694.4168, 0.0002, 15.3027], + [772.0327, 0.0001, 15.3494], + [849.617, 0.0001, 15.3717], + [927.2556, 0.0001, 15.3745], + [1004.87, 0.0001, 15.3912], + [1051.4648, 0.0001, 15.391] + ] + } + } + }, + "maxVolume": 1000, + "minVolume": 1, + "defaultTipracks": [ + "opentrons/opentrons_ot3_96_tiprack_1000ul/1", + "opentrons/opentrons_ot3_96_tiprack_200ul/1", + "opentrons/opentrons_ot3_96_tiprack_50ul/1" + ] +} diff --git a/shared-data/pipette/definitions/2/liquid/single_channel/p1000/1_0.json b/shared-data/pipette/definitions/2/liquid/single_channel/p1000/1_0.json new file mode 100644 index 00000000000..74cbc028bc7 --- /dev/null +++ b/shared-data/pipette/definitions/2/liquid/single_channel/p1000/1_0.json @@ -0,0 +1,308 @@ +{ + "$otSharedSchema": "#/pipette/schemas/2/pipetteLiquidPropertiesSchema.json", + "supportedTips": { + "t50": { + "defaultAspirateFlowRate": 6, + "defaultDispenseFlowRate": 6, + "defaultBlowOutFlowRate": 80, + "defaultTipLength": 57.9, + "defaultTipOverlap": 10.5, + "defaultReturnTipHeight": 0.83, + "aspirate": { + "default": [ + [0.4148, -1705.1015, 20.5455], + [0.4476, -80.633, 47.2788], + [0.5512, -1.5936, 11.9026], + [0.6027, -18.9998, 21.4972], + [0.6503, -15.8781, 19.6156], + [0.7733, 3.0612, 7.2993], + [0.8391, -5.2227, 13.7056], + [0.9736, 3.0706, 6.7467], + [1.16, -0.374, 10.1005], + [1.3964, 1.3004, 8.1582], + [1.5815, -0.4837, 10.6494], + [1.8306, 1.1464, 8.0714], + [2.0345, 0.0132, 10.1459], + [2.6221, 0.5374, 9.0794], + [2.9655, -1.7582, 15.0986], + [3.5124, 0.2754, 9.0681], + [4.6591, 1.406, 5.097], + [5.367, 0.394, 9.8123], + [6.0839, 0.3365, 10.1205], + [6.8312, 0.3379, 10.1121], + [7.5676, 0.2611, 10.637], + [8.2397, 0.095, 11.8939], + [8.9776, 0.2015, 11.0165], + [10.413, 0.1332, 11.6294], + [11.8539, 0.1074, 11.8979], + [13.3655, 0.1286, 11.6464], + [14.8236, 0.0758, 12.3519], + [16.3203, 0.083, 12.2457], + [17.7915, 0.0581, 12.6515], + [19.2145, 0.0273, 13.1995], + [20.6718, 0.0388, 12.9792], + [22.1333, 0.0357, 13.044], + [25.0761, 0.0332, 13.0977], + [28.0339, 0.029, 13.2035], + [30.967, 0.0201, 13.4538], + [33.8727, 0.013, 13.6737], + [36.8273, 0.0172, 13.5324], + [39.7594, 0.0121, 13.7191], + [42.6721, 0.0083, 13.8687], + [45.5964, 0.0085, 13.8618], + [48.5297, 0.0084, 13.8668], + [51.4512, 0.0064, 13.9651] + ] + }, + "dispense": { + "default": [ + [0.4148, -1705.1015, 20.5455], + [0.4476, -80.633, 47.2788], + [0.5512, -1.5936, 11.9026], + [0.6027, -18.9998, 21.4972], + [0.6503, -15.8781, 19.6156], + [0.7733, 3.0612, 7.2993], + [0.8391, -5.2227, 13.7056], + [0.9736, 3.0706, 6.7467], + [1.16, -0.374, 10.1005], + [1.3964, 1.3004, 8.1582], + [1.5815, -0.4837, 10.6494], + [1.8306, 1.1464, 8.0714], + [2.0345, 0.0132, 10.1459], + [2.6221, 0.5374, 9.0794], + [2.9655, -1.7582, 15.0986], + [3.5124, 0.2754, 9.0681], + [4.6591, 1.406, 5.097], + [5.367, 0.394, 9.8123], + [6.0839, 0.3365, 10.1205], + [6.8312, 0.3379, 10.1121], + [7.5676, 0.2611, 10.637], + [8.2397, 0.095, 11.8939], + [8.9776, 0.2015, 11.0165], + [10.413, 0.1332, 11.6294], + [11.8539, 0.1074, 11.8979], + [13.3655, 0.1286, 11.6464], + [14.8236, 0.0758, 12.3519], + [16.3203, 0.083, 12.2457], + [17.7915, 0.0581, 12.6515], + [19.2145, 0.0273, 13.1995], + [20.6718, 0.0388, 12.9792], + [22.1333, 0.0357, 13.044], + [25.0761, 0.0332, 13.0977], + [28.0339, 0.029, 13.2035], + [30.967, 0.0201, 13.4538], + [33.8727, 0.013, 13.6737], + [36.8273, 0.0172, 13.5324], + [39.7594, 0.0121, 13.7191], + [42.6721, 0.0083, 13.8687], + [45.5964, 0.0085, 13.8618], + [48.5297, 0.0084, 13.8668], + [51.4512, 0.0064, 13.9651] + ] + } + }, + "t200": { + "defaultAspirateFlowRate": 80, + "defaultDispenseFlowRate": 80, + "defaultBlowOutFlowRate": 80, + "defaultTipLength": 58.35, + "defaultTipOverlap": 10.5, + "defaultReturnTipHeight": 0.83, + "aspirate": { + "default": [ + [0.8314, -2.9322, 24.0741], + [0.8853, -30.0996, 48.7784], + [0.9778, -4.3627, 25.9941], + [0.975, 802.2301, -762.6744], + [1.1272, -4.6837, 24.0666], + [1.2747, -3.91, 23.1945], + [1.5656, -2.8032, 21.7836], + [1.6667, -7.2039, 28.6731], + [2.4403, -0.5147, 17.5244], + [3.0564, -1.6013, 20.1761], + [3.6444, -1.1974, 18.9418], + [4.1189, -1.7877, 21.0928], + [4.6467, -0.8591, 17.2684], + [5.2597, -0.207, 14.2379], + [5.8581, -0.2196, 14.3044], + [6.4772, -0.1025, 13.6183], + [7.8158, 0.0537, 12.6063], + [9.1664, 0.0507, 12.6302], + [10.5064, 0.0285, 12.8339], + [14.8361, 0.0818, 12.273], + [19.3933, 0.0801, 12.2991], + [23.9242, 0.0487, 12.9079], + [28.4922, 0.0379, 13.1666], + [36.145, 0.0277, 13.4572], + [43.7972, 0.0184, 13.7916], + [51.5125, 0.0154, 13.9248], + [59.2467, 0.0121, 14.0931], + [66.9428, 0.0084, 14.3151], + [74.6853, 0.0079, 14.3498], + [82.3722, 0.0052, 14.5512], + [90.1106, 0.0054, 14.5333], + [97.8369, 0.0043, 14.6288], + [105.6153, 0.0046, 14.5983], + [113.3686, 0.0036, 14.7076], + [121.1108, 0.003, 14.7785], + [136.61, 0.0026, 14.826], + [152.0708, 0.0018, 14.9298], + [167.6433, 0.0021, 14.8827], + [183.1011, 0.0012, 15.0438], + [198.5845, 0.0011, 15.0538], + [214.0264, 0.0008, 15.123] + ] + }, + "dispense": { + "default": [ + [0.8314, -2.9322, 24.0741], + [0.8853, -30.0996, 48.7784], + [0.9778, -4.3627, 25.9941], + [0.975, 802.2301, -762.6744], + [1.1272, -4.6837, 24.0666], + [1.2747, -3.91, 23.1945], + [1.5656, -2.8032, 21.7836], + [1.6667, -7.2039, 28.6731], + [2.4403, -0.5147, 17.5244], + [3.0564, -1.6013, 20.1761], + [3.6444, -1.1974, 18.9418], + [4.1189, -1.7877, 21.0928], + [4.6467, -0.8591, 17.2684], + [5.2597, -0.207, 14.2379], + [5.8581, -0.2196, 14.3044], + [6.4772, -0.1025, 13.6183], + [7.8158, 0.0537, 12.6063], + [9.1664, 0.0507, 12.6302], + [10.5064, 0.0285, 12.8339], + [14.8361, 0.0818, 12.273], + [19.3933, 0.0801, 12.2991], + [23.9242, 0.0487, 12.9079], + [28.4922, 0.0379, 13.1666], + [36.145, 0.0277, 13.4572], + [43.7972, 0.0184, 13.7916], + [51.5125, 0.0154, 13.9248], + [59.2467, 0.0121, 14.0931], + [66.9428, 0.0084, 14.3151], + [74.6853, 0.0079, 14.3498], + [82.3722, 0.0052, 14.5512], + [90.1106, 0.0054, 14.5333], + [97.8369, 0.0043, 14.6288], + [105.6153, 0.0046, 14.5983], + [113.3686, 0.0036, 14.7076], + [121.1108, 0.003, 14.7785], + [136.61, 0.0026, 14.826], + [152.0708, 0.0018, 14.9298], + [167.6433, 0.0021, 14.8827], + [183.1011, 0.0012, 15.0438], + [198.5845, 0.0011, 15.0538], + [214.0264, 0.0008, 15.123] + ] + } + }, + "t1000": { + "defaultAspirateFlowRate": 160, + "defaultDispenseFlowRate": 160, + "defaultBlowOutFlowRate": 80, + "defaultTipLength": 95.6, + "defaultTipOverlap": 10.5, + "defaultReturnTipHeight": 0.83, + "aspirate": { + "default": [ + [0.7511, 3.9556, 6.455], + [1.3075, 2.1664, 5.8839], + [1.8737, 1.1513, 7.2111], + [3.177, 0.9374, 7.612], + [4.5368, 0.5531, 8.8328], + [7.3103, 0.3035, 9.9651], + [10.0825, 0.1513, 11.0781], + [12.9776, 0.1293, 11.2991], + [15.9173, 0.0976, 11.7115], + [18.8243, 0.0624, 12.2706], + [21.8529, 0.07, 12.1275], + [24.8068, 0.0418, 12.7442], + [27.7744, 0.0356, 12.8984], + [35.2873, 0.0303, 13.0454], + [42.7989, 0.0202, 13.4038], + [50.4562, 0.0196, 13.4293], + [58.1081, 0.0145, 13.6843], + [65.7267, 0.0104, 13.9252], + [73.2857, 0.0068, 14.1606], + [81.0016, 0.0091, 13.9883], + [88.6617, 0.0064, 14.2052], + [103.9829, 0.0051, 14.3271], + [119.4408, 0.0049, 14.3475], + [134.889, 0.0037, 14.485], + [150.273, 0.0026, 14.6402], + [181.2798, 0.0026, 14.6427], + [212.4724, 0.0022, 14.7002], + [243.577, 0.0015, 14.8558], + [274.7216, 0.0012, 14.9205], + [305.8132, 0.0009, 15.0118], + [368.0697, 0.0007, 15.0668], + [430.2513, 0.0005, 15.1594], + [492.3487, 0.0003, 15.2291], + [554.5713, 0.0003, 15.2367], + [616.6825, 0.0002, 15.2949], + [694.4168, 0.0002, 15.3027], + [772.0327, 0.0001, 15.3494], + [849.617, 0.0001, 15.3717], + [927.2556, 0.0001, 15.3745], + [1004.87, 0.0001, 15.3912], + [1051.4648, 0.0001, 15.391] + ] + }, + "dispense": { + "default": [ + [0.7511, 3.9556, 6.455], + [1.3075, 2.1664, 5.8839], + [1.8737, 1.1513, 7.2111], + [3.177, 0.9374, 7.612], + [4.5368, 0.5531, 8.8328], + [7.3103, 0.3035, 9.9651], + [10.0825, 0.1513, 11.0781], + [12.9776, 0.1293, 11.2991], + [15.9173, 0.0976, 11.7115], + [18.8243, 0.0624, 12.2706], + [21.8529, 0.07, 12.1275], + [24.8068, 0.0418, 12.7442], + [27.7744, 0.0356, 12.8984], + [35.2873, 0.0303, 13.0454], + [42.7989, 0.0202, 13.4038], + [50.4562, 0.0196, 13.4293], + [58.1081, 0.0145, 13.6843], + [65.7267, 0.0104, 13.9252], + [73.2857, 0.0068, 14.1606], + [81.0016, 0.0091, 13.9883], + [88.6617, 0.0064, 14.2052], + [103.9829, 0.0051, 14.3271], + [119.4408, 0.0049, 14.3475], + [134.889, 0.0037, 14.485], + [150.273, 0.0026, 14.6402], + [181.2798, 0.0026, 14.6427], + [212.4724, 0.0022, 14.7002], + [243.577, 0.0015, 14.8558], + [274.7216, 0.0012, 14.9205], + [305.8132, 0.0009, 15.0118], + [368.0697, 0.0007, 15.0668], + [430.2513, 0.0005, 15.1594], + [492.3487, 0.0003, 15.2291], + [554.5713, 0.0003, 15.2367], + [616.6825, 0.0002, 15.2949], + [694.4168, 0.0002, 15.3027], + [772.0327, 0.0001, 15.3494], + [849.617, 0.0001, 15.3717], + [927.2556, 0.0001, 15.3745], + [1004.87, 0.0001, 15.3912], + [1051.4648, 0.0001, 15.391] + ] + } + } + }, + "maxVolume": 1000, + "minVolume": 1, + "defaultTipracks": [ + "opentrons/opentrons_ot3_96_tiprack_1000ul/1", + "opentrons/opentrons_ot3_96_tiprack_200ul/1", + "opentrons/opentrons_ot3_96_tiprack_50ul/1" + ] +} diff --git a/shared-data/pipette/definitions/2/liquid/single_channel/p50/1_0.json b/shared-data/pipette/definitions/2/liquid/single_channel/p50/1_0.json new file mode 100644 index 00000000000..92e6be23beb --- /dev/null +++ b/shared-data/pipette/definitions/2/liquid/single_channel/p50/1_0.json @@ -0,0 +1,80 @@ +{ + "$otSharedSchema": "#/pipette/schemas/2/pipetteLiquidPropertiesSchema.json", + "supportedTips": { + "t50": { + "defaultAspirateFlowRate": 8, + "defaultDispenseFlowRate": 8, + "defaultBlowOutFlowRate": 4, + "defaultTipLength": 57.9, + "defaultTipOverlap": 10.5, + "defaultReturnTipHeight": 0.83, + "aspirate": { + "default": [ + [0.6464, 0.4817, 0.0427], + [1.0889, 0.2539, 0.1591], + [1.5136, 0.1624, 0.2587], + [1.9108, 0.1042, 0.3467], + [2.2941, 0.0719, 0.4085], + [2.9978, 0.037, 0.4886], + [3.7731, 0.0378, 0.4863], + [4.7575, 0.0516, 0.4342], + [5.5024, 0.011, 0.6275], + [6.2686, 0.0114, 0.6253], + [7.005, 0.0054, 0.6625], + [8.5207, 0.0063, 0.6563], + [10.0034, 0.003, 0.6844], + [11.5075, 0.0031, 0.6833], + [13.0327, 0.0032, 0.6829], + [14.5356, 0.0018, 0.7003], + [17.5447, 0.0014, 0.7063], + [20.5576, 0.0011, 0.7126], + [23.5624, 0.0007, 0.7197], + [26.5785, 0.0007, 0.721], + [29.593, 0.0005, 0.7248], + [32.6109, 0.0004, 0.7268], + [35.6384, 0.0004, 0.727], + [38.6439, 0.0002, 0.7343], + [41.6815, 0.0004, 0.7284], + [44.6895, 0.0002, 0.7372], + [47.6926, 0.0001, 0.7393], + [51.4567, 0.0001, 0.7382] + ] + }, + "dispense": { + "default": [ + [0.6464, 0.4817, 0.0427], + [1.0889, 0.2539, 0.1591], + [1.5136, 0.1624, 0.2587], + [1.9108, 0.1042, 0.3467], + [2.2941, 0.0719, 0.4085], + [2.9978, 0.037, 0.4886], + [3.7731, 0.0378, 0.4863], + [4.7575, 0.0516, 0.4342], + [5.5024, 0.011, 0.6275], + [6.2686, 0.0114, 0.6253], + [7.005, 0.0054, 0.6625], + [8.5207, 0.0063, 0.6563], + [10.0034, 0.003, 0.6844], + [11.5075, 0.0031, 0.6833], + [13.0327, 0.0032, 0.6829], + [14.5356, 0.0018, 0.7003], + [17.5447, 0.0014, 0.7063], + [20.5576, 0.0011, 0.7126], + [23.5624, 0.0007, 0.7197], + [26.5785, 0.0007, 0.721], + [29.593, 0.0005, 0.7248], + [32.6109, 0.0004, 0.7268], + [35.6384, 0.0004, 0.727], + [38.6439, 0.0002, 0.7343], + [41.6815, 0.0004, 0.7284], + [44.6895, 0.0002, 0.7372], + [47.6926, 0.0001, 0.7393], + [51.4567, 0.0001, 0.7382] + ] + } + } + }, + "maxVolume": 50, + "minVolume": 0.5, + "defaultTipracks": ["opentrons/opentrons_ot3_96_tiprack_50ul/1"] +} diff --git a/shared-data/pipette/schemas/pipetteModelSpecsSchema.json b/shared-data/pipette/schemas/1/pipetteModelSpecsSchema.json similarity index 100% rename from shared-data/pipette/schemas/pipetteModelSpecsSchema.json rename to shared-data/pipette/schemas/1/pipetteModelSpecsSchema.json diff --git a/shared-data/pipette/schemas/pipetteNameSpecsSchema.json b/shared-data/pipette/schemas/1/pipetteNameSpecsSchema.json similarity index 100% rename from shared-data/pipette/schemas/pipetteNameSpecsSchema.json rename to shared-data/pipette/schemas/1/pipetteNameSpecsSchema.json diff --git a/shared-data/pipette/schemas/2/pipetteGeometrySchema.json b/shared-data/pipette/schemas/2/pipetteGeometrySchema.json new file mode 100644 index 00000000000..c65d5e37f8d --- /dev/null +++ b/shared-data/pipette/schemas/2/pipetteGeometrySchema.json @@ -0,0 +1,27 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "xyzArray": { + "type": "array", + "description": "Array of 3 numbers, [x, y, z]", + "items": { "type": "number" }, + "minItems": 3, + "maxItems": 3 + } + }, + "type": "object", + "required": ["$otSharedSchema", "nozzleOffset", "pathTo3D"], + "additionalProperties": false, + "properties": { + "$otSharedSchema": { + "type": "string", + "description": "The path to a valid Opentrons shared schema relative to the shared-data directory, without its extension. For instance, #/pipette/schemas/2/pipetteGeometrySchema.json is a reference to this schema." + }, + "nozzleOffset": { "$ref": "#/definitions/xyzArray" }, + "pathTo3D": { + "description": "path to the gltf file representing the 3D pipette model", + "type": "string", + "pattern": "^pipette/definitions/[2]/([a-z]*_[a-z]*)+/p[0-9]{2,4}/[a-z]*[.]gltf" + } + } +} diff --git a/shared-data/pipette/schemas/2/pipetteLiquidPropertiesSchema.json b/shared-data/pipette/schemas/2/pipetteLiquidPropertiesSchema.json new file mode 100644 index 00000000000..6b0604588f1 --- /dev/null +++ b/shared-data/pipette/schemas/2/pipetteLiquidPropertiesSchema.json @@ -0,0 +1,109 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "positiveNumber": { + "type": "number", + "minimum": 0 + }, + "xyzArray": { + "type": "array", + "description": "Array of 3 numbers, [x, y, z]", + "items": { "type": "number" }, + "minItems": 3, + "maxItems": 3 + }, + "liquidHandlingSpecs": { + "description": "Object containing linear equations for translating between uL of liquid and mm of plunger travel. There is one linear equation for aspiration and one for dispense", + "type": "object", + "required": ["aspirate", "dispense"], + "additionalProperties": false, + "properties": { + "aspirate": { "$ref": "#/definitions/xyzArray" }, + "dispense": { "$ref": "#/definitions/xyzArray" } + } + }, + "flowRate": { + "type": "object", + "required": ["value", "min", "max"], + "properties": { + "value": { + "$ref": "#/definitions/positiveNumber", + "$comment": "This key is deprecated in favor of valuesByApiLevel" + }, + "min": { "$ref": "#/definitions/positiveNumber" }, + "max": { "$ref": "#/definitions/positiveNumber" } + } + } + }, + "type": "object", + "required": [ + "$otSharedSchema", + "maxVolume", + "minVolume", + "defaultTipracks", + "supportedTips" + ], + "additionalProperties": false, + "properties": { + "$otSharedSchema": { + "type": "string", + "description": "The path to a valid Opentrons shared schema relative to the shared-data directory, without its extension. For instance, #/pipette/schemas/2/pipetteLiquidPropertiesSchema.json is a reference to this schema." + }, + "supportedTips": { + "type": "object", + "description": "A container of supported tip types", + "properties": { + "patternProperties": { + "description": "Tip specific liquid handling properties for a given pipette. Using the active tip on a pipette, we will look up the pipetting configurations associated with that tip+pipette combo.", + "type": "object", + "$comment": "Example key: 't50'", + "^t[0-9]{2,4}": { + "required": [ + "defaultAspirateFlowRate", + "defaultDispenseFlowRate", + "defaultBlowOutFlowRate", + "aspirate", + "dispense" + ], + "properties": { + "defaultAspirateFlowRate": { + "$ref": "#/definitions/flowRate" + }, + "defaultDispenseFlowRate": { + "$ref": "#/definitions/flowRate" + }, + "defaultBlowOutFlowRate": { + "$ref": "#/definitions/flowRate" + }, + "defaultTipLength": { + "$ref": "#/definitions/positiveNumber" + }, + "defaultTipOverlap": { + "$ref": "#/definitions/positiveNumber" + }, + "defaultReturnTipHeight": { + "$ref": "#/definitions/positiveNumber" + }, + "aspirate": { + "type": "array", + "items": { "$ref": "#/definitions/liquidHandlingSpecs" } + }, + "dispense": { + "type": "array", + "items": { "$ref": "#/definitions/liquidHandlingSpecs" } + } + } + } + } + } + }, + "maxVolume": { "$ref": "#/definitions/positiveNumber" }, + "minVolume": { "$ref": "#/definitions/positiveNumber" }, + "defaultTipracks": { + "type": "array", + "items": { + "type": "string" + } + } + } +} diff --git a/shared-data/pipette/schemas/2/pipettePropertiesSchema.json b/shared-data/pipette/schemas/2/pipettePropertiesSchema.json new file mode 100644 index 00000000000..2cb2cb4cd89 --- /dev/null +++ b/shared-data/pipette/schemas/2/pipettePropertiesSchema.json @@ -0,0 +1,178 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "opentronsPipetteGeometrySchemaV2", + "definitions": { + "channels": { + "enum": [1, 8, 96, 384] + }, + "displayCategory": { + "type": "string", + "enum": ["GEN1"] + }, + "positiveNumber": { + "type": "number", + "minimum": 0 + }, + "currentRange": { + "type": "number", + "minimum": 0.01, + "maximum": 2.5 + }, + "xyzArray": { + "type": "array", + "description": "Array of 3 numbers, [x, y, z]", + "items": { "type": "number" }, + "minItems": 3, + "maxItems": 3 + }, + "linearEquations": { + "description": "Array containing any number of 3-arrays. Each inner 3-array describes a line segment: [boundary, slope, intercept]. So [1, 2, 3] would mean 'where (next_boundary > x >= 1), y = 2x + 3'", + "type": "array", + "items": { + "type": "array", + "items": { "type": "number" }, + "minItems": 3, + "maxItems": 3 + } + }, + "liquidHandlingSpecs": { + "description": "Object containing linear equations for translating between uL of liquid and mm of plunger travel. There is one linear equation for aspiration and one for dispense", + "type": "object", + "required": ["aspirate", "dispense"], + "additionalProperties": false, + "properties": { + "aspirate": { "$ref": "#/definitions/linearEquations" }, + "dispense": { "$ref": "#/definitions/linearEquations" } + } + }, + "editConfigurations": { + "type": "object", + "description": "Object allowing you to modify a config", + "required": ["value"], + "properties": { + "value": { "type": ["number", "array"] }, + "min": { "type": "number" }, + "max": { "type": "number" }, + "units": { "type": "string" }, + "type": { "type": "string" }, + "displayName": { "type": "string" } + } + }, + "tipConfigurations": { + "type": "object", + "description": "Object containing configurations specific to tip handling", + "required": ["current", "speed"], + "properties": { + "current": { "$ref": "#/definitions/currentRange" }, + "presses": {}, + "speed": { "$ref": "#/definitions/editConfigurations" }, + "increment": {}, + "distance": {} + } + } + }, + "description": "Version-level pipette specifications, which may vary across different versions of the same pipette", + "type": "object", + "required": [ + "$otSharedSchema", + "pickUpTipConfigurations", + "dropTipConfigurations", + "partialTipConfigurations", + "plungerPositionsConfigurations", + "plungerMotorConfigurations", + "displayCategory", + "channels", + "model", + "displayName" + ], + "properties": { + "additionalProperties": false, + "$otSharedSchema": { + "type": "string", + "description": "The path to a valid Opentrons shared schema relative to the shared-data directory, without its extension. For instance, #/pipette/schemas/2/pipettePropertiesSchema.json is a reference to this schema." + }, + "channels": { "$ref": "#/definitions/channels" }, + "partialTipConfigurations": { + "type": "object", + "description": "Object containing information on partial tip configurations", + "required": ["partialTipSupported"], + "properties": { + "partialTipSupported": { "type": "boolean" }, + "availableConfigurations": { + "type": "array", + "description": "Array of available configurations", + "items": { + "type": "number", + "enum": [1, 2, 3, 4, 5, 6, 7, 8, 12, 96, 384] + } + } + } + }, + "availableSensors": { + "type": "object", + "description": "object with keyed by sensor and number available", + "required": ["sensors"], + "properties": { + "sensors": { + "type": "array", + "description": "Array of available sensor types", + "items": { + "type": "string" + } + }, + "patternProperties": { + "description": "The count of each sensor type available on a given pipette model.", + "type": "object", + ".*": { + "required": ["count"], + "count": { "type": "integer" } + } + } + } + }, + "plungerPositionsConfigurations": { + "type": "object", + "description": "Object containing configurations specific to tip handling", + "required": ["top", "bottom", "blowout", "drop"], + "properties": { + "top": { "$ref": "#/definitions/currentRange" }, + "bottom": {}, + "blowout": { "$ref": "#/definitions/editConfigurations" }, + "drop": {} + } + }, + "plungerMotorConfigurations": { + "type": "object", + "description": "Object containing configurations specific to the plunger motor", + "required": ["idle", "run"], + "properties": { + "idle": { "$ref": "#/definitions/currentRange" }, + "run": { "$ref": "#/definitions/currentRange" } + } + }, + "gearMotorConfigurations": { + "type": "object", + "description": "Object containing configurations specific to the clamp motors, if applicable", + "required": ["idle", "run"], + "properties": { + "idle": { "$ref": "#/definitions/currentRange" }, + "run": { "$ref": "#/definitions/currentRange" } + } + }, + "pickUpTipConfigurations": { + "$ref": "#/definitions/tipConfigurations" + }, + "dropTipConfigurations": { + "$ref": "#/definitions/tipConfigurations" + }, + "displayName": { + "type": "string", + "description": "Display name of the pipette include model and generation number in readable format." + }, + "model": { + "type": "string", + "description": "the model of the pipette, for example an eightChannel pipette" + }, + "displayCategory": { "$ref": "#/definitions/displayCategory" } + } +} diff --git a/shared-data/python/opentrons_shared_data/pipette/__init__.py b/shared-data/python/opentrons_shared_data/pipette/__init__.py index 685f479d86f..86ff39a57b9 100644 --- a/shared-data/python/opentrons_shared_data/pipette/__init__.py +++ b/shared-data/python/opentrons_shared_data/pipette/__init__.py @@ -28,7 +28,7 @@ def model_config() -> PipetteModelSpecs: @lru_cache(maxsize=None) def _model_config() -> PipetteModelSpecs: return json.loads( - load_shared_data("pipette/definitions/pipetteModelSpecs.json") or "{}" + load_shared_data("pipette/definitions/1/pipetteModelSpecs.json") or "{}" ) @@ -40,7 +40,7 @@ def name_config() -> PipetteNameSpecs: @lru_cache(maxsize=None) def _name_config() -> PipetteNameSpecs: return json.loads( - load_shared_data("pipette/definitions/pipetteNameSpecs.json") or "{}" + load_shared_data("pipette/definitions/1/pipetteNameSpecs.json") or "{}" ) diff --git a/shared-data/python/opentrons_shared_data/pipette/load_data.py b/shared-data/python/opentrons_shared_data/pipette/load_data.py new file mode 100644 index 00000000000..5082f0440bd --- /dev/null +++ b/shared-data/python/opentrons_shared_data/pipette/load_data.py @@ -0,0 +1,90 @@ +import json + +from typing import Dict, Any +from typing_extensions import Literal +from functools import lru_cache + +from .. import load_shared_data, get_shared_data_root + +from .pipette_definition import ( + PipetteConfigurations, + PipetteChannelType, + PipetteVersionType, + PipetteModelType, + PipetteModelMajorVersion, + PipetteModelMinorVersion, +) + + +LoadedConfiguration = Dict[PipetteChannelType, Dict[PipetteModelType, Any]] + + +def _get_configuration_dictionary( + config_type: Literal["general", "geometry", "liquid"], + channels: PipetteChannelType, + max_volume: PipetteModelType, + version: PipetteVersionType, +) -> LoadedConfiguration: + config_path = ( + get_shared_data_root() + / "pipette" + / "definitions" + / "2" + / config_type + / channels.name.lower() + / max_volume.value + / f"{version.major}_{version.minor}.json" + ) + return json.loads(load_shared_data(config_path)) + + +@lru_cache(maxsize=None) +def _geometry( + channels: PipetteChannelType, + max_volume: PipetteModelType, + version: PipetteVersionType, +) -> LoadedConfiguration: + return _get_configuration_dictionary("geometry", channels, max_volume, version) + + +@lru_cache(maxsize=None) +def _liquid( + channels: PipetteChannelType, + max_volume: PipetteModelType, + version: PipetteVersionType, +) -> LoadedConfiguration: + return _get_configuration_dictionary("liquid", channels, max_volume, version) + + +@lru_cache(maxsize=None) +def _physical( + channels: PipetteChannelType, + max_volume: PipetteModelType, + version: PipetteVersionType, +) -> LoadedConfiguration: + return _get_configuration_dictionary("general", channels, max_volume, version) + + +def load_definition( + max_volume: PipetteModelType, + channels: PipetteChannelType, + version: PipetteVersionType, +) -> PipetteConfigurations: + if ( + version.major not in PipetteModelMajorVersion + or version.minor not in PipetteModelMinorVersion + ): + raise KeyError("Pipette version not found.") + + updated_version = version + if updated_version.as_tuple != (1, 0): + # TODO (lc 12-5-2022) Temporary measure until we have full version support + # in the new configurations. Should be removed ASAP. + updated_version = PipetteVersionType(1, 0) + geometry_dict = _geometry(channels, max_volume, updated_version) + physical_dict = _physical(channels, max_volume, updated_version) + liquid_dict = _liquid(channels, max_volume, updated_version) + + return PipetteConfigurations.parse_obj( + {**geometry_dict, **physical_dict, **liquid_dict, "version": version} + ) diff --git a/shared-data/python/opentrons_shared_data/pipette/pipette_definition.py b/shared-data/python/opentrons_shared_data/pipette/pipette_definition.py new file mode 100644 index 00000000000..56bd96dfd02 --- /dev/null +++ b/shared-data/python/opentrons_shared_data/pipette/pipette_definition.py @@ -0,0 +1,296 @@ +from typing_extensions import Literal +from typing import List, Dict, Tuple, cast +from pydantic import BaseModel, Field, validator +from enum import Enum +from dataclasses import dataclass + +PLUNGER_CURRENT_MINIMUM = 0.1 +PLUNGER_CURRENT_MAXIMUM = 1.5 + + +PipetteModelMajorVersion = [1, 2, 3] +PipetteModelMinorVersion = [0, 1, 2, 3] + +# 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] + + +class PipetteTipType(Enum): + t50 = 50 + t200 = 200 + t1000 = 1000 + + +class PipetteChannelType(Enum): + SINGLE_CHANNEL = 1 + EIGHT_CHANNEL = 8 + NINETY_SIX_CHANNEL = 96 + + @property + def as_int(self) -> int: + return self.value + + def __str__(self) -> str: + if self.value == 96: + return "96" + elif self.value == 8: + return "multi" + else: + return "single" + + +class PipetteModelType(Enum): + p50 = "p50" + p1000 = "p1000" + + +class PipetteGenerationType(Enum): + GEN1 = "GEN1" + GEN2 = "GEN2" + GEN3 = "GEN3" + + +PIPETTE_AVAILABLE_TYPES = [m.name for m in PipetteModelType] +PIPETTE_CHANNELS_INTS = [c.as_int for c in PipetteChannelType] +PIPETTE_GENERATIONS = [g.name.lower() for g in PipetteGenerationType] + + +@dataclass(frozen=True) +class PipetteVersionType: + major: PipetteModelMajorVersionType + minor: PipetteModelMinorVersionType + + @classmethod + def convert_from_float(cls, version: float) -> "PipetteVersionType": + major = cast(PipetteModelMajorVersionType, int(version // 1)) + minor = cast(PipetteModelMinorVersionType, int(round((version % 1), 2) * 10)) + return cls(major=major, minor=minor) + + def __str__(self) -> str: + return f"{self.major}.{self.minor}" + + @property + def as_tuple( + self, + ) -> Tuple[PipetteModelMajorVersionType, PipetteModelMinorVersionType]: + return (self.major, self.minor) + + +class SupportedTipsDefinition(BaseModel): + """Tip parameters available for every tip size.""" + + default_aspirate_flowrate: float = Field( + ..., + description="The flowrate used in aspirations by default.", + alias="defaultAspirateFlowRate", + ) + default_dispense_flowrate: float = Field( + ..., + description="The flowrate used in dispenses by default.", + alias="defaultDispenseFlowRate", + ) + default_blowout_flowrate: float = Field( + ..., + description="The flowrate used in blowouts by default.", + alias="defaultBlowOutFlowRate", + ) + default_tip_length: float = Field( + ..., + description="The default tip length associated with this tip type.", + alias="defaultTipLength", + ) + default_tip_overlap: float = Field( + ..., + description="The default tip overlap associated with this tip type.", + alias="defaultTipOverlap", + ) + default_return_tip_height: float = Field( + ..., + description="The height to return a tip to its tiprack.", + alias="defaultReturnTipHeight", + ) + aspirate: Dict[str, List[Tuple[float, float, float]]] = Field( + ..., description="The default pipetting functions list for aspirate." + ) + dispense: Dict[str, List[Tuple[float, float, float]]] = Field( + ..., description="The default pipetting functions list for dispensing." + ) + + +class MotorConfigurations(BaseModel): + idle: float = Field( + ..., description="The plunger motor current to use during idle states." + ) + run: float = Field( + ..., description="The plunger motor current to use during active states." + ) + + +class PlungerPositions(BaseModel): + top: float = Field( + ..., + description="The plunger position that describes max available volume of a pipette in mm.", + ) + bottom: float = Field( + ..., + description="The plunger position that describes min available volume of a pipette in mm.", + ) + blow_out: float = Field( + ..., + description="The plunger position past 0 volume to blow out liquid.", + alias="blowout", + ) + drop_tip: float = Field( + ..., description="The plunger position used to drop tips.", alias="drop" + ) + + +class TipHandlingConfigurations(BaseModel): + current: float = Field( + ..., + description="Either the z motor current needed for picking up tip or the plunger motor current for dropping tip off the nozzle.", + ) + speed: float = Field( + ..., + description="The speed to move the z or plunger axis for tip pickup or drop off.", + ) + + +class PickUpTipConfigurations(TipHandlingConfigurations): + presses: int = Field( + ..., description="The number of tries required to force pick up a tip." + ) + increment: float = Field( + ..., + description="The increment to move the pipette down for force tip pickup retries.", + ) + distance: float = Field( + ..., description="The distance to begin a pick up tip from." + ) + + +class AvailableSensorDefinition(BaseModel): + """The number and type of sensors available in the pipette.""" + + sensors: List[str] = Field(..., description="") + + +class PartialTipDefinition(BaseModel): + partial_tip_supported: bool = Field( + ..., + description="Whether partial tip pick up is supported.", + alias="partialTipSupported", + ) + available_configurations: List[int] = Field( + default=None, + description="A list of the types of partial tip configurations supported, listed by channel ints", + alias="availableConfigurations", + ) + + +class PipettePhysicalPropertiesDefinition(BaseModel): + """The physical properties definition of a pipette.""" + + display_name: str = Field( + ..., + description="The display or full product name of the pipette.", + alias="displayName", + ) + pipette_type: PipetteModelType = Field( + ..., + description="The pipette model type (related to number of channels).", + alias="model", + ) + display_category: PipetteGenerationType = Field( + ..., description="The product model of the pipette.", alias="displayCategory" + ) + pick_up_tip_configurations: PickUpTipConfigurations = Field( + ..., alias="pickUpTipConfigurations" + ) + drop_tip_configurations: TipHandlingConfigurations = Field( + ..., alias="dropTipConfigurations" + ) + plunger_motor_configurations: MotorConfigurations = Field( + ..., alias="plungerMotorConfigurations" + ) + plunger_positions_configurations: PlungerPositions = Field( + ..., alias="plungerPositionsConfigurations" + ) + available_sensors: AvailableSensorDefinition = Field(..., alias="availableSensors") + partial_tip_configurations: PartialTipDefinition = Field( + ..., alias="partialTipConfigurations" + ) + channels: PipetteChannelType = Field( + ..., description="The maximum number of channels on the pipette." + ) + + @validator("pipette_type", pre=True) + def convert_pipette_model_string(cls, v: str) -> PipetteModelType: + return PipetteModelType(v) + + @validator("channels", pre=True) + def convert_channels(cls, v: int) -> PipetteChannelType: + return PipetteChannelType(v) + + @validator("display_category", pre=True) + def convert_display_category(cls, v: str) -> PipetteGenerationType: + if not v: + return PipetteGenerationType.GEN1 + return PipetteGenerationType(v) + + +class PipetteGeometryDefinition(BaseModel): + """The geometry properties definition of a pipette.""" + + nozzle_offset: List[float] = Field(..., alias="nozzleOffset") + path_to_3D: str = Field( + ..., + description="The shared data relative path to the 3D representation of the pipette model.", + alias="pathTo3D", + ) + + +class PipetteLiquidPropertiesDefinition(BaseModel): + """The liquid properties definition of a pipette.""" + + supported_tips: Dict[PipetteTipType, SupportedTipsDefinition] = Field( + ..., alias="supportedTips" + ) + max_volume: int = Field( + ..., + description="The maximum supported volume of the pipette.", + alias="maxVolume", + ) + min_volume: float = Field( + ..., + description="The minimum supported volume of the pipette.", + alias="minVolume", + ) + default_tipracks: List[str] = Field( + ..., + description="A list of default tiprack paths.", + regex="opentrons/[a-z0-9._]+/[0-9]", + alias="defaultTipracks", + ) + + @validator("supported_tips", pre=True) + def convert_aspirate_key_to_channel_type( + cls, v: Dict[str, SupportedTipsDefinition] + ) -> Dict[PipetteTipType, SupportedTipsDefinition]: + return {PipetteTipType[key]: value for key, value in v.items()} + + +class PipetteConfigurations( + PipetteGeometryDefinition, + PipettePhysicalPropertiesDefinition, + PipetteLiquidPropertiesDefinition, +): + """The full pipette configurations of a given model and version.""" + + version: PipetteVersionType = Field( + ..., description="The version of the configuration loaded." + ) diff --git a/shared-data/python/tests/pipette/test_load_data.py b/shared-data/python/tests/pipette/test_load_data.py new file mode 100644 index 00000000000..4514584894b --- /dev/null +++ b/shared-data/python/tests/pipette/test_load_data.py @@ -0,0 +1,24 @@ +from opentrons_shared_data.pipette import load_data +from opentrons_shared_data.pipette.pipette_definition import ( + PipetteChannelType, + PipetteModelType, + PipetteVersionType, + PipetteTipType, +) + + +def test_load_pipette_definition() -> None: + pipette_config = load_data.load_definition( + PipetteModelType.p50, + PipetteChannelType.SINGLE_CHANNEL, + PipetteVersionType(major=1, minor=0), + ) + + assert pipette_config.channels.as_int == 1 + assert pipette_config.pipette_type.value == "p50" + assert pipette_config.nozzle_offset == [-8.0, -22.0, -259.15] + + assert ( + pipette_config.supported_tips[PipetteTipType.t50].default_aspirate_flowrate + == 8.0 + ) diff --git a/shared-data/python/tests/pipette/test_pipette_definition.py b/shared-data/python/tests/pipette/test_pipette_definition.py new file mode 100644 index 00000000000..d0fcf9e67d1 --- /dev/null +++ b/shared-data/python/tests/pipette/test_pipette_definition.py @@ -0,0 +1,43 @@ +import pytest +from typing import cast +from opentrons_shared_data.pipette.pipette_definition import ( + PipetteChannelType, + PipetteModelType, + PipetteVersionType, + PipetteModelMajorVersionType, + PipetteModelMinorVersionType, +) + + +@pytest.mark.parametrize( + argnames=["model", "expected_enum"], + argvalues=[["p50", PipetteModelType.p50], ["p1000", PipetteModelType.p1000]], +) +def test_model_enum(model: str, expected_enum: PipetteModelType) -> None: + assert expected_enum == PipetteModelType(model) + + +@pytest.mark.parametrize(argnames="channels", argvalues=[1, 8, 96]) +def test_channel_enum(channels: int) -> None: + channel_type = PipetteChannelType(channels) + assert channels == channel_type.as_int + + +def test_incorrect_values() -> None: + with pytest.raises(ValueError): + PipetteModelType("p100") + + with pytest.raises(ValueError): + PipetteChannelType(99) + + +@pytest.mark.parametrize( + argnames=["major", "minor"], + argvalues=[[1, 0], [1, 3], [3, 9]], +) +def test_version_enum(major: int, minor: int) -> None: + version_type = PipetteVersionType( + cast(PipetteModelMajorVersionType, major), + cast(PipetteModelMinorVersionType, minor), + ) + assert version_type.as_tuple == (major, minor)