-
Notifications
You must be signed in to change notification settings - Fork 176
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): add liquid sensing functionality #11926
Conversation
Codecov Report
@@ Coverage Diff @@
## edge #11926 +/- ##
==========================================
+ Coverage 74.03% 74.05% +0.01%
==========================================
Files 2172 2172
Lines 59831 59890 +59
Branches 6145 6145
==========================================
+ Hits 44297 44351 +54
- Misses 14151 14156 +5
Partials 1383 1383
Flags with carried forward coverage won't be shown. Click here to find out more.
|
23e5648
to
a632aca
Compare
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 that we should take a step back and consider what we want out of this API.
The goal is that
- Assuming
- the pipette is already in the correct XY position
- Given
- a suspected nominal location for the top of the liquid
- a range of possibilities for the height of the liquid
- a pipette (with a tip) to use to check the height of the liquid
- Determine
- either the height of the liquid
- or that the height of the liquid cannot be found
To me, that means that we need to change the signature of this function somewhat. For instance
- It should return the height of the liquid in deck coordinates
- It should take a range of possibilities for the height of the liquid
- it should perform hardware state safety checks
It should figure out on its own where to move the pipette in z to determine this. We don't want the hardware control layer to have to understand things about labware, so we should rely on higher layers to tell us information about where the liquid will probably be found. For instance, the caller can give us maximum and minimum absolute heights in deck coordinates corresponding to the top and bottom of the well being tested. This function can then use its configuration to move to an appropriate position above the top of the range and move to an appropriate position around the bottom of the range. That probably means we'll have to change the config to contain things like a margin above the top of the liquid that is required for the move to spool up, and a margin below the bottom of the liquid that is required to safely stop, rather than absolute positions. The hardware control layer in ot3api
can then determine the distance to move the pipette and the mount.
It should also perform its own safety checks. For instance, it should raise an exception if a tip isn't present, because when it moves around it will need to do so based on the length of the tip. It should check that the aspirated volume is 0, since it'll need to raise the plunger to prepare, which would be dangerous if the tip contained liquid.
We probably may also want this configuration to live in pipette settings, since pressures at least will likely be very different based on pressure chamber and probably tip geometry, but that could be in a follow up PR.
@@ -628,6 +632,16 @@ async def watch(self, loop: asyncio.AbstractEventLoop) -> None: | |||
while can_watch and (not self._event_watcher.closed): | |||
await self._handle_watch_event() | |||
|
|||
def get_slot_center_pos(self, slot_num: int) -> OT3AxisMap[float]: |
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 doesn't seem to be used and doesn't really belong in this layer anyway, maybe get rid of it?
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.
^ bump here, this needs to be removed.
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.
bump
if NodeId.pipette_left in movers or NodeId.pipette_right in movers: | ||
primary_mover = [ax for ax in movers if ax in [NodeId.head_l, NodeId.head_r]][0] | ||
else: | ||
primary_mover = movers[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.
the time should be the same for all the movers, right? so we could always just do this
4e8abd1
to
864756e
Compare
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 is awesome and getting there! Couple more things we should change in the hardware control layer.
) | ||
except MoveConditionNotMet: | ||
self._log.exception("Liquid Sensing failed- threshold never reached.") | ||
self._current_position.clear() |
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.
do we need to clear the position? the liquid sensing part failed, sure, we don't know where the liquid is - but we certainly should still know where the pipette is.
self._log.exception( | ||
"Liquid Sensing failed- threshold reached too early." | ||
) | ||
self._current_position.clear() |
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.
same question with this clear position - we still know where the pipette is, even if we don't know where the liquid is
) | ||
self._encoder_current_position.update(encoder_position) | ||
|
||
await self.home_plunger(mount) |
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.
here, do we need to home (meaning that we don't know the position) or is this more of a park (just getting everything out of the way)? I think it might be a good idea to not home the pipette, because frequently you're gonna want to
- sense liquid height
- move the pipette clear of the liquid
- prep the plunger for aspiration
- move the pipette down to the liquid
- aspirate
Homing or parking the mount isn't really necessary and just sort of wastes time.
What might be a good idea instead is recovering up to the prep position specified by the liquid sense settings and then homing the plunger.
a ThresholdReachedTooEarly error will be thrown. | ||
|
||
Otherwise, the function will stop moving once the threshold is triggered, | ||
home the mount and plunger to avoid collision, and return the height at |
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.
let's avoid using words like "height" here - this is the z position, in deck coordinates, in mm, of the position of the top of the liquid.
self._current_position.clear() | ||
raise | ||
else: | ||
final_mount_pos_um: float = positions[head_node_for_mount(mount)][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.
I think this section has some unit confusion. Is this value really in micrometers? or is it converted to millimeters by opentrons_hardware
? if it is in um, then you definitely can't directly subtract the starting mount height from it, since that's in mm, and you should convert it back into mm before returning it. If it's in mm, then you should change the variable name.
362610b
to
768be59
Compare
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.
Mostly cleanup requests. Looking good in general.
log_pressure=True, | ||
home_plunger_at_start=False, | ||
aspirate_while_sensing=False, | ||
read_only=False, |
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.
what's the difference between log_pressure
and read_only
? To me they mean essentially the same thing. We should pick only one config to keep here.
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.
read_only
sets the sensor to report
instead of sync
, so someone who uses this can just take a bunch of readings without necessarily stopping or having an error thrown. I put this in here so the testing team can use it, I think it can be restructured to be clearer though
DEFAULT_LIQUID_PROBE_SETTINGS: Final[LiquidProbeSettings] = LiquidProbeSettings( | ||
starting_mount_height=100, | ||
prep_move_speed=10, | ||
max_z_distance=40, |
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.
Let's keep the max/min z for now, but can we put a comment that we may want to think a little more about how we can cleanly handle triggering "early" or triggering "late"
@@ -347,7 +351,7 @@ def _build_home_pipettes_runner( | |||
) | |||
|
|||
distances_pipette = { | |||
ax: -1 * self.axis_bounds[ax][1] - self.axis_bounds[ax][0] | |||
ax: -1 * self.phony_bounds[ax][1] - self.phony_bounds[ax][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.
Why do we have this change?
@@ -628,6 +632,16 @@ async def watch(self, loop: asyncio.AbstractEventLoop) -> None: | |||
while can_watch and (not self._event_watcher.closed): | |||
await self._handle_watch_event() | |||
|
|||
def get_slot_center_pos(self, slot_num: int) -> OT3AxisMap[float]: |
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.
^ bump here, this needs to be removed.
@property | ||
def axis_bounds(self) -> OT3AxisMap[Tuple[float, float]]: | ||
"""Get the axis bounds.""" | ||
# TODO (CM): gripper axis bounds need to be defined | ||
return { |
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.
What is the purpose of this change?
Can we put it in a separate PR if it's unrelated to liquid sensing?
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.
also wonder this
@@ -27,16 +43,167 @@ | |||
ProbeTarget = Union[Literal[NodeId.pipette_left, NodeId.pipette_right, NodeId.gripper]] | |||
|
|||
|
|||
def _build_pass_step(mover: NodeId, distance: float, speed: float) -> MoveGroupStep: | |||
class SensorLog: | |||
"""Capture incoming sensor messages.""" |
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.
Can we put this class in a better place, like a utils.py
or something?
) | ||
binding.append(SensorOutputBinding.report) | ||
|
||
async with sensor_driver.bind_output(messenger, pressure_sensor, binding): |
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.
nice, much cleaner :D
stop_threshold=threshold_fixed_point, | ||
) | ||
|
||
if read_only: |
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.
yea not quite sure what this read_only
stuff is for? If they want standalone pressure readings, they should use the capture_sensor script or whatever it's called rather than this function.
c3715bf
to
47ba406
Compare
07c68bc
to
18986b3
Compare
6758857
to
a86fb9b
Compare
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 is looking really strong! There's some stuff to fix up still but it's all details that don't affect higher level functionality.
@@ -628,6 +632,16 @@ async def watch(self, loop: asyncio.AbstractEventLoop) -> None: | |||
while can_watch and (not self._event_watcher.closed): | |||
await self._handle_watch_event() | |||
|
|||
def get_slot_center_pos(self, slot_num: int) -> OT3AxisMap[float]: |
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.
bump
@property | ||
def axis_bounds(self) -> OT3AxisMap[Tuple[float, float]]: | ||
"""Get the axis bounds.""" | ||
# TODO (CM): gripper axis bounds need to be defined | ||
return { |
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.
also wonder this
plunger_speed: float, | ||
threshold_pascals: float, | ||
starting_mount_height: float, | ||
prep_move_speed: float, |
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.
agree with the starting_mount_height
thing, and this is part of that too right? We should pull that out
@@ -437,24 +454,47 @@ async def watch(self, loop: asyncio.AbstractEventLoop) -> None: | |||
] | |||
await self.module_controls.register_modules(new_mods_at_ports=new_mods_at_ports) | |||
|
|||
def get_slot_center_pos(self, slot_num: int) -> OT3AxisMap[float]: |
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 should go away as it should in ot3controller
@property | ||
def axis_bounds(self) -> OT3AxisMap[Tuple[float, float]]: | ||
"""Get the axis bounds.""" |
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.
Still don't quite get the point of this change
30fed4b
to
90ebf12
Compare
probe_settings.sensor_threshold_pascals, | ||
probe_settings.log_pressure, | ||
) | ||
position = deck_from_machine( |
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'm adding a call to axis_convert
in here since deck_from_machine
wants an AxisType
dict and not NodeId
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.
BEAUTIFUL! Excited to get this in.
cd41b39
to
daea942
Compare
a12cad7
to
2b3522c
Compare
468a34b
to
58e821a
Compare
This pr implements a liquid-level sensing function in
ot3api.py
. This function takes in a mount, and a set ofLiquidProbeSettings
, which can be used to adjust various details of the process.The mount first moves down to the
starting_mount_height
, to move the pipette tip close enough to the surface of the liquid to start reading. Then, it will set the pressure sensor's threshold for considering liquid to be found, and use thebind_output
context manager to trigger the sync line the first time that pressure threshold is detected by the sensor. After this, the specified mount will move down toward the liquid's surface, while the pressure sensor is continuously reading, until the pipette tip touches the liquid. The pipette plunger needs to be moving simultaneously while the z motor is moving to maintain the internal pressure of the pipette and avoid triggering the sync line prematurely. This is done in the "blow-out" direction by default, but can be reversed with theaspirate_while_sensing
parameter. The velocities of the mount and plunger during this move can also be specified in theLiquidProbeSettings
.To avoid crashing, only a given range of distances in the z direction will be accepted for liquid sensing. If the given
max_z_distance
is exceeded ormin_z_distance
not reached before the sync line is triggered, aLiquidNotFound
orEarlyLiquidSenseTrigger
error will be thrown after the move is completed.The creation of the
LogListener
class allows all of the sensor readings received to be plotted in a csv file against time, with relevant metadata being stored in the first row. For liquid sensing, this option is represented by thelog_pressure
parameter.