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

feat(api): Forbid Heater-Shakers in slot 9, just south of the trash #11019

Merged
merged 3 commits into from
Jul 6, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 88 additions & 26 deletions api/src/opentrons/protocols/geometry/deck_conflict.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
_FIXED_TRASH_SLOT: Final = 12


class _NotAllowed(NamedTuple):
class _NothingAllowed(NamedTuple):
"""Nothing is allowed in this slot."""

location: int
Expand Down Expand Up @@ -48,7 +48,7 @@ def is_allowed(self, item: DeckItem) -> bool:


class _NoModule(NamedTuple):
"""A module is not allowed in this slot."""
"""No module of any kind is allowed in this slot."""

location: int
source_item: DeckItem
Expand All @@ -58,21 +58,33 @@ def is_allowed(self, item: DeckItem) -> bool:
return not isinstance(item, ModuleGeometry)


class _FixedTrash(NamedTuple):
class _NoHeaterShakerModule(NamedTuple):
"""No Heater-Shaker module is allowed in this slot."""

location: int
source_item: DeckItem
source_location: int

def is_allowed(self, item: DeckItem) -> bool:
return not isinstance(item, HeaterShakerGeometry)


class _FixedTrashOnly(NamedTuple):
"""Only fixed-trash labware is allowed in this slot."""

location: int = _FIXED_TRASH_SLOT

def is_allowed(self, item: DeckItem) -> bool:
if isinstance(item, AbstractLabware):
return "fixedTrash" in item.get_quirks()
if isinstance(item, Labware):
return "fixedTrash" in item.quirks

return False
return _is_fixed_trash(item)


_DeckRestriction = Union[_NotAllowed, _MaxHeight, _NoModule, _FixedTrash]
_DeckRestriction = Union[
_NothingAllowed,
_MaxHeight,
_NoModule,
_NoHeaterShakerModule,
_FixedTrashOnly,
]
"""A restriction on what is allowed in a given slot."""


