Skip to content

Commit

Permalink
Pipette transfer (#151)
Browse files Browse the repository at this point in the history
* len(WellSeries) returns length of Wells contained

* adds transfer() command and helper methods

* adds gradient option to transfer

* adds tests to Pipette.transfer()

* fixes bug when aspirate more volume than max_volume

* adds helper methods to helpers.py

* pylama

* adds transfer test for linear gradient

* pylama

* returns tip if no trash attached to pipette

* transfer source/targets can take Placeables with children to iterate through

* adding special case where if using multi-channel and accessing WellSeries in .transfer()

* pylama

* tests multichannel transfer with WellSeries

* changes kwarg defaults and key names

* fixes merge conflicts

* fixes tests

* adds tests

* adds test

* handles Containers of length=1

* refactors transfer to include consolidate and distribute commands

* pylama

* adds tests for consolidate

* adds docstings

* adds more docstrings, comments

* adds comments and organizes a bit

* adds mix_after and mix_before

* pylama

* moves multichannel special case to helper method

* adds coverage to test

* removes smoothie config file from repo

* containers with lenght=1 return list [container[0]]

* distribute/consolidate can take only 1 or 0 tips

* setting kwarg defaults inside consolidate/distribute methods

* does not drop tip if tips=0

* updates tests

* try and avoid small volumes occuring during carryover

* adds tests for carryover volumes

* pylama

* adds test

* uses kwargs touch_tip and blow_out

* adds support for container slices to accept strings for start and stop

* adds Placeable.get_children_from_slice()

* uses new method

* add Placeable.chain() and Placeable.group()

* comment

* Placeable.chain() length defaults to length of container

* Placeable.well() return single well; Placeable.wells() returns list of wells

* refactors Placeable.get_name(); adds test for comparing WellSeries

* methods return WellSeries instead of list

* undoes Placeable.get_name() refactor

* .

* typo

* removes redudant methods between Placeable and WellSeries

* adds tests

* adds test for slices with negative step

* allows group() range() chain() to move in negative direction

* refactors .chain()

* add feature for using .chain() or .group() through calling Container instance

* adds access to Container.wells() through call instance

* pylama

* cleans up parse_string a bit

* adds instance methods to WellSeries

* multiple parsed argument on callable instrument, oh my

* adds WellSeries.crop() and WellSeries.flatten()

* trim() and flatten() return new WellSeries

* adding blow_out to distribute

* tests Placeable.remove_child()

* includes blow_out commands while distributing with extra volume

* adds access to Placeable.range() through string syntax using ':'

* sets kwarg trash to default to True

* allows >3 args to consolidate and distribute

* tips kwargs is now new_tip='once'||'never'||'always'; repeater is now repeat

* adds tests

* removes all features other than slicing with lists
  • Loading branch information
andySigler authored Feb 7, 2017
1 parent 2d3e638 commit 8c6ef33
Show file tree
Hide file tree
Showing 9 changed files with 1,142 additions and 153 deletions.
6 changes: 3 additions & 3 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -80,9 +80,9 @@ sample_protocol.py

# Local Calibration Data
calibrations/
*/calibrations/
smoothie-config.ini
*/smoothie-config.ini
smoothie/
*/smoothie
*/calibrations

# SDK logs
*.log
Expand Down
97 changes: 68 additions & 29 deletions opentrons/containers/placeable.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,9 @@ def __getitem__(self, name):
Returns placeable by name or index
If slice is given, returns a list
"""
if isinstance(name, int) or isinstance(name, slice):
if isinstance(name, slice):
return self.get_children_from_slice(name)
elif isinstance(name, int):
return self.get_children_list()[name]
elif isinstance(name, str):
return self.get_child_by_name(name)
Expand All @@ -106,10 +108,10 @@ def __str__(self):
self.__class__.__name__, self.get_name())

def __iter__(self):
return iter(self.children_by_reference.keys())
return iter(self.get_children_list())

def __len__(self):
return len(self.children_by_name)
return len(self.get_children_list())

def __bool__(self):
return True
Expand Down Expand Up @@ -236,6 +238,25 @@ def get_child_by_name(self, name):
"""
return self.children_by_name.get(name)

def get_index_from_name(self, name):
"""
Retrieves child's name by index
"""
return self.get_children_list().index(
self.get_child_by_name(name))

def get_children_from_slice(self, s):
"""
Retrieves list of children within slice
"""
if isinstance(s.start, str):
s = slice(
self.get_index_from_name(s.start), s.stop, s.step)
if isinstance(s.stop, str):
s = slice(
s.start, self.get_index_from_name(s.stop), s.step)
return WellSeries(self.get_children_list()[s])

def has_children(self):
"""
Returns *True* if :Placeable: has children
Expand Down Expand Up @@ -490,7 +511,7 @@ def transpose(self, rows):

def get_wellseries(self, matrix):
"""
Returns the grid as a series of WellSeries
Returns the grid as a WellSeries of WellSeries
"""
res = OrderedDict()
for row, cells in matrix.items():
Expand All @@ -500,19 +521,19 @@ def get_wellseries(self, matrix):
res[row][col] = self.children_by_name[
''.join(reversed(cell))
]
res[row] = WellSeries(res[row])
res[row] = WellSeries(res[row], name=row)
return WellSeries(res)

@property
def rows(self):
"""
Rows can be accessed as:
>>> plate.row[0]
>>> plate.row['1']
>>> plate.rows[0]
>>> plate.rows['1']
Wells can be accessed as:
>>> plate.row[0][0]
>>> plate.row['1']['A']
>>> plate.rows[0][0]
>>> plate.rows['1']['A']
"""
self.calculate_grid()
return self.grid
Expand Down Expand Up @@ -544,20 +565,27 @@ def cols(self):
"""
return self.columns

def well(self, name):
def well(self, name=None):
"""
Returns well by :name:
"""
return self.get_child_by_name(name)
return self.__getitem__(name)

def wells(self):
def wells(self, *args):
"""
Returns all wells
Returns child Well or list of child Wells
"""
return self.get_children()
new_wells = None
if len(args) > 0:
new_wells = WellSeries([self.well(n) for n in args])
else:
new_wells = WellSeries(self.get_children_list())
if len(new_wells) is 1:
return new_wells[0]
return new_wells


class WellSeries(Placeable):
class WellSeries(Container):
"""
:WellSeries: represents a series of wells to make
accessing rows and columns easier. You can access
Expand All @@ -568,35 +596,46 @@ class WellSeries(Placeable):
Default well index can be overriden using :set_offset:
"""
def __init__(self, items):
self.items = items
if isinstance(items, dict):
items = list(self.items.values())
self.values = items
def __init__(self, wells, name=None):
if isinstance(wells, dict):
self.items = wells
self.values = list(wells.values())
else:
self.items = {w.get_name(): w for w in wells}
self.values = wells
self.offset = 0
self.name = name

def set_offset(self, offset):
"""
Set index of a well that will be used to mimic :Placeable:
"""
self.offset = offset

def __iter__(self):
return iter(self.values)

def __str__(self):
return '<Series: {}>'.format(
' '.join([str(well) for well in self.values]))

def __getitem__(self, index):
if isinstance(index, str):
return self.items[index]
else:
return list(self.values)[index]

def __getattr__(self, name):
# getstate/setstate are used by pickle and are not implemented by
# downstream objects (Wells) therefore raise attribute error
if name in ('__getstate__', '__setstate__'):
raise AttributeError()
return getattr(self.values[self.offset], name)

def get_name(self):
if self.name is None:
return str(self)
return str(self.name)

def get_name_by_instance(self, well):
for name, value in self.items.items():
if value is well:
return name
return None

def get_children_list(self):
return list(self.values)

def get_child_by_name(self, name):
return self.items.get(name)
182 changes: 182 additions & 0 deletions opentrons/helpers/helpers.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import json

from opentrons.util.vector import Vector
from opentrons.containers.placeable import Placeable


def unpack_coordinates(coordinates):
Expand Down Expand Up @@ -98,3 +99,184 @@ def import_calibration_file(file_name, robot):
with open(file_name) as f:
json_string = '\n'.join(f)
import_calibration_json(json_string, robot)


def _get_list(n):
if not hasattr(n, '__len__') or len(n) == 0 or isinstance(n, tuple):
n = [n]
if isinstance(n, Placeable) and len(n) == 1:
n = [n[0]]
return n


def _create_source_target_lists(s, t, **kwargs):
s = _get_list(s)
t = _get_list(t)
mode = kwargs.get('mode', 'transfer')
if mode == 'transfer':
if len(s) != len(t):
raise RuntimeError(
'Transfer sources/targets must be same length')
elif mode == 'distribute':
if not (len(t) >= len(s) == 1):
raise RuntimeError(
'Distribute must have 1 source and multiple targets')
s *= len(t)
elif mode == 'consolidate':
if not (len(s) >= len(t) == 1):
raise RuntimeError(
'Consolidate must have multiple sources and 1 target')
t *= len(s)
return (s, t)


def _create_volume_list(v, total, **kwargs):

gradient = kwargs.get('gradient', None)

if isinstance(v, tuple):
return _create_volume_gradient(
v[0], v[-1], total, gradient=gradient)

v = _get_list(v)
t_vol = len(v)
if (t_vol < total and t_vol != 1) or t_vol > total:
raise RuntimeError(
'{0} volumes do not match with {1} transfers'.format(
t_vol, total))
if t_vol < total:
v = [v[0]] * total
return v


def _create_volume_gradient(min_v, max_v, total, gradient=None):

diff_vol = max_v - min_v

def _map_volume(i):
nonlocal diff_vol, total
rel_x = i / (total - 1)
rel_y = gradient(rel_x) if gradient else rel_x
return (rel_y * diff_vol) + min_v

return [_map_volume(i) for i in range(total)]


def _compress_for_repeater(min_vol, max_vol, plan, **kwargs):
min_vol = float(min_vol)
max_vol = float(max_vol)
mode = kwargs.get('mode', 'transfer')
if mode == 'distribute': # combine target volumes into single aspirate
return _compress_for_distribute(min_vol, max_vol, plan, **kwargs)
if mode == 'consolidate': # combine target volumes into multiple aspirates
return _compress_for_consolidate(min_vol, max_vol, plan, **kwargs)
else:
return plan


def _compress_for_distribute(min_vol, max_vol, plan, **kwargs):
source = plan[0]['aspirate']['location']
a_vol = 0
temp_dispenses = []
new_transfer_plan = []

def _add():
nonlocal a_vol, temp_dispenses, new_transfer_plan, source

if not temp_dispenses:
return

# distribute commands get an extra volume added
if len(temp_dispenses) > 1:
a_vol += min_vol

new_transfer_plan.append({
'aspirate': {
'location': source, 'volume': a_vol
}
})
for d in temp_dispenses:
new_transfer_plan.append({
'dispense': {
'location': d['location'], 'volume': d['volume']
}
})

if min_vol and len(temp_dispenses) > 1:
new_transfer_plan.append({
'blow_out': {'blow_out': True}
})

for p in plan:
this_vol = p['aspirate']['volume']
if this_vol + a_vol > max_vol - min_vol:
_add()
a_vol = 0
temp_dispenses = []
a_vol += this_vol
temp_dispenses.append(p['dispense'])
_add()
return new_transfer_plan


def _compress_for_consolidate(min_vol, max_vol, plan, **kwargs):
target = plan[0]['dispense']['location']
d_vol = 0
temp_aspirates = []
new_transfer_plan = []

def _add():
nonlocal d_vol, temp_aspirates, new_transfer_plan, target
for a in temp_aspirates:
new_transfer_plan.append({
'aspirate': {
'location': a['location'], 'volume': a['volume']
}
})
new_transfer_plan.append({
'dispense': {
'location': target, 'volume': d_vol
}
})
d_vol = 0
temp_aspirates = []

for i, p in enumerate(plan):
this_vol = p['aspirate']['volume']
if this_vol + d_vol > max_vol:
_add()
d_vol += this_vol
temp_aspirates.append(p['aspirate'])
_add()
return new_transfer_plan


def _expand_for_carryover(min_vol, max_vol, plan, **kwargs):
max_vol = float(max_vol)
carryover = kwargs.get('carryover', True)
if not carryover:
return plan
new_transfer_plan = []
for p in plan:
source = p['aspirate']['location']
target = p['dispense']['location']
volume = float(p['aspirate']['volume'])
while volume > max_vol * 2:
new_transfer_plan.append({
'aspirate': {'location': source, 'volume': max_vol},
'dispense': {'location': target, 'volume': max_vol}
})
volume -= max_vol

# try and make sure no volumes are less than min_vol
if volume > max_vol:
volume /= 2
new_transfer_plan.append({
'aspirate': {'location': source, 'volume': float(volume)},
'dispense': {'location': target, 'volume': float(volume)}
})
new_transfer_plan.append({
'aspirate': {'location': source, 'volume': float(volume)},
'dispense': {'location': target, 'volume': float(volume)}
})
return new_transfer_plan
Loading

0 comments on commit 8c6ef33

Please sign in to comment.