Skip to content
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

refactor(api): refactor aspirate/dispense #2481

Merged
merged 1 commit into from
Oct 19, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import os
import json
from collections import namedtuple
from typing import List
from opentrons import __file__ as root_file


Expand Down Expand Up @@ -114,3 +115,24 @@ def load(pipette_model: str) -> pipette_config:
assert res.model_offset[2] == Z_OFFSET_P50

return res


def piecewise_volume_conversion(
ul: float, sequence: List[List[float]]) -> float:
"""
Takes a volume in microliters and a sequence representing a piecewise
function for the slope and y-intercept of a ul/mm function, where each
sub-list in the sequence contains:

- the max volume for the piece of the function (minimum implied from the
max of the previous item or 0
- the slope of the segment
- the y-intercept of the segment

:return: the ul/mm value for the specified volume
"""
# pick the first item from the seq for which the target is less than
# the bracketing element
i = list(filter(lambda x: ul <= x[0], sequence))[0]
# use that element to calculate the movement distance in mm
return i[1]*ul + i[2]
1 change: 1 addition & 0 deletions api/src/opentrons/config/robot_configs.py
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,7 @@ def _clear_file(filename):
os.remove(filename)


# TODO: move to util (write a default load, save JSON function)
def _load_json(filename) -> dict:
try:
with open(filename, 'r') as file:
Expand Down
2 changes: 1 addition & 1 deletion api/src/opentrons/deck_calibration/endpoints.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from aiohttp import web
from uuid import uuid1
from opentrons.legacy_api.instruments import pipette_config
from opentrons.config import pipette_config
from opentrons import instruments, robot
from opentrons.config import robot_configs
from . import jog, position, dots_set, z_pos
Expand Down
194 changes: 175 additions & 19 deletions api/src/opentrons/hardware_control/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,13 @@
from collections import OrderedDict
import functools
import logging
from typing import Any, Dict, Union, List, Optional, Tuple

from typing import Any, Dict, Union, List, Tuple
from opentrons import types as top_types
from opentrons.util import linal
from .simulator import Simulator
from opentrons.config import robot_configs

from contextlib import contextmanager
from opentrons.config import pipette_config
try:
from .controller import Controller
except ModuleNotFoundError:
Expand All @@ -45,6 +45,10 @@ class MustHomeError(RuntimeError):
pass


class PipetteNotAttachedError(KeyError):
pass


_Backend = Union[Controller, Simulator]


