diff --git a/api/src/opentrons/protocol_engine/state/addressable_areas.py b/api/src/opentrons/protocol_engine/state/addressable_areas.py index 077e78ab2a5..b6bfe01f2cc 100644 --- a/api/src/opentrons/protocol_engine/state/addressable_areas.py +++ b/api/src/opentrons/protocol_engine/state/addressable_areas.py @@ -20,6 +20,7 @@ AreaNotInDeckConfigurationError, SlotDoesNotExistError, AddressableAreaDoesNotExistError, + CutoutDoesNotExistError, ) from ..resources import deck_configuration_provider from ..types import ( @@ -138,6 +139,9 @@ def _get_conflicting_addressable_areas_error_string( "cutoutD2": DeckSlotName.SLOT_D2, "cutoutD3": DeckSlotName.SLOT_D3, } +DECK_SLOT_TO_CUTOUT_MAP = { + deck_slot: cutout for cutout, deck_slot in CUTOUT_TO_DECK_SLOT_MAP.items() +} class AddressableAreaStore(HasState[AddressableAreaState], HandlesActions): @@ -460,6 +464,30 @@ def get_addressable_area_center(self, addressable_area_name: str) -> Point: z=position.z, ) + def get_fixture_by_deck_slot_name( + self, slot_name: DeckSlotName + ) -> Optional[PotentialCutoutFixture]: + """Get the Potential Cutout Fixture of a fixture currently loaded into a specific Deck Slot.""" + deck_config = self.state.deck_configuration + potential_fixtures = self.state.potential_cutout_fixtures_by_cutout_id + if deck_config: + slot_cutout_id = DECK_SLOT_TO_CUTOUT_MAP[slot_name] + slot_cutout_fixture_id = None + # This will only ever be one under current assumptions + for cutout_id, cutout_fixture_id in deck_config: + if cutout_id == slot_cutout_id: + slot_cutout_fixture_id = cutout_fixture_id + break + if slot_cutout_fixture_id is None: + raise CutoutDoesNotExistError( + f"No Cutout was found in the Deck that matched provided slot {slot_name}." + ) + + for fixture in potential_fixtures[slot_cutout_id]: + if fixture.cutout_fixture_id == slot_cutout_fixture_id: + return fixture + return None + def get_fixture_height(self, cutout_fixture_name: str) -> float: """Get the z height of a cutout fixture.""" cutout_fixture = deck_configuration_provider.get_cutout_fixture( diff --git a/api/src/opentrons/protocol_engine/state/geometry.py b/api/src/opentrons/protocol_engine/state/geometry.py index bc65a957b18..2ddbcdddccc 100644 --- a/api/src/opentrons/protocol_engine/state/geometry.py +++ b/api/src/opentrons/protocol_engine/state/geometry.py @@ -33,6 +33,7 @@ OnDeckLabwareLocation, AddressableAreaLocation, AddressableOffsetVector, + PotentialCutoutFixture, ) from .config import Config from .labware import LabwareView @@ -158,6 +159,10 @@ def get_highest_z_in_slot(self, slot: DeckSlotLocation) -> float: 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) + elif isinstance(slot_item, PotentialCutoutFixture): + return self._addressable_areas.get_fixture_height( + slot_item.cutout_fixture_id + ) else: return 0 @@ -679,21 +684,33 @@ def get_extra_waypoints( def get_slot_item( self, slot_name: Union[DeckSlotName, StagingSlotName] - ) -> Union[LoadedLabware, LoadedModule, None]: + ) -> Union[LoadedLabware, LoadedModule, PotentialCutoutFixture, None]: """Get the item present in a deck slot, if any.""" maybe_labware = self._labware.get_by_slot( slot_name=slot_name, ) if isinstance(slot_name, DeckSlotName): + maybe_fixture = self._addressable_areas.get_fixture_by_deck_slot_name( + slot_name + ) + # Ignore generic single slot fixtures + if maybe_fixture and maybe_fixture.cutout_fixture_id in { + "singleLeftSlot", + "singleCenterSlot", + "singleRightSlot", + }: + maybe_fixture = None + maybe_module = self._modules.get_by_slot( slot_name=slot_name, ) else: - # Modules can't be loaded on staging slots + # Modules and fixtures can't be loaded on staging slots + maybe_fixture = None maybe_module = None - return maybe_labware or maybe_module or None + return maybe_labware or maybe_module or maybe_fixture or None @staticmethod def get_slot_column(slot_name: DeckSlotName) -> int: 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 5f07cb3a386..68fb3d87f02 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 @@ -454,6 +454,83 @@ def test_deck_conflict_raises_for_bad_partial_96_channel_move( ) +@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=10), + pytest.raises( + deck_conflict.PartialTipMovementNotAllowedError, + match="Moving to destination-labware in slot D2 with pipette column A1 nozzle configuration will result in collision with items in deck slot D3.", + ), + ), + ], +) +def test_deck_conflict_raises_for_bad_partial_96_channel_move_with_fixtures( + decoy: Decoy, + mock_state_view: StateView, + destination_well_point: Point, + expected_raise: ContextManager[Any], +) -> None: + """It should raise an error when moving to locations adjacent to fixtures with restrictions for partial tip 96-channel movement. + + Test premise: + - we are using a pipette configured for COLUMN nozzle layout with primary nozzle A1 + - there's a waste chute with in D3 + - we are checking for conflicts when moving to column A12 of a labware in D2. + """ + decoy.when(mock_state_view.pipettes.get_channels("pipette-id")).then_return(96) + decoy.when( + mock_state_view.labware.get_display_name("destination-labware-id") + ).then_return("destination-labware") + 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( + "A1" + ) + decoy.when( + mock_state_view.geometry.get_well_position( + labware_id="destination-labware-id", + well_name="A12", + well_location=WellLocation(origin=WellOrigin.TOP, offset=WellOffset(z=10)), + ) + ).then_return(destination_well_point) + decoy.when( + mock_state_view.geometry.get_ancestor_slot_name("destination-labware-id") + ).then_return(DeckSlotName.SLOT_D2) + decoy.when( + mock_state_view.addressable_areas.get_fixture_height( + "wasteChuteRightAdapterNoCover" + ) + ).then_return(124.5) + decoy.when( + mock_state_view.geometry.get_highest_z_in_slot( + DeckSlotLocation(slotName=DeckSlotName.SLOT_D3) + ) + ).then_return( + mock_state_view.addressable_areas.get_fixture_height( + "wasteChuteRightAdapterNoCover" + ) + ) + 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="A12", + well_location=WellLocation(origin=WellOrigin.TOP, offset=WellOffset(z=10)), + ) + + @pytest.mark.parametrize( ("robot_type", "deck_type"), [("OT-3 Standard", DeckType.OT3_STANDARD)],