diff --git a/api/src/opentrons/protocol_api/core/engine/deck_conflict.py b/api/src/opentrons/protocol_api/core/engine/deck_conflict.py index cd1c892c953..8922e2a6c43 100644 --- a/api/src/opentrons/protocol_api/core/engine/deck_conflict.py +++ b/api/src/opentrons/protocol_api/core/engine/deck_conflict.py @@ -1,10 +1,18 @@ """A Protocol-Engine-friendly wrapper for opentrons.motion_planning.deck_conflict.""" import itertools -from typing import Collection, Dict, Optional, Tuple, overload +import logging +from typing import Collection, Dict, Optional, Tuple, overload, Union +from opentrons_shared_data.errors.exceptions import MotionPlanningFailureError + +from opentrons.hardware_control.nozzle_manager import NozzleConfigurationType from opentrons.hardware_control.modules.types import ModuleType from opentrons.motion_planning import deck_conflict as wrapped_deck_conflict +from opentrons.motion_planning.adjacent_slots_getters import ( + get_north_slot, + get_west_slot, +) from opentrons.protocol_engine import ( StateView, DeckSlotLocation, @@ -12,9 +20,32 @@ OnLabwareLocation, AddressableAreaLocation, OFF_DECK_LOCATION, + WellLocation, + DropTipWellLocation, ) from opentrons.protocol_engine.errors.exceptions import LabwareNotLoadedOnModuleError -from opentrons.types import DeckSlotName +from opentrons.types import DeckSlotName, Point + + +class PartialTipMovementNotAllowedError(MotionPlanningFailureError): + """Error raised when trying to perform a partial tip movement to an illegal location.""" + + def __init__(self, message: str) -> None: + super().__init__( + message=message, + ) + + +_log = logging.getLogger(__name__) + +# TODO (spp, 2023-12-06): move this to a location like motion planning where we can +# derive these values from geometry definitions +# Bounding box measurements +A12_column_front_left_bound = Point(x=-1.8, y=2) +A12_column_back_right_bound = Point(x=592, y=506.2) + +# Arbitrary safety margin in z-direction +Z_SAFETY_MARGIN = 10 @overload @@ -106,6 +137,201 @@ def check( ) +def check_safe_for_pipette_movement( + engine_state: StateView, + pipette_id: str, + labware_id: str, + well_name: str, + well_location: Union[WellLocation, DropTipWellLocation], +) -> None: + """Check if the labware is safe to move to with a pipette in partial tip configuration. + Args: + engine_state: engine state view + pipette_id: ID of the pipette to be moved + labware_id: ID of the labware we are moving to + well_name: Name of the well to move to + well_location: exact location within the well to move to + """ + # TODO: either hide unsupported configurations behind an advance setting + # or log a warning that deck conflicts cannot be checked for tip config other than + # column config with A12 primary nozzle for the 96 channel + # or single tip config for 8-channel. + if engine_state.pipettes.get_channels(pipette_id) == 96: + _check_deck_conflict_for_96_channel( + engine_state=engine_state, + pipette_id=pipette_id, + labware_id=labware_id, + well_name=well_name, + well_location=well_location, + ) + elif engine_state.pipettes.get_channels(pipette_id) == 8: + _check_deck_conflict_for_8_channel( + engine_state=engine_state, + pipette_id=pipette_id, + labware_id=labware_id, + well_name=well_name, + well_location=well_location, + ) + + +def _check_deck_conflict_for_96_channel( + engine_state: StateView, + pipette_id: str, + labware_id: str, + well_name: str, + well_location: Union[WellLocation, DropTipWellLocation], +) -> None: + """Check if there are any conflicts moving to the given labware with the configuration of 96-ch pipette.""" + if not ( + engine_state.pipettes.get_nozzle_layout_type(pipette_id) + == NozzleConfigurationType.COLUMN + and engine_state.pipettes.get_primary_nozzle(pipette_id) == "A12" + ): + # Checking deck conflicts only for 12th column config + return + + if isinstance(well_location, DropTipWellLocation): + # convert to WellLocation + well_location = engine_state.geometry.get_checked_tip_drop_location( + pipette_id=pipette_id, + labware_id=labware_id, + well_location=well_location, + partially_configured=True, + ) + + well_location_point = engine_state.geometry.get_well_position( + labware_id=labware_id, well_name=well_name, well_location=well_location + ) + + if not _is_within_pipette_extents( + engine_state=engine_state, pipette_id=pipette_id, location=well_location_point + ): + raise PartialTipMovementNotAllowedError( + "Requested motion with A12 nozzle column configuration" + " is outside of robot bounds for the 96-channel." + ) + + labware_slot = engine_state.geometry.get_ancestor_slot_name(labware_id) + west_slot_number = get_west_slot( + _deck_slot_to_int(DeckSlotLocation(slotName=labware_slot)) + ) + if west_slot_number is None: + return + + west_slot = DeckSlotName.from_primitive( + west_slot_number + ).to_equivalent_for_robot_type(engine_state.config.robot_type) + + west_slot_highest_z = engine_state.geometry.get_highest_z_in_slot( + DeckSlotLocation(slotName=west_slot) + ) + + pipette_tip = engine_state.pipettes.get_attached_tip(pipette_id) + tip_length = pipette_tip.length if pipette_tip else 0.0 + + if ( + west_slot_highest_z + Z_SAFETY_MARGIN > well_location_point.z + tip_length + ): # a safe margin magic number + raise PartialTipMovementNotAllowedError( + f"Moving to {engine_state.labware.get_load_name(labware_id)} in slot {labware_slot}" + f" with a Column nozzle configuration will result in collision with" + f" items in deck slot {west_slot}." + ) + + +def _check_deck_conflict_for_8_channel( + engine_state: StateView, + pipette_id: str, + labware_id: str, + well_name: str, + well_location: Union[WellLocation, DropTipWellLocation], +) -> None: + """Check if there are any conflicts moving to the given labware with the configuration of 8-ch pipette.""" + if not ( + engine_state.pipettes.get_nozzle_layout_type(pipette_id) + == NozzleConfigurationType.SINGLE + and engine_state.pipettes.get_primary_nozzle(pipette_id) == "H1" + ): + # Checking deck conflicts only for H1 single tip config + return + + if isinstance(well_location, DropTipWellLocation): + # convert to WellLocation + well_location = engine_state.geometry.get_checked_tip_drop_location( + pipette_id=pipette_id, + labware_id=labware_id, + well_location=well_location, + partially_configured=True, + ) + + well_location_point = engine_state.geometry.get_well_position( + labware_id=labware_id, well_name=well_name, well_location=well_location + ) + + if not _is_within_pipette_extents( + engine_state=engine_state, pipette_id=pipette_id, location=well_location_point + ): + # WARNING: (spp, 2023-11-30: this needs to be wired up to check for + # 8-channel pipette extents on both OT2 & Flex!!) + raise PartialTipMovementNotAllowedError( + "Requested motion with single H1 nozzle configuration" + " is outside of robot bounds for the 8-channel." + ) + + labware_slot = engine_state.geometry.get_ancestor_slot_name(labware_id) + north_slot_number = get_north_slot( + _deck_slot_to_int(DeckSlotLocation(slotName=labware_slot)) + ) + if north_slot_number is None: + return + + north_slot = DeckSlotName.from_primitive( + north_slot_number + ).to_equivalent_for_robot_type(engine_state.config.robot_type) + + north_slot_highest_z = engine_state.geometry.get_highest_z_in_slot( + DeckSlotLocation(slotName=north_slot) + ) + + pipette_tip = engine_state.pipettes.get_attached_tip(pipette_id) + tip_length = pipette_tip.length if pipette_tip else 0.0 + + if north_slot_highest_z + Z_SAFETY_MARGIN > well_location_point.z + tip_length: + raise PartialTipMovementNotAllowedError( + f"Moving to {engine_state.labware.get_load_name(labware_id)} in slot {labware_slot}" + f" with a Single nozzle configuration will result in collision with" + f" items in deck slot {north_slot}." + ) + + +def _is_within_pipette_extents( + engine_state: StateView, + pipette_id: str, + location: Point, +) -> bool: + """Whether a given point is within the extents of a configured pipette on the specified robot.""" + robot_type = engine_state.config.robot_type + pipette_channels = engine_state.pipettes.get_channels(pipette_id) + nozzle_config = engine_state.pipettes.get_nozzle_layout_type(pipette_id) + primary_nozzle = engine_state.pipettes.get_primary_nozzle(pipette_id) + if robot_type == "OT-3 Standard": + if ( + pipette_channels == 96 + and nozzle_config == NozzleConfigurationType.COLUMN + and primary_nozzle == "A12" + ): + return ( + A12_column_front_left_bound.x + < location.x + < A12_column_back_right_bound.x + and A12_column_front_left_bound.y + < location.y + < A12_column_back_right_bound.y + ) + # TODO (spp, 2023-11-07): check for 8-channel nozzle H1 extents on Flex & OT2 + return True + + def _map_labware( engine_state: StateView, labware_id: str, diff --git a/api/src/opentrons/protocol_api/core/engine/instrument.py b/api/src/opentrons/protocol_api/core/engine/instrument.py index c81847fcc04..d064ec6f518 100644 --- a/api/src/opentrons/protocol_api/core/engine/instrument.py +++ b/api/src/opentrons/protocol_api/core/engine/instrument.py @@ -32,6 +32,7 @@ from opentrons_shared_data.pipette.dev_types import PipetteNameType from opentrons.protocol_api._nozzle_layout import NozzleLayout from opentrons.hardware_control.nozzle_manager import NozzleConfigurationType +from . import deck_conflict from ..instrument import AbstractInstrument from .well import WellCore @@ -141,7 +142,13 @@ def aspirate( absolute_point=location.point, ) ) - + deck_conflict.check_safe_for_pipette_movement( + engine_state=self._engine_client.state, + pipette_id=self._pipette_id, + labware_id=labware_id, + well_name=well_name, + well_location=well_location, + ) self._engine_client.aspirate( pipette_id=self._pipette_id, labware_id=labware_id, @@ -202,7 +209,13 @@ def dispense( absolute_point=location.point, ) ) - + deck_conflict.check_safe_for_pipette_movement( + engine_state=self._engine_client.state, + pipette_id=self._pipette_id, + labware_id=labware_id, + well_name=well_name, + well_location=well_location, + ) self._engine_client.dispense( pipette_id=self._pipette_id, labware_id=labware_id, @@ -252,7 +265,13 @@ def blow_out( absolute_point=location.point, ) ) - + deck_conflict.check_safe_for_pipette_movement( + engine_state=self._engine_client.state, + pipette_id=self._pipette_id, + labware_id=labware_id, + well_name=well_name, + well_location=well_location, + ) self._engine_client.blow_out( pipette_id=self._pipette_id, labware_id=labware_id, @@ -289,7 +308,13 @@ def touch_tip( well_location = WellLocation( origin=WellOrigin.TOP, offset=WellOffset(x=0, y=0, z=z_offset) ) - + deck_conflict.check_safe_for_pipette_movement( + engine_state=self._engine_client.state, + pipette_id=self._pipette_id, + labware_id=labware_id, + well_name=well_name, + well_location=well_location, + ) self._engine_client.touch_tip( pipette_id=self._pipette_id, labware_id=labware_id, @@ -331,7 +356,13 @@ def pick_up_tip( well_name=well_name, absolute_point=location.point, ) - + deck_conflict.check_safe_for_pipette_movement( + engine_state=self._engine_client.state, + pipette_id=self._pipette_id, + labware_id=labware_id, + well_name=well_name, + well_location=well_location, + ) self._engine_client.pick_up_tip( pipette_id=self._pipette_id, labware_id=labware_id, @@ -376,7 +407,13 @@ def drop_tip( ) else: well_location = DropTipWellLocation() - + deck_conflict.check_safe_for_pipette_movement( + engine_state=self._engine_client.state, + pipette_id=self._pipette_id, + labware_id=labware_id, + well_name=well_name, + well_location=WellLocation(), + ) self._engine_client.drop_tip( pipette_id=self._pipette_id, labware_id=labware_id, diff --git a/api/src/opentrons/protocol_engine/state/geometry.py b/api/src/opentrons/protocol_engine/state/geometry.py index 99703fecd3a..eadc5eb5a52 100644 --- a/api/src/opentrons/protocol_engine/state/geometry.py +++ b/api/src/opentrons/protocol_engine/state/geometry.py @@ -7,6 +7,7 @@ from opentrons_shared_data.labware.constants import WELL_NAME_PATTERN from .. import errors +from ..errors import LabwareNotLoadedOnLabwareError, LabwareNotLoadedOnModuleError from ..resources import fixture_validation from ..types import ( OFF_DECK_LOCATION, @@ -100,6 +101,8 @@ def get_all_obstacle_highest_z(self) -> float: default=0.0, ) + # Fixme (spp, 2023-12-04): the overall height is not the true highest z of modules + # on a Flex. highest_module_z = max( ( self._modules.get_overall_height(module.id) @@ -136,6 +139,38 @@ def get_all_obstacle_highest_z(self) -> float: highest_fixture_z, ) + def get_highest_z_in_slot(self, slot: DeckSlotLocation) -> float: + """Get the highest Z-point of all items stacked in the given deck slot.""" + slot_item = self.get_slot_item(slot.slotName) + if isinstance(slot_item, LoadedModule): + # get height of module + all labware on it + module_id = slot_item.id + try: + labware_id = self._labware.get_id_by_module(module_id=module_id) + except LabwareNotLoadedOnModuleError: + deck_type = DeckType(self._labware.get_deck_definition()["otId"]) + return self._modules.get_module_highest_z( + module_id=module_id, deck_type=deck_type + ) + else: + return self.get_highest_z_of_labware_stack(labware_id) + elif isinstance(slot_item, LoadedLabware): + # get stacked heights of all labware in the slot + return self.get_highest_z_of_labware_stack(slot_item.id) + else: + return 0 + + def get_highest_z_of_labware_stack(self, labware_id: str) -> float: + """Get the highest Z-point of the topmost labware in the stack of labware on the given labware. + + If there is no labware on the given labware, returns highest z of the given labware. + """ + try: + stacked_labware_id = self._labware.get_id_by_labware(labware_id) + except LabwareNotLoadedOnLabwareError: + return self.get_labware_highest_z(labware_id) + return self.get_highest_z_of_labware_stack(stacked_labware_id) + def get_min_travel_z( self, pipette_id: str, @@ -378,6 +413,13 @@ def _get_highest_z_from_labware_data(self, lw_data: LoadedLabware) -> float: z_dim = definition.dimensions.zDimension height_over_labware: float = 0 if isinstance(lw_data.location, ModuleLocation): + # Note: when calculating highest z of stacked labware, height-over-labware + # gets accounted for only if the top labware is directly on the module. + # So if there's a labware on an adapter on a module, then this + # over-module-height gets ignored. We currently do not have any modules + # that use an adapter and has height over labware so this doesn't cause + # any issues yet. But if we add one in the future then this calculation + # should be updated. module_id = lw_data.location.moduleId height_over_labware = self._modules.get_height_over_labware(module_id) return labware_pos.z + z_dim + height_over_labware diff --git a/api/src/opentrons/protocol_engine/state/modules.py b/api/src/opentrons/protocol_engine/state/modules.py index 1239feab138..f63cd631204 100644 --- a/api/src/opentrons/protocol_engine/state/modules.py +++ b/api/src/opentrons/protocol_engine/state/modules.py @@ -702,6 +702,41 @@ def get_height_over_labware(self, module_id: str) -> float: """Get the height of module parts above module labware base.""" return self.get_dimensions(module_id).overLabwareHeight + def get_module_highest_z(self, module_id: str, deck_type: DeckType) -> float: + """Get the highest z point of the module, as placed on the robot. + + The highest Z of a module, unlike the bare overall height, depends on + the robot it is on. We will calculate this value using the info we already have + about the transformation of the module's placement, based on the deck it is on. + + This value is calculated as: + highest_z = ( nominal_robot_transformed_labware_offset_z + + z_difference_between_default_labware_offset_point_and_overall_height + + module_calibration_offset_z + ) + + For OT2, the default_labware_offset point is the same as nominal_robot_transformed_labware_offset_z + and hence the highest z will equal to the overall height of the module. + + For Flex, since those two offsets are not the same, the final highest z will be + transformed the same amount as the labware offset point is. + + Note: For thermocycler, the lid height is not taken into account. + """ + module_height = self.get_overall_height(module_id) + default_lw_offset_point = self.get_definition(module_id).labwareOffset.z + z_difference = module_height - default_lw_offset_point + + nominal_transformed_lw_offset_z = self.get_nominal_module_offset( + module_id=module_id, deck_type=deck_type + ).z + calibration_offset = self.get_module_calibration_offset(module_id) + return ( + nominal_transformed_lw_offset_z + + z_difference + + (calibration_offset.moduleOffsetVector.z if calibration_offset else 0) + ) + # TODO(mc, 2022-01-19): this method is missing unit test coverage and # is also unused. Remove or add tests. def get_lid_height(self, module_id: str) -> float: diff --git a/api/src/opentrons/protocol_engine/state/pipettes.py b/api/src/opentrons/protocol_engine/state/pipettes.py index 73d88df37be..384a0189f0b 100644 --- a/api/src/opentrons/protocol_engine/state/pipettes.py +++ b/api/src/opentrons/protocol_engine/state/pipettes.py @@ -637,3 +637,8 @@ def get_nozzle_layout_type(self, pipette_id: str) -> NozzleConfigurationType: def get_is_partially_configured(self, pipette_id: str) -> bool: """Determine if the provided pipette is partially configured.""" return self.get_nozzle_layout_type(pipette_id) != NozzleConfigurationType.FULL + + def get_primary_nozzle(self, pipette_id: str) -> Optional[str]: + """Get the primary nozzle, if any, related to the given pipette's nozzle configuration.""" + nozzle_map = self._state.nozzle_configuration_by_id.get(pipette_id) + return nozzle_map.starting_nozzle if nozzle_map else None diff --git a/api/tests/opentrons/protocol_api/core/engine/test_deck_conflict.py b/api/tests/opentrons/protocol_api/core/engine/test_deck_conflict.py index 6d682b3e9a5..a47f5ad04c9 100644 --- a/api/tests/opentrons/protocol_api/core/engine/test_deck_conflict.py +++ b/api/tests/opentrons/protocol_api/core/engine/test_deck_conflict.py @@ -1,18 +1,28 @@ """Unit tests for the deck_conflict module.""" -from decoy import Decoy import pytest - +from typing import ContextManager, Any +from decoy import Decoy +from contextlib import nullcontext as does_not_raise from opentrons_shared_data.labware.dev_types import LabwareUri from opentrons_shared_data.robot.dev_types import RobotType +from opentrons.hardware_control.nozzle_manager import NozzleConfigurationType from opentrons.motion_planning import deck_conflict as wrapped_deck_conflict from opentrons.protocol_api.core.engine import deck_conflict from opentrons.protocol_engine import Config, DeckSlotLocation, ModuleModel, StateView from opentrons.protocol_engine.errors import LabwareNotLoadedOnModuleError -from opentrons.types import DeckSlotName +from opentrons.types import DeckSlotName, Point -from opentrons.protocol_engine.types import DeckType +from opentrons.protocol_engine.types import ( + DeckType, + LoadedLabware, + LoadedModule, + WellLocation, + WellOrigin, + WellOffset, + TipGeometry, +) @pytest.fixture(autouse=True) @@ -265,3 +275,201 @@ def get_expected_mapping_result() -> wrapped_deck_conflict.DeckItem: robot_type=mock_state_view.config.robot_type, ) ) + + +plate = LoadedLabware( + id="plate-id", + loadName="plate-load-name", + location=DeckSlotLocation(slotName=DeckSlotName.SLOT_C1), + definitionUri="some-plate-uri", + offsetId=None, + displayName="Fancy Plate Name", +) + +module = LoadedModule( + id="module-id", + model=ModuleModel.TEMPERATURE_MODULE_V1, + location=DeckSlotLocation(slotName=DeckSlotName.SLOT_C1), + serialNumber="serial-number", +) + + +@pytest.mark.parametrize( + ("robot_type", "deck_type"), + [("OT-3 Standard", DeckType.OT3_STANDARD)], +) +@pytest.mark.parametrize( + ["destination_well_point", "expected_raise"], + [ + (Point(x=100, y=100, z=60), does_not_raise()), + # Z-collisions + ( + Point(x=100, y=100, z=10), + pytest.raises( + deck_conflict.PartialTipMovementNotAllowedError, + match="collision with items in deck slot", + ), + ), + ( + Point(x=100, y=100, z=20), + pytest.raises( + deck_conflict.PartialTipMovementNotAllowedError, + match="collision with items in deck slot", + ), + ), + # Out-of-bounds error + ( + Point(x=-10, y=100, z=60), + pytest.raises( + deck_conflict.PartialTipMovementNotAllowedError, + match="outside of robot bounds", + ), + ), + ( + Point(x=593, y=100, z=60), + pytest.raises( + deck_conflict.PartialTipMovementNotAllowedError, + match="outside of robot bounds", + ), + ), + ( + Point(x=100, y=1, z=60), + pytest.raises( + deck_conflict.PartialTipMovementNotAllowedError, + match="outside of robot bounds", + ), + ), + ( + Point(x=100, y=507, z=60), + pytest.raises( + deck_conflict.PartialTipMovementNotAllowedError, + match="outside of robot bounds", + ), + ), + ], +) +def test_deck_conflict_raises_for_bad_partial_96_channel_move( + decoy: Decoy, + mock_state_view: StateView, + destination_well_point: Point, + expected_raise: ContextManager[Any], +) -> None: + """It should raise errors when moving to locations with restrictions for partial tip 96-channel movement. + + Test premise: + - we are using a pipette configured for COLUMN nozzle layout with primary nozzle A12 + - there's a labware of height 50mm in C1 + - we are checking for conflicts when moving to a labware in C2. + For each test case, we are moving to a different point in the destination labware, + with the same pipette and tip (tip length is 10mm) + """ + decoy.when(mock_state_view.pipettes.get_channels("pipette-id")).then_return(96) + decoy.when( + mock_state_view.pipettes.get_nozzle_layout_type("pipette-id") + ).then_return(NozzleConfigurationType.COLUMN) + decoy.when(mock_state_view.pipettes.get_primary_nozzle("pipette-id")).then_return( + "A12" + ) + decoy.when( + mock_state_view.geometry.get_ancestor_slot_name("destination-labware-id") + ).then_return(DeckSlotName.SLOT_C2) + decoy.when( + mock_state_view.geometry.get_highest_z_in_slot( + DeckSlotLocation(slotName=DeckSlotName.SLOT_C1) + ) + ).then_return(50) + decoy.when( + mock_state_view.geometry.get_well_position( + labware_id="destination-labware-id", + well_name="A2", + well_location=WellLocation(origin=WellOrigin.TOP, offset=WellOffset(z=10)), + ) + ).then_return(destination_well_point) + decoy.when(mock_state_view.pipettes.get_attached_tip("pipette-id")).then_return( + TipGeometry(length=10, diameter=100, volume=0) + ) + + with expected_raise: + deck_conflict.check_safe_for_pipette_movement( + engine_state=mock_state_view, + pipette_id="pipette-id", + labware_id="destination-labware-id", + well_name="A2", + well_location=WellLocation(origin=WellOrigin.TOP, offset=WellOffset(z=10)), + ) + + +@pytest.mark.parametrize( + ("robot_type", "deck_type"), + [("OT-3 Standard", DeckType.OT3_STANDARD)], +) +@pytest.mark.parametrize( + ["destination_well_point", "expected_raise"], + [ + (Point(x=100, y=100, z=60), does_not_raise()), + # Z-collisions + ( + Point(x=100, y=100, z=10), + pytest.raises( + deck_conflict.PartialTipMovementNotAllowedError, + match="collision with items in deck slot", + ), + ), + ( + Point(x=100, y=100, z=20), + pytest.raises( + deck_conflict.PartialTipMovementNotAllowedError, + match="collision with items in deck slot", + ), + ), + ], +) +def test_deck_conflict_raises_for_bad_partial_8_channel_move( + decoy: Decoy, + mock_state_view: StateView, + destination_well_point: Point, + expected_raise: ContextManager[Any], +) -> None: + """It should raise errors when moving to locations with restrictions for partial tip 8-channel movement. + + Test premise: + - we are using a pipette configured for SINGLE nozzle layout with primary nozzle H1 + - there's a labware of height 50mm in B2 + - we are checking for conflicts when moving to a labware in C2. + For each test case, we are moving to a different point in the destination labware, + with the same pipette and tip (tip length is 10mm) + """ + decoy.when(mock_state_view.pipettes.get_channels("pipette-id")).then_return(8) + decoy.when( + mock_state_view.pipettes.get_nozzle_layout_type("pipette-id") + ).then_return(NozzleConfigurationType.SINGLE) + decoy.when(mock_state_view.pipettes.get_primary_nozzle("pipette-id")).then_return( + "H1" + ) + decoy.when( + mock_state_view.geometry.get_ancestor_slot_name("destination-labware-id") + ).then_return(DeckSlotName.SLOT_C2) + decoy.when( + mock_state_view.geometry.get_highest_z_in_slot( + DeckSlotLocation(slotName=DeckSlotName.SLOT_B2) + ) + ).then_return(50) + decoy.when( + mock_state_view.geometry.get_well_position( + labware_id="destination-labware-id", + well_name="A2", + well_location=WellLocation(origin=WellOrigin.TOP, offset=WellOffset(z=10)), + ) + ).then_return(destination_well_point) + decoy.when(mock_state_view.pipettes.get_attached_tip("pipette-id")).then_return( + TipGeometry(length=10, diameter=100, volume=0) + ) + + with expected_raise: + deck_conflict.check_safe_for_pipette_movement( + engine_state=mock_state_view, + pipette_id="pipette-id", + labware_id="destination-labware-id", + well_name="A2", + well_location=WellLocation(origin=WellOrigin.TOP, offset=WellOffset(z=10)), + ) diff --git a/api/tests/opentrons/protocol_engine/commands/test_configure_nozzle_layout.py b/api/tests/opentrons/protocol_engine/commands/test_configure_nozzle_layout.py index 641a8bae08b..7dbb07332da 100644 --- a/api/tests/opentrons/protocol_engine/commands/test_configure_nozzle_layout.py +++ b/api/tests/opentrons/protocol_engine/commands/test_configure_nozzle_layout.py @@ -388,5 +388,6 @@ async def test_configure_nozzle_layout_implementation( assert result == ConfigureNozzleLayoutResult() assert private_result == ConfigureNozzleLayoutPrivateResult( - pipette_id="pipette-id", nozzle_map=expected_nozzlemap + pipette_id="pipette-id", + nozzle_map=expected_nozzlemap, ) 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 a92b9aa9f05..39211c5bb24 100644 --- a/api/tests/opentrons/protocol_engine/state/test_geometry_view.py +++ b/api/tests/opentrons/protocol_engine/state/test_geometry_view.py @@ -635,6 +635,275 @@ def test_get_all_obstacle_highest_z_with_fixtures( assert result == 1337.0 +def test_get_highest_z_in_slot_with_single_labware( + decoy: Decoy, + labware_view: LabwareView, + addressable_area_view: AddressableAreaView, + subject: GeometryView, + well_plate_def: LabwareDefinition, +) -> None: + """It should get the highest Z in slot with just a single labware.""" + # Case: Slot has a labware that doesn't have any other labware on it. Highest z is equal to labware height. + labware_in_slot = LoadedLabware( + id="just-labware-id", + loadName="just-labware-name", + definitionUri="definition-uri", + location=DeckSlotLocation(slotName=DeckSlotName.SLOT_3), + offsetId="offset-id", + ) + slot_pos = Point(1, 2, 3) + calibration_offset = LabwareOffsetVector(x=1, y=-2, z=3) + + decoy.when(labware_view.get_by_slot(DeckSlotName.SLOT_3)).then_return( + labware_in_slot + ) + decoy.when(labware_view.get_id_by_labware("just-labware-id")).then_raise( + errors.LabwareNotLoadedOnLabwareError("no more labware") + ) + decoy.when(labware_view.get("just-labware-id")).then_return(labware_in_slot) + decoy.when(labware_view.get_definition("just-labware-id")).then_return( + well_plate_def + ) + decoy.when(labware_view.get_labware_offset_vector("just-labware-id")).then_return( + calibration_offset + ) + decoy.when( + addressable_area_view.get_addressable_area_position(DeckSlotName.SLOT_3.id) + ).then_return(slot_pos) + + expected_highest_z = well_plate_def.dimensions.zDimension + 3 + 3 + assert ( + subject.get_highest_z_in_slot(DeckSlotLocation(slotName=DeckSlotName.SLOT_3)) + == expected_highest_z + ) + + +def test_get_highest_z_in_slot_with_single_module( + decoy: Decoy, + labware_view: LabwareView, + module_view: ModuleView, + addressable_area_view: AddressableAreaView, + subject: GeometryView, + ot2_standard_deck_def: DeckDefinitionV4, +) -> None: + """It should get the highest Z in slot with just a single module.""" + # Case: Slot has a module that doesn't have any labware on it. Highest z is equal to module height. + module_in_slot = LoadedModule.construct( + id="only-module", + model=ModuleModel.THERMOCYCLER_MODULE_V2, + location=DeckSlotLocation(slotName=DeckSlotName.SLOT_4), + ) + + decoy.when(module_view.get_by_slot(DeckSlotName.SLOT_3)).then_return(module_in_slot) + decoy.when(labware_view.get_id_by_module("only-module")).then_raise( + errors.LabwareNotLoadedOnModuleError("only module") + ) + decoy.when(labware_view.get_deck_definition()).then_return(ot2_standard_deck_def) + decoy.when( + module_view.get_module_highest_z( + module_id="only-module", deck_type=DeckType("ot2_standard") + ) + ).then_return(12345) + + assert ( + subject.get_highest_z_in_slot(DeckSlotLocation(slotName=DeckSlotName.SLOT_3)) + == 12345 + ) + + +# TODO (spp, 2023-12-05): this is mocking out too many things and is hard to follow. +# Create an integration test that loads labware and modules and tests the geometry +# in an easier-to-understand manner. +def test_get_highest_z_in_slot_with_stacked_labware_on_slot( + decoy: Decoy, + labware_view: LabwareView, + addressable_area_view: AddressableAreaView, + subject: GeometryView, + well_plate_def: LabwareDefinition, +) -> None: + """It should get the highest z in slot of the topmost labware in stack. + + Tests both `get_highest_z_in_slot` and `get_highest_z_of_labware_stack`. + """ + labware_in_slot = LoadedLabware( + id="bottom-labware-id", + loadName="bottom-labware-name", + definitionUri="bottom-definition-uri", + location=DeckSlotLocation(slotName=DeckSlotName.SLOT_3), + offsetId="offset-id", + ) + middle_labware = LoadedLabware( + id="middle-labware-id", + loadName="middle-labware-name", + definitionUri="middle-definition-uri", + location=OnLabwareLocation(labwareId="bottom-labware-id"), + offsetId="offset-id", + ) + top_labware = LoadedLabware( + id="top-labware-id", + loadName="top-labware-name", + definitionUri="top-definition-uri", + location=OnLabwareLocation(labwareId="middle-labware-id"), + offsetId="offset-id", + ) + slot_pos = Point(11, 22, 33) + top_lw_lpc_offset = LabwareOffsetVector(x=1, y=-2, z=3) + + decoy.when(labware_view.get_by_slot(DeckSlotName.SLOT_3)).then_return( + labware_in_slot + ) + + decoy.when(labware_view.get_id_by_labware("bottom-labware-id")).then_return( + "middle-labware-id" + ) + decoy.when(labware_view.get_id_by_labware("middle-labware-id")).then_return( + "top-labware-id" + ) + decoy.when(labware_view.get_id_by_labware("top-labware-id")).then_raise( + errors.LabwareNotLoadedOnLabwareError("top labware") + ) + + decoy.when(labware_view.get("bottom-labware-id")).then_return(labware_in_slot) + decoy.when(labware_view.get("middle-labware-id")).then_return(middle_labware) + decoy.when(labware_view.get("top-labware-id")).then_return(top_labware) + + decoy.when(labware_view.get_definition("top-labware-id")).then_return( + well_plate_def + ) + decoy.when(labware_view.get_labware_offset_vector("top-labware-id")).then_return( + top_lw_lpc_offset + ) + decoy.when(labware_view.get_dimensions("middle-labware-id")).then_return( + Dimensions(x=10, y=20, z=30) + ) + decoy.when(labware_view.get_dimensions("bottom-labware-id")).then_return( + Dimensions(x=11, y=12, z=13) + ) + + decoy.when( + labware_view.get_labware_overlap_offsets( + "top-labware-id", below_labware_name="middle-labware-name" + ) + ).then_return(OverlapOffset(x=4, y=5, z=6)) + decoy.when( + labware_view.get_labware_overlap_offsets( + "middle-labware-id", below_labware_name="bottom-labware-name" + ) + ).then_return(OverlapOffset(x=7, y=8, z=9)) + + decoy.when( + addressable_area_view.get_addressable_area_position(DeckSlotName.SLOT_3.id) + ).then_return(slot_pos) + + expected_highest_z = ( + slot_pos.z + well_plate_def.dimensions.zDimension - 6 + 30 - 9 + 13 + 3 + ) + assert ( + subject.get_highest_z_in_slot(DeckSlotLocation(slotName=DeckSlotName.SLOT_3)) + == expected_highest_z + ) + + +# TODO (spp, 2023-12-05): this is mocking out too many things and is hard to follow. +# Create an integration test that loads labware and modules and tests the geometry +# in an easier-to-understand manner. +def test_get_highest_z_in_slot_with_labware_stack_on_module( + decoy: Decoy, + labware_view: LabwareView, + module_view: ModuleView, + addressable_area_view: AddressableAreaView, + subject: GeometryView, + well_plate_def: LabwareDefinition, + ot2_standard_deck_def: DeckDefinitionV4, +) -> None: + """It should get the highest z in slot of labware on module. + + Tests both `get_highest_z_in_slot` and `get_highest_z_of_labware_stack`. + """ + top_labware = LoadedLabware( + id="top-labware-id", + loadName="top-labware-name", + definitionUri="top-labware-uri", + location=OnLabwareLocation(labwareId="adapter-id"), + offsetId="offset-id1", + ) + adapter = LoadedLabware( + id="adapter-id", + loadName="adapter-name", + definitionUri="adapter-uri", + location=ModuleLocation(moduleId="module-id"), + offsetId="offset-id2", + ) + module_on_slot = LoadedModule.construct( + id="module-id", + model=ModuleModel.THERMOCYCLER_MODULE_V2, + location=DeckSlotLocation(slotName=DeckSlotName.SLOT_4), + ) + + slot_pos = Point(11, 22, 33) + top_lw_lpc_offset = LabwareOffsetVector(x=1, y=-2, z=3) + + decoy.when(module_view.get("module-id")).then_return(module_on_slot) + decoy.when(module_view.get_by_slot(DeckSlotName.SLOT_3)).then_return(module_on_slot) + + decoy.when(labware_view.get_id_by_module("module-id")).then_return("adapter-id") + decoy.when(labware_view.get_id_by_labware("adapter-id")).then_return( + "top-labware-id" + ) + decoy.when(labware_view.get_id_by_labware("top-labware-id")).then_raise( + errors.LabwareNotLoadedOnLabwareError("top labware") + ) + + decoy.when(labware_view.get_deck_definition()).then_return(ot2_standard_deck_def) + decoy.when(labware_view.get_definition("top-labware-id")).then_return( + well_plate_def + ) + + decoy.when(labware_view.get("adapter-id")).then_return(adapter) + decoy.when(labware_view.get("top-labware-id")).then_return(top_labware) + decoy.when(labware_view.get_labware_offset_vector("top-labware-id")).then_return( + top_lw_lpc_offset + ) + decoy.when(labware_view.get_dimensions("adapter-id")).then_return( + Dimensions(x=10, y=20, z=30) + ) + decoy.when( + labware_view.get_labware_overlap_offsets( + labware_id="top-labware-id", below_labware_name="adapter-name" + ) + ).then_return(OverlapOffset(x=4, y=5, z=6)) + + decoy.when(module_view.get_location("module-id")).then_return( + DeckSlotLocation(slotName=DeckSlotName.SLOT_3) + ) + decoy.when( + module_view.get_nominal_module_offset( + module_id="module-id", deck_type=DeckType("ot2_standard") + ) + ).then_return(LabwareOffsetVector(x=40, y=50, z=60)) + decoy.when(module_view.get_connected_model("module-id")).then_return( + ModuleModel.TEMPERATURE_MODULE_V2 + ) + + decoy.when( + labware_view.get_module_overlap_offsets( + "adapter-id", ModuleModel.TEMPERATURE_MODULE_V2 + ) + ).then_return(OverlapOffset(x=1.1, y=2.2, z=3.3)) + + decoy.when( + addressable_area_view.get_addressable_area_position(DeckSlotName.SLOT_3.id) + ).then_return(slot_pos) + + expected_highest_z = ( + slot_pos.z + 60 + 30 - 3.3 + well_plate_def.dimensions.zDimension - 6 + 3 + ) + assert ( + subject.get_highest_z_in_slot(DeckSlotLocation(slotName=DeckSlotName.SLOT_3)) + == expected_highest_z + ) + + @pytest.mark.parametrize( ["location", "min_z_height", "expected_min_z"], [ 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 e4498c0ec7d..586423a0d86 100644 --- a/api/tests/opentrons/protocol_engine/state/test_module_view.py +++ b/api/tests/opentrons/protocol_engine/state/test_module_view.py @@ -1,5 +1,6 @@ """Tests for module state accessors in the protocol engine state store.""" import pytest +from math import isclose from pytest_lazyfixture import lazy_fixture # type: ignore[import] from contextlib import nullcontext as does_not_raise @@ -1759,3 +1760,35 @@ def test_get_default_gripper_offsets( }, ) assert subject.get_default_gripper_offsets("module-1") == expected_offset_data + + +@pytest.mark.parametrize( + argnames=["deck_type", "slot_name", "expected_highest_z"], + argvalues=[ + (DeckType.OT2_STANDARD, DeckSlotName.SLOT_1, 84), + (DeckType.OT3_STANDARD, DeckSlotName.SLOT_D1, 12.91), + ], +) +def test_get_module_highest_z( + tempdeck_v2_def: ModuleDefinition, + deck_type: DeckType, + slot_name: DeckSlotName, + expected_highest_z: float, +) -> None: + """It should get the highest z point of the module.""" + subject = make_module_view( + slot_by_module_id={"module-id": slot_name}, + requested_model_by_module_id={ + "module-id": ModuleModel.TEMPERATURE_MODULE_V2, + }, + hardware_by_module_id={ + "module-id": HardwareModule( + serial_number="module-serial", + definition=tempdeck_v2_def, + ) + }, + ) + assert isclose( + subject.get_module_highest_z(module_id="module-id", deck_type=deck_type), + expected_highest_z, + ) diff --git a/api/tests/opentrons/protocol_engine/state/test_pipette_view.py b/api/tests/opentrons/protocol_engine/state/test_pipette_view.py index 4ddee00d410..f7b32c9d37e 100644 --- a/api/tests/opentrons/protocol_engine/state/test_pipette_view.py +++ b/api/tests/opentrons/protocol_engine/state/test_pipette_view.py @@ -1,4 +1,6 @@ """Tests for pipette state accessors in the protocol_engine state store.""" +from collections import OrderedDict + import pytest from typing import cast, Dict, List, Optional @@ -6,7 +8,7 @@ from opentrons_shared_data.pipette import pipette_definition from opentrons.config.defaults_ot2 import Z_RETRACT_DISTANCE -from opentrons.types import MountType, Mount as HwMount +from opentrons.types import MountType, Mount as HwMount, Point from opentrons.hardware_control.dev_types import PipetteDict from opentrons.protocol_engine import errors from opentrons.protocol_engine.types import ( @@ -24,7 +26,7 @@ HardwarePipette, StaticPipetteConfig, ) -from opentrons.hardware_control.nozzle_manager import NozzleMap +from opentrons.hardware_control.nozzle_manager import NozzleMap, NozzleConfigurationType from opentrons.protocol_engine.errors import TipNotAttachedError, PipetteNotLoadedError @@ -512,3 +514,19 @@ def test_get_motor_axes( assert subject.get_z_axis("pipette-id") == expected_z_axis assert subject.get_plunger_axis("pipette-id") == expected_plunger_axis + + +def test_nozzle_configuration_getters() -> None: + """Test that pipette view returns correct nozzle configuration data.""" + nozzle_map = NozzleMap.build( + physical_nozzles=OrderedDict({"A1": Point(0, 0, 0)}), + physical_rows=OrderedDict({"A": ["A1"]}), + physical_columns=OrderedDict({"1": ["A1"]}), + starting_nozzle="A1", + back_left_nozzle="A1", + front_right_nozzle="A1", + ) + subject = get_pipette_view(nozzle_layout_by_id={"pipette-id": nozzle_map}) + assert subject.get_nozzle_layout_type("pipette-id") == NozzleConfigurationType.FULL + assert subject.get_is_partially_configured("pipette-id") is False + assert subject.get_primary_nozzle("pipette-id") == "A1" diff --git a/shared-data/module/definitions/3/thermocyclerModuleV2.json b/shared-data/module/definitions/3/thermocyclerModuleV2.json index 531890def74..b5d8b1fbd9e 100644 --- a/shared-data/module/definitions/3/thermocyclerModuleV2.json +++ b/shared-data/module/definitions/3/thermocyclerModuleV2.json @@ -8,7 +8,7 @@ "z": 108.96 }, "dimensions": { - "bareOverallHeight": 98.0, + "bareOverallHeight": 108.96, "overLabwareHeight": 0.0, "lidHeight": 61.7, "xDimension": 172,