Expand Down Expand Up @@ -81,8 +85,12 @@ def __init__(self,
# {'X': 0.0, 'Y': 0.0, 'Z': 0.0, 'A': 0.0, 'B': 0.0, 'C': 0.0}
self._current_position: Dict[Axis, float] = {}

self._attached_instruments = {top_types.Mount.LEFT: None,
top_types.Mount.RIGHT: None}
self._attached_instruments: \
Dict[top_types.Mount, Dict[str, Any]] = {top_types.Mount.LEFT: {},
top_types.Mount.RIGHT: {}}
self._current_volume: \
Dict[top_types.Mount, float] = {top_types.Mount.LEFT: 0,
top_types.Mount.RIGHT: 0}
self._attached_modules: Dict[str, Any] = {}

@classmethod
Expand All @@ -98,13 +106,14 @@ def build_hardware_controller(
if None is Controller:
raise RuntimeError(
'The hardware controller may only be instantiated on a robot')
return cls(Controller(config, loop),
config=config, loop=loop)
backend = Controller(config, loop)
backend._connect()
return cls(backend, config=config, loop=loop)

@classmethod
def build_hardware_simulator(
cls,
attached_instruments: Dict[top_types.Mount, Optional[str]] = None,
attached_instruments: Dict[top_types.Mount, Dict[str, Any]] = None,
attached_modules: List[str] = None,
config: robot_configs.robot_config = None,
loop: asyncio.AbstractEventLoop = None) -> 'API':
Expand All @@ -114,8 +123,9 @@ def build_hardware_simulator(
Multiple simulating hardware controllers may be active at one time.
"""
if None is attached_instruments:
attached_instruments = {top_types.Mount.LEFT: None,
top_types.Mount.RIGHT: None}
attached_instruments = {top_types.Mount.LEFT: {},
top_types.Mount.RIGHT: {}}

if None is attached_modules:
attached_modules = []
return cls(Simulator(attached_instruments,
Expand Down Expand Up @@ -152,11 +162,32 @@ async def identify(self, seconds):
pass

@_log_call
async def cache_instrument_models(self):
async def cache_instruments(self):
"""
- Get the attached instrument on each mount and
- Cache their pipette configs from pipette-config.json
"""
self._log.info("Updating instrument model cache")
for mount in top_types.Mount:
self._attached_instruments[mount] = \
self._backend.get_attached_instruments(mount)
instrument_model = self._backend.get_attached_instrument(mount)
if instrument_model:
configs = pipette_config.load(instrument_model)
self._attached_instruments[mount].update(configs._asdict())
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we need to explicitly put the 'model' key in here, it doesn't seem to be present

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

'model' is 'name' in pipette_config
But _backend.get_attached_instrument returns just the model name from the driver (or fetches self._attached_instruments.get(mount).get('name') if backend is simulator)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah but something isn't actually populating it. From wolf-wolf-wolf-moon:

>>> l.run_until_complete(a.cache_instruments())
>>> a.attached_instruments
{<Mount.LEFT: 1>: {}, <Mount.RIGHT: 2>: {}}
>>> a._attached_instruments
{<Mount.LEFT: 1>: {'plunger_positions': {'top': 19.5, 'bottom': 0.5, 'blow_out': -2.5, 'drop_tip': -5.5}, 'pick_up_current': 0.4, 'pick_up_distance': 10, 'aspirate_flow_rate': 5, 'dispense_flow_rate': 10, 'channels': 8, 'name': 'p10_multi_v1.3', 'model_offset': [0.0, 31.5, -25.8], 'plunger_current': 0.5, 'drop_tip_current': 0.5, 'min_volume': 1, 'max_volume': 10, 'ul_per_mm': {'aspirate': [[1.893415617, -1.1069, 3.042593193], [2.497849452, -0.1888, 1.30410391], [5.649462387, -0.0081, 0.8528667891], [12.74444519, -0.0018, 0.8170558891]], 'dispense': [[12.74444519, 0, 0.8058688085]]}, 'quirks': [], 'tip_length': 33}, <Mount.RIGHT: 2>: {'plunger_positions': {'top': 19.5, 'bottom': 1.5, 'blow_out': 0, 'drop_tip': -4}, 'pick_up_current': 0.1, 'pick_up_distance': 10, 'aspirate_flow_rate': 150, 'dispense_flow_rate': 300, 'channels': 1, 'name': 'p300_single_v1', 'model_offset': [0.0, 0.0, 0.0], 'plunger_current': 0.3, 'drop_tip_current': 0.5, 'min_volume': 30, 'max_volume': 300, 'ul_per_mm': {'aspirate': [[36.19844973, 0.043, 16.548], [54.98518519, 0.012, 17.658], [73.90077516, 0.008, 17.902], [111.8437953, 0.004, 18.153], [302.3895337, 0.001, 18.23]], 'dispense': [[302.3895337, 0, 18.83156277]]}, 'quirks': [], 'tip_length': 51.7}}

(before the latest commit it would raise KeyError('model')

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're totally right. It should be 'name' and not 'model'

sfoster1 marked this conversation as resolved.
Show resolved Hide resolved
mod_log.info("Instruments found:{}".format(self._attached_instruments))

@property
def attached_instruments(self):
configs = ['name', 'min_volume', 'max_volume',
'aspirate_flow_rate', 'dispense_flow_rate']
instruments = {top_types.Mount.LEFT: {},
top_types.Mount.RIGHT: {}}
for mount in top_types.Mount:
if not self._attached_instruments[mount].get('name'):
continue
for key in configs:
instruments[mount][key] = \
self._attached_instruments[mount][key]
return instruments

@_log_call
async def update_smoothie_firmware(self, firmware_file):
Expand All @@ -175,6 +206,10 @@ async def resume(self):
async def halt(self):
pass

@_log_call
async def reset(self):
pass

# Gantry/frame (i.e. not pipette) action API
@_log_call
async def home_z(self, mount: top_types.Mount):
Expand Down Expand Up @@ -243,10 +278,12 @@ def current_position(self, mount: top_types.Mount) -> Dict[Axis, float]:
else:
offset = top_types.Point(*self.config.mount_offset)
z_ax = Axis.by_mount(mount)
plunger_ax = Axis.of_plunger(mount)
return {
Axis.X: self._current_position[Axis.X] + offset[0],
Axis.Y: self._current_position[Axis.Y] + offset[1],
z_ax: self._current_position[z_ax] + offset[2]
z_ax: self._current_position[z_ax] + offset[2],
plunger_ax: self._current_position[plunger_ax]
}

@_log_call
Expand Down Expand Up @@ -372,12 +409,127 @@ async def head_speed(self, combined_speed=None,

# Pipette action API
@_log_call
async def aspirate(self, mount, volume=None, rate=None):
pass
async def aspirate(self, mount: top_types.Mount, volume: float = None,
rate: float = 1.0):
"""
Aspirate a volume of liquid (in microliters/uL) using this pipette
from the *current location*. If no volume is passed, `aspirate` will
default to max available volume (after taking into account the volume
already present in the tip).

mount : Mount.LEFT or Mount.RIGHT
volume : [float] The number of microliters to aspirate
rate : [float] Set plunger speed for this aspirate, where
speed = rate * aspirate_speed
"""
this_pipette = self._attached_instruments[mount]
if 'name' not in this_pipette.keys():
raise PipetteNotAttachedError("No pipette attached to {} mount"
.format(mount.name))

if volume is None:
asp_vol = this_pipette['max_volume'] - self._current_volume[mount]
mod_log.debug(
"No aspirate volume defined. Aspirating up to pipette "
"max_volume ({}uL)".format(this_pipette['max_volume']))
else:
asp_vol = volume

assert self._current_volume[mount] + asp_vol \
<= this_pipette['max_volume'], \
"Cannot aspirate more than pipette max volume"
if asp_vol == 0:
return
# using a context generator to temporarily change pipette speed to a
# user specified rate, then switch back to default
with self._set_temp_pipette_speed(mount, 'aspirate', rate):
self._backend.set_active_current(
Axis.of_plunger(mount), this_pipette['plunger_current'])
target_position = {Axis.of_plunger(mount): self._plunger_position(
mount,
self._current_volume[mount] + asp_vol,
'aspirate')}
try:
self._backend.move({ax.name: pos
for ax, pos in target_position.items()})
except Exception:
self._log.exception('Aspirate failed')
self._current_volume.clear()
raise
else:
self._current_position.update(target_position)
self._current_volume[mount] += asp_vol

@_log_call
async def dispense(self, mount, volume=None, rate=None):
pass
async def dispense(self, mount: top_types.Mount, volume: float = None,
rate: float = 1.0):
"""
Dispense a volume of liquid (in microliters/uL) using this pipette
at the current location. If no volume is specified, `dispense` will
dispense all volume currently present in pipette

mount : Mount.LEFT or Mount.RIGHT
volume : [float] The number of microliters to dispense
rate : [float] Set plunger speed for this dispense, where
speed = rate * dispense_speed
"""
this_pipette = self._attached_instruments[mount]
if 'name' not in this_pipette.keys():
raise PipetteNotAttachedError("No pipette attached to {} mount"
.format(mount.name))
if volume is None:
disp_vol = self._current_volume[mount]
mod_log.debug("No dispense volume specified. Dispensing all "
"remaining liquid ({}uL) from pipette".format
(disp_vol))
else:
disp_vol = volume
# Ensure we don't dispense more than the current volume
disp_vol = min(self._current_volume[mount], disp_vol)

if disp_vol == 0:
return
# using a context generator to temporarily change pipette speed to a
# user specified rate, then switch back to default
with self._set_temp_pipette_speed(mount, 'dispense', rate):
self._backend.set_active_current(
Axis.of_plunger(mount), this_pipette['plunger_current'])
target_position = {Axis.of_plunger(mount): self._plunger_position(
mount,
self._current_volume[mount] - disp_vol,
'dispense')}
try:
self._backend.move({ax.name: pos
for ax, pos in target_position.items()})
except Exception:
self._log.exception('Dispense failed')
self._current_volume.clear()
raise
else:
self._current_position.update(target_position)
self._current_volume[mount] -= disp_vol

def _plunger_position(self, mount: top_types.Mount, ul: float,
action: str) -> float:
mm = ul / self._ul_per_mm(mount, ul, action)
position = mm + self._attached_instruments[
mount]['plunger_positions']['bottom']
return round(position, 6)

def _ul_per_mm(self, mount: top_types.Mount,
ul: float, action: str) -> float:
sequence = self._attached_instruments[mount]['ul_per_mm'][action]
return pipette_config.piecewise_volume_conversion(ul, sequence)

@contextmanager
def _set_temp_pipette_speed(self, mount, action, rate):
action_str = '{}_flow_rate'.format(action)
saved_speed = self._attached_instruments[mount][action_str]
self._backend.set_pipette_speed(saved_speed * rate)
try:
yield
finally:
self._backend.set_pipette_speed(saved_speed)

@_log_call
async def blow_out(self, mount):
Expand All @@ -403,9 +555,13 @@ async def calibrate_plunger(

@_log_call
async def set_flow_rate(self, mount, aspirate=None, dispense=None):
pass
if aspirate:
self._attached_instruments[mount]['aspirate_flow_rate'] = aspirate
if dispense:
self._attached_instruments[mount]['dispense_flow_rate'] = dispense

@_log_call
# Used by pick_up_tip
async def set_pick_up_current(self, mount, amperes):
pass

Expand Down
11 changes: 10 additions & 1 deletion api/src/opentrons/hardware_control/controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,9 +82,15 @@ def home(self, axes: List[Axis] = None) -> Dict[str, float]:
args = tuple()
return self._smoothie_driver.home(*args)

def get_attached_instruments(self, mount) -> Optional[str]:
def get_attached_instrument(self, mount) -> Optional[str]:
return self._smoothie_driver.read_pipette_model(mount.name.lower())

def set_active_current(self, axis, amp):
self._smoothie_driver.set_active_current({axis.name: amp})

def set_pipette_speed(self, val: float):
self._smoothie_driver.set_speed(val)

def get_attached_modules(self) -> List[Tuple[str, str]]:
return modules.discover()

Expand All @@ -99,3 +105,6 @@ async def update_module(
-> modules.AbstractModule:
return await modules.update_firmware(
module, firmware_file, loop)

def _connect(self):
self._smoothie_driver.connect()
17 changes: 13 additions & 4 deletions api/src/opentrons/hardware_control/simulator.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import asyncio
from typing import Dict, Optional, List, Tuple
from typing import Dict, Optional, List, Tuple, Any

from opentrons import types
from . import modules
Expand All @@ -12,7 +12,7 @@ class Simulator:
a robot with no smoothie connected.
"""
def __init__(self,
attached_instruments: Dict[types.Mount, Optional[str]],
attached_instruments: Dict[types.Mount, Dict[str, Any]],
attached_modules: List[str],
config, loop) -> None:
self._config = config
Expand All @@ -29,8 +29,17 @@ def home(self, axes: List[Axis] = None) -> Dict[str, float]:
# driver_3_0-> HOMED_POSITION
return {'X': 418, 'Y': 353, 'Z': 218, 'A': 218, 'B': 19, 'C': 19}

def get_attached_instruments(self, mount) -> Optional[str]:
return self._attached_instruments[mount]
def get_attached_instrument(self, mount) -> Optional[str]:
try:
return self._attached_instruments[mount]['name']
except KeyError:
return None

def set_active_current(self, axis, amp):
pass

def set_pipette_speed(self, speed):
pass

def get_attached_modules(self) -> List[Tuple[str, str]]:
return self._attached_modules
Expand Down
6 changes: 6 additions & 0 deletions api/src/opentrons/hardware_control/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,9 @@ def gantry_axes(cls) -> Tuple['Axis', 'Axis', 'Axis', 'Axis']:
calibration transform
"""
return (cls.X, cls.Y, cls.Z, cls.A)

@classmethod
def of_plunger(cls, mount: opentrons.types.Mount):
pm = {opentrons.types.Mount.LEFT: cls.B,
opentrons.types.Mount.RIGHT: cls.C}
return pm[mount]
Loading