Skip to content

Commit

Permalink
refactor(api): Make pipette get tip length from tip rack
Browse files Browse the repository at this point in the history
Closes #2271
  • Loading branch information
btmorr committed Nov 5, 2018
1 parent 8e7530c commit d105886
Show file tree
Hide file tree
Showing 7 changed files with 139 additions and 28 deletions.
8 changes: 6 additions & 2 deletions api/src/opentrons/hardware_control/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -671,7 +671,11 @@ async def blow_out(self, mount):
this_pipette.set_current_volume(0)

@_log_call
async def pick_up_tip(self, mount, presses: int = 3, increment: float = 1):
async def pick_up_tip(self,
mount,
tip_length: float,
presses: int = 3,
increment: float = 1):
"""
Pick up tip from current location
"""
Expand Down Expand Up @@ -700,7 +704,7 @@ async def pick_up_tip(self, mount, presses: int = 3, increment: float = 1):
# move nozzle back up
backup_pos = top_types.Point(0, 0, -dist)
await self.move_rel(mount, backup_pos)
instr.add_tip()
instr.add_tip(tip_length=tip_length)
instr.set_current_volume(0)

# neighboring tips tend to get stuck in the space between
Expand Down
45 changes: 32 additions & 13 deletions api/src/opentrons/hardware_control/pipette.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ def __init__(self, model: str, pipette_id: str = None) -> None:
self._config = pipette_config.load(model)
self._name = model
self._current_volume = 0.0
self._has_tip = False
self._current_tip_length = 0.0
self._pipette_id = pipette_id

@property
Expand All @@ -37,19 +37,25 @@ def pipette_id(self) -> Optional[str]:

@property
def critical_point(self) -> Point:
""" The vector from the pipette's origin to its critical point """
if not self.has_tip:
return Point(*self.config.model_offset)
else:
return Point(self.config.model_offset[0],
self.config.model_offset[1],
self.config.model_offset[2] - self.config.tip_length)
"""
The vector from the pipette's origin to its critical point. The
critical point for a pipette is the end of the nozzle if no tip is
attached, or the end of the tip if a tip is attached.
"""
return Point(self.config.model_offset[0],
self.config.model_offset[1],
self.config.model_offset[2] - self.current_tip_length)

@property
def current_volume(self) -> float:
""" The amount of liquid currently aspirated """
return self._current_volume

@property
def current_tip_length(self) -> float:
""" The length of the current tip attached (0.0 if no tip) """
return self._current_tip_length

@property
def available_volume(self) -> float:
""" The amount of liquid possible to aspirate """
Expand All @@ -71,17 +77,30 @@ def remove_current_volume(self, volume_incr: float):
def ok_to_add_volume(self, volume_incr: float) -> bool:
return self.current_volume + volume_incr <= self.config.max_volume

def add_tip(self):
def add_tip(self, tip_length) -> None:
"""
Add a tip to the pipette for position tracking and validation
(effectively updates the pipette's critical point)
:param tip_length: a positive, non-zero float representing the distance
in Z from the end of the pipette nozzle to the end of the tip
:return:
"""
assert tip_length > 0.0, "tip_length must be greater than 0"
assert not self.has_tip
self._has_tip = True
self._current_tip_length = tip_length

def remove_tip(self):
def remove_tip(self) -> None:
"""
Remove the tip from the pipette (effectively updates the pipette's
critical point)
"""
assert self.has_tip
self._has_tip = False
self._current_tip_length = 0.0

@property
def has_tip(self) -> bool:
return self._has_tip
return self.current_tip_length != 0.0

def ul_per_mm(self, ul: float, action: str) -> float:
sequence = self._config.ul_per_mm[action]
Expand Down
38 changes: 36 additions & 2 deletions api/src/opentrons/protocol_api/contexts.py
Original file line number Diff line number Diff line change
Expand Up @@ -246,12 +246,13 @@ class InstrumentContext:
def __init__(self,
ctx: ProtocolContext,
hardware: adapters.SynchronousAdapter,
mount: types.Mount, tip_racks,
mount: types.Mount, tip_racks: List[Labware],
log_parent: logging.Logger,
**config_kwargs) -> None:
self._hardware = hardware
self._ctx = ctx
self._mount = mount
self._tip_racks = tip_racks
self._last_location: Union[Labware, Well, None] = None
self._log = log_parent.getChild(repr(self))
self._log.info("attached")
Expand Down Expand Up @@ -386,7 +387,40 @@ def return_tip(self, home_after: bool = True):
def pick_up_tip(self, location: Well = None,
presses: int = 3,
increment: int = 1):
raise NotImplementedError
"""
Pick up a tip for the Pipette to run liquid-handling commands with
Notes
-----
A tip can be manually set by passing a `location`. If no location
is passed, the Pipette will pick up the next available tip in
it's `tip_racks` list (see :any:`Pipette`)
Parameters
----------
:param location: The `Well` to perform the pick_up_tip.
:type location: `Labware`, `Well`, or None
:param presses: The number of times to lower and then raise the pipette
when picking up a tip, to ensure a good seal (0 [zero]
will result in the pipette hovering over the tip but
not picking it up--generally not desireable, but could
be used for dry-run)
:type presses: int
:param increment: The additional distance to travel on each successive
press (e.g.: if presses=3 and increment=1, then the
first press will travel down into the tip by 3.5mm,
the second by 4.5mm, and the third by 5.5mm
:type increment: int or float
:returns: This instance
"""

assert tiprack.is_tiprack

self.move_to(tiprack.wells_by_index()[well_name].top())
self._hardware.pick_up_tip(tiprack.tip_length)

def drop_tip(self, location: Well = None,
home_after: bool = True):
Expand Down
54 changes: 50 additions & 4 deletions api/src/opentrons/protocol_api/labware.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
"""This module will replace Placeable"""
from collections import defaultdict
from enum import Enum, auto
import json
import os
import re
import time
from typing import List, Dict

from opentrons.types import Point, Location
from opentrons.types import Location
from enum import Enum, auto
from opentrons.types import Point
from opentrons.util import environment as env
from collections import defaultdict


class WellShape(Enum):
Expand Down Expand Up @@ -383,6 +384,18 @@ def highest_z(self) -> float:
"""
return self._dimensions['overallHeight'] + self._calibrated_offset.z

@property
def is_tiprack(self) -> bool:
return self._parameters['isTiprack']

@property
def tip_length(self) -> float:
return self._parameters['tipLength']

@tip_length.setter
def tip_length(self, length: float):
self._parameters['tipLength'] = length

def __repr__(self):
return self._display_name

Expand All @@ -401,7 +414,7 @@ def save_calibration(labware: Labware, delta: Point):
Function to be used whenever an updated delta is found for the first well
of a given labware. If an offset file does not exist, create the file
using labware id as the filename. If the file does exist, load it and
modify the delta and the lastModified field.
modify the delta and the lastModified fields under the "default" key.
"""
if not os.path.exists(persistent_path):
os.mkdir(persistent_path)
Expand All @@ -413,6 +426,24 @@ def save_calibration(labware: Labware, delta: Point):
labware.set_calibration(delta)


def save_tip_length(labware: Labware, length: float):
"""
Function to be used whenever an updated tip length is found for
of a given tip rack. If an offset file does not exist, create the file
using labware id as the filename. If the file does exist, load it and
modify the length and the lastModified fields under the "tipLength" key.
"""
if not os.path.exists(persistent_path):
os.mkdir(persistent_path)
labware_offset_path = os.path.join(
persistent_path, "{}.json".format(labware._id))
calibration_data = _helper_tip_length_data_format(
labware_offset_path, length)
with open(labware_offset_path, 'w') as f:
json.dump(calibration_data, f)
labware.tip_length = length


def load_calibration(labware: Labware):
"""
Look up a calibration if it exists and apply it to the given labware.
Expand Down Expand Up @@ -442,6 +473,21 @@ def _helper_offset_data_format(filepath: str, delta: Point) -> dict:
return calibration_data


def _helper_tip_length_data_format(filepath: str, length: float) -> dict:
if not os.path.exists(filepath):
calibration_data = {
"tipLength": {
"length": length,
"lastModified": time.time()
}
}
else:
calibration_data = _read_file(filepath)
calibration_data['tipLength']['length'] = length
calibration_data['tipLength']['lastModified'] = time.time()
return calibration_data


def _read_file(filepath: str) -> dict:
with open(filepath, 'r') as f:
calibration_data = json.load(f)
Expand Down
7 changes: 6 additions & 1 deletion api/tests/opentrons/hardware_control/test_instruments.py
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,12 @@ async def test_pick_up_tip(dummy_instruments, loop):
Axis.B: 2,
Axis.C: 19}
await hw_api.move_to(mount, tip_position)
await hw_api.pick_up_tip(mount)

# Note: pick_up_tip without a tip_length argument requires the pipette on
# the associated mount to have an associated tip rack from which to infer
# the tip length. That behavior is not tested here.
tip_length = 25.0
await hw_api.pick_up_tip(mount, tip_length)
assert hw_api._attached_instruments[mount].has_tip
assert hw_api._attached_instruments[mount].current_volume == 0
assert hw_api._current_position == target_position
5 changes: 3 additions & 2 deletions api/tests/opentrons/hardware_control/test_moves.py
Original file line number Diff line number Diff line change
Expand Up @@ -143,10 +143,11 @@ async def test_critical_point_applied(hardware_api, monkeypatch):
Axis.A: 0,
Axis.C: 19}
assert hardware_api.current_position(types.Mount.RIGHT) == target
await hardware_api.pick_up_tip(types.Mount.RIGHT)
p10_tip_length = 33
await hardware_api.pick_up_tip(types.Mount.RIGHT, p10_tip_length)
# Now the current position (with offset applied) should change
# pos_after_pickup + model_offset + critical point
target[Axis.A] = 218 + (-13) + (-33)
target[Axis.A] = 218 + (-13) + (-1 * p10_tip_length)
target_no_offset[Axis.C] = target[Axis.C] = 2
assert hardware_api.current_position(types.Mount.RIGHT) == target
# This move should take the new critical point into account
Expand Down
10 changes: 6 additions & 4 deletions api/tests/opentrons/hardware_control/test_pipette.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,11 @@ def test_tip_tracking():
with pytest.raises(AssertionError):
pip.remove_tip()
assert not pip.has_tip
pip.add_tip()
tip_length = 25.0
pip.add_tip(tip_length)
assert pip.has_tip
with pytest.raises(AssertionError):
pip.add_tip()
pip.add_tip(tip_length)
pip.remove_tip()
assert not pip.has_tip
with pytest.raises(AssertionError):
Expand All @@ -25,8 +26,9 @@ def test_critical_points():
pip = pipette.Pipette(config, 'testID')
mod_offset = Point(*loaded.model_offset)
assert pip.critical_point == mod_offset
pip.add_tip()
new = mod_offset._replace(z=mod_offset.z - loaded.tip_length)
tip_length = 25.0
pip.add_tip(tip_length)
new = mod_offset._replace(z=mod_offset.z - tip_length)
assert pip.critical_point == new
pip.remove_tip()
assert pip.critical_point == mod_offset
Expand Down

0 comments on commit d105886

Please sign in to comment.