Skip to content

Commit

Permalink
feat(hardware): allow variable number of tip presence messages (#13478)
Browse files Browse the repository at this point in the history
  • Loading branch information
caila-marashaj authored Sep 13, 2023
1 parent 81bd611 commit de9e42c
Show file tree
Hide file tree
Showing 12 changed files with 128 additions and 49 deletions.
30 changes: 23 additions & 7 deletions api/src/opentrons/hardware_control/backends/ot3controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,7 @@
from opentrons_shared_data.errors.exceptions import (
EStopActivatedError,
EStopNotPresentError,
UnmatchedTipPresenceStates,
)

from .subsystem_manager import SubsystemManager
Expand Down Expand Up @@ -835,18 +836,33 @@ async def get_limit_switches(self) -> OT3AxisMap[bool]:
res = await get_limit_switches(self._messenger, motor_nodes)
return {node_to_axis(node): bool(val) for node, val in res.items()}

async def get_tip_present(self, mount: OT3Mount, tip_state: TipStateType) -> None:
async def check_for_tip_presence(
self,
mount: OT3Mount,
tip_state: TipStateType,
expect_multiple_responses: bool = False,
) -> None:
"""Raise an error if the expected tip state does not match the current state."""
res = await self.get_tip_present_state(mount)
res = await self.get_tip_present_state(mount, expect_multiple_responses)
if res != tip_state.value:
raise FailedTipStateCheck(tip_state, res)

async def get_tip_present_state(self, mount: OT3Mount) -> int:
async def get_tip_present_state(
self,
mount: OT3Mount,
expect_multiple_responses: bool = False,
) -> bool:
"""Get the state of the tip ejector flag for a given mount."""
res = await get_tip_ejector_state(
self._messenger, sensor_node_for_mount(OT3Mount(mount.value)) # type: ignore
)
return res
expected_responses = 2 if expect_multiple_responses else 1
node = sensor_node_for_mount(OT3Mount(mount.value))
assert node != NodeId.gripper
res = await get_tip_ejector_state(self._messenger, node, expected_responses) # type: ignore[arg-type]
vals = list(res.values())
if not all([r == vals[0] for r in vals]):
states = {int(sensor): res[sensor] for sensor in res}
raise UnmatchedTipPresenceStates(states)
tip_present_state = bool(vals[0])
return tip_present_state

@staticmethod
def _tip_motor_nodes(axis_current_keys: KeysView[Axis]) -> List[NodeId]:
Expand Down
11 changes: 9 additions & 2 deletions api/src/opentrons/hardware_control/backends/ot3simulator.py
Original file line number Diff line number Diff line change
Expand Up @@ -383,11 +383,18 @@ async def gripper_hold_jaw(
_ = create_gripper_jaw_hold_group(encoder_position_um)
self._encoder_position[NodeId.gripper_g] = encoder_position_um / 1000.0

async def get_tip_present(self, mount: OT3Mount, tip_state: TipStateType) -> None:
async def check_for_tip_presence(
self,
mount: OT3Mount,
tip_state: TipStateType,
expect_multiple_responses: bool = False,
) -> None:
"""Raise an error if the given state doesn't match the physical state."""
pass

async def get_tip_present_state(self, mount: OT3Mount) -> int:
async def get_tip_present_state(
self, mount: OT3Mount, expect_multiple_responses: bool = False
) -> bool:
"""Get the state of the tip ejector flag for a given mount."""
pass

Expand Down
6 changes: 3 additions & 3 deletions api/src/opentrons/hardware_control/ot3api.py
Original file line number Diff line number Diff line change
Expand Up @@ -1926,7 +1926,7 @@ async def pick_up_tip(
self.gantry_load != GantryLoad.HIGH_THROUGHPUT
and ff.tip_presence_detection_enabled()
):
await self._backend.get_tip_present(realmount, TipStateType.PRESENT)
await self._backend.check_for_tip_presence(realmount, TipStateType.PRESENT)

_add_tip_to_instrs()

Expand Down Expand Up @@ -2001,7 +2001,7 @@ async def drop_tip(
self.gantry_load != GantryLoad.HIGH_THROUGHPUT
and ff.tip_presence_detection_enabled()
):
await self._backend.get_tip_present(realmount, TipStateType.ABSENT)
await self._backend.check_for_tip_presence(realmount, TipStateType.ABSENT)

# home mount axis
if home_after:
Expand Down Expand Up @@ -2066,7 +2066,7 @@ async def get_instrument_state(
# this function with additional state (such as critical points)
realmount = OT3Mount.from_mount(mount)
res = await self._backend.get_tip_present_state(realmount)
pipette_state_for_mount: PipetteStateDict = {"tip_detected": bool(res)}
pipette_state_for_mount: PipetteStateDict = {"tip_detected": res}
return pipette_state_for_mount

def reset_instrument(
Expand Down
2 changes: 1 addition & 1 deletion api/src/opentrons/hardware_control/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -637,7 +637,7 @@ class FailedTipStateCheck(RuntimeError):
"""Error raised if the tip ejector state does not match the expected value."""

def __init__(self, tip_state_type: TipStateType, actual_state: int) -> None:
"""Iniitialize FailedTipStateCheck error."""
"""Initialize FailedTipStateCheck error."""
super().__init__(
f"Failed to correctly determine tip state for tip {str(tip_state_type)} "
f"received {bool(actual_state)} but expected {bool(tip_state_type.value)}"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1068,16 +1068,16 @@ async def test_monitor_pressure(
@pytest.mark.parametrize(
"tip_state_type, mocked_ejector_response, expectation",
[
[TipStateType.PRESENT, 1, does_not_raise()],
[TipStateType.ABSENT, 0, does_not_raise()],
[TipStateType.PRESENT, 0, pytest.raises(FailedTipStateCheck)],
[TipStateType.ABSENT, 1, pytest.raises(FailedTipStateCheck)],
[TipStateType.PRESENT, {0: 1, 1: 1}, does_not_raise()],
[TipStateType.ABSENT, {0: 0, 1: 0}, does_not_raise()],
[TipStateType.PRESENT, {0: 0, 1: 0}, pytest.raises(FailedTipStateCheck)],
[TipStateType.ABSENT, {0: 1, 1: 1}, pytest.raises(FailedTipStateCheck)],
],
)
async def test_get_tip_present(
controller: OT3Controller,
tip_state_type: TipStateType,
mocked_ejector_response: int,
mocked_ejector_response: Dict[int, int],
expectation: ContextManager[None],
) -> None:
mount = OT3Mount.LEFT
Expand All @@ -1086,7 +1086,7 @@ async def test_get_tip_present(
return_value=mocked_ejector_response,
):
with expectation:
await controller.get_tip_present(mount, tip_state_type)
await controller.check_for_tip_presence(mount, tip_state_type)


@pytest.mark.parametrize(
Expand Down
4 changes: 2 additions & 2 deletions api/tests/opentrons/hardware_control/test_ot3_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -1695,8 +1695,8 @@ async def test_tip_presence_disabled_ninety_six_channel(
# TODO remove this check once we enable tip presence for 96 chan.
with patch.object(
ot3_hardware.managed_obj._backend,
"get_tip_present",
AsyncMock(spec=ot3_hardware.managed_obj._backend.get_tip_present),
"check_for_tip_presence",
AsyncMock(spec=ot3_hardware.managed_obj._backend.check_for_tip_presence),
) as tip_present:
pipette_config = load_pipette_data.load_definition(
PipetteModelType("p1000"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1002,7 +1002,7 @@ async def _jog(_step: float) -> None:
async def _matches_state(_state: TipStateType) -> bool:
try:
await asyncio.sleep(0.2)
await api._backend.get_tip_present(mount, _state)
await api._backend.check_for_tip_presence(mount, _state)
return True
except FailedTipStateCheck:
return False
Expand Down
60 changes: 38 additions & 22 deletions hardware/opentrons_hardware/hardware_control/tip_presence.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,53 +3,69 @@
import logging

from typing_extensions import Literal
from typing import Union, Dict

from opentrons_shared_data.errors.exceptions import CommandTimedOutError

from opentrons_hardware.firmware_bindings.arbitration_id import ArbitrationId

from opentrons_hardware.firmware_bindings.messages.messages import MessageDefinition
from opentrons_hardware.drivers.can_bus.can_messenger import CanMessenger
from opentrons_hardware.drivers.can_bus.can_messenger import (
CanMessenger,
MultipleMessagesWaitableCallback,
WaitableCallback,
)
from opentrons_hardware.firmware_bindings.messages.message_definitions import (
TipStatusQueryRequest,
PushTipPresenceNotification,
)

from opentrons_hardware.firmware_bindings.constants import MessageId, NodeId
from opentrons_hardware.firmware_bindings.constants import MessageId, NodeId, SensorId

log = logging.getLogger(__name__)


async def get_tip_ejector_state(
can_messenger: CanMessenger,
node: Literal[NodeId.pipette_left, NodeId.pipette_right],
) -> int:
expected_responses: Union[Literal[1], Literal[2]],
timeout: float = 1.0,
) -> Dict[SensorId, int]:
"""Get the state of the tip presence interrupter.
When the tip ejector flag is occuluded, then we
know that there is a tip on the pipette.
"""
tip_ejector_state = 0

event = asyncio.Event()

def _listener(message: MessageDefinition, arb_id: ArbitrationId) -> None:
nonlocal tip_ejector_state
if isinstance(message, PushTipPresenceNotification):
event.set()
tip_ejector_state = message.payload.ejector_flag_status.value

def _filter(arbitration_id: ArbitrationId) -> bool:
return (NodeId(arbitration_id.parts.originating_node_id) == node) and (
MessageId(arbitration_id.parts.message_id)
== MessageId.tip_presence_notification
)

can_messenger.add_listener(_listener, _filter)
await can_messenger.send(node_id=node, message=TipStatusQueryRequest())
async def gather_responses(
reader: WaitableCallback,
) -> Dict[SensorId, int]:
data: Dict[SensorId, int] = {}
async for response, _ in reader:
assert isinstance(response, PushTipPresenceNotification)
tip_ejector_state = response.payload.ejector_flag_status.value
data[SensorId(response.payload.sensor_id.value)] = tip_ejector_state
return data

with MultipleMessagesWaitableCallback(
can_messenger,
_filter,
number_of_messages=expected_responses,
) as _reader:
await can_messenger.send(node_id=node, message=TipStatusQueryRequest())
try:

try:
await asyncio.wait_for(event.wait(), 1.0)
except asyncio.TimeoutError:
log.error("tip ejector state request timed out before expected nodes responded")
finally:
can_messenger.remove_listener(_listener)
return tip_ejector_state
data_dict = await asyncio.wait_for(
gather_responses(_reader),
timeout,
)
except asyncio.TimeoutError as te:
msg = f"Tip presence poll of {node} timed out"
log.warning(msg)
raise CommandTimedOutError(message=msg) from te
return data_dict
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
"""Tests for reading the current status of the tip presence photointerrupter."""
import pytest
from mock import AsyncMock

from typing import List, Tuple, cast
Expand All @@ -15,6 +16,7 @@
from opentrons_hardware.firmware_bindings.messages.fields import SensorIdField
from opentrons_hardware.firmware_bindings.utils import UInt8Field
from opentrons_hardware.firmware_bindings.constants import NodeId, SensorId
from opentrons_shared_data.errors.exceptions import CommandTimedOutError
from tests.conftest import CanLoopback


Expand Down Expand Up @@ -46,7 +48,9 @@ def responder(
message_send_loopback.add_responder(responder)

res = await get_tip_ejector_state(
mock_messenger, cast(Literal[NodeId.pipette_left, NodeId.pipette_right], node)
mock_messenger,
cast(Literal[NodeId.pipette_left, NodeId.pipette_right], node),
1,
)

# We should have sent a request
Expand All @@ -61,7 +65,10 @@ async def test_tip_ejector_state_times_out(mock_messenger: AsyncMock) -> None:
"""Test that a timeout is handled."""
node = NodeId.pipette_left

res = await get_tip_ejector_state(
mock_messenger, cast(Literal[NodeId.pipette_left, NodeId.pipette_right], node)
)
assert not res
with pytest.raises(CommandTimedOutError):
res = await get_tip_ejector_state(
mock_messenger,
cast(Literal[NodeId.pipette_left, NodeId.pipette_right], node),
1,
)
assert not res
4 changes: 4 additions & 0 deletions shared-data/errors/definitions/1/errors.json
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,10 @@
"detail": "Misaligned Gantry",
"category": "roboticsControlError"
},
"2012": {
"detail": "Unmatched tip presence states",
"category": "roboticsControlError"
},
"3000": {
"detail": "A robotics interaction error occurred.",
"category": "roboticsInteractionError"
Expand Down
1 change: 1 addition & 0 deletions shared-data/python/opentrons_shared_data/errors/codes.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ class ErrorCodes(Enum):
EARLY_CAPACITIVE_SENSE_TRIGGER = _code_from_dict_entry("2009")
INACCURATE_NON_CONTACT_SWEEP = _code_from_dict_entry("2010")
MISALIGNED_GANTRY = _code_from_dict_entry("2011")
UNMATCHED_TIP_PRESENCE_STATES = _code_from_dict_entry("2012")
ROBOTICS_INTERACTION_ERROR = _code_from_dict_entry("3000")
LABWARE_DROPPED = _code_from_dict_entry("3001")
LABWARE_NOT_PICKED_UP = _code_from_dict_entry("3002")
Expand Down
28 changes: 28 additions & 0 deletions shared-data/python/opentrons_shared_data/errors/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -528,6 +528,34 @@ def __init__(
)


class UnmatchedTipPresenceStates(RoboticsControlError):
"""An error indicating that a tip presence check resulted in two differing responses."""

def __init__(
self,
states: Dict[int, int],
detail: Optional[Dict[str, Any]] = None,
wrapping: Optional[Sequence[EnumeratedError]] = None,
) -> None:
"""Build an UnmatchedTipPresenceStatesError."""
format_tip_state = {0: "not detected", 1: "detected"}
msg = (
"Received two differing tip presence statuses:"
"\nRear Sensor tips"
+ format_tip_state[states[0]]
+ "\nFront Sensor tips"
+ format_tip_state[states[1]]
)
if detail:
msg += str(detail)
super().__init__(
ErrorCodes.UNMATCHED_TIP_PRESENCE_STATES,
msg,
detail,
wrapping,
)


class LabwareDroppedError(RoboticsInteractionError):
"""An error indicating that the gripper dropped labware it was holding."""

Expand Down

0 comments on commit de9e42c

Please sign in to comment.