Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(api): Addition of Cutout Fixtures to slot height checks #14371

Merged
merged 7 commits into from
Jan 31, 2024
28 changes: 28 additions & 0 deletions api/src/opentrons/protocol_engine/state/addressable_areas.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
AreaNotInDeckConfigurationError,
SlotDoesNotExistError,
AddressableAreaDoesNotExistError,
CutoutDoesNotExistError,
)
from ..resources import deck_configuration_provider
from ..types import (
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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(
Expand Down
23 changes: 20 additions & 3 deletions api/src/opentrons/protocol_engine/state/geometry.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
OnDeckLabwareLocation,
AddressableAreaLocation,
AddressableOffsetVector,
PotentialCutoutFixture,
)
from .config import Config
from .labware import LabwareView
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)],
Expand Down
Loading