From 222b60c74155975103b3a3f4e604f7612924d262 Mon Sep 17 00:00:00 2001 From: CaseyBatten Date: Wed, 24 Jan 2024 17:21:05 -0500 Subject: [PATCH 1/7] Addition of addressable areas to z height checking --- .../protocol_engine/state/geometry.py | 29 +++++++++++++++---- 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/api/src/opentrons/protocol_engine/state/geometry.py b/api/src/opentrons/protocol_engine/state/geometry.py index bc65a957b18..9145c9c08cc 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, + AddressableArea, ) from .config import Config from .labware import LabwareView @@ -158,6 +159,8 @@ 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, AddressableArea): + return slot_item.position.z else: return 0 @@ -679,21 +682,35 @@ def get_extra_waypoints( def get_slot_item( self, slot_name: Union[DeckSlotName, StagingSlotName] - ) -> Union[LoadedLabware, LoadedModule, None]: + ) -> Union[LoadedLabware, LoadedModule, AddressableArea, 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_module = self._modules.get_by_slot( - slot_name=slot_name, - ) + areas = self._addressable_areas.get_all() + maybe_fixture = None + if areas: + for area in areas: + if ( + self._addressable_areas.get_addressable_area_base_slot(area) + == slot_name + ): + maybe_fixture = self._addressable_areas.get_addressable_area( + area + ) + + if maybe_fixture is 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_fixture or maybe_module or None @staticmethod def get_slot_column(slot_name: DeckSlotName) -> int: From 5f088514347cf6d1010c36bf8bcfec6e6a52fb89 Mon Sep 17 00:00:00 2001 From: CaseyBatten Date: Mon, 29 Jan 2024 11:13:50 -0500 Subject: [PATCH 2/7] Adjustment to utilize fixture height instead of addressable area height --- .../state/addressable_areas.py | 13 ++++++++ .../protocol_engine/state/geometry.py | 33 ++++++++----------- 2 files changed, 26 insertions(+), 20 deletions(-) diff --git a/api/src/opentrons/protocol_engine/state/addressable_areas.py b/api/src/opentrons/protocol_engine/state/addressable_areas.py index 077e78ab2a5..5e212f51be2 100644 --- a/api/src/opentrons/protocol_engine/state/addressable_areas.py +++ b/api/src/opentrons/protocol_engine/state/addressable_areas.py @@ -460,6 +460,19 @@ 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) -> (str | None): + """Get the Cutout Fixture ID of a fixture currently loaded into a specific Deck Slot, if one exists.""" + for cutout in CUTOUT_TO_DECK_SLOT_MAP: + if CUTOUT_TO_DECK_SLOT_MAP[cutout] == slot_name: + deck_config = ( + self.state.deck_configuration + ) # we need to use that get cutout fixture by id thing + if deck_config: + for deck_item in deck_config: + if cutout in deck_item: + return deck_item[1] + 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 9145c9c08cc..1a2fb2b1bca 100644 --- a/api/src/opentrons/protocol_engine/state/geometry.py +++ b/api/src/opentrons/protocol_engine/state/geometry.py @@ -34,6 +34,7 @@ AddressableAreaLocation, AddressableOffsetVector, AddressableArea, + PotentialCutoutFixture, ) from .config import Config from .labware import LabwareView @@ -159,8 +160,8 @@ 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, AddressableArea): - return slot_item.position.z + elif isinstance(slot_item, str): + return self._addressable_areas.get_fixture_height(slot_item) else: return 0 @@ -682,35 +683,27 @@ def get_extra_waypoints( def get_slot_item( self, slot_name: Union[DeckSlotName, StagingSlotName] - ) -> Union[LoadedLabware, LoadedModule, AddressableArea, None]: + ) -> Union[LoadedLabware, LoadedModule, str, 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): - areas = self._addressable_areas.get_all() - maybe_fixture = None - if areas: - for area in areas: - if ( - self._addressable_areas.get_addressable_area_base_slot(area) - == slot_name - ): - maybe_fixture = self._addressable_areas.get_addressable_area( - area - ) - - if maybe_fixture is None: - maybe_module = self._modules.get_by_slot( - slot_name=slot_name, - ) + maybe_fixture = self._addressable_areas.get_fixture_by_deck_slot_name( + slot_name + ) + + if maybe_fixture is None: + maybe_module = self._modules.get_by_slot( + slot_name=slot_name, + ) else: # Modules and fixtures can't be loaded on staging slots maybe_fixture = None maybe_module = None - return maybe_labware or maybe_fixture 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: From b1ce4f78b421a5411d28f1b96c7677c6b187c215 Mon Sep 17 00:00:00 2001 From: CaseyBatten Date: Mon, 29 Jan 2024 11:15:18 -0500 Subject: [PATCH 3/7] linting fix --- api/src/opentrons/protocol_engine/state/geometry.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/api/src/opentrons/protocol_engine/state/geometry.py b/api/src/opentrons/protocol_engine/state/geometry.py index 1a2fb2b1bca..4f84f88dfdb 100644 --- a/api/src/opentrons/protocol_engine/state/geometry.py +++ b/api/src/opentrons/protocol_engine/state/geometry.py @@ -33,8 +33,6 @@ OnDeckLabwareLocation, AddressableAreaLocation, AddressableOffsetVector, - AddressableArea, - PotentialCutoutFixture, ) from .config import Config from .labware import LabwareView From db86da058131f0da14064a1886815954577dd3ec Mon Sep 17 00:00:00 2001 From: CaseyBatten Date: Mon, 29 Jan 2024 11:24:29 -0500 Subject: [PATCH 4/7] correction to ensure base slot fixtures do not filter out modules --- api/src/opentrons/protocol_engine/state/geometry.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/api/src/opentrons/protocol_engine/state/geometry.py b/api/src/opentrons/protocol_engine/state/geometry.py index 4f84f88dfdb..b42f2afb7a3 100644 --- a/api/src/opentrons/protocol_engine/state/geometry.py +++ b/api/src/opentrons/protocol_engine/state/geometry.py @@ -692,10 +692,9 @@ def get_slot_item( slot_name ) - if maybe_fixture is None: - maybe_module = self._modules.get_by_slot( - slot_name=slot_name, - ) + maybe_module = self._modules.get_by_slot( + slot_name=slot_name, + ) else: # Modules and fixtures can't be loaded on staging slots maybe_fixture = None From 3a59ceb27581881d72468826ed8b2ecb79a4fc86 Mon Sep 17 00:00:00 2001 From: CaseyBatten Date: Tue, 30 Jan 2024 19:00:33 -0500 Subject: [PATCH 5/7] addition of test case for fixtures in adjacent slots to 96 channels --- .../core/engine/test_deck_conflict.py | 77 +++++++++++++++++++ 1 file changed, 77 insertions(+) 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)], From b6a069d6903a7da6abd7a04249f6555db642101c Mon Sep 17 00:00:00 2001 From: CaseyBatten Date: Wed, 31 Jan 2024 12:54:56 -0500 Subject: [PATCH 6/7] optimizations and return type adjustment alongside case filtering --- .../state/addressable_areas.py | 28 +++++++++++-------- .../protocol_engine/state/geometry.py | 16 +++++++++-- 2 files changed, 30 insertions(+), 14 deletions(-) diff --git a/api/src/opentrons/protocol_engine/state/addressable_areas.py b/api/src/opentrons/protocol_engine/state/addressable_areas.py index 5e212f51be2..487c4c1dd59 100644 --- a/api/src/opentrons/protocol_engine/state/addressable_areas.py +++ b/api/src/opentrons/protocol_engine/state/addressable_areas.py @@ -460,17 +460,23 @@ 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) -> (str | None): - """Get the Cutout Fixture ID of a fixture currently loaded into a specific Deck Slot, if one exists.""" - for cutout in CUTOUT_TO_DECK_SLOT_MAP: - if CUTOUT_TO_DECK_SLOT_MAP[cutout] == slot_name: - deck_config = ( - self.state.deck_configuration - ) # we need to use that get cutout fixture by id thing - if deck_config: - for deck_item in deck_config: - if cutout in deck_item: - return deck_item[1] + 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: + cutout_id = list(CUTOUT_TO_DECK_SLOT_MAP.keys())[ + list(CUTOUT_TO_DECK_SLOT_MAP.values()).index(slot_name) + ] + deck_items = { + deck_item for deck_item in deck_config if cutout_id in deck_item + } + for item in deck_items: + for fixture in potential_fixtures[cutout_id]: + if fixture.cutout_fixture_id == item[1]: + return fixture return None def get_fixture_height(self, cutout_fixture_name: str) -> float: diff --git a/api/src/opentrons/protocol_engine/state/geometry.py b/api/src/opentrons/protocol_engine/state/geometry.py index b42f2afb7a3..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,8 +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, str): - return self._addressable_areas.get_fixture_height(slot_item) + elif isinstance(slot_item, PotentialCutoutFixture): + return self._addressable_areas.get_fixture_height( + slot_item.cutout_fixture_id + ) else: return 0 @@ -681,7 +684,7 @@ def get_extra_waypoints( def get_slot_item( self, slot_name: Union[DeckSlotName, StagingSlotName] - ) -> Union[LoadedLabware, LoadedModule, str, 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, @@ -691,6 +694,13 @@ def get_slot_item( 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, From 21c4a828e3022596a96ed5101f84ba83f005a1e0 Mon Sep 17 00:00:00 2001 From: CaseyBatten Date: Wed, 31 Jan 2024 14:10:15 -0500 Subject: [PATCH 7/7] further improvements, raise case addition --- .../state/addressable_areas.py | 29 ++++++++++++------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/api/src/opentrons/protocol_engine/state/addressable_areas.py b/api/src/opentrons/protocol_engine/state/addressable_areas.py index 487c4c1dd59..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): @@ -467,16 +471,21 @@ def get_fixture_by_deck_slot_name( deck_config = self.state.deck_configuration potential_fixtures = self.state.potential_cutout_fixtures_by_cutout_id if deck_config: - cutout_id = list(CUTOUT_TO_DECK_SLOT_MAP.keys())[ - list(CUTOUT_TO_DECK_SLOT_MAP.values()).index(slot_name) - ] - deck_items = { - deck_item for deck_item in deck_config if cutout_id in deck_item - } - for item in deck_items: - for fixture in potential_fixtures[cutout_id]: - if fixture.cutout_fixture_id == item[1]: - return fixture + 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: