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

feat(api): move-to-slot JSON protocol command #3242

Merged
merged 12 commits into from
Mar 26, 2019
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)]:
Copy link
Member

Choose a reason for hiding this comment

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

Can't we do slot in context.deck

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I think that's too permissive b/c it will work fine with integers even though the schema says it must be a string: "enum": ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12"].

But I'm pretty sure 1 in context.deck and "1" in context.deck will both be True... didn't test though

Probably the best thing to do is to throw before execution even begins if the protocol does not conform to a schema supported by the executor, but until we have something like that I don't want users to run out-of-spec JSON and have things work fine - then they'll not bother making it a string and we'll have a tough time making a migration script to move their protocols to future JSON schema versions :/

Copy link
Member

Choose a reason for hiding this comment

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

Yeah I agree with that but still, "what the deck will accept" is exactly the source of truth we should be checking here, not duplicating logic.

Copy link
Contributor

Choose a reason for hiding this comment

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

I agree with Seth about duplicating logic and to extend that thought, this would be a great place to start using the ot2Standard.json deck definition in shared data. And a helper method to grab the slot ids from the definition would return an array of strings by default.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The source of truth for the JSON executor about what inputs are valid should always be the JSON Protocol schema def, not the Deck class of a certain version of the Python API, or anything else outside the JSON Protocol schema def matching the protocol version of the uploaded protocol.

Permissivity in the executors is dangerous. Our JSON Protocol schema is not intended to be read only by the JSON executor(s) in our Python server/API. For example, we might want to display commands info in Run App or Protocol Library from the JSON file directly. If we encourage protocols to deviate from the schema definition by having a permissive executor, our JSON protocols "in the wild" will become much harder to work in all these cases. As a superuser, if my protocol runs fine when I use integers for slots, I'm not going to really care if I'm going against the schema -- I likely won't even know that my protocol is invalid because it runs fine on the robot. Then if Run App wants to add a new feature that reads the slots param and expects a string, we have to either make users convert their invalid protocols to string slots, or handle that out-of-spec case to the application.

I think the long-term right thing to do is adding JSON schema validation checks to the executors, which would throw an error if the uploaded protocol does not validate under the JSON Protocol schema that matches that uploaded protocol's schema version ("protocol-schema" / "protocolSchema" key). We should probably do that pretty soon. Then, we can take this type of "is the input valid?" assertions out of the executors.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

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