Skip to content

Commit

Permalink
feat(api): move-to-slot JSON protocol command (#3242)
Browse files Browse the repository at this point in the history
* Python API v2: add z margin safety param to `move_to` fn
* create new JSON Schema v2 def, only change vs v1 is addition of 'move-to-slot' command
* Python APIv1 and APIv2 JSON executors support move-to-slot JSON cmd
  • Loading branch information
IanLondon authored Mar 26, 2019
1 parent 9bf5cad commit cef5123
Show file tree
Hide file tree
Showing 8 changed files with 548 additions and 67 deletions.
16 changes: 8 additions & 8 deletions api/src/opentrons/protocol_api/contexts.py
Original file line number Diff line number Diff line change
Expand Up @@ -1231,18 +1231,17 @@ def _execute_transfer(self, plan: transfers.TransferPlan):
def delay(self):
return self._ctx.delay()

def move_to(self, location: types.Location, z_safety: float = None
def move_to(self, location: types.Location, force_direct: bool = False,
minimum_z_height: float = None
) -> 'InstrumentContext':
""" Move the instrument.
:param location: The location to move to.
:type location: :py:class:`.types.Location`
:param z_safety: An optional height to retract the pipette to before
moving. If not specified, it will be generated based
on the labware from which and to which the pipette is
moving; if it is 0, the pipette will move directly;
and if it is non-zero, the pipette will rise to the
z_safety point before moving in x and y.
:param force_direct: If set to true, move directly to destination
without arc motion.
:param minimum_z_height: When specified, this Z margin is able to raise
(but never lower) the mid-arc height.
"""
if self._ctx.location_cache:
from_lw = self._ctx.location_cache.labware
Expand All @@ -1258,7 +1257,8 @@ def move_to(self, location: types.Location, z_safety: float = None
from_lw)

moves = geometry.plan_moves(from_loc, location, self._ctx.deck,
z_margin_override=z_safety)
force_direct=force_direct,
minimum_z_height=minimum_z_height)
self._log.debug("move_to: {}->{} via:\n\t{}"
.format(from_loc, location, moves))
try:
Expand Down
18 changes: 18 additions & 0 deletions api/src/opentrons/protocol_api/execute.py
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,24 @@ def dispatch_json(context: ProtocolContext, # noqa(C901)
well = _get_well(labware, params)
offset = default_values.get('touch-tip-mm-from-top', -1)
pipette.touch_tip(location, v_offset=offset) # type: ignore

elif command_type == 'move-to-slot':
slot = params.get('slot')
if slot not in [str(s+1) for s in range(12)]:
raise ValueError('Invalid "slot" for "move-to-slot": {}'
.format(slot))
slot_obj = context.deck.position_for(slot)

offset = params.get('offset', {})
offsetPoint = Point(
offset.get('x', 0),
offset.get('y', 0),
offset.get('z', 0))

pipette.move_to( # type: ignore
slot_obj.move(offsetPoint),
force_direct=params.get('force-direct'),
minimum_z_height=params.get('minimum-z-height'))
else:
MODULE_LOG.warning("Bad command type {}".format(command_type))

Expand Down
64 changes: 38 additions & 26 deletions api/src/opentrons/protocol_api/geometry.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ def plan_moves(
deck: 'Deck',
well_z_margin: float = 5.0,
lw_z_margin: float = 20.0,
z_margin_override: float = None)\
force_direct: bool = False,
minimum_z_height: float = None)\
-> List[Tuple[types.Point,
Optional[CriticalPoint]]]:
""" Plan moves between one :py:class:`.Location` and another.
Expand All @@ -40,11 +41,16 @@ def plan_moves(
:param float lw_z_margin: How much extra Z margin to raise the cp by over
the bare minimum to clear different pieces of
labware. Default: 20mm
:param force_direct: If True, ignore any Z margins force a direct move
:param minimum_z_height: When specified, this Z margin is able to raise
(but never lower) the mid-arc height.
:returns: A list of tuples of :py:class:`.Point` and critical point
overrides to move through.
"""

assert minimum_z_height is None or minimum_z_height >= 0.0

def _split_loc_labware(
loc: types.Location) -> Tuple[Optional[Labware], Optional[Well]]:
if isinstance(loc.labware, Labware):
Expand All @@ -65,35 +71,41 @@ def _split_loc_labware(
dest_cp_override = CriticalPoint.XY_CENTER if to_center else None
origin_cp_override = CriticalPoint.XY_CENTER if from_center else None

is_same_location = ((to_lw and to_lw == from_lw)
and (to_well and to_well == from_well))
if (force_direct or (is_same_location and not
(minimum_z_height or 0) > 0)):
# If we’re going direct, we can assume we’re already in the correct
# cp so we can use the override without prep
return [(to_point, dest_cp_override)]

# Generate arc moves

# Find the safe z heights based on the destination and origin labware/well
if to_lw and to_lw == from_lw:
# Two valid labwares. We’ll either raise to clear a well or go direct
if z_margin_override == 0.0 or (to_well and to_well == from_well):
# If we’re going direct, we can assume we’re already in the correct
# cp so we can use the override without prep
return [(to_point, dest_cp_override)]
# If we know the labwares we’re moving from and to, we can calculate
# a safe z based on their heights
if to_well:
to_safety = to_well.top().point.z + well_z_margin
else:
if to_well:
to_safety = to_well.top().point.z + well_z_margin
else:
to_safety = to_lw.highest_z + well_z_margin
if from_well:
from_safety = from_well.top().point.z + well_z_margin
else:
from_safety = from_lw.highest_z + well_z_margin

safe = max_many(
to_point.z,
from_point.z,
to_safety,
from_safety)
to_safety = to_lw.highest_z + well_z_margin
if from_well:
from_safety = from_well.top().point.z + well_z_margin
else:
from_safety = from_lw.highest_z + well_z_margin
else:
# For now, the only fallback is to clear all known labware
safe = max_many(to_point.z,
from_point.z,
deck.highest_z + lw_z_margin)
# One of our labwares is invalid so we have to just go above
# deck.highest_z since we don’t know where we are
to_safety = deck.highest_z + lw_z_margin
from_safety = 0.0 # (ignore since it’s in a max())

safe = max_many(
to_point.z,
from_point.z,
to_safety,
from_safety,
minimum_z_height or 0)

if z_margin_override is not None and z_margin_override >= 0.0:
safe = z_margin_override
# We should use the origin’s cp for the first move since it should
# move only in z and the destination’s cp subsequently
return [(from_point._replace(z=safe), origin_cp_override),
Expand Down
23 changes: 23 additions & 0 deletions api/src/opentrons/protocols/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from numpy import add
import time
from itertools import chain
from opentrons import instruments, labware, robot
Expand Down Expand Up @@ -34,6 +35,7 @@ def load_labware(protocol_data):
loaded_labware = {}
for labware_id, props in data.items():
slot = props.get('slot')
# TODO: Ian 2019-03-19 throw error if slot is number, only allow string
model = props.get('model')
display_name = props.get('display-name')

Expand Down Expand Up @@ -210,6 +212,27 @@ def dispatch_commands(protocol_data, loaded_pipettes, loaded_labware): # noqa:

pipette.touch_tip(well_object, v_offset=offset_from_top)

elif command_type == 'move-to-slot':
slot = params.get('slot')
if slot not in [str(s+1) for s in range(12)]:
raise ValueError('"move-to-slot" requires a valid slot, got {}'
.format(slot))
x_offset = params.get('offset', {}).get('x', 0)
y_offset = params.get('offset', {}).get('y', 0)
z_offset = params.get('offset', {}).get('z', 0)
slot_placeable = robot.deck[slot]
slot_offset = (x_offset, y_offset, z_offset)

strategy = 'direct' if params.get('force-direct') else None

# NOTE: Robot.move_to subtracts the offset from Slot.top()[1],
# so in order not to translate our desired offset,
# we have to compensate by adding it here :/
pipette.move_to(
(slot_placeable,
add(slot_offset, tuple(slot_placeable.top()[1]))),
strategy=strategy)


def execute_protocol(protocol):
loaded_pipettes = load_pipettes(protocol)
Expand Down
3 changes: 2 additions & 1 deletion api/tests/opentrons/protocol_api/test_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,8 @@ def test_location_cache(loop, monkeypatch, load_my_labware):
def fake_plan_move(from_loc, to_loc, deck,
well_z_margin=None,
lw_z_margin=None,
z_margin_override=None):
force_direct=False,
minimum_z_height=None):
nonlocal test_args
test_args = (from_loc, to_loc, deck, well_z_margin, lw_z_margin)
return [(Point(0, 1, 10), None),
Expand Down
75 changes: 43 additions & 32 deletions api/tests/opentrons/protocol_api/test_geometry.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ def test_highest_z():
assert deck.highest_z == mod.highest_z


def check_arc_basic(arc, from_loc, to_loc, use_z_margin_override=False):
def check_arc_basic(arc, from_loc, to_loc):
""" Check the tests that should always be true for different-well moves
- we should always go only up, then only xy, then only down
- we should have three moves
Expand All @@ -59,9 +59,8 @@ def check_arc_basic(arc, from_loc, to_loc, use_z_margin_override=False):
assert arc[0][0].z == arc[1][0].z
assert arc[1][0]._replace(z=0) == to_loc.point._replace(z=0)
assert arc[2][0] == to_loc.point
if not use_z_margin_override:
assert arc[0][0].z >= from_loc.point.z
assert arc[1][0].z >= to_loc.point.z
assert arc[0][0].z >= from_loc.point.z
assert arc[1][0].z >= to_loc.point.z


def test_direct_movs():
Expand Down Expand Up @@ -98,6 +97,25 @@ def test_basic_arc():
assert different_lw[0][0].z == deck.highest_z + 15.0


def test_force_direct():
deck = Deck()
lw1 = labware.load(labware_name, deck.position_for(1))
lw2 = labware.load(labware_name, deck.position_for(2))
# same-labware moves should move direct
same_lw = plan_moves(lw1.wells()[0].top(),
lw1.wells()[8].bottom(),
deck,
7.0, 15.0, force_direct=True)
assert same_lw == [(lw1.wells()[8].bottom().point, None)]

# different-labware moves should move direct
different_lw = plan_moves(lw1.wells()[0].top(),
lw2.wells()[0].bottom(),
deck,
7.0, 15.0, force_direct=True)
assert different_lw == [(lw2.wells()[0].bottom().point, None)]


def test_no_labware_loc():
labware_def = labware.load_definition_by_name(labware_name)

Expand Down Expand Up @@ -149,50 +167,44 @@ def test_arc_tall_point():
check_arc_basic(from_tall_lw, no_well, lw1.wells()[4].bottom())


def test_arc_lower_z_margin_override():
def test_arc_lower_minimum_z_height():
deck = Deck()
lw1 = labware.load(labware_name, deck.position_for(1))
tall_z = 100
z_margin_override = 42
minimum_z_height = 42
old_top = lw1.wells()[0].top()
tall_point = old_top.point._replace(z=tall_z)
tall_top = old_top._replace(point=tall_point)
to_tall = plan_moves(
lw1.wells()[2].top(), tall_top, deck, 7.0, 15.0, z_margin_override)
check_arc_basic(to_tall, lw1.wells()[2].top(), tall_top, True)
assert to_tall[0][0].z == z_margin_override
lw1.wells()[2].top(), tall_top, deck, 7.0, 15.0, False,
minimum_z_height=minimum_z_height)
check_arc_basic(to_tall, lw1.wells()[2].top(), tall_top)
assert to_tall[0][0].z == tall_z

from_tall = plan_moves(
tall_top, lw1.wells()[3].top(), deck, 7.0, 15.0, z_margin_override)
check_arc_basic(from_tall, tall_top, lw1.wells()[3].top(), True)
assert from_tall[0][0].z == z_margin_override
tall_top, lw1.wells()[3].top(), deck, 7.0, 15.0,
minimum_z_height=minimum_z_height)
check_arc_basic(from_tall, tall_top, lw1.wells()[3].top())
assert from_tall[0][0].z == tall_z

no_well = tall_top._replace(labware=lw1)
from_tall_lw = plan_moves(no_well, lw1.wells()[4].bottom(), deck,
7.0, 15.0)
check_arc_basic(from_tall_lw, no_well, lw1.wells()[4].bottom(), True)
check_arc_basic(from_tall_lw, no_well, lw1.wells()[4].bottom())


def test_arc_zero_z_margin_override():
def test_direct_minimum_z_height():
deck = Deck()
lw1 = labware.load(labware_name, deck.position_for(1))
tall_z = 100
z_margin_override = 0
old_top = lw1.wells()[0].top()
tall_point = old_top.point._replace(z=tall_z)
tall_top = old_top._replace(point=tall_point)
to_tall = plan_moves(
lw1.wells()[2].top(), tall_top, deck, 7.0, 15.0, z_margin_override)
assert to_tall == [(tall_top.point, None)]

from_tall = plan_moves(
tall_top, lw1.wells()[3].top(), deck, 7.0, 15.0, z_margin_override)
assert from_tall == [(lw1.wells()[3].top().point, None)]

no_well = tall_top._replace(labware=lw1)
from_tall_lw = plan_moves(no_well, lw1.wells()[4].bottom(), deck,
7.0, 15.0, z_margin_override)
assert from_tall_lw == [(lw1.wells()[4].bottom().point, None)]
from_loc = lw1.wells()[0].bottom().move(Point(x=-2))
to_loc = lw1.wells()[0].bottom().move(Point(x=2))
zmo = 150
# This would normally be a direct move since it’s inside the same well,
# but we want to check that we override it into an arc
moves = plan_moves(from_loc, to_loc, deck, minimum_z_height=zmo)
assert len(moves) == 3
assert moves[0][0].z == zmo # equals zmo b/c 150 is max of all safe z's
check_arc_basic(moves, from_loc, to_loc)


def test_direct_cp():
Expand All @@ -204,7 +216,6 @@ def test_direct_cp():
from_nothing = plan_moves(Location(Point(50, 50, 50), None),
trough.wells()[0].top(),
deck)
# import pdb; pdb.set_trace()
check_arc_basic(from_nothing, Location(Point(50, 50, 50), None),
trough.wells()[0].top())
assert from_nothing[0][1] is None
Expand Down
Loading

0 comments on commit cef5123

Please sign in to comment.