Expand All @@ -95,7 +107,7 @@ def check(
Raises:
DeckConflictError: Adding this item should not be allowed.
"""
restrictions: List[_DeckRestriction] = [_FixedTrash()]
restrictions: List[_DeckRestriction] = [_FixedTrashOnly()]

# build restrictions driven by existing items
for location, item in existing_items.items():
Expand Down Expand Up @@ -127,18 +139,33 @@ def _create_restrictions(item: DeckItem, location: int) -> List[_DeckRestriction
restrictions: List[_DeckRestriction] = []

if location != _FIXED_TRASH_SLOT:
# Disallow a different item from overlapping this item in this deck slot.
restrictions.append(
_NotAllowed(
_NothingAllowed(
location=location,
source_item=item,
source_location=location,
)
)

if _is_fixed_trash(item):
# A Heater-Shaker can't safely be placed just south of the fixed trash,
# because the fixed trash blocks access to the screw that locks the
# Heater-Shaker onto the deck.
location_south_of_fixed_trash = _get_south_location(location)
if location_south_of_fixed_trash is not None:
restrictions.append(
_NoHeaterShakerModule(
location=location_south_of_fixed_trash,
source_item=item,
source_location=location,
)
)

if isinstance(item, ThermocyclerGeometry):
for covered_location in item.covered_slots:
restrictions.append(
_NotAllowed(
_NothingAllowed(
location=covered_location,
source_item=item,
source_location=location,
Expand Down Expand Up @@ -178,7 +205,7 @@ def _create_deck_conflict_error_message(
new_item is not None or existing_item is not None
), "Conflict error expects either new_item or existing_item"

if isinstance(restriction, _FixedTrash):
if isinstance(restriction, _FixedTrashOnly):
message = f"Only fixed-trash is allowed in slot {restriction.location}"

elif new_item is not None:
Expand All @@ -199,21 +226,56 @@ def _create_deck_conflict_error_message(
return message


def _get_east_west_locations(location: int) -> List[int]:
if location in [1, 4, 7, 10]:
return [location + 1]
elif location in [2, 5, 8, 11]:
return [location - 1, location + 1]
def _is_fixed_trash(item: DeckItem) -> bool:
# `item` is inconsistently provided to us as an AbstractLabware or Labware.
# See TODO comments in opentrons.protocols.geometry.deck,
# the module that calls into this module.
Comment on lines +230 to +232
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@mcous I think you looked into this when you wrote this module originally. Is this a fair summary?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes it is. In practice, it will be a Labware when running a Python protocol, and AbstractLabware when running a JSON protocol

if isinstance(item, AbstractLabware):
return "fixedTrash" in item.get_quirks()
if isinstance(item, Labware):
return "fixedTrash" in item.quirks
return False


def _get_north_location(location: int) -> Optional[int]:
if location in [10, 11, 12]:
return None
else:
return [location - 1]
return location + 3


def _get_adjacent_locations(location: int) -> List[int]:
def _get_south_location(location: int) -> Optional[int]:
if location in [1, 2, 3]:
north_south = [location + 3]
elif location in [4, 5, 6, 7, 8, 9]:
north_south = [location - 3, location + 3]
return None
else:
north_south = [location - 3]
return location - 3

return north_south + _get_east_west_locations(location)

def _get_east_location(location: int) -> Optional[int]:
if location in [3, 6, 9, 12]:
return None
else:
return location + 1


def _get_west_location(location: int) -> Optional[int]:
if location in [1, 4, 7, 10]:
return None
else:
return location - 1


def _get_east_west_locations(location: int) -> List[int]:
east = _get_east_location(location)
west = _get_west_location(location)
return [maybe_loc for maybe_loc in [east, west] if maybe_loc is not None]


def _get_north_south_locations(location: int) -> List[int]:
north = _get_north_location(location)
south = _get_south_location(location)
return [maybe_loc for maybe_loc in [north, south] if maybe_loc is not None]


def _get_adjacent_locations(location: int) -> List[int]:
return _get_east_west_locations(location) + _get_north_south_locations(location)
24 changes: 23 additions & 1 deletion api/tests/opentrons/protocols/geometry/test_deck_conflict.py
Original file line number Diff line number Diff line change
Expand Up @@ -320,9 +320,11 @@ def test_tip_rack_when_heater_shaker(
decoy.when(cool_tip_rack.load_name).then_return("cool_tip_rack")
decoy.when(cool_tip_rack.highest_z).then_return(11)
decoy.when(cool_tip_rack.get_uri()).then_return("test/cool_tip_rack/1")
decoy.when(cool_tip_rack.get_quirks()).then_return([])
decoy.when(lame_tip_rack.load_name).then_return("lame_tip_rack")
decoy.when(lame_tip_rack.highest_z).then_return(11)
decoy.when(lame_tip_rack.get_uri()).then_return("test/lame_tip_rack/1")
decoy.when(lame_tip_rack.get_quirks()).then_return([])

check(
existing_items={heater_shaker_location: heater_shaker},
Expand Down Expand Up @@ -362,7 +364,7 @@ def test_tip_rack_when_heater_shaker(
)


def test_no_heater_shaker_trash(decoy: Decoy) -> None:
def test_no_heater_shaker_west_of_trash(decoy: Decoy) -> None:
"""It should check that fixed trash does not conflict with heater-shaker."""
heater_shaker = decoy.mock(cls=HeaterShakerGeometry)
trash = decoy.mock(cls=Labware)
Expand All @@ -382,3 +384,23 @@ def test_no_heater_shaker_trash(decoy: Decoy) -> None:
),
):
check(existing_items={12: trash}, new_item=heater_shaker, new_location=11)


def test_no_heater_shaker_south_of_trash_(decoy: Decoy) -> None:
"""It should check that fixed trash does not conflict with heater-shaker."""
heater_shaker = decoy.mock(cls=HeaterShakerGeometry)
trash = decoy.mock(cls=Labware)

decoy.when(trash.load_name).then_return("some_fixed_trash")
decoy.when(trash.quirks).then_return(["fixedTrash"])

decoy.when(heater_shaker.load_name).then_return("some_heater_shaker")

with pytest.raises(
DeckConflictError,
match=(
"some_fixed_trash in slot 12"
" prevents some_heater_shaker from using slot 9"
),
):
check(existing_items={12: trash}, new_item=heater_shaker, new_location=9)