diff --git a/api/release-notes-internal.md b/api/release-notes-internal.md index 67ac7f391b0..c9a61bf32df 100644 --- a/api/release-notes-internal.md +++ b/api/release-notes-internal.md @@ -10,8 +10,21 @@ This is internal release 0.11.0 for the Opentrons Flex robot software, involving Some things are known not to work, and are listed below. Specific compatibility notes about peripheral hardware are also listed. -## Smaller Fun Features +## Update Notes +- ⚠️ After upgrading your robot to 0.11.0, you'll need to factory-reset its run history before you can use it. + + 1. From the robot's 3-dot menu (⋮), go to **Robot settings.** + 2. Under **Advanced > Factory reset**, select **Choose reset settings.** + 3. Choose **Clear protocol run history,** and then select **Clear data and restart robot.** + + Note that this will remove all of your saved labware offsets. + + You will need to follow these steps if you subsequently downgrade back to a prior release, too. + +## New Stuff In This Release + +- When interacting with an OT-3, the app will use the newer names for the deck slots, like "C2", instead of the names from the OT-2, like "5". - The `requirements` dict in Python protocols can now have `"robotType": "Flex"` instead of `"robotType": "OT-3"`. `"OT-3"` will still work, but it's discouraged because it's not the customer-facing name. # Internal Release 0.9.0 diff --git a/api/src/opentrons/calibration_storage/ot3/models/v1.py b/api/src/opentrons/calibration_storage/ot3/models/v1.py index e132a2ecffe..226fd168d8c 100644 --- a/api/src/opentrons/calibration_storage/ot3/models/v1.py +++ b/api/src/opentrons/calibration_storage/ot3/models/v1.py @@ -89,7 +89,14 @@ class Config: class ModuleOffsetModel(BaseModel): offset: Point = Field(..., description="Module offset found from calibration.") mount: OT3Mount = Field(..., description="The mount used to calibrate this module.") - slot: int = Field(..., description="The slot this module was calibrated in.") + slot: int = Field( + ..., + description=( + "The slot this module was calibrated in." + " For historical reasons, this is specified as an OT-2-style integer like `5`," + " not an OT-3-style string like `'C2'`." + ), + ) module: ModuleType = Field(..., description="The module type of this module.") module_id: str = Field(..., description="The unique id of this module.") instrument_id: str = Field( diff --git a/api/src/opentrons/protocol_api/deck.py b/api/src/opentrons/protocol_api/deck.py index 6a2525eae42..ca5b8263b43 100644 --- a/api/src/opentrons/protocol_api/deck.py +++ b/api/src/opentrons/protocol_api/deck.py @@ -82,6 +82,7 @@ def __len__(self) -> int: """Get the number of slots on the deck.""" return len(self._slot_definitions_by_name) + # todo(mm, 2023-05-08): This may be internal and removable from this public class. Jira RSS-236. def right_of(self, slot: DeckLocation) -> Optional[DeckItem]: """Get the item directly to the right of the given slot, if any.""" slot_name = _get_slot_name(slot) @@ -89,6 +90,7 @@ def right_of(self, slot: DeckLocation) -> Optional[DeckItem]: return self[east_slot] if east_slot is not None else None + # todo(mm, 2023-05-08): This may be internal and removable from this public class. Jira RSS-236. def left_of(self, slot: DeckLocation) -> Optional[DeckItem]: """Get the item directly to the left of the given slot, if any.""" slot_name = _get_slot_name(slot) @@ -96,6 +98,9 @@ def left_of(self, slot: DeckLocation) -> Optional[DeckItem]: return self[west_slot] if west_slot is not None else None + # todo(mm, 2023-05-08): This is undocumented in the public PAPI, but is used in some protocols + # written by Applications Engineering. Either officially document this, or decide it's internal + # and remove it from this class. Jira RSS-236. def position_for(self, slot: DeckLocation) -> Location: """Get the absolute location of a deck slot's front-left corner.""" slot_definition = self.get_slot_definition(slot) @@ -103,26 +108,33 @@ def position_for(self, slot: DeckLocation) -> Location: return Location(point=Point(x, y, z), labware=slot_definition["id"]) + # todo(mm, 2023-05-08): This may be internal and removable from this public class. Jira RSS-236. def get_slot_definition(self, slot: DeckLocation) -> SlotDefV3: """Get the geometric definition data of a slot.""" - slot_name = _get_slot_name(slot) - return self._slot_definitions_by_name[slot_name.id] + slot_name = validation.ensure_deck_slot_string( + _get_slot_name(slot), self._protocol_core.robot_type + ) + return self._slot_definitions_by_name[slot_name] + # todo(mm, 2023-05-08): This may be internal and removable from this public class. Jira RSS-236. def get_slot_center(self, slot: DeckLocation) -> Point: """Get the absolute coordinates of a slot's center.""" slot_name = _get_slot_name(slot) return self._protocol_core.get_slot_center(slot_name) + # todo(mm, 2023-05-08): This may be internal and removable from this public class. Jira RSS-236. @property def highest_z(self) -> float: """Get the height of the tallest known point on the deck.""" return self._protocol_core.get_highest_z() + # todo(mm, 2023-05-08): This appears internal. Remove it from this public class. Jira RSS-236. @property def slots(self) -> List[SlotDefV3]: """Get a list of all slot definitions.""" return list(self._slot_definitions_by_name.values()) + # todo(mm, 2023-05-08): This appears internal. Remove it from this public class. Jira RSS-236. @property def calibration_positions(self) -> List[CalibrationPosition]: """Get a list of all calibration positions on the deck.""" diff --git a/api/src/opentrons/protocol_api/validation.py b/api/src/opentrons/protocol_api/validation.py index 6e38c11e514..dd3b353eaca 100644 --- a/api/src/opentrons/protocol_api/validation.py +++ b/api/src/opentrons/protocol_api/validation.py @@ -100,10 +100,7 @@ def ensure_deck_slot(deck_slot: Union[int, str]) -> DeckSlotName: def ensure_deck_slot_string(slot_name: DeckSlotName, robot_type: RobotType) -> str: - if robot_type == "OT-2 Standard": - return str(slot_name) - else: - return slot_name.as_coordinate() + return slot_name.to_equivalent_for_robot_type(robot_type).id def ensure_lowercase_name(name: str) -> str: diff --git a/api/src/opentrons/protocol_engine/protocol_engine.py b/api/src/opentrons/protocol_engine/protocol_engine.py index 69b07d1083d..60a48ab2150 100644 --- a/api/src/opentrons/protocol_engine/protocol_engine.py +++ b/api/src/opentrons/protocol_engine/protocol_engine.py @@ -6,7 +6,7 @@ from opentrons.hardware_control.modules import AbstractModule as HardwareModuleAPI from opentrons.hardware_control.types import PauseType as HardwarePauseType -from . import commands +from . import commands, slot_standardization from .resources import ModelUtils, ModuleDataProvider from .types import ( LabwareOffset, @@ -148,6 +148,10 @@ def add_command(self, request: commands.CommandCreate) -> commands.Command: RunStoppedError: the run has been stopped, so no new commands may be added. """ + request = slot_standardization.standardize_command( + request, self.state_view.config.robot_type + ) + command_id = self._model_utils.generate_id() request_hash = commands.hash_command_params( create=request, @@ -281,6 +285,10 @@ def add_labware_offset(self, request: LabwareOffsetCreate) -> LabwareOffset: To retrieve offsets later, see `.state_view.labware`. """ + request = slot_standardization.standardize_labware_offset( + request, self.state_view.config.robot_type + ) + labware_offset_id = self._model_utils.generate_id() created_at = self._model_utils.get_timestamp() self._action_dispatcher.dispatch( diff --git a/api/src/opentrons/protocol_engine/slot_standardization.py b/api/src/opentrons/protocol_engine/slot_standardization.py new file mode 100644 index 00000000000..b42d05d7c95 --- /dev/null +++ b/api/src/opentrons/protocol_engine/slot_standardization.py @@ -0,0 +1,133 @@ +"""Convert deck slots into the preferred style for a robot. + +The deck slots on an OT-2 are labeled like "1", "2", ..., and on an OT-3 they're labeled like +"D1," "D2", .... + +When Protocol Engine takes a deck slot as input, we generally want it to accept either style +of label. This helps make protocols more portable across robot types. + +But, Protocol Engine should then immediately convert it to the "correct" style for the robot that +it's controlling or simulating. This makes it simpler to consume the robot's HTTP API, +and it makes it easier for us to reason about Protocol Engine's internal state. + +This module does that conversion, for any Protocol Engine input that contains a reference to a +deck slot. +""" + + +from typing import Any, Callable, Dict, Type + +from opentrons_shared_data.robot.dev_types import RobotType + +from . import commands +from .types import ( + OFF_DECK_LOCATION, + DeckSlotLocation, + LabwareLocation, + LabwareOffsetCreate, + ModuleLocation, +) + + +def standardize_labware_offset( + original: LabwareOffsetCreate, robot_type: RobotType +) -> LabwareOffsetCreate: + """Convert the deck slot in the given `LabwareOffsetCreate` to match the given robot type.""" + return original.copy( + update={ + "location": original.location.copy( + update={ + "slotName": original.location.slotName.to_equivalent_for_robot_type( + robot_type + ) + } + ) + } + ) + + +def standardize_command( + original: commands.CommandCreate, robot_type: RobotType +) -> commands.CommandCreate: + """Convert any deck slots in the given `CommandCreate` to match the given robot type.""" + try: + standardize = _standardize_command_functions[type(original)] + except KeyError: + return original + else: + return standardize(original, robot_type) + + +# Command-specific standardization: +# +# Our use of .copy(update=...) in these implementations, instead of .construct(...), is a tradeoff. +# .construct() would give us better type-checking for the fields that we set, +# but .copy(update=...) avoids the hazard of forgetting to set fields that have defaults. + + +def _standardize_load_labware( + original: commands.LoadLabwareCreate, robot_type: RobotType +) -> commands.LoadLabwareCreate: + params = original.params.copy( + update={ + "location": _standardize_labware_location( + original.params.location, robot_type + ) + } + ) + return original.copy(update={"params": params}) + + +def _standardize_load_module( + original: commands.LoadModuleCreate, robot_type: RobotType +) -> commands.LoadModuleCreate: + params = original.params.copy( + update={ + "location": _standardize_deck_slot_location( + original.params.location, robot_type + ) + } + ) + return original.copy(update={"params": params}) + + +def _standardize_move_labware( + original: commands.MoveLabwareCreate, robot_type: RobotType +) -> commands.MoveLabwareCreate: + params = original.params.copy( + update={ + "newLocation": _standardize_labware_location( + original.params.newLocation, robot_type + ) + } + ) + return original.copy(update={"params": params}) + + +_standardize_command_functions: Dict[ + Type[commands.CommandCreate], Callable[[Any, RobotType], commands.CommandCreate] +] = { + commands.LoadLabwareCreate: _standardize_load_labware, + commands.LoadModuleCreate: _standardize_load_module, + commands.MoveLabwareCreate: _standardize_move_labware, +} + + +# Helpers: + + +def _standardize_labware_location( + original: LabwareLocation, robot_type: RobotType +) -> LabwareLocation: + if isinstance(original, DeckSlotLocation): + return _standardize_deck_slot_location(original, robot_type) + elif isinstance(original, ModuleLocation) or original == OFF_DECK_LOCATION: + return original + + +def _standardize_deck_slot_location( + original: DeckSlotLocation, robot_type: RobotType +) -> DeckSlotLocation: + return original.copy( + update={"slotName": original.slotName.to_equivalent_for_robot_type(robot_type)} + ) diff --git a/api/src/opentrons/protocol_engine/state/geometry.py b/api/src/opentrons/protocol_engine/state/geometry.py index 39ce14af6d0..845d1cdbb90 100644 --- a/api/src/opentrons/protocol_engine/state/geometry.py +++ b/api/src/opentrons/protocol_engine/state/geometry.py @@ -22,6 +22,7 @@ CurrentWell, TipGeometry, ) +from .config import Config from .labware import LabwareView from .modules import ModuleView from .pipettes import PipetteView @@ -34,11 +35,13 @@ class GeometryView: def __init__( self, + config: Config, labware_view: LabwareView, module_view: ModuleView, pipette_view: PipetteView, ) -> None: """Initialize a GeometryView instance.""" + self._config = config self._labware = labware_view self._modules = module_view self._pipettes = pipette_view @@ -374,10 +377,13 @@ def get_extra_waypoints( from_slot=self.get_ancestor_slot_name(location.labware_id), to_slot=self.get_ancestor_slot_name(labware_id), ): - slot_5_center = self._labware.get_slot_center_position( - slot=DeckSlotName.SLOT_5 + middle_slot = DeckSlotName.SLOT_5.to_equivalent_for_robot_type( + self._config.robot_type ) - return [(slot_5_center.x, slot_5_center.y)] + middle_slot_center = self._labware.get_slot_center_position( + slot=middle_slot, + ) + return [(middle_slot_center.x, middle_slot_center.y)] return [] # TODO(mc, 2022-12-09): enforce data integrity (e.g. one module per slot) diff --git a/api/src/opentrons/protocol_engine/state/labware.py b/api/src/opentrons/protocol_engine/state/labware.py index de153913768..4c3411e2074 100644 --- a/api/src/opentrons/protocol_engine/state/labware.py +++ b/api/src/opentrons/protocol_engine/state/labware.py @@ -59,7 +59,20 @@ "opentrons/usascientific_96_wellplate_2.4ml_deep/1", } -_INSTRUMENT_ATTACH_SLOT = DeckSlotName.SLOT_1 +_OT3_INSTRUMENT_ATTACH_SLOT = DeckSlotName.SLOT_D1 + +_RIGHT_SIDE_SLOTS = { + # OT-2: + DeckSlotName.FIXED_TRASH, + DeckSlotName.SLOT_9, + DeckSlotName.SLOT_6, + DeckSlotName.SLOT_3, + # OT-3: + DeckSlotName.SLOT_A3, + DeckSlotName.SLOT_B3, + DeckSlotName.SLOT_C3, + DeckSlotName.SLOT_D3, +} class LabwareLoadParams(NamedTuple): @@ -269,11 +282,11 @@ def get_slot_definition(self, slot: DeckSlotName) -> SlotDefV3: deck_def = self.get_deck_definition() for slot_def in deck_def["locations"]["orderedSlots"]: - if slot_def["id"] == str(slot): + if slot_def["id"] == slot.id: return slot_def raise errors.SlotDoesNotExistError( - f"Slot ID {slot} does not exist in deck {deck_def['otId']}" + f"Slot ID {slot.id} does not exist in deck {deck_def['otId']}" ) def get_slot_position(self, slot: DeckSlotName) -> Point: @@ -408,12 +421,7 @@ def get_edge_path_type( left_path_criteria = mount is MountType.RIGHT and well_name in left_column right_path_criteria = mount is MountType.LEFT and well_name in right_column - labware_right_side = labware_slot in [ - DeckSlotName.SLOT_3, - DeckSlotName.SLOT_6, - DeckSlotName.SLOT_9, - DeckSlotName.FIXED_TRASH, - ] + labware_right_side = labware_slot in _RIGHT_SIDE_SLOTS if left_path_criteria and (next_to_module or labware_right_side): return EdgePathType.LEFT @@ -580,10 +588,12 @@ def get_fixed_trash_id(self) -> str: that is currently in use for the protocol run. """ for labware in self._state.labware_by_id.values(): - if ( - isinstance(labware.location, DeckSlotLocation) - and labware.location.slotName == DeckSlotName.FIXED_TRASH - ): + if isinstance( + labware.location, DeckSlotLocation + ) and labware.location.slotName in { + DeckSlotName.FIXED_TRASH, + DeckSlotName.SLOT_A3, + }: return labware.id raise errors.LabwareNotLoadedError( @@ -606,7 +616,7 @@ def raise_if_labware_in_location( def get_calibration_coordinates(self, offset: Point) -> Point: """Get calibration critical point and target position.""" - target_center = self.get_slot_center_position(_INSTRUMENT_ATTACH_SLOT) + target_center = self.get_slot_center_position(_OT3_INSTRUMENT_ATTACH_SLOT) # TODO (tz, 11-30-22): These coordinates wont work for OT-2. We will need to apply offsets after # https://opentrons.atlassian.net/browse/RCORE-382 diff --git a/api/src/opentrons/protocol_engine/state/modules.py b/api/src/opentrons/protocol_engine/state/modules.py index ee8afdc9e9a..b4ed1e6093c 100644 --- a/api/src/opentrons/protocol_engine/state/modules.py +++ b/api/src/opentrons/protocol_engine/state/modules.py @@ -80,7 +80,7 @@ class SlotTransit(NamedTuple): end: DeckSlotName -_THERMOCYCLER_SLOT_TRANSITS_TO_DODGE = [ +_OT2_THERMOCYCLER_SLOT_TRANSITS_TO_DODGE = { SlotTransit(start=DeckSlotName.SLOT_1, end=DeckSlotName.FIXED_TRASH), SlotTransit(start=DeckSlotName.FIXED_TRASH, end=DeckSlotName.SLOT_1), SlotTransit(start=DeckSlotName.SLOT_4, end=DeckSlotName.FIXED_TRASH), @@ -95,7 +95,16 @@ class SlotTransit(NamedTuple): SlotTransit(start=DeckSlotName.SLOT_11, end=DeckSlotName.SLOT_4), SlotTransit(start=DeckSlotName.SLOT_1, end=DeckSlotName.SLOT_11), SlotTransit(start=DeckSlotName.SLOT_11, end=DeckSlotName.SLOT_1), -] +} + +_OT3_THERMOCYCLER_SLOT_TRANSITS_TO_DODGE = { + SlotTransit(start=t.start.to_ot3_equivalent(), end=t.end.to_ot3_equivalent()) + for t in _OT2_THERMOCYCLER_SLOT_TRANSITS_TO_DODGE +} + +_THERMOCYCLER_SLOT_TRANSITS_TO_DODGE = ( + _OT2_THERMOCYCLER_SLOT_TRANSITS_TO_DODGE | _OT3_THERMOCYCLER_SLOT_TRANSITS_TO_DODGE +) @dataclass(frozen=True) diff --git a/api/src/opentrons/protocol_engine/state/state.py b/api/src/opentrons/protocol_engine/state/state.py index 72dd40426ff..d9e8811f4ce 100644 --- a/api/src/opentrons/protocol_engine/state/state.py +++ b/api/src/opentrons/protocol_engine/state/state.py @@ -263,6 +263,7 @@ def _initialize_state(self) -> None: # Derived states self._geometry = GeometryView( + config=self._config, labware_view=self._labware, module_view=self._modules, pipette_view=self._pipettes, diff --git a/api/src/opentrons/protocol_engine/types.py b/api/src/opentrons/protocol_engine/types.py index c3ced3df108..04158089c7a 100644 --- a/api/src/opentrons/protocol_engine/types.py +++ b/api/src/opentrons/protocol_engine/types.py @@ -37,7 +37,21 @@ class EngineStatus(str, Enum): class DeckSlotLocation(BaseModel): """The location of something placed in a single deck slot.""" - slotName: DeckSlotName + slotName: DeckSlotName = Field( + ..., + description=( + # This description should be kept in sync with LabwareOffsetLocation.slotName. + "A slot on the robot's deck." + "\n\n" + 'The plain numbers like `"5"` are for the OT-2,' + ' and the letter-number pairs like `"C2"` are for the Flex.' + "\n\n" + "When you provide one of these values, you can use either style." + " It will automatically be converted to match the robot." + "\n\n" + "When one of these values is returned, it will always match the robot." + ), + ) class ModuleLocation(BaseModel): @@ -431,6 +445,15 @@ class LabwareOffsetLocation(BaseModel): "The deck slot where the protocol will load the labware." " Or, if the protocol will load the labware on a module," " the deck slot where the protocol will load that module." + "\n\n" + # This description should be kept in sync with DeckSlotLocation.slotName. + 'The plain numbers like `"5"` are for the OT-2,' + ' and the letter-number pairs like `"C2"` are for the Flex.' + "\n\n" + "When you provide one of these values, you can use either style." + " It will automatically be converted to match the robot." + "\n\n" + "When one of these values is returned, it will always match the robot." ), ) moduleModel: Optional[ModuleModel] = Field( diff --git a/api/src/opentrons/types.py b/api/src/opentrons/types.py index da705dbf37f..0bbdb2c7552 100644 --- a/api/src/opentrons/types.py +++ b/api/src/opentrons/types.py @@ -3,6 +3,8 @@ from math import sqrt, isclose from typing import TYPE_CHECKING, Any, NamedTuple, Iterable, Union, List +from opentrons_shared_data.robot.dev_types import RobotType + from .protocols.api_support.labware_like import LabwareLike if TYPE_CHECKING: @@ -217,33 +219,13 @@ class OT3MountType(str, enum.Enum): GRIPPER = "gripper" -DECK_COORDINATE_TO_SLOT_NAME = { - "D1": "1", - "D2": "2", - "D3": "3", - "C1": "4", - "C2": "5", - "C3": "6", - "B1": "7", - "B2": "8", - "B3": "9", - "A1": "10", - "A2": "11", - "A3": "12", -} - -DECK_SLOT_NAME_TO_COORDINATE = { - slot_name: coordinate - for coordinate, slot_name in DECK_COORDINATE_TO_SLOT_NAME.items() -} - - # TODO(mc, 2020-11-09): this makes sense in shared-data or other common # model library # https://github.com/Opentrons/opentrons/pull/6943#discussion_r519029833 class DeckSlotName(enum.Enum): """Deck slot identifiers.""" + # OT-2: SLOT_1 = "1" SLOT_2 = "2" SLOT_3 = "3" @@ -257,18 +239,64 @@ class DeckSlotName(enum.Enum): SLOT_11 = "11" FIXED_TRASH = "12" + # OT-3: + SLOT_A1 = "A1" + SLOT_A2 = "A2" + SLOT_A3 = "A3" + SLOT_B1 = "B1" + SLOT_B2 = "B2" + SLOT_B3 = "B3" + SLOT_C1 = "C1" + SLOT_C2 = "C2" + SLOT_C3 = "C3" + SLOT_D1 = "D1" + SLOT_D2 = "D2" + SLOT_D3 = "D3" + @classmethod def from_primitive(cls, value: DeckLocation) -> DeckSlotName: str_val = str(value).upper() - if str_val in DECK_COORDINATE_TO_SLOT_NAME: - str_val = DECK_COORDINATE_TO_SLOT_NAME[str_val] return cls(str_val) + # TODO(mm, 2023-05-08): + # Migrate callers off of this method. https://opentrons.atlassian.net/browse/RLAB-345 def as_int(self) -> int: - return int(self.value) + """Return this deck slot as an OT-2-style integer. + + For example, `SLOT_5.as_int()` and `SLOT_C2.as_int()` are both `5`. + + Deprecated: + This will not make sense when the OT-3 has staging area slots. + """ + return int(self.to_ot2_equivalent().value) + + def to_ot2_equivalent(self) -> DeckSlotName: + """Return the OT-2 deck slot that's in the same place as this one. + + For example, `SLOT_C2.to_ot3_equivalent()` is `SLOT_5`. + + If this is already an OT-2 deck slot, returns itself. + """ + return _ot3_to_ot2.get(self, self) + + def to_ot3_equivalent(self) -> DeckSlotName: + """Return the OT-3 deck slot that's in the same place as this one. + + For example, `SLOT_5.to_ot3_equivalent()` is `SLOT_C2`. - def as_coordinate(self) -> str: - return DECK_SLOT_NAME_TO_COORDINATE[self.value] + If this is already an OT-3 deck slot, returns itself. + """ + return _ot2_to_ot3.get(self, self) + + def to_equivalent_for_robot_type(self, robot_type: RobotType) -> DeckSlotName: + """Return the deck slot, for the given robot type, that's in the same place as this one. + + See `to_ot2_equivalent()` and `to_ot3_equivalent()`. + """ + if robot_type == "OT-2 Standard": + return self.to_ot2_equivalent() + elif robot_type == "OT-3 Standard": + return self.to_ot3_equivalent() @property def id(self) -> str: @@ -288,6 +316,27 @@ def __str__(self) -> str: return self.id +# fmt: off +_slot_equivalencies = [ + (DeckSlotName.SLOT_1, DeckSlotName.SLOT_D1), + (DeckSlotName.SLOT_2, DeckSlotName.SLOT_D2), + (DeckSlotName.SLOT_3, DeckSlotName.SLOT_D3), + (DeckSlotName.SLOT_4, DeckSlotName.SLOT_C1), + (DeckSlotName.SLOT_5, DeckSlotName.SLOT_C2), + (DeckSlotName.SLOT_6, DeckSlotName.SLOT_C3), + (DeckSlotName.SLOT_7, DeckSlotName.SLOT_B1), + (DeckSlotName.SLOT_8, DeckSlotName.SLOT_B2), + (DeckSlotName.SLOT_9, DeckSlotName.SLOT_B3), + (DeckSlotName.SLOT_10, DeckSlotName.SLOT_A1), + (DeckSlotName.SLOT_11, DeckSlotName.SLOT_A2), + (DeckSlotName.FIXED_TRASH, DeckSlotName.SLOT_A3), +] +# fmt: on + +_ot2_to_ot3 = {ot2: ot3 for ot2, ot3 in _slot_equivalencies} +_ot3_to_ot2 = {ot3: ot2 for ot2, ot3 in _slot_equivalencies} + + class TransferTipPolicy(enum.Enum): ONCE = enum.auto() NEVER = enum.auto() diff --git a/api/tests/opentrons/protocol_api/test_deck.py b/api/tests/opentrons/protocol_api/test_deck.py index ae1a7f1b9e2..6ccc52f9b90 100644 --- a/api/tests/opentrons/protocol_api/test_deck.py +++ b/api/tests/opentrons/protocol_api/test_deck.py @@ -137,26 +137,32 @@ def test_slot_keys_iter(subject: Deck) -> None: { "locations": { "orderedSlots": [ - {"id": "1"}, - {"id": "2"}, - {"id": "3"}, + {"id": "fee"}, + {"id": "foe"}, + {"id": "fum"}, ], "calibrationPoints": [], } }, ], ) -def test_get_slots(decoy: Decoy, subject: Deck) -> None: +def test_get_slots( + decoy: Decoy, mock_protocol_core: ProtocolCore, subject: Deck +) -> None: """It should provide slot definitions.""" decoy.when(mock_validation.ensure_deck_slot(222)).then_return(DeckSlotName.SLOT_2) + decoy.when(mock_protocol_core.robot_type).then_return("OT-2 Standard") + decoy.when( + mock_validation.ensure_deck_slot_string(DeckSlotName.SLOT_2, "OT-2 Standard") + ).then_return("fee") assert subject.slots == [ - {"id": "1"}, - {"id": "2"}, - {"id": "3"}, + {"id": "fee"}, + {"id": "foe"}, + {"id": "fum"}, ] - assert subject.get_slot_definition(222) == {"id": "2"} + assert subject.get_slot_definition(222) == {"id": "fee"} @pytest.mark.parametrize( @@ -165,21 +171,27 @@ def test_get_slots(decoy: Decoy, subject: Deck) -> None: { "locations": { "orderedSlots": [ - {"id": "3", "position": [1.0, 2.0, 3.0]}, + {"id": "foo", "position": [1.0, 2.0, 3.0]}, ], "calibrationPoints": [], } }, ], ) -def test_get_position_for(decoy: Decoy, subject: Deck) -> None: +def test_get_position_for( + decoy: Decoy, mock_protocol_core: ProtocolCore, subject: Deck +) -> None: """It should return a `Location` for a deck slot.""" decoy.when(mock_validation.ensure_deck_slot(333)).then_return(DeckSlotName.SLOT_3) + decoy.when(mock_protocol_core.robot_type).then_return("OT-3 Standard") + decoy.when( + mock_validation.ensure_deck_slot_string(DeckSlotName.SLOT_3, "OT-3 Standard") + ).then_return("foo") result = subject.position_for(333) assert result.point == Point(x=1.0, y=2.0, z=3.0) assert result.labware.is_slot is True - assert str(result.labware) == "3" + assert str(result.labware) == "foo" def test_highest_z( diff --git a/api/tests/opentrons/protocol_api/test_validation.py b/api/tests/opentrons/protocol_api/test_validation.py index e54b39dbc92..8abefb3e408 100644 --- a/api/tests/opentrons/protocol_api/test_validation.py +++ b/api/tests/opentrons/protocol_api/test_validation.py @@ -76,12 +76,12 @@ def test_ensure_pipette_input_invalid() -> None: [ ("1", DeckSlotName.SLOT_1), (1, DeckSlotName.SLOT_1), - ("d1", DeckSlotName.SLOT_1), - ("D1", DeckSlotName.SLOT_1), + ("d1", DeckSlotName.SLOT_D1), + ("D1", DeckSlotName.SLOT_D1), (12, DeckSlotName.FIXED_TRASH), ("12", DeckSlotName.FIXED_TRASH), - ("a3", DeckSlotName.FIXED_TRASH), - ("A3", DeckSlotName.FIXED_TRASH), + ("a3", DeckSlotName.SLOT_A3), + ("A3", DeckSlotName.SLOT_A3), ], ) def test_ensure_deck_slot(input_value: Union[str, int], expected: DeckSlotName) -> None: diff --git a/api/tests/opentrons/protocol_engine/resources/test_deck_data_provider.py b/api/tests/opentrons/protocol_engine/resources/test_deck_data_provider.py index d636b790cc2..f7e04646d22 100644 --- a/api/tests/opentrons/protocol_engine/resources/test_deck_data_provider.py +++ b/api/tests/opentrons/protocol_engine/resources/test_deck_data_provider.py @@ -126,7 +126,7 @@ async def test_get_deck_labware_fixtures_ot3_standard( assert result == [ DeckFixedLabware( labware_id="fixedTrash", - location=DeckSlotLocation(slotName=DeckSlotName.FIXED_TRASH), + location=DeckSlotLocation(slotName=DeckSlotName.SLOT_A3), definition=ot3_fixed_trash_def, ) ] diff --git a/api/tests/opentrons/protocol_engine/state/test_geometry_view.py b/api/tests/opentrons/protocol_engine/state/test_geometry_view.py index 4d40b4465ea..7ad62cf79d2 100644 --- a/api/tests/opentrons/protocol_engine/state/test_geometry_view.py +++ b/api/tests/opentrons/protocol_engine/state/test_geometry_view.py @@ -30,6 +30,7 @@ CurrentWell, ) from opentrons.protocol_engine.state import move_types +from opentrons.protocol_engine.state.config import Config from opentrons.protocol_engine.state.labware import LabwareView from opentrons.protocol_engine.state.modules import ModuleView from opentrons.protocol_engine.state.pipettes import PipetteView @@ -67,6 +68,10 @@ def subject( ) -> GeometryView: """Get a GeometryView with its store dependencies mocked out.""" return GeometryView( + config=Config( + robot_type="OT-3 Standard", + deck_type=DeckType.OT3_STANDARD, + ), labware_view=labware_view, module_view=module_view, pipette_view=mock_pipette_view, @@ -1064,7 +1069,8 @@ def test_get_extra_waypoints( ) ).then_return(should_dodge) decoy.when( - labware_view.get_slot_center_position(slot=DeckSlotName.SLOT_5) + # Assume the subject's Config is for an OT-3, so use an OT-3 slot name. + labware_view.get_slot_center_position(slot=DeckSlotName.SLOT_C2) ).then_return(Point(x=11, y=22, z=33)) extra_waypoints = subject.get_extra_waypoints("to-labware-id", location) diff --git a/api/tests/opentrons/protocol_engine/state/test_labware_view.py b/api/tests/opentrons/protocol_engine/state/test_labware_view.py index a338a7ba94d..f8cf2c26dec 100644 --- a/api/tests/opentrons/protocol_engine/state/test_labware_view.py +++ b/api/tests/opentrons/protocol_engine/state/test_labware_view.py @@ -513,9 +513,7 @@ def test_get_slot_definition_raises_with_bad_slot_name( subject = get_labware_view(deck_definition=ot2_standard_deck_def) with pytest.raises(errors.SlotDoesNotExistError): - # note: normally the typechecker should catch this, but clients may - # not be using typechecking or our enums - subject.get_slot_definition(42) # type: ignore[arg-type] + subject.get_slot_definition(DeckSlotName.SLOT_A1) def test_get_slot_position(ot2_standard_deck_def: DeckDefinitionV3) -> None: @@ -731,6 +729,7 @@ def test_get_display_name() -> None: def test_get_fixed_trash_id() -> None: """It should return the ID of the labware loaded into the fixed trash slot.""" + # OT-2 fixed trash slot: subject = get_labware_view( labware_by_id={ "abc123": LoadedLabware( @@ -743,9 +742,24 @@ def test_get_fixed_trash_id() -> None: ) }, ) + assert subject.get_fixed_trash_id() == "abc123" + # OT-3 fixed trash slot: + subject = get_labware_view( + labware_by_id={ + "abc123": LoadedLabware( + id="abc123", + loadName="trash-load-name", + location=DeckSlotLocation(slotName=DeckSlotName.SLOT_A3), + definitionUri="trash-definition-uri", + offsetId=None, + displayName=None, + ) + }, + ) assert subject.get_fixed_trash_id() == "abc123" + # Nothing in the fixed trash slot: subject = get_labware_view( labware_by_id={ "abc123": LoadedLabware( @@ -758,7 +772,6 @@ def test_get_fixed_trash_id() -> None: ) }, ) - with pytest.raises(errors.LabwareNotLoadedError): subject.get_fixed_trash_id() @@ -813,14 +826,14 @@ def test_get_calibration_coordinates() -> None: "locations": { "orderedSlots": [ { - "id": "1", + "id": "D1", "position": [2, 2, 0.0], "boundingBox": { "xDimension": 4.0, "yDimension": 6.0, "zDimension": 0, }, - "displayName": "Slot 1", + "displayName": "Slot D1", } ] } @@ -890,11 +903,17 @@ def test_get_by_slot_filter_ids() -> None: ["well_name", "mount", "labware_slot", "next_to_module", "expected_result"], [ ("abc", MountType.RIGHT, DeckSlotName.SLOT_3, False, EdgePathType.LEFT), + ("abc", MountType.RIGHT, DeckSlotName.SLOT_D3, False, EdgePathType.LEFT), ("abc", MountType.RIGHT, DeckSlotName.SLOT_1, True, EdgePathType.LEFT), + ("abc", MountType.RIGHT, DeckSlotName.SLOT_D1, True, EdgePathType.LEFT), ("pqr", MountType.LEFT, DeckSlotName.SLOT_3, True, EdgePathType.RIGHT), + ("pqr", MountType.LEFT, DeckSlotName.SLOT_D3, True, EdgePathType.RIGHT), ("pqr", MountType.LEFT, DeckSlotName.SLOT_3, False, EdgePathType.DEFAULT), + ("pqr", MountType.LEFT, DeckSlotName.SLOT_D3, False, EdgePathType.DEFAULT), ("pqr", MountType.RIGHT, DeckSlotName.SLOT_3, True, EdgePathType.DEFAULT), + ("pqr", MountType.RIGHT, DeckSlotName.SLOT_D3, True, EdgePathType.DEFAULT), ("def", MountType.LEFT, DeckSlotName.SLOT_3, True, EdgePathType.DEFAULT), + ("def", MountType.LEFT, DeckSlotName.SLOT_D3, True, EdgePathType.DEFAULT), ], ) def test_get_edge_path_type( diff --git a/api/tests/opentrons/protocol_engine/state/test_module_view.py b/api/tests/opentrons/protocol_engine/state/test_module_view.py index e5b8e85c9c6..2a8b5b89215 100644 --- a/api/tests/opentrons/protocol_engine/state/test_module_view.py +++ b/api/tests/opentrons/protocol_engine/state/test_module_view.py @@ -333,27 +333,27 @@ def test_get_module_offset_for_ot2_standard( argvalues=[ ( lazy_fixture("tempdeck_v2_def"), - DeckSlotName.SLOT_1, + DeckSlotName.SLOT_1.to_ot3_equivalent(), LabwareOffsetVector(x=0, y=0, z=9), ), ( lazy_fixture("tempdeck_v2_def"), - DeckSlotName.SLOT_3, + DeckSlotName.SLOT_3.to_ot3_equivalent(), LabwareOffsetVector(x=0, y=0, z=9), ), ( lazy_fixture("thermocycler_v2_def"), - DeckSlotName.SLOT_7, + DeckSlotName.SLOT_7.to_ot3_equivalent(), LabwareOffsetVector(x=-20.005, y=67.96, z=0.26), ), ( lazy_fixture("heater_shaker_v1_def"), - DeckSlotName.SLOT_1, + DeckSlotName.SLOT_1.to_ot3_equivalent(), LabwareOffsetVector(x=0, y=0, z=18.95), ), ( lazy_fixture("heater_shaker_v1_def"), - DeckSlotName.SLOT_3, + DeckSlotName.SLOT_3.to_ot3_equivalent(), LabwareOffsetVector(x=0, y=0, z=18.95), ), ( @@ -714,6 +714,13 @@ def test_thermocycler_dodging_by_slots( ) +@pytest.mark.parametrize( + argnames=["from_slot", "to_slot"], + argvalues=[ + (DeckSlotName.SLOT_8, DeckSlotName.SLOT_1), + (DeckSlotName.SLOT_B2, DeckSlotName.SLOT_D1), + ], +) @pytest.mark.parametrize( argnames=["module_definition", "should_dodge"], argvalues=[ @@ -727,6 +734,8 @@ def test_thermocycler_dodging_by_slots( ], ) def test_thermocycler_dodging_by_modules( + from_slot: DeckSlotName, + to_slot: DeckSlotName, module_definition: ModuleDefinition, should_dodge: bool, ) -> None: @@ -741,9 +750,7 @@ def test_thermocycler_dodging_by_modules( }, ) assert ( - subject.should_dodge_thermocycler( - from_slot=DeckSlotName.SLOT_8, to_slot=DeckSlotName.SLOT_1 - ) + subject.should_dodge_thermocycler(from_slot=from_slot, to_slot=to_slot) is should_dodge ) diff --git a/api/tests/opentrons/protocol_engine/test_create_protocol_engine.py b/api/tests/opentrons/protocol_engine/test_create_protocol_engine.py index 9f420b69071..78454056ca6 100644 --- a/api/tests/opentrons/protocol_engine/test_create_protocol_engine.py +++ b/api/tests/opentrons/protocol_engine/test_create_protocol_engine.py @@ -47,7 +47,7 @@ DeckType.OT3_STANDARD, lazy_fixture("ot3_standard_deck_def"), lazy_fixture("ot3_fixed_trash_def"), - DeckSlotName.FIXED_TRASH, # TODO: Update when OT-3 deck slots get renamed. + DeckSlotName.SLOT_A3, ), ], ) diff --git a/api/tests/opentrons/protocol_engine/test_protocol_engine.py b/api/tests/opentrons/protocol_engine/test_protocol_engine.py index 25b1afb73fd..45a5f8918b2 100644 --- a/api/tests/opentrons/protocol_engine/test_protocol_engine.py +++ b/api/tests/opentrons/protocol_engine/test_protocol_engine.py @@ -1,18 +1,22 @@ """Tests for the ProtocolEngine class.""" +import inspect from datetime import datetime from typing import Any import pytest from decoy import Decoy +from opentrons_shared_data.robot.dev_types import RobotType + from opentrons.types import DeckSlotName from opentrons.hardware_control import HardwareControlAPI, OT2HardwareControlAPI from opentrons.hardware_control.modules import MagDeck, TempDeck from opentrons.hardware_control.types import PauseType as HardwarePauseType - from opentrons.protocols.models import LabwareDefinition -from opentrons.protocol_engine import ProtocolEngine, commands + +from opentrons.protocol_engine import ProtocolEngine, commands, slot_standardization from opentrons.protocol_engine.types import ( + DeckType, LabwareOffset, LabwareOffsetCreate, LabwareOffsetVector, @@ -28,7 +32,7 @@ DoorWatcher, ) from opentrons.protocol_engine.resources import ModelUtils, ModuleDataProvider -from opentrons.protocol_engine.state import StateStore +from opentrons.protocol_engine.state import Config, StateStore from opentrons.protocol_engine.plugins import AbstractPlugin, PluginStarter from opentrons.protocol_engine.actions import ( @@ -103,6 +107,15 @@ def module_data_provider(decoy: Decoy) -> ModuleDataProvider: return decoy.mock(cls=ModuleDataProvider) +@pytest.fixture(autouse=True) +def _mock_slot_standardization_module( + decoy: Decoy, monkeypatch: pytest.MonkeyPatch +) -> None: + """Mock out opentrons.protocol_engine.slot_standardization functions.""" + for name, func in inspect.getmembers(slot_standardization, inspect.isfunction): + monkeypatch.setattr(slot_standardization, name, decoy.mock(func=func)) + + @pytest.fixture(autouse=True) def _mock_hash_command_params_module( decoy: Decoy, monkeypatch: pytest.MonkeyPatch @@ -154,26 +167,36 @@ def test_add_command( state_store: StateStore, action_dispatcher: ActionDispatcher, model_utils: ModelUtils, - queue_worker: QueueWorker, subject: ProtocolEngine, ) -> None: """It should add a command to the state from a request.""" created_at = datetime(year=2021, month=1, day=1) - params = commands.HomeParams() - request = commands.HomeCreate(params=params) + original_request = commands.WaitForResumeCreate( + params=commands.WaitForResumeParams() + ) + standardized_request = commands.HomeCreate(params=commands.HomeParams()) queued = commands.Home( id="command-id", key="command-key", status=commands.CommandStatus.QUEUED, createdAt=created_at, - params=params, + params=commands.HomeParams(), + ) + + robot_type: RobotType = "OT-3 Standard" + decoy.when(state_store.config).then_return( + Config(robot_type=robot_type, deck_type=DeckType.OT3_STANDARD) ) + decoy.when( + slot_standardization.standardize_command(original_request, robot_type) + ).then_return(standardized_request) + decoy.when(model_utils.generate_id()).then_return("command-id") decoy.when(model_utils.get_timestamp()).then_return(created_at) decoy.when(state_store.commands.get_latest_command_hash()).then_return("abc") decoy.when( - commands.hash_command_params(create=request, last_hash="abc") + commands.hash_command_params(create=standardized_request, last_hash="abc") ).then_return("123") def _stub_queued(*_a: object, **_k: object) -> None: @@ -184,7 +207,7 @@ def _stub_queued(*_a: object, **_k: object) -> None: QueueCommandAction( command_id="command-id", created_at=created_at, - request=request, + request=standardized_request, request_hash="123", ) ) @@ -192,7 +215,7 @@ def _stub_queued(*_a: object, **_k: object) -> None: QueueCommandAction( command_id="command-id-validated", created_at=created_at, - request=request, + request=standardized_request, request_hash="456", ) ) @@ -202,13 +225,13 @@ def _stub_queued(*_a: object, **_k: object) -> None: QueueCommandAction( command_id="command-id-validated", created_at=created_at, - request=request, + request=standardized_request, request_hash="456", ) ), ).then_do(_stub_queued) - result = subject.add_command(request) + result = subject.add_command(original_request) assert result == queued @@ -218,28 +241,38 @@ async def test_add_and_execute_command( state_store: StateStore, action_dispatcher: ActionDispatcher, model_utils: ModelUtils, - queue_worker: QueueWorker, subject: ProtocolEngine, ) -> None: """It should add and execute a command from a request.""" created_at = datetime(year=2021, month=1, day=1) - params = commands.HomeParams() - request = commands.HomeCreate(params=params) + original_request = commands.WaitForResumeCreate( + params=commands.WaitForResumeParams() + ) + standardized_request = commands.HomeCreate(params=commands.HomeParams()) queued = commands.Home( id="command-id", key="command-key", status=commands.CommandStatus.QUEUED, createdAt=created_at, - params=params, + params=commands.HomeParams(), ) completed = commands.Home( id="command-id", key="command-key", status=commands.CommandStatus.SUCCEEDED, createdAt=created_at, - params=params, + params=commands.HomeParams(), ) + robot_type: RobotType = "OT-3 Standard" + decoy.when(state_store.config).then_return( + Config(robot_type=robot_type, deck_type=DeckType.OT3_STANDARD) + ) + + decoy.when( + slot_standardization.standardize_command(original_request, robot_type) + ).then_return(standardized_request) + decoy.when(model_utils.generate_id()).then_return("command-id") decoy.when(model_utils.get_timestamp()).then_return(created_at) @@ -255,7 +288,7 @@ def _stub_completed(*_a: object, **_k: object) -> bool: QueueCommandAction( command_id="command-id", created_at=created_at, - request=request, + request=standardized_request, request_hash=None, ) ) @@ -263,7 +296,7 @@ def _stub_completed(*_a: object, **_k: object) -> bool: QueueCommandAction( command_id="command-id-validated", created_at=created_at, - request=request, + request=standardized_request, request_hash=None, ) ) @@ -273,7 +306,7 @@ def _stub_completed(*_a: object, **_k: object) -> bool: QueueCommandAction( command_id="command-id-validated", created_at=created_at, - request=request, + request=standardized_request, request_hash=None, ) ) @@ -286,7 +319,7 @@ def _stub_completed(*_a: object, **_k: object) -> bool: ), ).then_do(_stub_completed) - result = await subject.add_and_execute_command(request) + result = await subject.add_and_execute_command(original_request) assert result == completed @@ -296,7 +329,6 @@ def test_play( state_store: StateStore, action_dispatcher: ActionDispatcher, model_utils: ModelUtils, - queue_worker: QueueWorker, hardware_api: HardwareControlAPI, subject: ProtocolEngine, ) -> None: @@ -325,7 +357,6 @@ def test_play_blocked_by_door( state_store: StateStore, action_dispatcher: ActionDispatcher, model_utils: ModelUtils, - queue_worker: QueueWorker, hardware_api: HardwareControlAPI, subject: ProtocolEngine, ) -> None: @@ -380,7 +411,6 @@ async def test_finish( action_dispatcher: ActionDispatcher, plugin_starter: PluginStarter, queue_worker: QueueWorker, - hardware_api: HardwareControlAPI, subject: ProtocolEngine, hardware_stopper: HardwareStopper, drop_tips_and_home: bool, @@ -411,9 +441,6 @@ async def test_finish( async def test_finish_with_defaults( decoy: Decoy, action_dispatcher: ActionDispatcher, - plugin_starter: PluginStarter, - queue_worker: QueueWorker, - hardware_api: HardwareControlAPI, subject: ProtocolEngine, hardware_stopper: HardwareStopper, ) -> None: @@ -430,7 +457,6 @@ async def test_finish_with_error( decoy: Decoy, action_dispatcher: ActionDispatcher, queue_worker: QueueWorker, - hardware_api: HardwareControlAPI, model_utils: ModelUtils, subject: ProtocolEngine, hardware_stopper: HardwareStopper, @@ -465,7 +491,6 @@ async def test_finish_with_error( async def test_finish_stops_hardware_if_queue_worker_join_fails( decoy: Decoy, queue_worker: QueueWorker, - hardware_api: HardwareControlAPI, hardware_stopper: HardwareStopper, door_watcher: DoorWatcher, action_dispatcher: ActionDispatcher, @@ -512,7 +537,6 @@ async def test_stop( decoy: Decoy, action_dispatcher: ActionDispatcher, queue_worker: QueueWorker, - hardware_api: HardwareControlAPI, hardware_stopper: HardwareStopper, state_store: StateStore, subject: ProtocolEngine, @@ -559,6 +583,11 @@ def test_add_labware_offset( location=LabwareOffsetLocation(slotName=DeckSlotName.SLOT_1), vector=LabwareOffsetVector(x=1, y=2, z=3), ) + standardized_request = LabwareOffsetCreate( + definitionUri="standardized-definition-uri", + location=LabwareOffsetLocation(slotName=DeckSlotName.SLOT_2), + vector=LabwareOffsetVector(x=2, y=3, z=4), + ) id = "labware-offset-id" created_at = datetime(year=2021, month=11, day=15) @@ -566,11 +595,18 @@ def test_add_labware_offset( expected_result = LabwareOffset( id=id, createdAt=created_at, - definitionUri=request.definitionUri, - location=request.location, - vector=request.vector, + definitionUri=standardized_request.definitionUri, + location=standardized_request.location, + vector=standardized_request.vector, ) + robot_type: RobotType = "OT-3 Standard" + decoy.when(state_store.config).then_return( + Config(robot_type=robot_type, deck_type=DeckType.OT3_STANDARD) + ) + decoy.when( + slot_standardization.standardize_labware_offset(request, robot_type) + ).then_return(standardized_request) decoy.when(model_utils.generate_id()).then_return(id) decoy.when(model_utils.get_timestamp()).then_return(created_at) decoy.when( @@ -592,7 +628,7 @@ def test_add_labware_offset( AddLabwareOffsetAction( labware_offset_id=id, created_at=created_at, - request=request, + request=standardized_request, ) ) ) @@ -626,7 +662,6 @@ def _stub_get_definition_uri(*args: Any, **kwargs: Any) -> None: def test_add_liquid( decoy: Decoy, action_dispatcher: ActionDispatcher, - state_store: StateStore, subject: ProtocolEngine, ) -> None: """It should dispatch an AddLiquidAction action.""" diff --git a/api/tests/opentrons/protocol_engine/test_slot_standardization.py b/api/tests/opentrons/protocol_engine/test_slot_standardization.py new file mode 100644 index 00000000000..97eae9a515c --- /dev/null +++ b/api/tests/opentrons/protocol_engine/test_slot_standardization.py @@ -0,0 +1,258 @@ +"""Tests for `slot_standardization`.""" + +import pytest + +from opentrons_shared_data.robot.dev_types import RobotType + +from opentrons.types import DeckSlotName +from opentrons.protocol_engine import ( + commands, + slot_standardization as subject, + CommandIntent, + DeckSlotLocation, + LabwareLocation, + LabwareMovementStrategy, + LabwareOffsetCreate, + LabwareOffsetLocation, + LabwareOffsetVector, + ModuleLocation, + ModuleModel, + OFF_DECK_LOCATION, +) + + +@pytest.mark.parametrize("module_model", [None, ModuleModel.MAGNETIC_MODULE_V1]) +@pytest.mark.parametrize( + ("slot_name", "robot_type", "expected_slot_name"), + [ + (DeckSlotName.SLOT_5, "OT-2 Standard", DeckSlotName.SLOT_5), + (DeckSlotName.SLOT_C2, "OT-2 Standard", DeckSlotName.SLOT_5), + (DeckSlotName.SLOT_5, "OT-3 Standard", DeckSlotName.SLOT_C2), + (DeckSlotName.SLOT_C2, "OT-3 Standard", DeckSlotName.SLOT_C2), + ], +) +def test_standardize_labware_offset( + module_model: ModuleModel, + slot_name: DeckSlotName, + robot_type: RobotType, + expected_slot_name: DeckSlotName, +) -> None: + """It should convert deck slots in `LabwareOffsetCreate`s.""" + original = LabwareOffsetCreate( + definitionUri="opentrons-test/foo/1", + location=LabwareOffsetLocation( + moduleModel=module_model, + slotName=slot_name, + ), + vector=LabwareOffsetVector(x=1, y=2, z=3), + ) + expected = LabwareOffsetCreate( + definitionUri="opentrons-test/foo/1", + location=LabwareOffsetLocation( + moduleModel=module_model, + slotName=expected_slot_name, + ), + vector=LabwareOffsetVector(x=1, y=2, z=3), + ) + assert subject.standardize_labware_offset(original, robot_type) == expected + + +@pytest.mark.parametrize( + ("original_location", "robot_type", "expected_location"), + [ + # DeckSlotLocations should have their slotName standardized. + ( + DeckSlotLocation(slotName=DeckSlotName.SLOT_5), + "OT-2 Standard", + DeckSlotLocation(slotName=DeckSlotName.SLOT_5), + ), + ( + DeckSlotLocation(slotName=DeckSlotName.SLOT_5), + "OT-3 Standard", + DeckSlotLocation(slotName=DeckSlotName.SLOT_C2), + ), + ( + DeckSlotLocation(slotName=DeckSlotName.SLOT_C2), + "OT-2 Standard", + DeckSlotLocation(slotName=DeckSlotName.SLOT_5), + ), + ( + DeckSlotLocation(slotName=DeckSlotName.SLOT_C2), + "OT-3 Standard", + DeckSlotLocation(slotName=DeckSlotName.SLOT_C2), + ), + # ModuleLocations and OFF_DECK_LOCATIONs should be left alone. + ( + ModuleLocation(moduleId="module-id"), + "OT-3 Standard", + ModuleLocation(moduleId="module-id"), + ), + ( + OFF_DECK_LOCATION, + "OT-3 Standard", + OFF_DECK_LOCATION, + ), + ], +) +def test_standardize_load_labware_command( + original_location: LabwareLocation, + robot_type: RobotType, + expected_location: LabwareLocation, +) -> None: + """It should convert deck slots in `LoadLabwareCreate`s.""" + original = commands.LoadLabwareCreate( + intent=CommandIntent.SETUP, + key="key", + params=commands.LoadLabwareParams( + location=original_location, + loadName="loadName", + namespace="namespace", + version=123, + labwareId="labwareId", + displayName="displayName", + ), + ) + expected = commands.LoadLabwareCreate( + intent=CommandIntent.SETUP, + key="key", + params=commands.LoadLabwareParams( + location=expected_location, + loadName="loadName", + namespace="namespace", + version=123, + labwareId="labwareId", + displayName="displayName", + ), + ) + assert subject.standardize_command(original, robot_type) == expected + + +@pytest.mark.parametrize( + ("original_location", "robot_type", "expected_location"), + [ + # DeckSlotLocations should have their slotName standardized. + ( + DeckSlotLocation(slotName=DeckSlotName.SLOT_5), + "OT-2 Standard", + DeckSlotLocation(slotName=DeckSlotName.SLOT_5), + ), + ( + DeckSlotLocation(slotName=DeckSlotName.SLOT_5), + "OT-3 Standard", + DeckSlotLocation(slotName=DeckSlotName.SLOT_C2), + ), + ( + DeckSlotLocation(slotName=DeckSlotName.SLOT_C2), + "OT-2 Standard", + DeckSlotLocation(slotName=DeckSlotName.SLOT_5), + ), + ( + DeckSlotLocation(slotName=DeckSlotName.SLOT_C2), + "OT-3 Standard", + DeckSlotLocation(slotName=DeckSlotName.SLOT_C2), + ), + # ModuleLocations and OFF_DECK_LOCATIONs should be left alone. + ( + ModuleLocation(moduleId="module-id"), + "OT-3 Standard", + ModuleLocation(moduleId="module-id"), + ), + ( + OFF_DECK_LOCATION, + "OT-3 Standard", + OFF_DECK_LOCATION, + ), + ], +) +def test_standardize_move_labware_command( + original_location: LabwareLocation, + robot_type: RobotType, + expected_location: LabwareLocation, +) -> None: + """It should convert deck slots in `MoveLabwareCreate`s.""" + original = commands.MoveLabwareCreate( + intent=CommandIntent.SETUP, + key="key", + params=commands.MoveLabwareParams( + newLocation=original_location, + labwareId="labwareId", + strategy=LabwareMovementStrategy.USING_GRIPPER, + usePickUpLocationLpcOffset=True, + useDropLocationLpcOffset=True, + pickUpOffset=LabwareOffsetVector(x=1, y=2, z=3), + dropOffset=LabwareOffsetVector(x=4, y=5, z=6), + ), + ) + expected = commands.MoveLabwareCreate( + intent=CommandIntent.SETUP, + key="key", + params=commands.MoveLabwareParams( + newLocation=expected_location, + labwareId="labwareId", + strategy=LabwareMovementStrategy.USING_GRIPPER, + usePickUpLocationLpcOffset=True, + useDropLocationLpcOffset=True, + pickUpOffset=LabwareOffsetVector(x=1, y=2, z=3), + dropOffset=LabwareOffsetVector(x=4, y=5, z=6), + ), + ) + assert subject.standardize_command(original, robot_type) == expected + + +@pytest.mark.parametrize( + ("original_location", "robot_type", "expected_location"), + [ + ( + DeckSlotLocation(slotName=DeckSlotName.SLOT_5), + "OT-2 Standard", + DeckSlotLocation(slotName=DeckSlotName.SLOT_5), + ), + ( + DeckSlotLocation(slotName=DeckSlotName.SLOT_5), + "OT-3 Standard", + DeckSlotLocation(slotName=DeckSlotName.SLOT_C2), + ), + ( + DeckSlotLocation(slotName=DeckSlotName.SLOT_C2), + "OT-2 Standard", + DeckSlotLocation(slotName=DeckSlotName.SLOT_5), + ), + ( + DeckSlotLocation(slotName=DeckSlotName.SLOT_C2), + "OT-3 Standard", + DeckSlotLocation(slotName=DeckSlotName.SLOT_C2), + ), + ], +) +def test_standardize_load_module_command( + original_location: DeckSlotLocation, + robot_type: RobotType, + expected_location: DeckSlotLocation, +) -> None: + """It should convert deck slots in `LoadModuleCreate`s.""" + original = commands.LoadModuleCreate( + intent=CommandIntent.SETUP, + key="key", + params=commands.LoadModuleParams( + location=original_location, + model=ModuleModel.MAGNETIC_MODULE_V1, + moduleId="moduleId", + ), + ) + expected = commands.LoadModuleCreate( + intent=CommandIntent.SETUP, + key="key", + params=commands.LoadModuleParams( + location=expected_location, + model=ModuleModel.MAGNETIC_MODULE_V1, + moduleId="moduleId", + ), + ) + assert subject.standardize_command(original, robot_type) == expected + + +@pytest.mark.parametrize("robot_type", ["OT-2 Standard", "OT-3 Standard"]) +def test_standardize_other_commands(robot_type: RobotType) -> None: + """If a`CommandCreate` contains no deck slot, it should be passed through unchanged.""" + original = commands.HomeCreate(params=commands.HomeParams()) + assert subject.standardize_command(original, robot_type) == original diff --git a/api/tests/opentrons/protocols/geometry/test_geometry.py b/api/tests/opentrons/protocols/geometry/test_geometry.py index 2a81b7f06ae..10372f24980 100644 --- a/api/tests/opentrons/protocols/geometry/test_geometry.py +++ b/api/tests/opentrons/protocols/geometry/test_geometry.py @@ -1,7 +1,10 @@ import pytest from opentrons.types import Location, Point -from opentrons.protocols.api_support.deck_type import STANDARD_OT2_DECK +from opentrons.protocols.api_support.deck_type import ( + SHORT_TRASH_DECK, + STANDARD_OT2_DECK, +) from opentrons.protocols.geometry.planning import ( plan_moves, safe_height, @@ -23,9 +26,24 @@ P300M_GEN2_MAX_HEIGHT = 155.75 -@pytest.fixture -def deck(deck_definition_name) -> Deck: - return Deck(deck_type=deck_definition_name) +@pytest.fixture( + # Limit the tests in this file to just test with OT-2 deck definitions. + # + # We need to do this because the tests in this file use the `Deck` class from + # the older, non-Protocol-Engine versions of the Python Protocol API (apiLevel<=2.13), + # and those versions do not support OT-3s. + # + # TODO(mm, 2023-05-18) We should either: + # + # * Decide that the functions tested here are only for PAPIv<=2.13. + # Then, we should move them to `opentrons.protocol_api.core.legacy` to indicate that. + # + # * Or, decide that the functions tested here are for all versions of PAPI. + # Then, we should rewrite these tests to not depend on the OT-2-only `Deck` class. + params=[STANDARD_OT2_DECK, SHORT_TRASH_DECK] +) +def deck(request) -> Deck: + return Deck(deck_type=request.param) def check_arc_basic(arc, from_loc, to_loc): @@ -312,10 +330,6 @@ def test_gen2_module_transforms(deck): assert mmod2.labware_offset == Point(1.425, -0.125, 82.25) -# todo(mm, 2023-05-11): This test is limited to just the OT-2 deck definition -# because it depends on the details of the OT-2 trash height relative to troughs. -# See if it can be rewritten to avoid that. -@pytest.mark.parametrize("deck_definition_name", [STANDARD_OT2_DECK]) def test_instr_max_height(deck): fixed_trash = deck.get_fixed_trash() trough = labware.load(trough_name, deck.position_for(1)) diff --git a/api/tests/opentrons/test_types.py b/api/tests/opentrons/test_types.py index 6e692d796f7..6cd93dce125 100644 --- a/api/tests/opentrons/test_types.py +++ b/api/tests/opentrons/test_types.py @@ -1,5 +1,5 @@ import pytest -from opentrons.types import Point, Location +from opentrons.types import DeckSlotName, Point, Location from opentrons.protocol_api.labware import Labware @@ -46,3 +46,45 @@ def test_location_repr_slot() -> None: """It should represent labware as a slot""" loc = Location(point=Point(x=-1, y=2, z=3), labware="1") assert f"{loc}" == "Location(point=Point(x=-1, y=2, z=3), labware=1)" + + +@pytest.mark.parametrize( + ("input", "expected_ot2_equivalent", "expected_ot3_equivalent"), + [ + # Middle slot: + (DeckSlotName.SLOT_5, DeckSlotName.SLOT_5, DeckSlotName.SLOT_C2), + (DeckSlotName.SLOT_C2, DeckSlotName.SLOT_5, DeckSlotName.SLOT_C2), + # Northwest corner: + (DeckSlotName.SLOT_10, DeckSlotName.SLOT_10, DeckSlotName.SLOT_A1), + (DeckSlotName.SLOT_A1, DeckSlotName.SLOT_10, DeckSlotName.SLOT_A1), + # Northeast corner: + (DeckSlotName.FIXED_TRASH, DeckSlotName.FIXED_TRASH, DeckSlotName.SLOT_A3), + (DeckSlotName.SLOT_A3, DeckSlotName.FIXED_TRASH, DeckSlotName.SLOT_A3), + ], +) +def test_deck_slot_name_equivalencies( + input: DeckSlotName, + expected_ot2_equivalent: DeckSlotName, + expected_ot3_equivalent: DeckSlotName, +) -> None: + assert ( + input.to_ot2_equivalent() + == input.to_equivalent_for_robot_type("OT-2 Standard") + == expected_ot2_equivalent + ) + assert ( + input.to_ot3_equivalent() + == input.to_equivalent_for_robot_type("OT-3 Standard") + == expected_ot3_equivalent + ) + + +@pytest.mark.parametrize( + ("input", "expected_int"), + [ + (DeckSlotName.SLOT_5, 5), + (DeckSlotName.SLOT_C2, 5), + ], +) +def test_deck_slot_name_as_int(input: DeckSlotName, expected_int: int) -> None: + assert input.as_int() == expected_int diff --git a/app-shell/build/release-notes-internal.md b/app-shell/build/release-notes-internal.md index e597271f38b..de663285524 100644 --- a/app-shell/build/release-notes-internal.md +++ b/app-shell/build/release-notes-internal.md @@ -3,6 +3,28 @@ For more details about this release, please see the full [technical changelog][] --- +# Internal Release 0.11.0 + +This is 0.11.0, an internal release for the app supporting the Opentrons Flex. + +This is still pretty early in the process, so some things are known not to work, and are listed below. Some things that may surprise you do work, and are also listed below. There may also be some littler things of note, and those are at the bottom. + +## Update Notes + +- ⚠️ After upgrading your robot to 0.11.0, you'll need to factory-reset its run history before you can use it. + + 1. From the robot's 3-dot menu (⋮), go to **Robot settings.** + 2. Under **Advanced > Factory reset**, select **Choose reset settings.** + 3. Choose **Clear protocol run history,** and then select **Clear data and restart robot.** + + Note that this will remove all of your saved labware offsets. + + You will need to follow these steps if you subsequently downgrade back to a prior release, too. + +## New Stuff In This Release + +- The HTTP API will now accept both styles of deck slot name: coordinates like "C2", and integers like "5". Flexes will now return the "C2" style, and OT-2s will continue to return the "5" style. + # Internal Release 0.9.0 This is 0.9.0, an internal release for the app supporting the Opentrons Flex. diff --git a/app/src/organisms/LabwarePositionCheck/__tests__/CheckItem.test.tsx b/app/src/organisms/LabwarePositionCheck/__tests__/CheckItem.test.tsx index b468f4ddcf8..e9766b844ae 100644 --- a/app/src/organisms/LabwarePositionCheck/__tests__/CheckItem.test.tsx +++ b/app/src/organisms/LabwarePositionCheck/__tests__/CheckItem.test.tsx @@ -50,7 +50,7 @@ describe('CheckItem', () => { section: SECTIONS.CHECK_LABWARE, pipetteId: mockCompletedAnalysis.pipettes[0].id, labwareId: mockCompletedAnalysis.labware[0].id, - location: { slotName: '1' }, + location: { slotName: 'D1' }, protocolData: mockCompletedAnalysis, proceed: jest.fn(), chainRunCommands: mockChainRunCommands, @@ -69,10 +69,10 @@ describe('CheckItem', () => { }) it('renders correct copy when preparing space with tip rack', () => { const { getByText, getByRole } = render(props) - getByRole('heading', { name: 'Prepare tip rack in slot 1' }) + getByRole('heading', { name: 'Prepare tip rack in slot D1' }) getByText('Clear all deck slots of labware, leaving modules in place') getByText( - matchTextWithSpans('Place a full Mock TipRack Definition into slot 1') + matchTextWithSpans('Place a full Mock TipRack Definition into slot D1') ) getByRole('link', { name: 'Need help?' }) getByRole('button', { name: 'Confirm placement' }) @@ -81,13 +81,15 @@ describe('CheckItem', () => { props = { ...props, labwareId: mockCompletedAnalysis.labware[1].id, - location: { slotName: '2' }, + location: { slotName: 'D2' }, } const { getByText, getByRole } = render(props) - getByRole('heading', { name: 'Prepare labware in slot 2' }) + getByRole('heading', { name: 'Prepare labware in slot D2' }) getByText('Clear all deck slots of labware, leaving modules in place') - getByText(matchTextWithSpans('Place a Mock Labware Definition into slot 2')) + getByText( + matchTextWithSpans('Place a Mock Labware Definition into slot D2') + ) getByRole('link', { name: 'Need help?' }) getByRole('button', { name: 'Confirm placement' }) }) @@ -99,7 +101,7 @@ describe('CheckItem', () => { commandType: 'moveLabware', params: { labwareId: 'labwareId1', - newLocation: { slotName: '1' }, + newLocation: { slotName: 'D1' }, strategy: 'manualMoveWithoutPause', }, }, @@ -137,7 +139,7 @@ describe('CheckItem', () => { commandType: 'moveLabware', params: { labwareId: 'labwareId1', - newLocation: { slotName: '1' }, + newLocation: { slotName: 'D1' }, strategy: 'manualMoveWithoutPause', }, }, @@ -160,7 +162,7 @@ describe('CheckItem', () => { await expect(props.registerPosition).toHaveBeenNthCalledWith(1, { type: 'initialPosition', labwareId: 'labwareId1', - location: { slotName: '1' }, + location: { slotName: 'D1' }, position: mockStartPosition, }) }) @@ -169,7 +171,7 @@ describe('CheckItem', () => { ...props, workingOffsets: [ { - location: { slotName: '1' }, + location: { slotName: 'D1' }, labwareId: 'labwareId1', initialPosition: { x: 1, y: 2, z: 3 }, finalPosition: null, @@ -197,7 +199,7 @@ describe('CheckItem', () => { await expect(props.registerPosition).toHaveBeenNthCalledWith(1, { type: 'initialPosition', labwareId: 'labwareId1', - location: { slotName: '1' }, + location: { slotName: 'D1' }, position: null, }) }) @@ -245,7 +247,7 @@ describe('CheckItem', () => { ...props, workingOffsets: [ { - location: { slotName: '1' }, + location: { slotName: 'D1' }, labwareId: 'labwareId1', initialPosition: { x: 1, y: 2, z: 3 }, finalPosition: null, @@ -285,7 +287,7 @@ describe('CheckItem', () => { await expect(props.registerPosition).toHaveBeenNthCalledWith(1, { type: 'finalPosition', labwareId: 'labwareId1', - location: { slotName: '1' }, + location: { slotName: 'D1' }, position: mockEndPosition, }) }) @@ -293,7 +295,7 @@ describe('CheckItem', () => { it('executes heater shaker open latch command on component mount if step is on HS', async () => { props = { ...props, - location: { slotName: '1', moduleModel: HEATERSHAKER_MODULE_V1 }, + location: { slotName: 'D1', moduleModel: HEATERSHAKER_MODULE_V1 }, moduleId: 'firstHSId', protocolData: { ...props.protocolData, @@ -301,13 +303,13 @@ describe('CheckItem', () => { { id: 'firstHSId', model: HEATERSHAKER_MODULE_V1, - location: { slotName: '3' }, + location: { slotName: 'D3' }, serialNumber: 'firstHSSerial', }, { id: 'secondHSId', model: HEATERSHAKER_MODULE_V1, - location: { slotName: '10' }, + location: { slotName: 'A1' }, serialNumber: 'secondHSSerial', }, ], @@ -374,7 +376,7 @@ describe('CheckItem', () => { it('executes thermocycler open lid command on mount if checking labware on thermocycler', () => { props = { ...props, - location: { slotName: '7', moduleModel: THERMOCYCLER_MODULE_V2 }, + location: { slotName: 'B1', moduleModel: THERMOCYCLER_MODULE_V2 }, moduleId: 'tcId', protocolData: { ...props.protocolData, @@ -382,7 +384,7 @@ describe('CheckItem', () => { { id: 'tcId', model: THERMOCYCLER_MODULE_V2, - location: { slotName: '7' }, + location: { slotName: 'B1' }, serialNumber: 'tcSerial', }, ], diff --git a/app/src/organisms/LabwarePositionCheck/__tests__/PickUpTip.test.tsx b/app/src/organisms/LabwarePositionCheck/__tests__/PickUpTip.test.tsx index cdcb18c9ca1..aa352706348 100644 --- a/app/src/organisms/LabwarePositionCheck/__tests__/PickUpTip.test.tsx +++ b/app/src/organisms/LabwarePositionCheck/__tests__/PickUpTip.test.tsx @@ -45,7 +45,7 @@ describe('PickUpTip', () => { section: SECTIONS.PICK_UP_TIP, pipetteId: mockCompletedAnalysis.pipettes[0].id, labwareId: mockCompletedAnalysis.labware[0].id, - location: { slotName: '1' }, + location: { slotName: 'D1' }, protocolData: mockCompletedAnalysis, proceed: jest.fn(), chainRunCommands: mockChainRunCommands, @@ -64,10 +64,10 @@ describe('PickUpTip', () => { }) it('renders correct copy when preparing space', () => { const { getByText, getByRole } = render(props) - getByRole('heading', { name: 'Prepare tip rack in slot 1' }) + getByRole('heading', { name: 'Prepare tip rack in slot D1' }) getByText('Clear all deck slots of labware, leaving modules in place') getByText( - matchTextWithSpans('Place a full Mock TipRack Definition into slot 1') + matchTextWithSpans('Place a full Mock TipRack Definition into slot D1') ) getByRole('link', { name: 'Need help?' }) getByRole('button', { name: 'Confirm placement' }) @@ -77,14 +77,14 @@ describe('PickUpTip', () => { ...props, workingOffsets: [ { - location: { slotName: '1' }, + location: { slotName: 'D1' }, labwareId: 'labwareId1', initialPosition: { x: 1, y: 2, z: 3 }, finalPosition: null, }, ], }) - getByRole('heading', { name: 'Pick up tip from tip rack in slot 1' }) + getByRole('heading', { name: 'Pick up tip from tip rack in slot D1' }) getByText( "Ensure that the pipette nozzle furthest from you is centered above and level with the top of the tip in the A1 position. If it isn't, use the controls below or your keyboard to jog the pipette until it is properly aligned." ) @@ -106,7 +106,7 @@ describe('PickUpTip', () => { commandType: 'moveLabware', params: { labwareId: 'labwareId1', - newLocation: { slotName: '1' }, + newLocation: { slotName: 'D1' }, strategy: 'manualMoveWithoutPause', }, }, @@ -194,7 +194,7 @@ describe('PickUpTip', () => { ...props, workingOffsets: [ { - location: { slotName: '1' }, + location: { slotName: 'D1' }, labwareId: 'labwareId1', initialPosition: { x: 1, y: 2, z: 3 }, finalPosition: null, @@ -218,7 +218,7 @@ describe('PickUpTip', () => { await expect(props.registerPosition).toHaveBeenNthCalledWith(1, { type: 'finalPosition', labwareId: 'labwareId1', - location: { slotName: '1' }, + location: { slotName: 'D1' }, position: { x: 10, y: 20, z: 30 }, }) await expect(props.registerPosition).toHaveBeenNthCalledWith(2, { @@ -332,7 +332,7 @@ describe('PickUpTip', () => { ...props, workingOffsets: [ { - location: { slotName: '1' }, + location: { slotName: 'D1' }, labwareId: 'labwareId1', initialPosition: { x: 1, y: 2, z: 3 }, finalPosition: null, @@ -354,7 +354,7 @@ describe('PickUpTip', () => { await expect(props.registerPosition).toHaveBeenNthCalledWith(1, { type: 'finalPosition', labwareId: 'labwareId1', - location: { slotName: '1' }, + location: { slotName: 'D1' }, position: { x: 10, y: 20, z: 30 }, }) await expect(props.registerPosition).toHaveBeenNthCalledWith(2, { @@ -413,13 +413,13 @@ describe('PickUpTip', () => { { id: 'firstHSId', model: HEATERSHAKER_MODULE_V1, - location: { slotName: '3' }, + location: { slotName: 'D3' }, serialNumber: 'firstHSSerial', }, { id: 'secondHSId', model: HEATERSHAKER_MODULE_V1, - location: { slotName: '10' }, + location: { slotName: 'A1' }, serialNumber: 'secondHSSerial', }, ], @@ -442,7 +442,7 @@ describe('PickUpTip', () => { commandType: 'moveLabware', params: { labwareId: 'labwareId1', - newLocation: { slotName: '1' }, + newLocation: { slotName: 'D1' }, strategy: 'manualMoveWithoutPause', }, }, diff --git a/app/src/organisms/LabwarePositionCheck/__tests__/ReturnTip.test.tsx b/app/src/organisms/LabwarePositionCheck/__tests__/ReturnTip.test.tsx index 4ed03da0759..cd9d3264b7c 100644 --- a/app/src/organisms/LabwarePositionCheck/__tests__/ReturnTip.test.tsx +++ b/app/src/organisms/LabwarePositionCheck/__tests__/ReturnTip.test.tsx @@ -41,7 +41,7 @@ describe('ReturnTip', () => { section: SECTIONS.RETURN_TIP, pipetteId: mockCompletedAnalysis.pipettes[0].id, labwareId: mockCompletedAnalysis.labware[0].id, - location: { slotName: '1' }, + location: { slotName: 'D1' }, protocolData: mockCompletedAnalysis, proceed: jest.fn(), setFatalError: jest.fn(), @@ -56,11 +56,11 @@ describe('ReturnTip', () => { }) it('renders correct copy', () => { const { getByText, getByRole } = render(props) - getByRole('heading', { name: 'Return tip rack to slot 1' }) + getByRole('heading', { name: 'Return tip rack to slot D1' }) getByText('Clear all deck slots of labware, leaving modules in place') getByText( matchTextWithSpans( - 'Place the Mock TipRack Definition that you used before back into slot 1. The pipette will return tips to their original location in the rack.' + 'Place the Mock TipRack Definition that you used before back into slot D1. The pipette will return tips to their original location in the rack.' ) ) getByRole('link', { name: 'Need help?' }) @@ -75,7 +75,7 @@ describe('ReturnTip', () => { commandType: 'moveLabware', params: { labwareId: 'labwareId1', - newLocation: { slotName: '1' }, + newLocation: { slotName: 'D1' }, strategy: 'manualMoveWithoutPause', }, }, @@ -125,7 +125,7 @@ describe('ReturnTip', () => { commandType: 'moveLabware', params: { labwareId: 'labwareId1', - newLocation: { slotName: '1' }, + newLocation: { slotName: 'D1' }, strategy: 'manualMoveWithoutPause', }, }, @@ -174,13 +174,13 @@ describe('ReturnTip', () => { { id: 'firstHSId', model: HEATERSHAKER_MODULE_V1, - location: { slotName: '3' }, + location: { slotName: 'D3' }, serialNumber: 'firstHSSerial', }, { id: 'secondHSId', model: HEATERSHAKER_MODULE_V1, - location: { slotName: '10' }, + location: { slotName: 'A1' }, serialNumber: 'secondHSSerial', }, ], @@ -203,7 +203,7 @@ describe('ReturnTip', () => { commandType: 'moveLabware', params: { labwareId: 'labwareId1', - newLocation: { slotName: '1' }, + newLocation: { slotName: 'D1' }, strategy: 'manualMoveWithoutPause', }, }, diff --git a/hardware-testing/hardware_testing/opentrons_api/helpers_ot3.py b/hardware-testing/hardware_testing/opentrons_api/helpers_ot3.py index 11bc21d6ee4..7aabb82f06d 100644 --- a/hardware-testing/hardware_testing/opentrons_api/helpers_ot3.py +++ b/hardware-testing/hardware_testing/opentrons_api/helpers_ot3.py @@ -847,23 +847,43 @@ def get_default_tip_length(volume: int) -> float: def get_slot_bottom_left_position_ot3(slot: int) -> Point: - """Get slot bottom-left position.""" + """Get slot bottom-left position. + + Params: + slot: The OT-3 slot, specified as an OT-2-style slot number. + For example, specify 5 to get slot C2. + """ deck = load_deck("ot3_standard", version=3) slots = deck["locations"]["orderedSlots"] + + # Assume that the OT-3 deck definition has the same number of slots, and in the same order, + # as the OT-2. + # TODO(mm, 2023-05-22): This assumption will break down when the OT-3 has staging area slots. + # https://opentrons.atlassian.net/browse/RLAB-345 s = slots[slot - 1] - assert s["id"] == str(slot) + return Point(*s["position"]) def get_slot_top_left_position_ot3(slot: int) -> Point: - """Get slot top-left position.""" + """Get slot top-left position. + + Params: + slot: The OT-3 slot, specified as an OT-2-style slot number. + For example, specify 5 to get slot C2. + """ bottom_left = get_slot_bottom_left_position_ot3(slot) slot_size = get_slot_size() return bottom_left + Point(y=slot_size.y) def get_theoretical_a1_position(slot: int, labware: str) -> Point: - """Get the theoretical A1 position of a labware in a slot.""" + """Get the theoretical A1 position of a labware in a slot. + + Params: + slot: The OT-3 slot, specified as an OT-2-style slot number. + For example, specify 5 to get slot C2. + """ labware_def = load_labware(loadname=labware, version=1) dims = labware_def["dimensions"] well_a1 = labware_def["wells"]["A1"] @@ -874,7 +894,12 @@ def get_theoretical_a1_position(slot: int, labware: str) -> Point: def get_slot_calibration_square_position_ot3(slot: int) -> Point: - """Get slot calibration block position.""" + """Get slot calibration block position. + + Params: + slot: The OT-3 slot, specified as an OT-2-style slot number. + For example, specify 5 to get slot C2. + """ slot_top_left = get_slot_top_left_position_ot3(slot) calib_sq_offset = CALIBRATION_SQUARE_EVT.top_left_offset return slot_top_left + calib_sq_offset diff --git a/robot-server/tests/integration/http_api/runs/test_deck_slot_standardization.py b/robot-server/tests/integration/http_api/runs/test_deck_slot_standardization.py new file mode 100644 index 00000000000..d40d6aaa42a --- /dev/null +++ b/robot-server/tests/integration/http_api/runs/test_deck_slot_standardization.py @@ -0,0 +1,168 @@ +from typing import AsyncGenerator + +import pytest +from pytest_lazyfixture import lazy_fixture # type: ignore[import] + +from ...robot_client import RobotClient + + +@pytest.fixture +async def robot_client(base_url: str) -> AsyncGenerator[RobotClient, None]: + async with RobotClient.make(base_url=base_url, version="*") as robot_client: + yield robot_client + + +@pytest.mark.parametrize( + ( + "base_url", + "input_slot_1", + "input_slot_2", + "standardized_slot_1", + "standardized_slot_2", + ), + [ + (lazy_fixture("ot2_server_base_url"), "1", "2", "1", "2"), + (lazy_fixture("ot2_server_base_url"), "D1", "D2", "1", "2"), + (lazy_fixture("ot3_server_base_url"), "1", "2", "D1", "D2"), + (lazy_fixture("ot3_server_base_url"), "D1", "D2", "D1", "D2"), + ], +) +async def test_deck_slot_standardization( + robot_client: RobotClient, + input_slot_1: str, + input_slot_2: str, + standardized_slot_1: str, + standardized_slot_2: str, +) -> None: + """Make sure the server standardizes deck slots given over HTTP, according to its robot type. + + For example, if you send a command mentioning slot "5" to an OT-3, it should automatically get + standardized to "C2". + + We need to write this in Python instead of Tavern because we're parametrizing over different + server types, and Tavern doesn't support parametrized fixtures. + """ + module_model = "temperatureModuleV2" + + labware_load_name = "armadillo_96_wellplate_200ul_pcr_full_skirt" + labware_namespace = "opentrons" + labware_version = 1 + labware_uri = f"{labware_namespace}/{labware_load_name}/{labware_version}" + + # Create a run with labware offset #1, and make sure the server standardizes + # that labware offset's deck slot. + labware_offset_1_request = { + "definitionUri": labware_uri, + "location": { + "slotName": input_slot_1, + "moduleModel": module_model, + }, + "vector": {"x": 1, "y": 2, "z": 3}, + } + post_run_result = ( + await robot_client.post_run( + req_body={"data": {"labwareOffsets": [labware_offset_1_request]}} + ) + ).json() + run_id = post_run_result["data"]["id"] + [labware_offset_1_result] = post_run_result["data"]["labwareOffsets"] + assert labware_offset_1_result["location"]["slotName"] == standardized_slot_1 + + # Add labware offset #2 to the existing run, and make sure the server standardizes + # that labware offset's deck slot. + labware_offset_2_request = { + "definitionUri": labware_uri, + "location": {"slotName": input_slot_2}, + "vector": {"x": 4, "y": 5, "z": 6}, + } + labware_offset_2_result = ( + await robot_client.post_labware_offset( + run_id=run_id, req_body={"data": labware_offset_2_request} + ) + ).json()["data"] + assert labware_offset_2_result["location"]["slotName"] == standardized_slot_2 + + # Load a module and make sure the server normalizes the deck slot in its params. + load_module_result = ( + await robot_client.post_run_command( + run_id=run_id, + req_body={ + "data": { + "commandType": "loadModule", + "params": { + "model": module_model, + "location": {"slotName": input_slot_1}, + }, + } + }, + params={"waitUntilComplete": "true"}, + ) + ).json() + assert ( + load_module_result["data"]["params"]["location"]["slotName"] + == standardized_slot_1 + ) + + # Load labware #1 on the module, and make sure it picks up labware offset #1. + load_labware_1_result = ( + await robot_client.post_run_command( + run_id=run_id, + req_body={ + "data": { + "commandType": "loadLabware", + "params": { + "namespace": labware_namespace, + "loadName": labware_load_name, + "version": labware_version, + "location": { + "moduleId": load_module_result["data"]["result"]["moduleId"] + }, + }, + } + }, + params={"waitUntilComplete": "true"}, + ) + ).json() + assert ( + load_labware_1_result["data"]["result"]["offsetId"] + == labware_offset_1_result["id"] + ) + + # Load labware #2 on the deck, make sure its deck slot gets standardized, + # and make sure it picks up labware offset #2. + load_labware_2_result = ( + await robot_client.post_run_command( + run_id=run_id, + req_body={ + "data": { + "commandType": "loadLabware", + "params": { + "namespace": labware_namespace, + "loadName": labware_load_name, + "version": labware_version, + "location": {"slotName": input_slot_2}, + }, + } + }, + params={"waitUntilComplete": "true"}, + ) + ).json() + assert ( + load_labware_2_result["data"]["params"]["location"]["slotName"] + == standardized_slot_2 + ) + assert ( + load_labware_2_result["data"]["result"]["offsetId"] + == labware_offset_2_result["id"] + ) + + # Make sure the modules and labware in the run summary show the standardized deck slots. + run_summary = (await robot_client.get_run(run_id)).json()["data"] + [run_summary_module] = run_summary["modules"] + [ + run_summary_fixed_trash_labware, + run_summary_labware_1, + run_summary_labware_2, + ] = run_summary["labware"] + assert run_summary_module["location"]["slotName"] == standardized_slot_1 + assert run_summary_labware_2["location"]["slotName"] == standardized_slot_2 diff --git a/shared-data/command/schemas/7.json b/shared-data/command/schemas/7.json index 9dee24372db..8638582e3d8 100644 --- a/shared-data/command/schemas/7.json +++ b/shared-data/command/schemas/7.json @@ -861,7 +861,32 @@ "DeckSlotName": { "title": "DeckSlotName", "description": "Deck slot identifiers.", - "enum": ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12"] + "enum": [ + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9", + "10", + "11", + "12", + "A1", + "A2", + "A3", + "B1", + "B2", + "B3", + "C1", + "C2", + "C3", + "D1", + "D2", + "D3" + ] }, "DeckSlotLocation": { "title": "DeckSlotLocation", @@ -869,7 +894,12 @@ "type": "object", "properties": { "slotName": { - "$ref": "#/definitions/DeckSlotName" + "description": "A slot on the robot's deck.\n\nThe plain numbers like `\"5\"` are for the OT-2, and the letter-number pairs like `\"C2\"` are for the Flex.\n\nWhen you provide one of these values, you can use either style. It will automatically be converted to match the robot.\n\nWhen one of these values is returned, it will always match the robot.", + "allOf": [ + { + "$ref": "#/definitions/DeckSlotName" + } + ] } }, "required": ["slotName"] diff --git a/shared-data/deck/definitions/3/ot3_standard.json b/shared-data/deck/definitions/3/ot3_standard.json index 8206e9822da..2c6abca72ef 100644 --- a/shared-data/deck/definitions/3/ot3_standard.json +++ b/shared-data/deck/definitions/3/ot3_standard.json @@ -13,7 +13,7 @@ "locations": { "orderedSlots": [ { - "id": "1", + "id": "D1", "position": [0.0, 0.0, 0.0], "matingSurfaceUnitVector": [-1, 1, -1], "boundingBox": { @@ -21,7 +21,7 @@ "yDimension": 86.0, "zDimension": 0 }, - "displayName": "Slot 1", + "displayName": "Slot D1", "compatibleModuleTypes": [ "magneticModuleType", "temperatureModuleType", @@ -29,7 +29,7 @@ ] }, { - "id": "2", + "id": "D2", "position": [164.0, 0.0, 0.0], "matingSurfaceUnitVector": [-1, 1, -1], "boundingBox": { @@ -37,7 +37,7 @@ "yDimension": 86.0, "zDimension": 0 }, - "displayName": "Slot 2", + "displayName": "Slot D2", "compatibleModuleTypes": [ "magneticModuleType", "temperatureModuleType", @@ -45,7 +45,7 @@ ] }, { - "id": "3", + "id": "D3", "position": [328.0, 0.0, 0.0], "matingSurfaceUnitVector": [-1, 1, -1], "boundingBox": { @@ -53,7 +53,7 @@ "yDimension": 86.0, "zDimension": 0 }, - "displayName": "Slot 3", + "displayName": "Slot D3", "compatibleModuleTypes": [ "magneticModuleType", "temperatureModuleType", @@ -61,7 +61,7 @@ ] }, { - "id": "4", + "id": "C1", "position": [0.0, 107, 0.0], "matingSurfaceUnitVector": [-1, 1, -1], "boundingBox": { @@ -69,7 +69,7 @@ "yDimension": 86.0, "zDimension": 0 }, - "displayName": "Slot 4", + "displayName": "Slot C1", "compatibleModuleTypes": [ "magneticModuleType", "temperatureModuleType", @@ -77,7 +77,7 @@ ] }, { - "id": "5", + "id": "C2", "position": [164.0, 107, 0.0], "matingSurfaceUnitVector": [-1, 1, -1], "boundingBox": { @@ -85,7 +85,7 @@ "yDimension": 86.0, "zDimension": 0 }, - "displayName": "Slot 5", + "displayName": "Slot C2", "compatibleModuleTypes": [ "magneticModuleType", "temperatureModuleType", @@ -93,7 +93,7 @@ ] }, { - "id": "6", + "id": "C3", "position": [328.0, 107, 0.0], "matingSurfaceUnitVector": [-1, 1, -1], "boundingBox": { @@ -101,7 +101,7 @@ "yDimension": 86.0, "zDimension": 0 }, - "displayName": "Slot 6", + "displayName": "Slot C3", "compatibleModuleTypes": [ "magneticModuleType", "temperatureModuleType", @@ -109,7 +109,7 @@ ] }, { - "id": "7", + "id": "B1", "position": [0.0, 214.0, 0.0], "matingSurfaceUnitVector": [-1, 1, -1], "boundingBox": { @@ -117,7 +117,7 @@ "yDimension": 86.0, "zDimension": 0 }, - "displayName": "Slot 7", + "displayName": "Slot B1", "compatibleModuleTypes": [ "magneticModuleType", "temperatureModuleType", @@ -126,7 +126,7 @@ ] }, { - "id": "8", + "id": "B2", "position": [164.0, 214.0, 0.0], "matingSurfaceUnitVector": [-1, 1, -1], "boundingBox": { @@ -134,7 +134,7 @@ "yDimension": 86.0, "zDimension": 0 }, - "displayName": "Slot 8", + "displayName": "Slot B2", "compatibleModuleTypes": [ "magneticModuleType", "temperatureModuleType", @@ -142,7 +142,7 @@ ] }, { - "id": "9", + "id": "B3", "position": [328.0, 214.0, 0.0], "matingSurfaceUnitVector": [-1, 1, -1], "boundingBox": { @@ -150,7 +150,7 @@ "yDimension": 86.0, "zDimension": 0 }, - "displayName": "Slot 9", + "displayName": "Slot B3", "compatibleModuleTypes": [ "magneticModuleType", "temperatureModuleType", @@ -158,7 +158,7 @@ ] }, { - "id": "10", + "id": "A1", "position": [0.0, 321.0, 0.0], "matingSurfaceUnitVector": [-1, 1, -1], "boundingBox": { @@ -166,7 +166,7 @@ "yDimension": 86.0, "zDimension": 0 }, - "displayName": "Slot 10", + "displayName": "Slot A1", "compatibleModuleTypes": [ "magneticModuleType", "temperatureModuleType", @@ -174,7 +174,7 @@ ] }, { - "id": "11", + "id": "A2", "position": [164.0, 321.0, 0.0], "matingSurfaceUnitVector": [-1, 1, -1], "boundingBox": { @@ -182,7 +182,7 @@ "yDimension": 86.0, "zDimension": 0 }, - "displayName": "Slot 11", + "displayName": "Slot A2", "compatibleModuleTypes": [ "magneticModuleType", "temperatureModuleType", @@ -190,14 +190,14 @@ ] }, { - "id": "12", + "id": "A3", "position": [328.0, 321.0, 0.0], "boundingBox": { "xDimension": 128.0, "yDimension": 86.0, "zDimension": 0 }, - "displayName": "Slot 12", + "displayName": "Slot A3", "compatibleModuleTypes": [] } ], @@ -205,7 +205,7 @@ "fixtures": [ { "id": "fixedTrash", - "slot": "12", + "slot": "A3", "labware": "opentrons_1_trash_3200ml_fixed", "displayName": "Fixed Trash" } diff --git a/shared-data/module/definitions/3/heaterShakerModuleV1.json b/shared-data/module/definitions/3/heaterShakerModuleV1.json index 92758c95631..6f3cac096ac 100644 --- a/shared-data/module/definitions/3/heaterShakerModuleV1.json +++ b/shared-data/module/definitions/3/heaterShakerModuleV1.json @@ -89,7 +89,7 @@ } }, "ot3_standard": { - "1": { + "D1": { "labwareOffset": [ [1, 0, 0, 0.125], [0, 1, 0, -1.125], @@ -97,7 +97,7 @@ [0, 0, 0, 1] ] }, - "4": { + "C1": { "labwareOffset": [ [1, 0, 0, 0.125], [0, 1, 0, -1.125], @@ -105,7 +105,7 @@ [0, 0, 0, 1] ] }, - "7": { + "B1": { "labwareOffset": [ [1, 0, 0, 0.125], [0, 1, 0, -1.125], @@ -113,7 +113,7 @@ [0, 0, 0, 1] ] }, - "10": { + "A1": { "labwareOffset": [ [1, 0, 0, 0.125], [0, 1, 0, -1.125], @@ -121,7 +121,7 @@ [0, 0, 0, 1] ] }, - "3": { + "D3": { "labwareOffset": [ [1, 0, 0, 0.125], [0, 1, 0, -1.125], @@ -129,7 +129,7 @@ [0, 0, 0, 1] ] }, - "6": { + "C3": { "labwareOffset": [ [1, 0, 0, 0.125], [0, 1, 0, -1.125], @@ -137,7 +137,7 @@ [0, 0, 0, 1] ] }, - "9": { + "B3": { "labwareOffset": [ [1, 0, 0, 0.125], [0, 1, 0, -1.125], diff --git a/shared-data/module/definitions/3/temperatureModuleV2.json b/shared-data/module/definitions/3/temperatureModuleV2.json index c47d17db010..c23e179e06b 100644 --- a/shared-data/module/definitions/3/temperatureModuleV2.json +++ b/shared-data/module/definitions/3/temperatureModuleV2.json @@ -87,7 +87,7 @@ } }, "ot3_standard": { - "1": { + "D1": { "labwareOffset": [ [1, 0, 0, 1.45], [0, 1, 0, 0.15], @@ -95,7 +95,7 @@ [0, 0, 0, 1] ] }, - "4": { + "C1": { "labwareOffset": [ [1, 0, 0, 1.45], [0, 1, 0, 0.15], @@ -103,7 +103,7 @@ [0, 0, 0, 1] ] }, - "7": { + "B1": { "labwareOffset": [ [1, 0, 0, 1.45], [0, 1, 0, 0.15], @@ -111,7 +111,7 @@ [0, 0, 0, 1] ] }, - "10": { + "A1": { "labwareOffset": [ [1, 0, 0, 1.45], [0, 1, 0, 0.15], @@ -119,7 +119,7 @@ [0, 0, 0, 1] ] }, - "3": { + "D3": { "labwareOffset": [ [1, 0, 0, 1.45], [0, 1, 0, 0.15], @@ -127,7 +127,7 @@ [0, 0, 0, 1] ] }, - "6": { + "C3": { "labwareOffset": [ [1, 0, 0, 1.45], [0, 1, 0, 0.15], @@ -135,7 +135,7 @@ [0, 0, 0, 1] ] }, - "9": { + "B3": { "labwareOffset": [ [1, 0, 0, 1.45], [0, 1, 0, 0.15], diff --git a/shared-data/module/definitions/3/thermocyclerModuleV2.json b/shared-data/module/definitions/3/thermocyclerModuleV2.json index 454ccdfd743..4d793d246de 100644 --- a/shared-data/module/definitions/3/thermocyclerModuleV2.json +++ b/shared-data/module/definitions/3/thermocyclerModuleV2.json @@ -38,7 +38,7 @@ "quirks": [], "slotTransforms": { "ot3_standard": { - "7": { + "B1": { "labwareOffset": [ [1, 0, 0, -20.005], [0, 1, 0, -0.1], diff --git a/shared-data/python/opentrons_shared_data/deck/__init__.py b/shared-data/python/opentrons_shared_data/deck/__init__.py index 699f210d829..3d2c38b912d 100644 --- a/shared-data/python/opentrons_shared_data/deck/__init__.py +++ b/shared-data/python/opentrons_shared_data/deck/__init__.py @@ -57,11 +57,21 @@ def load_schema(version: int) -> "DeckSchema": def get_calibration_square_position_in_slot(slot: int) -> Offset: - """Get slot top-left position.""" + """Get the position of an OT-3 deck slot's calibration square. + + Params: + slot: The slot whose calibration square to retrieve, specified as an OT-2-style slot number. + For example, specify 5 to get slot C2. + """ deck = load("ot3_standard", version=3) slots = deck["locations"]["orderedSlots"] + + # Assume that the OT-3 deck definition has the same number of slots, and in the same order, + # as the OT-2. + # TODO(mm, 2023-05-22): This assumption will break down when the OT-3 has staging area slots. + # https://opentrons.atlassian.net/browse/RLAB-345 s = slots[slot - 1] - assert s["id"] == str(slot) + bottom_left = s["position"] slot_size_x = s["boundingBox"]["xDimension"] slot_size_y = s["boundingBox"]["yDimension"]