-
Notifications
You must be signed in to change notification settings - Fork 179
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): create instr ctx methods for checking liquid presence #15555
Conversation
still need to test |
…ailure capture (#15556) This PR is an automated snapshot update request. Please review the changes and merge if they are acceptable or find your bug and fix it. Co-authored-by: aaron-kulkarni <[email protected]>
try: | ||
height = self._core.find_liquid_level(well._core) | ||
return float(height) | ||
except Exception: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think we should only explicity catch LiquidNotFoundException if we're going to handle them. If there is some other issue that results in an error this could let that go undetected
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Couple changes we should make to the API. Also, if you merge edge into this PR it should get rid of the snapshot problem.
) | ||
if ( | ||
result is None | ||
): # this should probably only happen in testing with mock components |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
No, we shouldn't do this. If we're using mock components, those mock components should return data in the same way the real things do even if they get it from somewhere else - this is why we have mocks.
:returns: None. | ||
""" | ||
if well is None: | ||
raise WellDoesNotExistError("Well type was none.") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
raise WellDoesNotExistError("Well type was none.") | |
raise WellDoesNotExistError("You must provide a well to check.") |
:returns: A float representing the height of the liquid. | ||
""" | ||
if well is None: | ||
raise WellDoesNotExistError() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this error should take the same form as the same check in the other methods
height = self._core.find_liquid_level(well._core) | ||
return float(height) | ||
except PipetteLiquidNotFoundError: | ||
return 0 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
is this really an appropriate return value? Remember, this function returns a height in absolute deck coordinates, in which z=0 is the level of the deck. So this function can sometimes return, without indicating an error, that the liquid level is at the height of the deck. That doesn't make much sense to me.
I think this function should just let the PipetteLiquidNotFoundError
through.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I agree we should pass this through. The only api call that shouldn't raise errors is detect_liquid
well_location = WellLocation( | ||
origin=WellOrigin.TOP, offset=WellOffset(x=0, y=0, z=0) | ||
) | ||
result = self._engine_client.execute_command_without_recovery( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
result = self._engine_client.execute_command_without_recovery( | |
result = self._engine_client.execute_command( |
we definitely want to recover from this command
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think the problem here is that one of this method's callers, InstrumentContext.get_liquid_height()
, is returning the probed height. And if the liquidProbe
command fails and we let the frontend drive error recovery, there isn't a probed height in the general case. The client can choose to skip to the next command without retrying the failed liquidProbe
.
We can certainly solve this for InstrumentContext.require_liquid()
. Just, yeah, call execute_command()
(with recovery) and discard the liquidProbe
result. A successful liquidProbe
, or a failed-but-recovered liquidProbe
, will return from execute_command()
. A failed-and-not-recovered liquidProbe
will raise from execute_command()
, and we should let that propagate up.
But for InstrumentContext.get_liquid_height()
and InstrumentContext.detect_liquid_presence()
, it's unclear to me what we would even want to happen.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Recap of in-person discussion. Looks like you've already done some of this by the time I'm posting this comment.
InstrumentContext.require_liquid_presence()
We want this method to give the client the opportunity for error recovery, but raise an exception if that ultimately fails. That's what SyncClient.execute_command()
, so under the hood, this should be implemented using that. If that raises an exception, we should just let it propagate.
InstrumentContext.detect_liquid_presence()
We want this method to straight-up return True
/False
without giving the client the opportunity for error recovery. So, under the hood:
-
We should implement it via
SyncClient.execute_command_without_recovery()
. -
If the probe detects no liquid,
execute_command_without_recovery()
will raise that as an exception. We should look for that and translate it into aFalse
return.We can catch
ProtocolCommandFailedError
, and then check ifexception.original_error
is an instance ofLiquidNotFoundError
. If so, we should translate it into aFalse
return. Otherwise, we should re-raise the exception to let it propagate.
I think this will expose a bug where the robot will mistakenly go into error recovery mode (because "the command failed") and try to keep running the protocol (because we're catching the exception, translating it to False
, and letting the protocol continue). We can take that on in a separate PR: EXEC-598.
InstrumentContext.measure_liquid_height()
We want to keep this internal and experimental for now and keep our options open for the future. So:
- We should say in its docstring that it's internal and experimental, and exclude it from the public docs.opentrons.com website. Here is an example of how to exclude it from docs.opentrons.com.
- We should implement this via
SyncClient.execute_command_without_recovery()
. So if no liquid is detected, it will raise an exception. We should just let that propagate.
The reason we want to keep this internal and experimental is that we haven't really fleshed out how to support error recovery on PAPI methods that return results. Does the UI flow need to have a step to retry the failed probe? Should we just raise some documented exception for the protocol author to catch and deal with how they please?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks, this is coming along! Agreed with the existing comments.
well_location = WellLocation( | ||
origin=WellOrigin.TOP, offset=WellOffset(x=0, y=0, z=0) | ||
) | ||
result = self._engine_client.execute_command_without_recovery( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think the problem here is that one of this method's callers, InstrumentContext.get_liquid_height()
, is returning the probed height. And if the liquidProbe
command fails and we let the frontend drive error recovery, there isn't a probed height in the general case. The client can choose to skip to the next command without retrying the failed liquidProbe
.
We can certainly solve this for InstrumentContext.require_liquid()
. Just, yeah, call execute_command()
(with recovery) and discard the liquidProbe
result. A successful liquidProbe
, or a failed-but-recovered liquidProbe
, will return from execute_command()
. A failed-and-not-recovered liquidProbe
will raise from execute_command()
, and we should let that propagate up.
But for InstrumentContext.get_liquid_height()
and InstrumentContext.detect_liquid_presence()
, it's unclear to me what we would even want to happen.
I implemented the changes that we discussed in standup as I understood them. Probably forgot something or misunderstood it so if I did just let me know Changes include: Also edited the tests to reflect this |
Codecov ReportAll modified and coverable lines are covered by tests ✅
Additional details and impacted files@@ Coverage Diff @@
## edge #15555 +/- ##
===========================================
+ Coverage 60.43% 92.43% +32.00%
===========================================
Files 154 77 -77
Lines 12119 1283 -10836
===========================================
- Hits 7324 1186 -6138
+ Misses 4795 97 -4698
Flags with carried forward coverage won't be shown. Click here to find out more. |
Requested changes have been implemented
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
lgtm
def execute_command_with_result( | ||
self, params: commands.CommandParams | ||
) -> Optional[commands.CommandResult]: | ||
"""Execute a ProtocolEngine command, including error recovery, and return a result. | ||
|
||
See `ChildThreadTransport.execute_command_wait_for_recovery()` for exact | ||
behavior. | ||
""" | ||
CreateType = CREATE_TYPES_BY_PARAMS_TYPE[type(params)] | ||
create_request = CreateType(params=cast(Any, params)) | ||
result = self._transport.execute_command_wait_for_recovery(create_request) | ||
if result.error is None: | ||
return result.result | ||
if isinstance(result.error, BaseException): # necessary to pass lint | ||
raise result.error | ||
raise ProtocolCommandFailedError( | ||
original_error=result.error, | ||
message=f"{result.error.errorType}: {result.error.detail}", | ||
) | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think we should remove this new execute_command_with_result()
method for now. It seems like it's contributing to confusion about the tradeoff between returning a result and allowing for error recovery.
Thankfully, we shouldn't need it for the behavior that we want to implement right now. See my other comment.
well_location = WellLocation( | ||
origin=WellOrigin.TOP, offset=WellOffset(x=0, y=0, z=0) | ||
) | ||
result = self._engine_client.execute_command_without_recovery( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Recap of in-person discussion. Looks like you've already done some of this by the time I'm posting this comment.
InstrumentContext.require_liquid_presence()
We want this method to give the client the opportunity for error recovery, but raise an exception if that ultimately fails. That's what SyncClient.execute_command()
, so under the hood, this should be implemented using that. If that raises an exception, we should just let it propagate.
InstrumentContext.detect_liquid_presence()
We want this method to straight-up return True
/False
without giving the client the opportunity for error recovery. So, under the hood:
-
We should implement it via
SyncClient.execute_command_without_recovery()
. -
If the probe detects no liquid,
execute_command_without_recovery()
will raise that as an exception. We should look for that and translate it into aFalse
return.We can catch
ProtocolCommandFailedError
, and then check ifexception.original_error
is an instance ofLiquidNotFoundError
. If so, we should translate it into aFalse
return. Otherwise, we should re-raise the exception to let it propagate.
I think this will expose a bug where the robot will mistakenly go into error recovery mode (because "the command failed") and try to keep running the protocol (because we're catching the exception, translating it to False
, and letting the protocol continue). We can take that on in a separate PR: EXEC-598.
InstrumentContext.measure_liquid_height()
We want to keep this internal and experimental for now and keep our options open for the future. So:
- We should say in its docstring that it's internal and experimental, and exclude it from the public docs.opentrons.com website. Here is an example of how to exclude it from docs.opentrons.com.
- We should implement this via
SyncClient.execute_command_without_recovery()
. So if no liquid is detected, it will raise an exception. We should just let that propagate.
The reason we want to keep this internal and experimental is that we haven't really fleshed out how to support error recovery on PAPI methods that return results. Does the UI flow need to have a step to retry the failed probe? Should we just raise some documented exception for the protocol author to catch and deal with how they please?
def measure_liquid_height(self, well: labware.Well) -> float: | ||
"""Check the height of the liquid within a well. | ||
|
||
:returns: The height, in mm, of the liquid from the deck. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For API usability, I think we would ideally have this return the height from the bottom of the well, instead of from the deck. That seems more relevant because it wouldn't be coupled to the labware offsets that the operator happened to choose at the beginning of the run.
This doesn't need to happen right now as long as we're marking this method internal and experimental.
# should never get here | ||
raise PipetteLiquidNotFoundError( | ||
"Error while trying to find liquid level.", | ||
) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this gets a lot cleaner if you split this into two methods:
- One that does
SyncClient.execute_command()
. - One that does
SyncClient.execute_command_without_recovery()
.
SyncClient
is drawing a distinction those two fundamentally modes of operation, representing a tradeoff between having a command result and allowing for error recovery, and representing that tradeoff with two different method signatures. Those map cleanly to the public InstrumentContext
behaviors that we want to implement, if we maintain the two-method distinction through InstrumentCore
.
<!-- 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 Edit detect_liquid_presence, require_liquid_presence, and measure_liquid_height as per the standup today. Change instrument_context tests to check for the right error(LiquidNotFoundError) Split find_liquid_level into two functions: liquid_probe_with_recovery and liquid_probe_without_recovery <!-- 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 --> Added unit tests for liquid_probe_with_recovery and liquid_probe_without recovery to test_instrument_core.py Added unit tests for detect_liquid_presence, require_liquid_presence, and measure_liquid_height to test_instrument_context.py After the PR is merged, these API calls will be tested on the robot. # 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. --> Accidentally merged #15555 too soon, this is a continuation of that PR. # 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. --> Low: Since all the work was done in newly created functions, the risk that it breaks existing code is minimal. However, the new API calls still do need to be tested on a robot before they can be used in other parts of the system.
Added detect_liquid and require_liquid to instrument_context. The two methods both check for liquid in a specific well, but one returns true/false and the other raises exceptions.
Also adds measure_liquid_level to instrument context. This method probes the well to find the height of the liquid.
close EXEC-544, EXEC-545, EXEC-546
Overview
Test Plan
Changelog
Review requests
In find_liquid_level in the instrument core, I couldn't find a method that would execute the command with recovery and also return the result of the command.
So I settled for execute_command_without_recovery but I'm not sure if that's the right way to go about it.I created a new method called execute_command_with_result to get around this.Risk assessment