Skip to content

Commit e4ff49a

Browse files
feat(robot-server): start logic for dynamic error recovery policy (#15707)
<!-- Thanks for taking the time to open a pull request! Please make sure you've read the "Opening Pull Requests" section of our Contributing Guide: https://github.com/Opentrons/opentrons/blob/edge/CONTRIBUTING.md#opening-pull-requests To ensure your code is reviewed quickly and thoroughly, please fill out the sections below to the best of your ability! --> # Overview Create robot-server function that turns a List of ErrorRecoveryRules into a full ErrorRecoveryPolicy. A future PR will implement the HTTP calls that actually create the list of rules. EXEC-589 <!-- Use this section to describe your pull-request at a high level. If the PR addresses any open issues, please tag the issues here. --> # Test Plan <!-- Use this section to describe the steps that you took to test your Pull Request. If you did not perform any testing provide justification why. OT-3 Developers: You should default to testing on actual physical hardware. Once again, if you did not perform testing against hardware, justify why. Note: It can be helpful to write a test plan before doing development Example Test Plan (HTTP API Change) - Verified that new optional argument `dance-party` causes the robot to flash its lights, move the pipettes, then home. - Verified that when you omit the `dance-party` option the robot homes normally - Added protocol that uses `dance-party` argument to G-Code Testing Suite - Ran protocol that did not use `dance-party` argument and everything was successful - Added unit tests to validate that changes to pydantic model are correct --> # Changelog <!-- List out the changes to the code in this PR. Please try your best to categorize your changes and describe what has changed and why. Example changelog: - Fixed app crash when trying to calibrate an illegal pipette - Added state to API to track pipette usage - Updated API docs to mention only two pipettes are supported IMPORTANT: MAKE SURE ANY BREAKING CHANGES ARE PROPERLY COMMUNICATED --> # Review requests <!-- Describe any requests for your reviewers here. --> # Risk assessment <!-- Carefully go over your pull request and look at the other parts of the codebase it may affect. Look for the possibility, even if you think it's small, that your change may affect some other part of the system - for instance, changing return tip behavior in protocol may also change the behavior of labware calibration. Identify the other parts of the system your codebase may affect, so that in addition to your own review and testing, other people who may not have the system internalized as much as you can focus their attention and testing there. -->
1 parent 603dc2f commit e4ff49a

File tree

6 files changed

+240
-5
lines changed

6 files changed

+240
-5
lines changed

api/src/opentrons/protocol_engine/error_recovery_policy.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,8 @@ class ErrorRecoveryType(enum.Enum):
2828
WAIT_FOR_RECOVERY = enum.auto()
2929
"""Stop and wait for the error to be recovered from manually."""
3030

31-
# TODO(mm, 2023-03-18): Add something like this for
32-
# https://opentrons.atlassian.net/browse/EXEC-302.
33-
# CONTINUE = enum.auto()
34-
# """Continue with the run, as if the command never failed."""
31+
IGNORE_AND_CONTINUE = enum.auto()
32+
"""Continue with the run, as if the command never failed."""
3533

3634

3735
class ErrorRecoveryPolicy(Protocol):

api/src/opentrons/protocol_engine/state/commands.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -337,7 +337,10 @@ def _handle_fail_command_action(self, action: FailCommandAction) -> None:
337337
other_command_ids_to_fail = list(
338338
self._state.command_history.get_queue_ids()
339339
)
340-
elif action.type == ErrorRecoveryType.WAIT_FOR_RECOVERY:
340+
elif (
341+
action.type == ErrorRecoveryType.WAIT_FOR_RECOVERY
342+
or action.type == ErrorRecoveryType.IGNORE_AND_CONTINUE
343+
):
341344
other_command_ids_to_fail = []
342345
else:
343346
assert_never(action.type)
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
"""Functions used for managing error recovery policy."""
2+
from typing import Optional
3+
from opentrons.protocol_engine.state.config import Config
4+
from robot_server.runs.error_recovery_models import ErrorRecoveryRule, ReactionIfMatch
5+
from opentrons.protocol_engine.commands.command_unions import (
6+
Command,
7+
CommandDefinedErrorData,
8+
)
9+
from opentrons.protocol_engine.error_recovery_policy import (
10+
ErrorRecoveryPolicy,
11+
ErrorRecoveryType,
12+
standard_run_policy,
13+
)
14+
15+
16+
def create_error_recovery_policy_from_rules(
17+
rules: list[ErrorRecoveryRule],
18+
) -> ErrorRecoveryPolicy:
19+
"""Given a list of error recovery rules return an error recovery policy."""
20+
21+
def _policy(
22+
config: Config,
23+
failed_command: Command,
24+
defined_error_data: Optional[CommandDefinedErrorData],
25+
) -> ErrorRecoveryType:
26+
for rule in rules:
27+
for i, criteria in enumerate(rule.matchCriteria):
28+
command_type_matches = (
29+
failed_command.commandType == criteria.command.commandType
30+
)
31+
error_type_matches = (
32+
defined_error_data is not None
33+
and defined_error_data.public.errorType
34+
== criteria.command.error.errorType
35+
)
36+
if command_type_matches and error_type_matches:
37+
if rule.ifMatch[i] == ReactionIfMatch.IGNORE_AND_CONTINUE:
38+
raise NotImplementedError # No protocol engine support for this yet. It's in EXEC-302.
39+
elif rule.ifMatch[i] == ReactionIfMatch.FAIL_RUN:
40+
return ErrorRecoveryType.FAIL_RUN
41+
elif rule.ifMatch[i] == ReactionIfMatch.WAIT_FOR_RECOVERY:
42+
return ErrorRecoveryType.WAIT_FOR_RECOVERY
43+
44+
return standard_run_policy(config, failed_command, defined_error_data)
45+
46+
return _policy
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
"""Request and response models for dealing with error recovery policies."""
2+
from enum import Enum
3+
from pydantic import BaseModel, Field
4+
5+
6+
class ReactionIfMatch(Enum):
7+
"""The type of the error recovery setting.
8+
9+
* `"ignoreAndContinue"`: Ignore this error and future errors of the same type.
10+
* `"failRun"`: Errors of this type should fail the run.
11+
* `"waitForRecovery"`: Instances of this error should initiate a recover operation.
12+
13+
"""
14+
15+
IGNORE_AND_CONTINUE = "ignoreAndContinue"
16+
FAIL_RUN = "failRun"
17+
WAIT_FOR_RECOVERY = "waitForRecovery"
18+
19+
20+
# There's a lot of nested classes here. This is the JSON schema this code models.
21+
# "ErrorRecoveryRule": {
22+
# "matchCriteria": {
23+
# "command": {
24+
# "commandType": "foo",
25+
# "error": {
26+
# "errorType": "bar"
27+
# }
28+
# }
29+
# },
30+
# "ifMatch": "baz"
31+
# }
32+
33+
34+
class ErrorMatcher(BaseModel):
35+
"""The error type that this rule applies to."""
36+
37+
errorType: str = Field(..., description="The error type that this rule applies to.")
38+
39+
40+
class CommandMatcher(BaseModel):
41+
"""Command/error data used for matching rules."""
42+
43+
commandType: str = Field(
44+
..., description="The command type that this rule applies to."
45+
)
46+
error: ErrorMatcher = Field(
47+
..., description="The error details that this rule applies to."
48+
)
49+
50+
51+
class MatchCriteria(BaseModel):
52+
"""The criteria that this rule will attempt to match."""
53+
54+
command: CommandMatcher = Field(
55+
..., description="The command and error types that this rule applies to."
56+
)
57+
58+
59+
class ErrorRecoveryRule(BaseModel):
60+
"""Request/Response model for new error recovery rule creation."""
61+
62+
matchCriteria: list[MatchCriteria] = Field(
63+
default_factory=list,
64+
description="The criteria that must be met for this rule to be applied.",
65+
)
66+
ifMatch: list[ReactionIfMatch] = Field(
67+
default_factory=list,
68+
description="The specific recovery setting that will be in use if the type parameters match.",
69+
)

robot-server/robot_server/service/errors.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
# TODO(mc, 2021-05-10): delete this file; these models have been moved to
22
# robot_server/errors/error_responses.py and robot_server/errors/global_errors.py
3+
# Note: (2024-07-18): this file does not actually seem to be safe to delete
34
from dataclasses import dataclass, asdict
45
from enum import Enum
56
from typing import Any, Dict, Optional, Sequence, Tuple
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
"""Unit tests for `error_recovery_mapping`."""
2+
import pytest
3+
from decoy import Decoy
4+
5+
6+
from opentrons.protocol_engine.commands.pipetting_common import (
7+
LiquidNotFoundError,
8+
LiquidNotFoundErrorInternalData,
9+
)
10+
from opentrons.protocol_engine.commands.command import (
11+
DefinedErrorData,
12+
)
13+
from opentrons.protocol_engine.commands.command_unions import CommandDefinedErrorData
14+
from opentrons.protocol_engine.commands.liquid_probe import LiquidProbe
15+
from opentrons.protocol_engine.error_recovery_policy import ErrorRecoveryType
16+
from opentrons.protocol_engine.state.config import Config
17+
from opentrons.protocol_engine.types import DeckType
18+
from robot_server.runs.error_recovery_mapping import (
19+
create_error_recovery_policy_from_rules,
20+
)
21+
from robot_server.runs.error_recovery_models import (
22+
ErrorRecoveryRule,
23+
MatchCriteria,
24+
CommandMatcher,
25+
ErrorMatcher,
26+
ReactionIfMatch,
27+
)
28+
29+
30+
@pytest.fixture
31+
def mock_command(decoy: Decoy) -> LiquidProbe:
32+
"""Get a mock PickUpTip command."""
33+
mock = decoy.mock(cls=LiquidProbe)
34+
decoy.when(mock.commandType).then_return("liquidProbe")
35+
return mock
36+
37+
38+
@pytest.fixture
39+
def mock_error_data(decoy: Decoy) -> CommandDefinedErrorData:
40+
"""Get a mock TipPhysicallyMissingError."""
41+
mock = decoy.mock(
42+
cls=DefinedErrorData[LiquidNotFoundError, LiquidNotFoundErrorInternalData]
43+
)
44+
mock_lnfe = decoy.mock(cls=LiquidNotFoundError)
45+
decoy.when(mock.public).then_return(mock_lnfe)
46+
decoy.when(mock_lnfe.errorType).then_return("liquidNotFound")
47+
return mock
48+
49+
50+
@pytest.fixture
51+
def mock_criteria(decoy: Decoy) -> MatchCriteria:
52+
"""Get a mock Match Criteria."""
53+
mock = decoy.mock(cls=MatchCriteria)
54+
mock_command = decoy.mock(cls=CommandMatcher)
55+
decoy.when(mock_command.commandType).then_return("liquidProbe")
56+
mock_error_matcher = decoy.mock(cls=ErrorMatcher)
57+
decoy.when(mock_error_matcher.errorType).then_return("liquidNotFound")
58+
decoy.when(mock.command).then_return(mock_command)
59+
decoy.when(mock_command.error).then_return(mock_error_matcher)
60+
return mock
61+
62+
63+
@pytest.fixture
64+
def mock_rule(decoy: Decoy, mock_criteria: MatchCriteria) -> ErrorRecoveryRule:
65+
"""Get a mock ErrorRecoveryRule."""
66+
mock = decoy.mock(cls=ErrorRecoveryRule)
67+
decoy.when(mock.ifMatch).then_return([ReactionIfMatch.IGNORE_AND_CONTINUE])
68+
decoy.when(mock.matchCriteria).then_return([mock_criteria])
69+
return mock
70+
71+
72+
def test_create_error_recovery_policy_with_rules(
73+
decoy: Decoy,
74+
mock_command: LiquidProbe,
75+
mock_error_data: CommandDefinedErrorData,
76+
mock_rule: ErrorRecoveryRule,
77+
) -> None:
78+
"""Should return IGNORE_AND_CONTINUE if that's what we specify as the rule."""
79+
policy = create_error_recovery_policy_from_rules([mock_rule])
80+
exampleConfig = Config(
81+
robot_type="OT-3 Standard",
82+
deck_type=DeckType.OT3_STANDARD,
83+
)
84+
with pytest.raises(NotImplementedError):
85+
policy(exampleConfig, mock_command, mock_error_data)
86+
87+
88+
def test_create_error_recovery_policy_undefined_error(
89+
decoy: Decoy, mock_command: LiquidProbe
90+
) -> None:
91+
"""Should return a FAIL_RUN policy when error is not defined."""
92+
rule1 = ErrorRecoveryRule(matchCriteria=[], ifMatch=[])
93+
94+
policy = create_error_recovery_policy_from_rules([rule1])
95+
exampleConfig = Config(
96+
robot_type="OT-3 Standard",
97+
deck_type=DeckType.OT3_STANDARD,
98+
)
99+
100+
assert policy(exampleConfig, mock_command, None) == ErrorRecoveryType.FAIL_RUN
101+
102+
103+
def test_create_error_recovery_policy_defined_error(
104+
decoy: Decoy, mock_command: LiquidProbe, mock_error_data: CommandDefinedErrorData
105+
) -> None:
106+
"""Should return a WAIT_FOR_RECOVERY policy when error is defined."""
107+
rule1 = ErrorRecoveryRule(matchCriteria=[], ifMatch=[])
108+
109+
policy = create_error_recovery_policy_from_rules([rule1])
110+
exampleConfig = Config(
111+
robot_type="OT-3 Standard",
112+
deck_type=DeckType.OT3_STANDARD,
113+
)
114+
115+
assert (
116+
policy(exampleConfig, mock_command, mock_error_data)
117+
== ErrorRecoveryType.WAIT_FOR_RECOVERY
118+
)

0 commit comments

Comments
 (0)