diff --git a/api/opentrons/api/calibration.py b/api/opentrons/api/calibration.py index 84362ace84b..675eb862ded 100644 --- a/api/opentrons/api/calibration.py +++ b/api/opentrons/api/calibration.py @@ -1,21 +1,74 @@ +from copy import copy + +from opentrons.robot.robot import Robot +from opentrons.util import calibration_functions +from opentrons.broker import publish + +from .models import Container + + +VALID_STATES = {'probing', 'moving', 'ready'} + + class CalibrationManager: + TOPIC = 'calibration' + def __init__(self, loop=None): self._loop = loop + self._robot = Robot() + + self.state = None + + def _set_state(self, state): + if state not in VALID_STATES: + raise ValueError( + 'State {0} not in {1}'.format(state, VALID_STATES)) + self.state = state + self._on_state_changed() def tip_probe(self, instrument): - raise NotImplemented() + self._set_state('probing') + calibration_functions.probe_instrument(instrument, self._robot) + self._set_state('ready') def move_to_front(self, instrument): - # instrument.move_to(PIPETTE_CHANGE_POSITION) - raise NotImplemented() + self._set_state('moving') + calibration_functions.move_instrument_for_probing_prep( + instrument._instrument, self._robot + ) + self._set_state('ready') def move_to(self, instrument, obj): - # instrument.move_to(obj[0]) - raise NotImplemented() + if not isinstance(obj, Container): + raise ValueError( + 'Invalid object type {0}. Expected models.Container' + .format(type(obj))) + + self._set_state('moving') + instrument._instrument.move_to(obj._container[0]) + self._set_state('ready') def jog(self, instrument, coordinates): - # instrument.jog(coordinates) - raise NotImplemented() + self._set_state('moving') + instrument._instrument.jog(coordinates) + self._set_state('ready') + + def update_container_offset(self, container, instrument): + self._robot.calibrate_container_with_instrument( + container=container._container, + instrument=instrument._instrument, + save=True + ) + + # TODO (artyom, 20171003): along with session, consider extracting this + # into abstract base class or find any other way to keep notifications + # consistent across all managers + def _snapshot(self): + return { + 'topic': CalibrationManager.TOPIC, + 'name': 'state', + 'payload': copy(self) + } - def update_container_offset(self): - raise NotImplemented() + def _on_state_changed(self): + publish(CalibrationManager.TOPIC, self._snapshot()) diff --git a/api/opentrons/api/models.py b/api/opentrons/api/models.py index 31f03b2e431..ddcc828af4c 100644 --- a/api/opentrons/api/models.py +++ b/api/opentrons/api/models.py @@ -1,6 +1,8 @@ class Container: def __init__(self, container, instruments=None): instruments = instruments or [] + self._container = container + self.id = id(container) self.name = container.get_name() self.type = container.get_type() @@ -8,20 +10,13 @@ def __init__(self, container, instruments=None): self.instruments = [ Instrument(instrument) for instrument in instruments] - self.wells = [Well(well) for well in container] - - -class Well: - def __init__(self, well): - self.id = id(well) - self.properties = well.properties.copy() - self.coordinates = well.coordinates(reference=well.parent) class Instrument: def __init__(self, instrument, containers=None): containers = containers or [] - self._instruments = instrument + self._instrument = instrument + self.id = id(instrument) self.name = instrument.name self.channels = instrument.channels diff --git a/api/opentrons/api/routers.py b/api/opentrons/api/routers.py index 25eb13a2311..de95af9899c 100644 --- a/api/opentrons/api/routers.py +++ b/api/opentrons/api/routers.py @@ -1,16 +1,23 @@ +from opentrons.broker import subscribe, Notifications + from .session import SessionManager, Session from .calibration import CalibrationManager -from opentrons.broker import subscribe, Notifications class MainRouter: def __init__(self, loop=None): self._notifications = Notifications(loop=loop) + + self._unsubscribe = [] + self._unsubscribe += [subscribe( + Session.TOPIC, + self._notifications.on_notify)] + self._unsubscribe += [subscribe( + CalibrationManager.TOPIC, + self._notifications.on_notify)] + self.session_manager = SessionManager(loop=loop) self.calibration_manager = CalibrationManager(loop=loop) - self._unsubscribe = subscribe( - Session.TOPIC, - self._notifications.on_notify) @property def notifications(self): @@ -20,4 +27,5 @@ def __enter__(self): return self def __exit__(self, exc_type, exc_value, traceback): - self._unsubscribe() + for unsubscribe in self._unsubscribe: + unsubscribe() diff --git a/api/opentrons/api/session.py b/api/opentrons/api/session.py index 08c96f617bb..6cdd445c30f 100644 --- a/api/opentrons/api/session.py +++ b/api/opentrons/api/session.py @@ -1,14 +1,15 @@ import ast +from copy import copy from datetime import datetime from functools import reduce -from .models import Container, Instrument - from opentrons.broker import publish, subscribe +from opentrons.containers import get_container from opentrons.commands import tree, types from opentrons import robot from opentrons.robot.robot import Robot -from opentrons.containers import get_container + +from .models import Container, Instrument VALID_STATES = {'loaded', 'running', 'finished', 'stopped', 'paused'} @@ -43,6 +44,9 @@ def __init__(self, name, text): self._instruments = [] self._interactions = [] + self.instruments = None + self.containers = None + self.refresh() def get_instruments(self): @@ -76,6 +80,8 @@ def clear_logs(self): self.errors.clear() def _simulate(self): + self._reset() + stack = [] res = [] commands = [] @@ -105,12 +111,15 @@ def on_command(message): unsubscribe = subscribe(types.COMMAND, on_command) try: - self.run() + exec(self._protocol, {}) + except Exception as e: + self.error_append(e) + raise e finally: unsubscribe() # Accumulate containers, instruments, interactions from commands - containers, instruments, interactions = _accumulate( + instruments, containers, interactions = _accumulate( [_get_labware(command) for command in commands]) self._containers.extend(_dedupe(containers)) @@ -120,7 +129,7 @@ def on_command(message): return res def refresh(self): - self.clear_logs() + self._reset() try: parsed = ast.parse(self.protocol_text) @@ -129,6 +138,10 @@ def refresh(self): finally: if self.errors: raise Exception(*self.errors) + + self.containers = self.get_containers() + self.instruments = self.get_instruments() + self.set_state('loaded') return self @@ -147,21 +160,16 @@ def resume(self): self.set_state('running') return self - def run(self, devicename=None): - # HACK: hard reset singleton by replacing all of it's attributes - # with the one from a newly constructed robot - robot.__dict__ = {**Robot().__dict__} - self.clear_logs() - _unsubscribe = None - + def run(self, devicename): def on_command(message): if message['$'] == 'before': self.log_append() - if devicename is not None: - _unsubscribe = subscribe(types.COMMAND, on_command) - self.set_state('running') - robot.connect(devicename) + self._reset() + + _unsubscribe = subscribe(types.COMMAND, on_command) + self.set_state('running') + robot.connect(devicename) try: exec(self._protocol, {}) @@ -169,12 +177,8 @@ def on_command(message): self.error_append(e) raise e finally: - if _unsubscribe: - _unsubscribe() - # TODO (artyom, 20170927): we should fully separate - # run and simulate code - if devicename is not None: - self.set_state('finished') + _unsubscribe() + self.set_state('finished') robot.disconnect() return self @@ -204,18 +208,24 @@ def error_append(self, error): ) self._on_state_changed() + def _reset(self): + # HACK: hard reset singleton by replacing all of it's attributes + # with the one from a newly constructed robot + robot.__dict__ = {**Robot().__dict__} + self.clear_logs() + + # TODO (artyom, 20171003): along with calibration, consider extracting this + # into abstract base class or find any other way to keep notifications + # consistent across all managers def _snapshot(self): return { 'topic': Session.TOPIC, 'name': 'state', - 'payload': { - 'name': self.name, - 'state': self.state, - 'protocol_text': self.protocol_text, - 'commands': self.commands.copy(), - 'command_log': self.command_log.copy(), - 'errors': self.errors.copy() - } + # we are making a copy to avoid the scenario + # when object state is updated elsewhere before + # it is serialized and transferred + 'payload': copy(self) + } def _on_state_changed(self): diff --git a/api/opentrons/broker/broker.py b/api/opentrons/broker/broker.py index 10cc91bb395..88d0b5f9ac6 100644 --- a/api/opentrons/broker/broker.py +++ b/api/opentrons/broker/broker.py @@ -48,7 +48,6 @@ def __aiter__(self): def subscribe(topic, handler): handlers = subscriptions[topic] = subscriptions.get(topic, []) - if handler in handlers: return diff --git a/api/opentrons/containers/placeable.py b/api/opentrons/containers/placeable.py index cd231985192..ce44d6689a2 100644 --- a/api/opentrons/containers/placeable.py +++ b/api/opentrons/containers/placeable.py @@ -30,10 +30,12 @@ def unpack_location(location): def get_container(location): obj, _ = unpack_location(location) - if isinstance(obj, Container): - return obj - if isinstance(obj.parent, Container): - return obj.parent + + for obj in obj.get_trace(): + # TODO(artyom 20171003): WellSeries will go away once we start + # supporting multi-channel properly + if isinstance(obj, Container) and not isinstance(obj, WellSeries): + return obj def humanize_location(location): @@ -107,7 +109,7 @@ def __repr__(self): """ Return full path to the :Placeable: for debugging """ - return ''.join([str(i) for i in reversed(self.get_trace())]) + return ''.join([str(i) for i in reversed(list(self.get_trace()))]) def __str__(self): if not self.parent: @@ -192,26 +194,21 @@ def get_path(self, reference=None): def get_trace(self, reference=None): """ - Returns a list of parents up to :reference:, including reference + Returns a generator of parents up to :reference:, including reference If :reference: is *None* root is assumed Closest ancestor goes first """ - def get_next_parent(): - item = self - while item: - yield item - if item == reference: - break - item = item.parent - - trace = list(get_next_parent()) + item = self + while item: + yield item + if item == reference: + return + item = item.parent - if reference is not None and reference not in trace: + if reference is not None: raise Exception( 'Reference {} is not in Ancestry'.format(reference)) - return trace - def coordinates(self, reference=None): """ Returns the coordinates of a :Placeable: relative to :reference: diff --git a/api/tests/opentrons/api/conftest.py b/api/tests/opentrons/api/conftest.py new file mode 100644 index 00000000000..feb2f9d34b8 --- /dev/null +++ b/api/tests/opentrons/api/conftest.py @@ -0,0 +1,50 @@ +import asyncio +import pytest +from functools import partial +from opentrons.api import models +from collections import namedtuple + + +async def wait_until(matcher, notifications, timeout=1, loop=None): + result = [] + for coro in iter(notifications.__anext__, None): + done, pending = await asyncio.wait([coro], timeout=timeout) + + if pending: + raise TimeoutError('While waiting for {0}'.format(matcher)) + + result += [done.pop().result()] + + if matcher(result[-1]): + return result + + +@pytest.fixture +def model(): + from opentrons import robot, instruments, containers + from opentrons.robot.robot import Robot + + robot.__dict__ = {**Robot().__dict__} + + pipette = instruments.Pipette(axis='a') + plate = containers.load('96-flat', 'A1') + + instrument = models.Instrument(pipette) + container = models.Container(plate) + + return namedtuple('model', 'instrument container')( + instrument=instrument, + container=container + ) + + +@pytest.fixture +def main_router(loop): + from opentrons.api.routers import MainRouter + + with MainRouter(loop=loop) as router: + router.wait_until = partial( + wait_until, + notifications=router.notifications, + loop=loop) + yield router diff --git a/api/tests/opentrons/api/test_calibration.py b/api/tests/opentrons/api/test_calibration.py new file mode 100644 index 00000000000..779311d9ad1 --- /dev/null +++ b/api/tests/opentrons/api/test_calibration.py @@ -0,0 +1,81 @@ +from unittest import mock + + +def state(state): + def _match(item): + return item['name'] == 'state' and \ + item['topic'] == 'calibration' and \ + item['payload'].state == state + + return _match + + +async def test_tip_probe(main_router): + # TODO (artyom, 20171002): remove create=True once driver code is merged + with mock.patch( + 'opentrons.util.calibration_functions.probe_instrument', + create=True) as patch: + main_router.calibration_manager.tip_probe('instrument') + patch.assert_called_with( + 'instrument', + main_router.calibration_manager._robot) + + await main_router.wait_until(state('probing')) + await main_router.wait_until(state('ready')) + + +async def test_move_to_front(main_router, model): + # TODO (artyom, 20171002): remove create=True once driver code is merged + with mock.patch( + 'opentrons.util.calibration_functions.move_instrument_for_probing_prep', # NOQA + create=True) as patch: + + main_router.calibration_manager.move_to_front(model.instrument) + patch.assert_called_with( + model.instrument._instrument, + main_router.calibration_manager._robot) + + await main_router.wait_until(state('moving')) + await main_router.wait_until(state('ready')) + + +async def test_move_to(main_router, model): + with mock.patch.object(model.instrument._instrument, 'move_to') as move_to: + main_router.calibration_manager.move_to( + model.instrument, + model.container) + + move_to.assert_called_with(model.container._container[0]) + + await main_router.wait_until(state('moving')) + await main_router.wait_until(state('ready')) + + +async def test_jog(main_router, model): + # TODO (artyom, 20171002): remove create=True once driver code is merged + with mock.patch.object( + model.instrument._instrument, 'jog', create=True) as jog: + main_router.calibration_manager.jog( + model.instrument, + (1, 2, 3) + ) + + jog.assert_called_with((1, 2, 3)) + + await main_router.wait_until(state('moving')) + await main_router.wait_until(state('ready')) + + +async def test_update_container_offset(main_router, model): + with mock.patch.object( + main_router.calibration_manager._robot, + 'calibrate_container_with_instrument') as call: + main_router.calibration_manager.update_container_offset( + model.container, + model.instrument + ) + call.assert_called_with( + container=model.container._container, + instrument=model.instrument._instrument, + save=True + ) diff --git a/api/tests/opentrons/api/test_session.py b/api/tests/opentrons/api/test_session.py index 721e9c7f939..5feb946ac9e 100644 --- a/api/tests/opentrons/api/test_session.py +++ b/api/tests/opentrons/api/test_session.py @@ -91,9 +91,9 @@ async def test_load_and_run(main_router, session_manager, protocol): name, payload = notification['name'], notification['payload'] if (name == 'state'): index += 1 # Command log in sync with add-command events emitted - state = payload['state'] + state = payload.state res.append(state) - if payload['state'] == 'finished': + if payload.state == 'finished': break assert [key for key, _ in itertools.groupby(res)] == \ @@ -233,3 +233,10 @@ def test_get_labware(labware_setup): [plates[0], plates[1]], [(p100, plates[0]), (p1000, plates[0]), (p1000, plates[1])] ] + + +async def test_session_model_functional(session_manager, protocol): + session = session_manager.create(name='', text=protocol.text) + assert [container.name for container in session.containers] == \ + ['tiprack', 'trough', 'plate'] + assert [instrument.name for instrument in session.instruments] == ['p200'] diff --git a/api/tests/opentrons/server/test_server.py b/api/tests/opentrons/server/test_server.py index 20d97f04951..a3d4cc4d4d6 100644 --- a/api/tests/opentrons/server/test_server.py +++ b/api/tests/opentrons/server/test_server.py @@ -9,6 +9,8 @@ class Foo(object): + STATIC = 'static' + def __init__(self, value): self.value = value @@ -87,15 +89,18 @@ async def test_call(session, root): @pytest.mark.parametrize('root', [Foo(0)]) async def test_init(session, root): + serialized_type = session.server.call_and_serialize(lambda: type(root)) expected = { 'root': { 'i': id(root), 't': type_id(root), 'v': {'value': 0} }, - 'type': session.server.call_and_serialize(lambda: type(root)), + 'type': serialized_type, '$': {'type': rpc.CONTROL_MESSAGE}} + assert serialized_type['v']['STATIC'] == 'static', \ + 'Class attributes are serialized correctly' res = await session.socket.receive_json() assert res == expected @@ -129,13 +134,14 @@ async def test_get_object_by_id(session, root): 'data': { 'i': type_id(session.server.root), 't': id(type), - 'v': set([ + 'v': { + 'STATIC', 'value', 'throw', 'next', 'combine', 'add' - ]) + } } } # We care only about dictionary keys, since we don't want