From 899a642d48d305b2e141772fd8f2e1c29309b265 Mon Sep 17 00:00:00 2001 From: IanLondon Date: Fri, 5 Apr 2019 18:35:40 -0400 Subject: [PATCH 1/9] feat(api): define & execute v3 json protocols * define v3 json protocol schema * support v3 JSON protocol execution in APIv2 executor Closes #3110 --- api/src/opentrons/protocol_api/execute.py | 247 +++++++++-- .../opentrons/protocol_api/test_execute.py | 67 ++- .../labware-json-schema/labware-schema.json | 1 + .../protocolSchemaV3.json | 383 ++++++++++++++++++ 4 files changed, 642 insertions(+), 56 deletions(-) create mode 100644 shared-data/protocol-json-schema/protocolSchemaV3.json diff --git a/api/src/opentrons/protocol_api/execute.py b/api/src/opentrons/protocol_api/execute.py index f88efd3fe01..e7e9d89e12b 100644 --- a/api/src/opentrons/protocol_api/execute.py +++ b/api/src/opentrons/protocol_api/execute.py @@ -120,9 +120,7 @@ def load_pipettes_from_json( model = props.get('model') mount = props.get('mount') - # TODO: Ian 2018-11-06 remove this fallback to 'model' when - # backwards-compatability for JSON protocols with versioned - # pipettes is dropped (next JSON protocol schema major bump) + # NOTE: 'name' is only used by v1 and v2 JSON protocols name = props.get('name') if not name: name = model.split('_v')[0] @@ -134,9 +132,31 @@ def load_pipettes_from_json( return pipettes_by_id -def load_labware_from_json( +def load_labware_from_json_defs( ctx: ProtocolContext, protocol: Dict[Any, Any]) -> Dict[str, labware.Labware]: + protocol_labware = protocol.get('labware', {}) + definitions = protocol.get('labwareDefinitions', {}) + loaded_labware = {} + + for labware_id, props in protocol_labware.items(): + slot = props.get('slot') + definition = definitions.get(props.get('definitionId')) + loaded_labware[labware_id] = ctx.load_labware( + labware.Labware( + definition, + ctx.deck.position_for(slot), + props.get('displayName') + ), + slot) + + return loaded_labware + + +def load_labware_from_json_loadnames( + ctx: ProtocolContext, + protocol: Dict[Any, Any]) -> Dict[str, labware.Labware]: + # NOTE: this is only used by v1 and v2 JSON protocols data = protocol.get('labware', {}) loaded_labware = {} bc = BCLabware(ctx) @@ -171,9 +191,9 @@ def _get_well(loaded_labware: Dict[str, labware.Labware], return plate.wells_by_index()[well] -def _get_bottom_offset(command_type: str, - params: Dict[str, Any], - default_values: Dict[str, float]) -> Optional[float]: +def _get_bottom_offset_v1(command_type: str, + params: Dict[str, Any], + default_values: Dict[str, float]) -> Optional[float]: # default offset from bottom for aspirate/dispense commands offset_default = default_values.get( '{}-mm-from-bottom'.format(command_type)) @@ -185,17 +205,17 @@ def _get_bottom_offset(command_type: str, return offset_from_bottom -def _get_location_with_offset(loaded_labware: Dict[str, labware.Labware], - command_type: str, - params: Dict[str, Any], - default_values: Dict[str, float]) -> Location: +def _get_location_with_offset_v1(loaded_labware: Dict[str, labware.Labware], + command_type: str, + params: Dict[str, Any], + default_values: Dict[str, float]) -> Location: well = _get_well(loaded_labware, params) # Never move to the bottom of the fixed trash if 'fixedTrash' in labware.quirks_from_any_parent(well): return well.top() - offset_from_bottom = _get_bottom_offset( + offset_from_bottom = _get_bottom_offset_v1( command_type, params, default_values) bot = well.bottom() @@ -208,9 +228,27 @@ def _get_location_with_offset(loaded_labware: Dict[str, labware.Labware], return with_offs +def _get_location_with_offset_v3(loaded_labware: Dict[str, labware.Labware], + command_type: str, + params: Dict[str, Any]) -> Location: + well = _get_well(loaded_labware, params) + + # Never move to the bottom of the fixed trash + if 'fixedTrash' in labware.quirks_from_any_parent(well): + return well.top() + + offset_from_bottom = params.get('offsetFromBottomMm') + if None is offset_from_bottom: + raise RuntimeError('"offsetFromBottomMm" is required for {}' + .format(command_type)) + + bottom = well.bottom() + return bottom.move(Point(z=offset_from_bottom)) + + # TODO (Ian 2018-08-22) once Pipette has more sensible way of managing # flow rate value (eg as an argument in aspirate/dispense fns), remove this -def _set_flow_rate( +def _set_flow_rate_v1( pipette_name, pipette, command_type, params, default_values): """ Set flow rate in uL/mm, to value obtained from command's params, @@ -244,10 +282,47 @@ def _set_flow_rate( } -def dispatch_json(context: ProtocolContext, # noqa(C901) - protocol_data: Dict[Any, Any], - instruments: Dict[str, InstrumentContext], - labware: Dict[str, labware.Labware]): +# TODO (Ian 2019-04-05) once Pipette commands allow flow rate as an +# absolute value (not % value) as an argument in +# aspirate/dispense/blowout/air_gap fns, remove this +def _set_flow_rate_v3( + pipette_name, pipette, command_type, params): + """ + Set flow rate in uL/mm, to value obtained from command's params. + """ + flow_rate_param = params.get('flowRate') + + pipette.flow_rate = { + 'aspirate': flow_rate_param, + 'dispense': flow_rate_param + } + + +def get_protocol_schema_version(protocol_json: Dict[Any, Any]) -> int: + # v3 and above uses `schemaVersion: integer` + version = protocol_json.get('schemaVersion') + if None is not version: + return version + # v1 uses 1.x.x and v2 uses 2.x.x + legacyKebabVersion = protocol_json.get('protocol-schema') + # No minor/patch schemas ever were released, + # do not permit protocols with nonexistent schema versions to load + if (legacyKebabVersion == '1.0.0'): + return 1 + if (legacyKebabVersion == '2.0.0'): + return 2 + if (legacyKebabVersion is not None): + raise RuntimeError(('No such schema version: "{}". Did you mean ' + + '"1.0.0" or "2.0.0"?').format(legacyKebabVersion)) + raise RuntimeError( + 'Could not determine schema version for protcol. ' + + 'Make sure there is a version number under "schemaVersion"') + + +def dispatch_json_v1(context: ProtocolContext, # noqa(C901) + protocol_data: Dict[Any, Any], + instruments: Dict[str, InstrumentContext], + loaded_labware: Dict[str, labware.Labware]): subprocedures = [ p.get('subprocedure', []) for p in protocol_data.get('procedure', [])] @@ -281,42 +356,42 @@ def dispatch_json(context: ProtocolContext, # noqa(C901) context.delay(seconds=wait) elif command_type == 'blowout': - well = _get_well(labware, params) + well = _get_well(loaded_labware, params) pipette.blow_out(well) # type: ignore elif command_type == 'pick-up-tip': - well = _get_well(labware, params) + well = _get_well(loaded_labware, params) pipette.pick_up_tip(well) # type: ignore elif command_type == 'drop-tip': - well = _get_well(labware, params) + well = _get_well(loaded_labware, params) pipette.drop_tip(well) # type: ignore elif command_type == 'aspirate': - location = _get_location_with_offset( - labware, 'aspirate', params, default_values) + location = _get_location_with_offset_v1( + loaded_labware, 'aspirate', params, default_values) volume = params['volume'] - _set_flow_rate( + _set_flow_rate_v1( pipette_name, pipette, command_type, params, default_values) pipette.aspirate(volume, location) # type: ignore elif command_type == 'dispense': - location = _get_location_with_offset( - labware, 'dispense', params, default_values) + location = _get_location_with_offset_v1( + loaded_labware, 'dispense', params, default_values) volume = params['volume'] - _set_flow_rate( + _set_flow_rate_v1( pipette_name, pipette, command_type, params, default_values) pipette.dispense(volume, location) # type: ignore elif command_type == 'touch-tip': - well = _get_well(labware, params) - offset = default_values.get('touch-tip-mm-from-top', -1) - pipette.touch_tip(location, v_offset=offset) # type: ignore + well = _get_well(loaded_labware, params) + offset = default_values.get('touchTipMmFromTop', -1) + pipette.touch_tip(well, 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": {}' + raise ValueError('Invalid "slot" for "moveToSlot": {}' .format(slot)) slot_obj = context.deck.position_for(slot) @@ -328,10 +403,102 @@ def dispatch_json(context: ProtocolContext, # noqa(C901) pipette.move_to( # type: ignore slot_obj.move(offsetPoint), - force_direct=params.get('force-direct'), - minimum_z_height=params.get('minimum-z-height')) + force_direct=params.get('forceDirect'), + minimum_z_height=params.get('minimumZHeight')) else: - MODULE_LOG.warning("Bad command type {}".format(command_type)) + raise RuntimeError( + "Unsupported command type {}".format(command_type)) + + +def dispatch_json_v3(context: ProtocolContext, # noqa(C901) + protocol_data: Dict[Any, Any], + instruments: Dict[str, InstrumentContext], + loaded_labware: Dict[str, labware.Labware]): + commands = protocol_data.get('commands', []) + + for command_item in commands: + command_type = command_item.get('command') + params = command_item.get('params', {}) + pipette = instruments.get(params.get('pipette')) + protocol_pipette_data = protocol_data\ + .get('pipettes', {})\ + .get(params.get('pipette'), {}) + pipette_name = protocol_pipette_data.get('name') + + if (not pipette_name): + # TODO: Ian 2018-11-06 remove this fallback to 'model' when + # backwards-compatability for JSON protocols with versioned + # pipettes is dropped (next JSON protocol schema major bump) + pipette_name = protocol_pipette_data.get('model') + + if command_type == 'delay': + wait = params.get('wait') + if wait is None: + raise ValueError('Delay cannot be null') + elif wait is True: + message = params.get('message', 'Pausing until user resumes') + context.pause(msg=message) + else: + context.delay(seconds=wait) + + elif command_type == 'blowout': + well = _get_well(loaded_labware, params) + _set_flow_rate_v3( + pipette_name, pipette, command_type, params) + pipette.blow_out(well) # type: ignore + + elif command_type == 'pickUpTip': + well = _get_well(loaded_labware, params) + pipette.pick_up_tip(well) # type: ignore + + elif command_type == 'dropTip': + well = _get_well(loaded_labware, params) + pipette.drop_tip(well) # type: ignore + + elif command_type == 'aspirate': + location = _get_location_with_offset_v3( + loaded_labware, 'aspirate', params) + volume = params['volume'] + _set_flow_rate_v3( + pipette_name, pipette, command_type, params) + pipette.aspirate(volume, location) # type: ignore + + elif command_type == 'dispense': + location = _get_location_with_offset_v3( + loaded_labware, 'dispense', params) + volume = params['volume'] + _set_flow_rate_v3( + pipette_name, pipette, command_type, params) + pipette.dispense(volume, location) # type: ignore + + elif command_type == 'touchTip': + location = _get_location_with_offset_v3( + loaded_labware, 'dispense', params) + well = _get_well(loaded_labware, params) + # convert mmFromBottom to v_offset + v_offset = location.point.z - well.top().point.z + pipette.touch_tip(well, v_offset=v_offset) # type: ignore + + elif command_type == 'moveToSlot': + slot = params.get('slot') + if slot not in [str(s+1) for s in range(12)]: + raise ValueError('Invalid "slot" for "moveToSlot": {}' + .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('forceDirect'), + minimum_z_height=params.get('minimumZHeight')) + else: + raise RuntimeError( + "Unsupported command type {}".format(command_type)) def run_protocol(protocol_code: Any = None, @@ -364,8 +531,18 @@ def run_protocol(protocol_code: Any = None, if None is not protocol_code: _run_python(protocol_code, true_context) elif None is not protocol_json: - lw = load_labware_from_json(true_context, protocol_json) + protocol_version = get_protocol_schema_version(protocol_json) + if (protocol_version) > 3: + raise RuntimeError('JSON Protocol version {} is not yet supported \ + in this version of the API'.format(protocol_version)) + ins = load_pipettes_from_json(true_context, protocol_json) - dispatch_json(true_context, protocol_json, ins, lw) + + if (protocol_version >= 3): + lw = load_labware_from_json_defs(true_context, protocol_json) + dispatch_json_v3(true_context, protocol_json, ins, lw) + else: + lw = load_labware_from_json_loadnames(true_context, protocol_json) + dispatch_json_v1(true_context, protocol_json, ins, lw) else: raise RuntimeError("run_protocol must have either code or json") diff --git a/api/tests/opentrons/protocol_api/test_execute.py b/api/tests/opentrons/protocol_api/test_execute.py index 9571f052bf6..dffe0bd7721 100644 --- a/api/tests/opentrons/protocol_api/test_execute.py +++ b/api/tests/opentrons/protocol_api/test_execute.py @@ -1,5 +1,5 @@ import pytest - +import json from opentrons.types import Point from opentrons.protocol_api import execute, ProtocolContext @@ -119,7 +119,7 @@ async def test_load_pipettes(loop, protocol_data): @pytest.mark.parametrize('command_type', ['aspirate', 'dispense']) -def test_get_location_with_offset(loop, command_type): +def test_get_location_with_offset_v1(loop, command_type): ctx = ProtocolContext(loop=loop) plate = ctx.load_labware_by_name("generic_96_wellplate_380_ul", 1) well = "B2" @@ -140,10 +140,10 @@ def test_get_location_with_offset(loop, command_type): "well": well, "offsetFromBottomMm": offset } - offs = execute._get_bottom_offset( + offs = execute._get_bottom_offset_v1( command_type, command_params, default_values) assert offs == offset - result = execute._get_location_with_offset( + result = execute._get_location_with_offset_v1( loaded_labware, command_type, command_params, default_values) assert result.labware == plate.wells_by_index()[well] assert result.point\ @@ -155,16 +155,16 @@ def test_get_location_with_offset(loop, command_type): } # no command-specific offset, use default - result = execute._get_location_with_offset( + result = execute._get_location_with_offset_v1( loaded_labware, command_type, command_params, default_values) default = default_values['{}-mm-from-bottom'.format(command_type)] - assert execute._get_bottom_offset( + assert execute._get_bottom_offset_v1( command_type, command_params, default_values) == default assert result.point\ == plate.wells_by_index()[well].bottom().point + Point(z=default) -def test_load_labware(loop): +def test_load_labware_v1(loop): ctx = ProtocolContext(loop=loop) data = { "labware": { @@ -185,7 +185,7 @@ def test_load_labware(loop): }, } } - loaded_labware = execute.load_labware_from_json(ctx, data) + loaded_labware = execute.load_labware_from_json_loadnames(ctx, data) # objects in loaded_labware should be same objs as labware objs in the deck assert loaded_labware['sourcePlateId'] == ctx.loaded_labwares[10] @@ -196,7 +196,37 @@ def test_load_labware(loop): assert 'Test Plate' in str(loaded_labware['oldPlateId']) -def test_load_labware_trash(loop): +def test_load_labware_v2(loop): + ctx = ProtocolContext(loop=loop) + # trough def with arbitrary ID + data = { + "labwareDefinitions": { + "someTroughDef": json.loads("""{"ordering":[["A1"],["A2"],["A3"],["A4"],["A5"],["A6"],["A7"],["A8"],["A9"],["A10"],["A11"],["A12"]],"otId":"THIS IS A CUSTOM ID","deprecated":false,"metadata":{"displayName":"CUSTOM 12 Channel Trough","displayVolumeUnits":"mL","displayCategory":"trough"},"cornerOffsetFromSlot":{"x":0,"y":0.32,"z":0},"dimensions":{"overallLength":127.76,"overallWidth":85.8,"overallHeight":44.45},"parameters":{"format":"trough","isTiprack":false,"isMagneticModuleCompatible":false,"loadName":"usa_scientific_12_trough_22_ml","quirks":["centerMultichannelOnWells"]},"wells":{"A1":{"shape":"rectangular","depth":42.16,"length":8.33,"width":71.88,"totalLiquidVolume":22000,"x":13.94,"y":42.9,"z":2.29},"A2":{"shape":"rectangular","depth":42.16,"length":8.33,"width":71.88,"totalLiquidVolume":22000,"x":23.03,"y":42.9,"z":2.29},"A3":{"shape":"rectangular","depth":42.16,"length":8.33,"width":71.88,"totalLiquidVolume":22000,"x":32.12,"y":42.9,"z":2.29},"A4":{"shape":"rectangular","depth":42.16,"length":8.33,"width":71.88,"totalLiquidVolume":22000,"x":41.21,"y":42.9,"z":2.29},"A5":{"shape":"rectangular","depth":42.16,"length":8.33,"width":71.88,"totalLiquidVolume":22000,"x":50.3,"y":42.9,"z":2.29},"A6":{"shape":"rectangular","depth":42.16,"length":8.33,"width":71.88,"totalLiquidVolume":22000,"x":59.39,"y":42.9,"z":2.29},"A7":{"shape":"rectangular","depth":42.16,"length":8.33,"width":71.88,"totalLiquidVolume":22000,"x":68.48,"y":42.9,"z":2.29},"A8":{"shape":"rectangular","depth":42.16,"length":8.33,"width":71.88,"totalLiquidVolume":22000,"x":77.57,"y":42.9,"z":2.29},"A9":{"shape":"rectangular","depth":42.16,"length":8.33,"width":71.88,"totalLiquidVolume":22000,"x":86.66,"y":42.9,"z":2.29},"A10":{"shape":"rectangular","depth":42.16,"length":8.33,"width":71.88,"totalLiquidVolume":22000,"x":95.75,"y":42.9,"z":2.29},"A11":{"shape":"rectangular","depth":42.16,"length":8.33,"width":71.88,"totalLiquidVolume":22000,"x":104.84,"y":42.9,"z":2.29},"A12":{"shape":"rectangular","depth":42.16,"length":8.33,"width":71.88,"totalLiquidVolume":22000,"x":113.93,"y":42.9,"z":2.29}},"brand":{"brand":"USA Scientific","brandId":["1061-8150"]}}""") # noqa + }, + "labware": { + "sourcePlateId": { + "slot": "10", + "definitionId": "someTroughDef", + "displayName": "Source (Buffer)" + }, + "destPlateId": { + "slot": "11", + "definitionId": "someTroughDef" + }, + } + } + loaded_labware = execute.load_labware_from_json_defs(ctx, data) + + # objects in loaded_labware should be same objs as labware objs in the deck + assert loaded_labware['sourcePlateId'] == ctx.loaded_labwares[10] + # use the displayName from protocol's labware.labwareId.displayName + assert 'Source (Buffer)' in str(loaded_labware['sourcePlateId']) + assert loaded_labware['destPlateId'] == ctx.loaded_labwares[11] + # use the metadata.displayName from embedded def + assert 'CUSTOM 12 Channel Trough' in str(loaded_labware['destPlateId']) + + +def test_load_labware_trash_v1(loop): ctx = ProtocolContext(loop=loop) data = { "labware": { @@ -206,18 +236,13 @@ def test_load_labware_trash(loop): } } } - result = execute.load_labware_from_json(ctx, data) + result = execute.load_labware_from_json_loadnames(ctx, data) assert result['someTrashId'] == ctx.fixed_trash -def test_blank_protocol(loop): - # Check that this doesn’t throw an exception - ctx = ProtocolContext(loop=loop) - execute.run_protocol(protocol_json={}, context=ctx) - - -protocol_data = { +protocol_v1_data = { + "protocol-schema": "1.0.0", "default-values": { "aspirate-flow-rate": { "p300_single_v1": 101 @@ -266,7 +291,7 @@ def test_blank_protocol(loop): } -def test_dispatch_commands(monkeypatch, loop): +def test_dispatch_commands_v1(monkeypatch, loop): ctx = ProtocolContext(loop=loop) cmd = [] flow_rates = [] @@ -283,7 +308,7 @@ def mock_dispense(volume, location): def mock_set_flow_rate(mount, aspirate=None, dispense=None): flow_rates.append((aspirate, dispense)) - insts = execute.load_pipettes_from_json(ctx, protocol_data) + insts = execute.load_pipettes_from_json(ctx, protocol_v1_data) source_plate = ctx.load_labware_by_name('generic_96_wellplate_380_ul', '1') dest_plate = ctx.load_labware_by_name('generic_96_wellplate_380_ul', '2') @@ -299,8 +324,8 @@ def mock_set_flow_rate(mount, aspirate=None, dispense=None): mock_set_flow_rate) monkeypatch.setattr(ctx, 'delay', mock_sleep) - execute.dispatch_json( - ctx, protocol_data, insts, loaded_labware) + execute.dispatch_json_v1( + ctx, protocol_v1_data, insts, loaded_labware) assert cmd == [ ("aspirate", 5, source_plate.wells_by_index()['A1'].bottom()), diff --git a/shared-data/labware-json-schema/labware-schema.json b/shared-data/labware-json-schema/labware-schema.json index 4bd0138d493..36b37445310 100644 --- a/shared-data/labware-json-schema/labware-schema.json +++ b/shared-data/labware-json-schema/labware-schema.json @@ -1,4 +1,5 @@ { + "$id": "opentronsLabwareSchemaV2", "$schema": "http://json-schema.org/draft-07/schema#", "definitions": { "positiveNumber": { diff --git a/shared-data/protocol-json-schema/protocolSchemaV3.json b/shared-data/protocol-json-schema/protocolSchemaV3.json new file mode 100644 index 00000000000..ad24d97245e --- /dev/null +++ b/shared-data/protocol-json-schema/protocolSchemaV3.json @@ -0,0 +1,383 @@ +{ + "$id": "opentronsProtocolSchemaV3", + "$schema": "http://json-schema.org/draft-07/schema#", + + "definitions": { + "pipetteName": { + "description": "Name of a pipette. Does not contain info about specific model/version. Should match keys in pipetteNameSpecs.json", + "type": "string", + "enum": [ + "p10_single", + "p10_multi", + "p50_single", + "p50_multi", + "p300_single", + "p300_multi", + "p1000_single", + "p1000_multi" + ] + }, + + "mmOffset": { + "description": "Millimeters for pipette location offsets", + "type": "number" + }, + + "flowRateForPipettes": { + "description": "Flow rate in mm/sec for each pipette used in the protocol, by pipette name", + "type": "object", + "propertyNames": {"$ref": "#/definitions/pipetteName"}, + "patternProperties": {".*": {"type": "number", "minimum": 0}}, + "additionalProperties": false + }, + + "offsetFromBottomMm": { + "description": "Offset from bottom of well in millimeters", + "required": ["offsetFromBottomMm"], + "properties": { + "offsetFromBottomMm": {"$ref": "#/definitions/mmOffset"} + } + }, + + "pipetteAccessParams": { + "required": ["pipette", "labware", "well"], + "properties": { + "pipette": { + "type": "string" + }, + "labware": { + "type": "string" + }, + "well": { + "type": "string" + } + } + }, + + "volumeParams": { + "required": ["volume"], + "volume": { + "type": "number" + } + }, + + "flowRate": { + "required": ["flowRate"], + "properties": { + "flowRate": { + "description": "Flow rate in uL/sec", + "type": "number" + } + } + }, + + "slot": { + "description": "Slot on the deck of an OT-2 robot", + "type": "string", + "enum": ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12"] + } + }, + + "type": "object", + "additionalProperties": false, + "required": [ + "schemaVersion", + "metadata", + "robot", + "pipettes", + "labware", + "labwareDefinitions", + "commands" + ], + "properties": { + "schemaVersion": { + "description": "Schema version of a protocol is a single integer", + "enum": [3] + }, + + "metadata": { + "description": "Optional metadata about the protocol", + "type": "object", + + "properties": { + "protocolName": { + "description": "A short, human-readable name for the protocol", + "type": "string" + }, + "author": { + "description": "The author or organization who created the protocol", + "type": "string" + }, + "description": { + "description": "A text description of the protocol.", + "type": ["string", "null"] + }, + + "created": { + "description": "UNIX timestamp when this file was created", + "type": "number" + }, + "lastModified": { + "description": "UNIX timestamp when this file was last modified", + "type": ["number", "null"] + }, + + "category": { + "description": "Category of protocol (eg, \"Basic Pipetting\")", + "type": ["string", "null"] + }, + "subcategory": { + "description": "Subcategory of protocol (eg, \"Cell Plating\")", + "type": ["string", "null"] + }, + "tags": { + "description": "Tags to be used in searching for this protocol", + "type": "array", + "items": { + "type": "string" + } + } + } + }, + + "designerApplication": { + "description": "Optional data & metadata not required to execute the protocol, used by the application that created this protocol", + "type": "object", + "properties": { + "name": { + "description": "Name of the application that created the protocol. Should be namespaced under the organization or individual who owns the organization, eg \"opentrons/protocol-designer\"", + "type": "string" + }, + "version": { + "description": "Version of the application that created the protocol", + "type": "string" + }, + "data": { + "description": "Any data used by the application that created this protocol", + "type": "object" + } + } + }, + + "robot": { + "required": ["model"], + "properties": { + "model": { + "description": "Model of the robot this protocol is written for (currently only OT-2 Standard is supported)", + "type": "string", + "enum": ["OT-2 Standard"] + } + } + }, + + "pipettes": { + "description": "The pipettes used in this protocol, keyed by an arbitrary unique ID", + "additionalProperties": false, + "patternProperties": { + ".+": { + "description": "Fields describing an individual pipette", + "type": "object", + "required": ["mount", "name"], + "additionalProperties": false, + "properties": { + "mount": { + "description": "Where the pipette is mounted", + "type": "string", + "enum": ["left", "right"] + }, + "name": { + "$ref": "#/definitions/pipetteName" + } + } + } + } + }, + + "labwareDefinitions": { + "description": "All labware definitions used by labware in this protocol, keyed by UUID", + "patternProperties": { + ".+": { + "$ref": "opentronsLabwareSchemaV2" + } + } + }, + + "labware": { + "description": "All types of labware used in this protocol, and references to their definitions", + "patternProperties": { + ".+": { + "description": "Fields describing a single labware on the deck", + "type": "object", + "required": ["slot", "definitionId"], + "additionalProperties": false, + "properties": { + "slot": {"$ref": "#/definitions/slot"}, + "definitionId": { + "description": "reference to this labware's ID in \"labwareDefinitions\"", + "type": "string" + }, + "displayName": { + "description": "An optional human-readable nickname for this labware. Eg \"Buffer Trough\"", + "type": "string" + } + } + } + } + }, + + "commands": { + "description": "An array of command objects representing steps to be executed on the robot", + "type": "array", + "items": { + "anyOf": [ + { + "description": "Aspirate / dispense / air gap commands", + "type": "object", + "required": ["command", "params"], + "additionalProperties": false, + "properties": { + "command": { + "enum": ["aspirate", "dispense", "airGap"] + }, + "params": { + "allOf": [ + {"$ref": "#/definitions/flowRate"}, + {"$ref": "#/definitions/pipetteAccessParams"}, + {"$ref": "#/definitions/volumeParams"}, + {"$ref": "#/definitions/offsetFromBottomMm"} + ] + } + } + }, + + { + "description": "Blowout command", + "type": "object", + "required": ["command", "params"], + "additionalProperties": false, + "properties": { + "command": { + "enum": ["blowout"] + }, + "params": { + "allOf": [ + {"$ref": "#/definitions/flowRate"}, + {"$ref": "#/definitions/pipetteAccessParams"}, + {"$ref": "#/definitions/offsetFromBottomMm"} + ] + } + } + }, + + { + "description": "Touch tip commands", + "type": "object", + "required": ["command", "params"], + "additionalProperties": false, + "properties": { + "command": { + "enum": ["touchTip"] + }, + "params": { + "allOf": [ + {"$ref": "#/definitions/pipetteAccessParams"}, + {"$ref": "#/definitions/offsetFromBottomMm"} + ] + } + } + }, + + + { + "description": "Pick up tip / drop tip commands", + "type": "object", + "required": ["command", "params"], + "additionalProperties": false, + "properties": { + "command": { + "enum": ["pickUpTip", "dropTip"] + }, + "params": { + "allOf": [ + {"$ref": "#/definitions/pipetteAccessParams"} + ] + } + } + }, + + { + "description": "Move to slot command. NOTE: this is an EXPERIMENTAL command, its behavior is subject to change in future releases.", + "type": "object", + "required": ["command", "params"], + "additionalProperties": false, + "properties": { + "command": {"enum": ["moveToSlot"]}, + "params": { + "type": "object", + "required": ["pipette", "slot"], + "additionalProperties": false, + "properties": { + "pipette": {"type": "string"}, + "slot": {"$ref": "#/definitions/slot"}, + "offset": { + "description": "Optional offset from slot bottom left corner, in mm", + "type": "object", + "required": ["x", "y", "z"], + "properties": { + "x": {"type": "number"}, + "y": {"type": "number"}, + "z": {"type": "number"} + } + }, + "minimumZHeight": { + "description": "Optional minimal Z margin in mm. If this is larger than the API's default safe Z margin, it will make the arc higher. If it's smaller, it will have no effect. Specifying this for movements that would not arc (moving within the same well in the same labware) will cause an arc movement instead. This param only supported in API v2, API v1 will ignore it.", + "type": "number", + "minimum": 0 + }, + "forceDirect": { + "description": "Default is false. If true, moving from one labware/well to another will not arc to the default safe z, but instead will move directly to the specified location. This will also force the 'minimumZHeight' param to be ignored. In APIv1, this will use strategy='direct', which moves first in X/Y plane and then in Z. In API v2, a 'direct' movement is in X/Y/Z simultaneously", + "type": "boolean" + } + } + } + } + }, + + { + "description": "Delay command", + "type": "object", + "required": ["command", "params"], + "additionalProperties": false, + "properties": { + "command": { + "enum": ["delay"] + }, + "params": { + "type": "object", + "additionalProperties": false, + "required": ["wait"], + "properties": { + "wait": { + "description": "either a number of seconds to wait (fractional values OK), or `true` to wait indefinitely until the user manually resumes the protocol", + "anyOf": [ + {"type": "number"}, + {"enum": [true]} + ] + }, + "message": { + "description": "optional message describing the delay" + } + } + } + } + } + ] + } + }, + + "commandAnnotations": { + "description": "An optional object of annotations associated with commands. Its usage has not yet been defined, so its shape is not enforced by this schema.", + "type": "object" + } + } +} From 2b08a48d20ff6be209aacaaa88b2e6073a41dc29 Mon Sep 17 00:00:00 2001 From: IanLondon Date: Mon, 8 Apr 2019 11:25:32 -0400 Subject: [PATCH 2/9] add tests --- .../opentrons/protocol_api/test_execute.py | 151 +++++++++++++++++- 1 file changed, 150 insertions(+), 1 deletion(-) diff --git a/api/tests/opentrons/protocol_api/test_execute.py b/api/tests/opentrons/protocol_api/test_execute.py index dffe0bd7721..188cbf7e3e9 100644 --- a/api/tests/opentrons/protocol_api/test_execute.py +++ b/api/tests/opentrons/protocol_api/test_execute.py @@ -297,7 +297,7 @@ def test_dispatch_commands_v1(monkeypatch, loop): flow_rates = [] def mock_sleep(minutes=0, seconds=0): - cmd.append(("sleep", seconds)) + cmd.append(("sleep", minutes * 60 + seconds)) def mock_aspirate(volume, location): cmd.append(("aspirate", volume, location)) @@ -337,3 +337,152 @@ def mock_set_flow_rate(mount, aspirate=None, dispense=None): (123, 102), (101, 102) ] + + +class MockPipette(object): + def __init__(self, command_log): + self.log = command_log + + def _make_logger(self, name): + def log_fn(*args, **kwargs): + if kwargs: + self.log.append((name, args, kwargs)) + else: + self.log.append((name, args)) + return log_fn + + def __getattr__(self, name): + if name == 'log': + return self.log + else: + return self._make_logger(name) + + def __setattr__(self, name, value): + if name == 'log': + super(MockPipette, self).__setattr__(name, value) + else: + self.log.append(("set: {}".format(name), value)) + + +def test_dispatch_commands_v3(monkeypatch, loop): + protocol_v3_data = { + "schemaVersion": "3", + "commands": [ + { + "command": "pickUpTip", + "params": { + "pipette": "pipetteId", + "labware": "tiprackId", + "well": "B1" + } + }, + { + "command": "aspirate", + "params": { + "pipette": "pipetteId", + "labware": "sourcePlateId", + "well": "A1", + "volume": 5, + "flowRate": 3, + "offsetFromBottomMm": 2 + } + }, + { + "command": "delay", + "params": { + "wait": 42 + } + }, + { + "command": "dispense", + "params": { + "pipette": "pipetteId", + "labware": "destPlateId", + "well": "B1", + "volume": 4.5, + "flowRate": 2.5, + "offsetFromBottomMm": 1 + } + }, + { + "command": "touchTip", + "params": { + "pipette": "pipetteId", + "labware": "destPlateId", + "well": "B1", + "offsetFromBottomMm": 11 + } + }, + { + "command": "blowout", + "params": { + "pipette": "pipetteId", + "labware": "destPlateId", + "well": "B1", + "flowRate": 2, + "offsetFromBottomMm": 12 + } + }, + { + "command": "moveToSlot", + "params": { + "pipette": "pipetteId", + "slot": "5", + "offset": { + "x": 1, + "y": 2, + "z": 3 + } + } + }, + { + "command": "dropTip", + "params": { + "pipette": "pipetteId", + "labware": "trashId", + "well": "A1" + } + } + ] + } + + command_log = [] + mock_pipette = MockPipette(command_log) + insts = {"pipetteId": mock_pipette} + + ctx = ProtocolContext(loop=loop) + + def mock_delay(seconds=0, minutes=0): + command_log.append(("delay", seconds + minutes * 60)) + + monkeypatch.setattr(ctx, 'delay', mock_delay) + + source_plate = ctx.load_labware_by_name('generic_96_wellplate_380_ul', '1') + dest_plate = ctx.load_labware_by_name('generic_96_wellplate_380_ul', '2') + tiprack = ctx.load_labware_by_name('opentrons_96_tiprack_10_ul', '3') + + loaded_labware = { + 'sourcePlateId': source_plate, + 'destPlateId': dest_plate, + 'tiprackId': tiprack, + 'trashId': ctx.fixed_trash + } + + execute.dispatch_json_v3( + ctx, protocol_v3_data, insts, loaded_labware) + + assert command_log == [ + ("pick_up_tip", (tiprack.wells_by_index()['B1'],)), + ("set: flow_rate", {"aspirate": 3, "dispense": 3}), + ("aspirate", (5, source_plate.wells_by_index()['A1'].bottom(2),)), + ("delay", 42), + ("set: flow_rate", {"aspirate": 2.5, "dispense": 2.5}), + ("dispense", (4.5, dest_plate.wells_by_index()['B1'].bottom(1),)), + ("touch_tip", (dest_plate.wells_by_index()['B1'],), + {"v_offset": 0.46000000000000085}), + ("set: flow_rate", {"aspirate": 2, "dispense": 2}), + ("blow_out", (dest_plate.wells_by_index()['B1'],)), + ("move_to", (ctx.deck.position_for('5').move(Point(1, 2, 3)),), + {"force_direct": None, "minimum_z_height": None}), + ("drop_tip", (ctx.fixed_trash.wells_by_index()['A1'],)) + ] From 519bf657af4a509b711d078386a75d98e0c048c3 Mon Sep 17 00:00:00 2001 From: IanLondon Date: Mon, 8 Apr 2019 12:13:29 -0400 Subject: [PATCH 3/9] split out v1 vs v3 execution code --- api/src/opentrons/protocol_api/execute.py | 394 +---------------- api/src/opentrons/protocol_api/execute_v1.py | 234 ++++++++++ api/src/opentrons/protocol_api/execute_v3.py | 180 ++++++++ .../opentrons/protocol_api/test_execute.py | 406 ------------------ .../opentrons/protocol_api/test_execute_v1.py | 228 ++++++++++ .../opentrons/protocol_api/test_execute_v3.py | 182 ++++++++ 6 files changed, 837 insertions(+), 787 deletions(-) create mode 100644 api/src/opentrons/protocol_api/execute_v1.py create mode 100644 api/src/opentrons/protocol_api/execute_v3.py create mode 100644 api/tests/opentrons/protocol_api/test_execute_v1.py create mode 100644 api/tests/opentrons/protocol_api/test_execute_v3.py diff --git a/api/src/opentrons/protocol_api/execute.py b/api/src/opentrons/protocol_api/execute.py index e7e9d89e12b..0c4387ad3df 100644 --- a/api/src/opentrons/protocol_api/execute.py +++ b/api/src/opentrons/protocol_api/execute.py @@ -1,14 +1,11 @@ import inspect -import itertools import logging import traceback import sys -from typing import Any, Callable, Dict, Optional +from typing import Any, Callable, Dict -from .contexts import ProtocolContext, InstrumentContext -from .back_compat import BCLabware -from . import labware -from opentrons.types import Point, Location +from .contexts import ProtocolContext +from . import execute_v1, execute_v3 from opentrons import config MODULE_LOG = logging.getLogger(__name__) @@ -111,193 +108,6 @@ def _run_python(proto: Any, context: ProtocolContext): raise ExceptionInProtocolError(e, tb, str(e), frame.lineno) -def load_pipettes_from_json( - ctx: ProtocolContext, - protocol: Dict[Any, Any]) -> Dict[str, InstrumentContext]: - pipette_data = protocol.get('pipettes', {}) - pipettes_by_id = {} - for pipette_id, props in pipette_data.items(): - model = props.get('model') - mount = props.get('mount') - - # NOTE: 'name' is only used by v1 and v2 JSON protocols - name = props.get('name') - if not name: - name = model.split('_v')[0] - - instr = ctx.load_instrument(name, mount) - - pipettes_by_id[pipette_id] = instr - - return pipettes_by_id - - -def load_labware_from_json_defs( - ctx: ProtocolContext, - protocol: Dict[Any, Any]) -> Dict[str, labware.Labware]: - protocol_labware = protocol.get('labware', {}) - definitions = protocol.get('labwareDefinitions', {}) - loaded_labware = {} - - for labware_id, props in protocol_labware.items(): - slot = props.get('slot') - definition = definitions.get(props.get('definitionId')) - loaded_labware[labware_id] = ctx.load_labware( - labware.Labware( - definition, - ctx.deck.position_for(slot), - props.get('displayName') - ), - slot) - - return loaded_labware - - -def load_labware_from_json_loadnames( - ctx: ProtocolContext, - protocol: Dict[Any, Any]) -> Dict[str, labware.Labware]: - # NOTE: this is only used by v1 and v2 JSON protocols - data = protocol.get('labware', {}) - loaded_labware = {} - bc = BCLabware(ctx) - for labware_id, props in data.items(): - slot = props.get('slot') - model = props.get('model') - if slot == '12': - if model == 'fixed-trash': - # pass in the pre-existing fixed-trash - loaded_labware[labware_id] = ctx.fixed_trash - else: - raise RuntimeError( - "Nothing but the fixed trash may be loaded in slot 12; " - "this protocol attempts to load a {} there." - .format(model)) - else: - loaded_labware[labware_id] = bc.load( - model, slot, label=props.get('display-name')) - - return loaded_labware - - -def _get_well(loaded_labware: Dict[str, labware.Labware], - params: Dict[str, Any]): - labwareId = params['labware'] - well = params['well'] - plate = loaded_labware.get(labwareId) - if not plate: - raise ValueError( - 'Command tried to use labware "{}", but that ID does not exist ' - 'in protocol\'s "labware" section'.format(labwareId)) - return plate.wells_by_index()[well] - - -def _get_bottom_offset_v1(command_type: str, - params: Dict[str, Any], - default_values: Dict[str, float]) -> Optional[float]: - # default offset from bottom for aspirate/dispense commands - offset_default = default_values.get( - '{}-mm-from-bottom'.format(command_type)) - - # optional command-specific value, fallback to default - offset_from_bottom = params.get( - 'offsetFromBottomMm', offset_default) - - return offset_from_bottom - - -def _get_location_with_offset_v1(loaded_labware: Dict[str, labware.Labware], - command_type: str, - params: Dict[str, Any], - default_values: Dict[str, float]) -> Location: - well = _get_well(loaded_labware, params) - - # Never move to the bottom of the fixed trash - if 'fixedTrash' in labware.quirks_from_any_parent(well): - return well.top() - - offset_from_bottom = _get_bottom_offset_v1( - command_type, params, default_values) - - bot = well.bottom() - if offset_from_bottom: - with_offs = bot.move(Point(z=offset_from_bottom)) - else: - with_offs = bot - MODULE_LOG.debug("offset from bottom for {}: {}->{}" - .format(command_type, bot, with_offs)) - return with_offs - - -def _get_location_with_offset_v3(loaded_labware: Dict[str, labware.Labware], - command_type: str, - params: Dict[str, Any]) -> Location: - well = _get_well(loaded_labware, params) - - # Never move to the bottom of the fixed trash - if 'fixedTrash' in labware.quirks_from_any_parent(well): - return well.top() - - offset_from_bottom = params.get('offsetFromBottomMm') - if None is offset_from_bottom: - raise RuntimeError('"offsetFromBottomMm" is required for {}' - .format(command_type)) - - bottom = well.bottom() - return bottom.move(Point(z=offset_from_bottom)) - - -# TODO (Ian 2018-08-22) once Pipette has more sensible way of managing -# flow rate value (eg as an argument in aspirate/dispense fns), remove this -def _set_flow_rate_v1( - pipette_name, pipette, command_type, params, default_values): - """ - Set flow rate in uL/mm, to value obtained from command's params, - or if unspecified in command params, then from protocol's "default-values". - """ - default_aspirate = default_values.get( - 'aspirate-flow-rate', {}).get(pipette_name) - - default_dispense = default_values.get( - 'dispense-flow-rate', {}).get(pipette_name) - - flow_rate_param = params.get('flow-rate') - - if flow_rate_param is not None: - if command_type == 'aspirate': - pipette.flow_rate = { - 'aspirate': flow_rate_param, - 'dispense': default_dispense - } - return - if command_type == 'dispense': - pipette.flow_rate = { - 'aspirate': default_aspirate, - 'dispense': flow_rate_param - } - return - - pipette.flow_rate = { - 'aspirate': default_aspirate, - 'dispense': default_dispense - } - - -# TODO (Ian 2019-04-05) once Pipette commands allow flow rate as an -# absolute value (not % value) as an argument in -# aspirate/dispense/blowout/air_gap fns, remove this -def _set_flow_rate_v3( - pipette_name, pipette, command_type, params): - """ - Set flow rate in uL/mm, to value obtained from command's params. - """ - flow_rate_param = params.get('flowRate') - - pipette.flow_rate = { - 'aspirate': flow_rate_param, - 'dispense': flow_rate_param - } - - def get_protocol_schema_version(protocol_json: Dict[Any, Any]) -> int: # v3 and above uses `schemaVersion: integer` version = protocol_json.get('schemaVersion') @@ -319,188 +129,6 @@ def get_protocol_schema_version(protocol_json: Dict[Any, Any]) -> int: 'Make sure there is a version number under "schemaVersion"') -def dispatch_json_v1(context: ProtocolContext, # noqa(C901) - protocol_data: Dict[Any, Any], - instruments: Dict[str, InstrumentContext], - loaded_labware: Dict[str, labware.Labware]): - subprocedures = [ - p.get('subprocedure', []) - for p in protocol_data.get('procedure', [])] - - default_values = protocol_data.get('default-values', {}) - flat_subs = itertools.chain.from_iterable(subprocedures) - - for command_item in flat_subs: - command_type = command_item.get('command') - params = command_item.get('params', {}) - pipette = instruments.get(params.get('pipette')) - protocol_pipette_data = protocol_data\ - .get('pipettes', {})\ - .get(params.get('pipette'), {}) - pipette_name = protocol_pipette_data.get('name') - - if (not pipette_name): - # TODO: Ian 2018-11-06 remove this fallback to 'model' when - # backwards-compatability for JSON protocols with versioned - # pipettes is dropped (next JSON protocol schema major bump) - pipette_name = protocol_pipette_data.get('model') - - if command_type == 'delay': - wait = params.get('wait') - if wait is None: - raise ValueError('Delay cannot be null') - elif wait is True: - message = params.get('message', 'Pausing until user resumes') - context.pause(msg=message) - else: - context.delay(seconds=wait) - - elif command_type == 'blowout': - well = _get_well(loaded_labware, params) - pipette.blow_out(well) # type: ignore - - elif command_type == 'pick-up-tip': - well = _get_well(loaded_labware, params) - pipette.pick_up_tip(well) # type: ignore - - elif command_type == 'drop-tip': - well = _get_well(loaded_labware, params) - pipette.drop_tip(well) # type: ignore - - elif command_type == 'aspirate': - location = _get_location_with_offset_v1( - loaded_labware, 'aspirate', params, default_values) - volume = params['volume'] - _set_flow_rate_v1( - pipette_name, pipette, command_type, params, default_values) - pipette.aspirate(volume, location) # type: ignore - - elif command_type == 'dispense': - location = _get_location_with_offset_v1( - loaded_labware, 'dispense', params, default_values) - volume = params['volume'] - _set_flow_rate_v1( - pipette_name, pipette, command_type, params, default_values) - pipette.dispense(volume, location) # type: ignore - - elif command_type == 'touch-tip': - well = _get_well(loaded_labware, params) - offset = default_values.get('touchTipMmFromTop', -1) - pipette.touch_tip(well, 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 "moveToSlot": {}' - .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('forceDirect'), - minimum_z_height=params.get('minimumZHeight')) - else: - raise RuntimeError( - "Unsupported command type {}".format(command_type)) - - -def dispatch_json_v3(context: ProtocolContext, # noqa(C901) - protocol_data: Dict[Any, Any], - instruments: Dict[str, InstrumentContext], - loaded_labware: Dict[str, labware.Labware]): - commands = protocol_data.get('commands', []) - - for command_item in commands: - command_type = command_item.get('command') - params = command_item.get('params', {}) - pipette = instruments.get(params.get('pipette')) - protocol_pipette_data = protocol_data\ - .get('pipettes', {})\ - .get(params.get('pipette'), {}) - pipette_name = protocol_pipette_data.get('name') - - if (not pipette_name): - # TODO: Ian 2018-11-06 remove this fallback to 'model' when - # backwards-compatability for JSON protocols with versioned - # pipettes is dropped (next JSON protocol schema major bump) - pipette_name = protocol_pipette_data.get('model') - - if command_type == 'delay': - wait = params.get('wait') - if wait is None: - raise ValueError('Delay cannot be null') - elif wait is True: - message = params.get('message', 'Pausing until user resumes') - context.pause(msg=message) - else: - context.delay(seconds=wait) - - elif command_type == 'blowout': - well = _get_well(loaded_labware, params) - _set_flow_rate_v3( - pipette_name, pipette, command_type, params) - pipette.blow_out(well) # type: ignore - - elif command_type == 'pickUpTip': - well = _get_well(loaded_labware, params) - pipette.pick_up_tip(well) # type: ignore - - elif command_type == 'dropTip': - well = _get_well(loaded_labware, params) - pipette.drop_tip(well) # type: ignore - - elif command_type == 'aspirate': - location = _get_location_with_offset_v3( - loaded_labware, 'aspirate', params) - volume = params['volume'] - _set_flow_rate_v3( - pipette_name, pipette, command_type, params) - pipette.aspirate(volume, location) # type: ignore - - elif command_type == 'dispense': - location = _get_location_with_offset_v3( - loaded_labware, 'dispense', params) - volume = params['volume'] - _set_flow_rate_v3( - pipette_name, pipette, command_type, params) - pipette.dispense(volume, location) # type: ignore - - elif command_type == 'touchTip': - location = _get_location_with_offset_v3( - loaded_labware, 'dispense', params) - well = _get_well(loaded_labware, params) - # convert mmFromBottom to v_offset - v_offset = location.point.z - well.top().point.z - pipette.touch_tip(well, v_offset=v_offset) # type: ignore - - elif command_type == 'moveToSlot': - slot = params.get('slot') - if slot not in [str(s+1) for s in range(12)]: - raise ValueError('Invalid "slot" for "moveToSlot": {}' - .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('forceDirect'), - minimum_z_height=params.get('minimumZHeight')) - else: - raise RuntimeError( - "Unsupported command type {}".format(command_type)) - - def run_protocol(protocol_code: Any = None, protocol_json: Dict[Any, Any] = None, simulate: bool = False, @@ -536,13 +164,17 @@ def run_protocol(protocol_code: Any = None, raise RuntimeError('JSON Protocol version {} is not yet supported \ in this version of the API'.format(protocol_version)) - ins = load_pipettes_from_json(true_context, protocol_json) - if (protocol_version >= 3): - lw = load_labware_from_json_defs(true_context, protocol_json) - dispatch_json_v3(true_context, protocol_json, ins, lw) + ins = execute_v3.load_pipettes_from_json( + true_context, protocol_json) + lw = execute_v3.load_labware_from_json_defs( + true_context, protocol_json) + execute_v3.dispatch_json(true_context, protocol_json, ins, lw) else: - lw = load_labware_from_json_loadnames(true_context, protocol_json) - dispatch_json_v1(true_context, protocol_json, ins, lw) + ins = execute_v1.load_pipettes_from_json( + true_context, protocol_json) + lw = execute_v1.load_labware_from_json_loadnames( + true_context, protocol_json) + execute_v1.dispatch_json(true_context, protocol_json, ins, lw) else: raise RuntimeError("run_protocol must have either code or json") diff --git a/api/src/opentrons/protocol_api/execute_v1.py b/api/src/opentrons/protocol_api/execute_v1.py new file mode 100644 index 00000000000..6f17ab55b08 --- /dev/null +++ b/api/src/opentrons/protocol_api/execute_v1.py @@ -0,0 +1,234 @@ +# execute v1 and v2 protocols +import itertools +import logging +from typing import Any, Dict, Optional + +from .contexts import ProtocolContext, InstrumentContext +from .back_compat import BCLabware +from . import labware +from opentrons.types import Point, Location + +MODULE_LOG = logging.getLogger(__name__) + + +def load_pipettes_from_json( + ctx: ProtocolContext, + protocol: Dict[Any, Any]) -> Dict[str, InstrumentContext]: + pipette_data = protocol.get('pipettes', {}) + pipettes_by_id = {} + for pipette_id, props in pipette_data.items(): + model = props.get('model') + mount = props.get('mount') + + # NOTE: 'name' is only used by v1 and v2 JSON protocols + name = props.get('name') + if not name: + name = model.split('_v')[0] + + instr = ctx.load_instrument(name, mount) + + pipettes_by_id[pipette_id] = instr + + return pipettes_by_id + + +def _get_well(loaded_labware: Dict[str, labware.Labware], + params: Dict[str, Any]): + labwareId = params['labware'] + well = params['well'] + plate = loaded_labware.get(labwareId) + if not plate: + raise ValueError( + 'Command tried to use labware "{}", but that ID does not exist ' + 'in protocol\'s "labware" section'.format(labwareId)) + return plate.wells_by_index()[well] + + +# TODO (Ian 2018-08-22) once Pipette has more sensible way of managing +# flow rate value (eg as an argument in aspirate/dispense fns), remove this +def _set_flow_rate( + pipette_name, pipette, command_type, params, default_values): + """ + Set flow rate in uL/mm, to value obtained from command's params, + or if unspecified in command params, then from protocol's "default-values". + """ + default_aspirate = default_values.get( + 'aspirate-flow-rate', {}).get(pipette_name) + + default_dispense = default_values.get( + 'dispense-flow-rate', {}).get(pipette_name) + + flow_rate_param = params.get('flow-rate') + + if flow_rate_param is not None: + if command_type == 'aspirate': + pipette.flow_rate = { + 'aspirate': flow_rate_param, + 'dispense': default_dispense + } + return + if command_type == 'dispense': + pipette.flow_rate = { + 'aspirate': default_aspirate, + 'dispense': flow_rate_param + } + return + + pipette.flow_rate = { + 'aspirate': default_aspirate, + 'dispense': default_dispense + } + + +def load_labware_from_json_loadnames( + ctx: ProtocolContext, + protocol: Dict[Any, Any]) -> Dict[str, labware.Labware]: + # NOTE: this is only used by v1 and v2 JSON protocols + data = protocol.get('labware', {}) + loaded_labware = {} + bc = BCLabware(ctx) + for labware_id, props in data.items(): + slot = props.get('slot') + model = props.get('model') + if slot == '12': + if model == 'fixed-trash': + # pass in the pre-existing fixed-trash + loaded_labware[labware_id] = ctx.fixed_trash + else: + raise RuntimeError( + "Nothing but the fixed trash may be loaded in slot 12; " + "this protocol attempts to load a {} there." + .format(model)) + else: + loaded_labware[labware_id] = bc.load( + model, slot, label=props.get('display-name')) + + return loaded_labware + + +def _get_bottom_offset(command_type: str, + params: Dict[str, Any], + default_values: Dict[str, float]) -> Optional[float]: + # default offset from bottom for aspirate/dispense commands + offset_default = default_values.get( + '{}-mm-from-bottom'.format(command_type)) + + # optional command-specific value, fallback to default + offset_from_bottom = params.get( + 'offsetFromBottomMm', offset_default) + + return offset_from_bottom + + +def _get_location_with_offset(loaded_labware: Dict[str, labware.Labware], + command_type: str, + params: Dict[str, Any], + default_values: Dict[str, float]) -> Location: + well = _get_well(loaded_labware, params) + + # Never move to the bottom of the fixed trash + if 'fixedTrash' in labware.quirks_from_any_parent(well): + return well.top() + + offset_from_bottom = _get_bottom_offset( + command_type, params, default_values) + + bot = well.bottom() + if offset_from_bottom: + with_offs = bot.move(Point(z=offset_from_bottom)) + else: + with_offs = bot + MODULE_LOG.debug("offset from bottom for {}: {}->{}" + .format(command_type, bot, with_offs)) + return with_offs + + +def dispatch_json(context: ProtocolContext, # noqa(C901) + protocol_data: Dict[Any, Any], + instruments: Dict[str, InstrumentContext], + loaded_labware: Dict[str, labware.Labware]): + subprocedures = [ + p.get('subprocedure', []) + for p in protocol_data.get('procedure', [])] + + default_values = protocol_data.get('default-values', {}) + flat_subs = itertools.chain.from_iterable(subprocedures) + + for command_item in flat_subs: + command_type = command_item.get('command') + params = command_item.get('params', {}) + pipette = instruments.get(params.get('pipette')) + protocol_pipette_data = protocol_data\ + .get('pipettes', {})\ + .get(params.get('pipette'), {}) + pipette_name = protocol_pipette_data.get('name') + + if (not pipette_name): + # TODO: Ian 2018-11-06 remove this fallback to 'model' when + # backwards-compatability for JSON protocols with versioned + # pipettes is dropped (next JSON protocol schema major bump) + pipette_name = protocol_pipette_data.get('model') + + if command_type == 'delay': + wait = params.get('wait') + if wait is None: + raise ValueError('Delay cannot be null') + elif wait is True: + message = params.get('message', 'Pausing until user resumes') + context.pause(msg=message) + else: + context.delay(seconds=wait) + + elif command_type == 'blowout': + well = _get_well(loaded_labware, params) + pipette.blow_out(well) # type: ignore + + elif command_type == 'pick-up-tip': + well = _get_well(loaded_labware, params) + pipette.pick_up_tip(well) # type: ignore + + elif command_type == 'drop-tip': + well = _get_well(loaded_labware, params) + pipette.drop_tip(well) # type: ignore + + elif command_type == 'aspirate': + location = _get_location_with_offset( + loaded_labware, 'aspirate', params, default_values) + volume = params['volume'] + _set_flow_rate( + pipette_name, pipette, command_type, params, default_values) + pipette.aspirate(volume, location) # type: ignore + + elif command_type == 'dispense': + location = _get_location_with_offset( + loaded_labware, 'dispense', params, default_values) + volume = params['volume'] + _set_flow_rate( + pipette_name, pipette, command_type, params, default_values) + pipette.dispense(volume, location) # type: ignore + + elif command_type == 'touch-tip': + well = _get_well(loaded_labware, params) + offset = default_values.get('touch-tip-mm-from-top', -1) + pipette.touch_tip(well, 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: + raise RuntimeError( + "Unsupported command type {}".format(command_type)) diff --git a/api/src/opentrons/protocol_api/execute_v3.py b/api/src/opentrons/protocol_api/execute_v3.py new file mode 100644 index 00000000000..50f328aee7f --- /dev/null +++ b/api/src/opentrons/protocol_api/execute_v3.py @@ -0,0 +1,180 @@ +import logging +from typing import Any, Dict + +from .contexts import ProtocolContext, InstrumentContext +from . import labware +from opentrons.types import Point, Location + +MODULE_LOG = logging.getLogger(__name__) + + +def load_pipettes_from_json( + ctx: ProtocolContext, + protocol: Dict[Any, Any]) -> Dict[str, InstrumentContext]: + pipette_data = protocol.get('pipettes', {}) + pipettes_by_id = {} + for pipette_id, props in pipette_data.items(): + mount = props.get('mount') + name = props.get('name') + instr = ctx.load_instrument(name, mount) + pipettes_by_id[pipette_id] = instr + + return pipettes_by_id + + +def _get_well(loaded_labware: Dict[str, labware.Labware], + params: Dict[str, Any]): + labwareId = params['labware'] + well = params['well'] + plate = loaded_labware.get(labwareId) + if not plate: + raise ValueError( + 'Command tried to use labware "{}", but that ID does not exist ' + 'in protocol\'s "labware" section'.format(labwareId)) + return plate.wells_by_index()[well] + + +# TODO (Ian 2019-04-05) once Pipette commands allow flow rate as an +# absolute value (not % value) as an argument in +# aspirate/dispense/blowout/air_gap fns, remove this +def _set_flow_rate( + pipette_name, pipette, command_type, params): + """ + Set flow rate in uL/mm, to value obtained from command's params. + """ + flow_rate_param = params.get('flowRate') + + pipette.flow_rate = { + 'aspirate': flow_rate_param, + 'dispense': flow_rate_param + } + + +def load_labware_from_json_defs( + ctx: ProtocolContext, + protocol: Dict[Any, Any]) -> Dict[str, labware.Labware]: + protocol_labware = protocol.get('labware', {}) + definitions = protocol.get('labwareDefinitions', {}) + loaded_labware = {} + + for labware_id, props in protocol_labware.items(): + slot = props.get('slot') + definition = definitions.get(props.get('definitionId')) + loaded_labware[labware_id] = ctx.load_labware( + labware.Labware( + definition, + ctx.deck.position_for(slot), + props.get('displayName') + ), + slot) + + return loaded_labware + + +def _get_location_with_offset(loaded_labware: Dict[str, labware.Labware], + command_type: str, + params: Dict[str, Any]) -> Location: + well = _get_well(loaded_labware, params) + + # Never move to the bottom of the fixed trash + if 'fixedTrash' in labware.quirks_from_any_parent(well): + return well.top() + + offset_from_bottom = params.get('offsetFromBottomMm') + if None is offset_from_bottom: + raise RuntimeError('"offsetFromBottomMm" is required for {}' + .format(command_type)) + + bottom = well.bottom() + return bottom.move(Point(z=offset_from_bottom)) + + +def dispatch_json(context: ProtocolContext, # noqa(C901) + protocol_data: Dict[Any, Any], + instruments: Dict[str, InstrumentContext], + loaded_labware: Dict[str, labware.Labware]): + commands = protocol_data.get('commands', []) + + for command_item in commands: + command_type = command_item.get('command') + params = command_item.get('params', {}) + pipette = instruments.get(params.get('pipette')) + protocol_pipette_data = protocol_data\ + .get('pipettes', {})\ + .get(params.get('pipette'), {}) + pipette_name = protocol_pipette_data.get('name') + + if (not pipette_name): + # TODO: Ian 2018-11-06 remove this fallback to 'model' when + # backwards-compatability for JSON protocols with versioned + # pipettes is dropped (next JSON protocol schema major bump) + pipette_name = protocol_pipette_data.get('model') + + if command_type == 'delay': + wait = params.get('wait') + if wait is None: + raise ValueError('Delay cannot be null') + elif wait is True: + message = params.get('message', 'Pausing until user resumes') + context.pause(msg=message) + else: + context.delay(seconds=wait) + + elif command_type == 'blowout': + well = _get_well(loaded_labware, params) + _set_flow_rate( + pipette_name, pipette, command_type, params) + pipette.blow_out(well) # type: ignore + + elif command_type == 'pickUpTip': + well = _get_well(loaded_labware, params) + pipette.pick_up_tip(well) # type: ignore + + elif command_type == 'dropTip': + well = _get_well(loaded_labware, params) + pipette.drop_tip(well) # type: ignore + + elif command_type == 'aspirate': + location = _get_location_with_offset( + loaded_labware, 'aspirate', params) + volume = params['volume'] + _set_flow_rate( + pipette_name, pipette, command_type, params) + pipette.aspirate(volume, location) # type: ignore + + elif command_type == 'dispense': + location = _get_location_with_offset( + loaded_labware, 'dispense', params) + volume = params['volume'] + _set_flow_rate( + pipette_name, pipette, command_type, params) + pipette.dispense(volume, location) # type: ignore + + elif command_type == 'touchTip': + location = _get_location_with_offset( + loaded_labware, 'dispense', params) + well = _get_well(loaded_labware, params) + # convert mmFromBottom to v_offset + v_offset = location.point.z - well.top().point.z + pipette.touch_tip(well, v_offset=v_offset) # type: ignore + + elif command_type == 'moveToSlot': + slot = params.get('slot') + if slot not in [str(s+1) for s in range(12)]: + raise ValueError('Invalid "slot" for "moveToSlot": {}' + .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('forceDirect'), + minimum_z_height=params.get('minimumZHeight')) + else: + raise RuntimeError( + "Unsupported command type {}".format(command_type)) diff --git a/api/tests/opentrons/protocol_api/test_execute.py b/api/tests/opentrons/protocol_api/test_execute.py index 188cbf7e3e9..42a55ec5cfa 100644 --- a/api/tests/opentrons/protocol_api/test_execute.py +++ b/api/tests/opentrons/protocol_api/test_execute.py @@ -1,6 +1,4 @@ import pytest -import json -from opentrons.types import Point from opentrons.protocol_api import execute, ProtocolContext @@ -82,407 +80,3 @@ def run(ctx): context=ctx) assert '[line 5]' in str(e) assert 'Exception [line 5]: hi' in str(e) - - -# TODO Ian 2018-11-07 when `model` is dropped, delete its test case -@pytest.mark.parametrize('protocol_data', - [ - # deprecated case - { - "pipettes": { - "leftPipetteHere": { - "mount": "left", - "model": "p10_single_v1.3" - } - } - }, - # future case - { - "pipettes": { - "leftPipetteHere": { - "mount": "left", - "name": "p10_single" - } - } - } - ]) -async def test_load_pipettes(loop, protocol_data): - - ctx = ProtocolContext(loop=loop) - - loaded_pipettes = execute.load_pipettes_from_json(ctx, protocol_data) - assert 'leftPipetteHere' in loaded_pipettes - assert len(loaded_pipettes) == 1 - pip = loaded_pipettes['leftPipetteHere'] - assert pip.mount == 'left' - assert ctx.loaded_instruments['left'] == pip - - -@pytest.mark.parametrize('command_type', ['aspirate', 'dispense']) -def test_get_location_with_offset_v1(loop, command_type): - ctx = ProtocolContext(loop=loop) - plate = ctx.load_labware_by_name("generic_96_wellplate_380_ul", 1) - well = "B2" - - default_values = { - 'aspirate-mm-from-bottom': 2, - 'dispense-mm-from-bottom': 3 - } - - loaded_labware = { - "someLabwareId": plate - } - - # test with nonzero and with zero command-specific offset - for offset in [5, 0]: - command_params = { - "labware": "someLabwareId", - "well": well, - "offsetFromBottomMm": offset - } - offs = execute._get_bottom_offset_v1( - command_type, command_params, default_values) - assert offs == offset - result = execute._get_location_with_offset_v1( - loaded_labware, command_type, command_params, default_values) - assert result.labware == plate.wells_by_index()[well] - assert result.point\ - == plate.wells_by_index()[well].bottom().point + Point(z=offset) - - command_params = { - "labware": "someLabwareId", - "well": well - } - - # no command-specific offset, use default - result = execute._get_location_with_offset_v1( - loaded_labware, command_type, command_params, default_values) - default = default_values['{}-mm-from-bottom'.format(command_type)] - assert execute._get_bottom_offset_v1( - command_type, command_params, default_values) == default - assert result.point\ - == plate.wells_by_index()[well].bottom().point + Point(z=default) - - -def test_load_labware_v1(loop): - ctx = ProtocolContext(loop=loop) - data = { - "labware": { - "sourcePlateId": { - "slot": "10", - "model": "usa_scientific_12_trough_22_ml", - "display-name": "Source (Buffer)" - }, - "destPlateId": { - "slot": "11", - "model": "generic_96_wellplate_380_ul", - "display-name": "Destination Plate" - }, - "oldPlateId": { - "slot": "9", - "model": "96-flat", - "display-name": "Test Plate" - }, - } - } - loaded_labware = execute.load_labware_from_json_loadnames(ctx, data) - - # objects in loaded_labware should be same objs as labware objs in the deck - assert loaded_labware['sourcePlateId'] == ctx.loaded_labwares[10] - assert 'Source (Buffer)' in str(loaded_labware['sourcePlateId']) - assert loaded_labware['destPlateId'] == ctx.loaded_labwares[11] - assert 'Destination Plate' in str(loaded_labware['destPlateId']) - assert loaded_labware['oldPlateId'].name == 'generic_96_wellplate_380_ul' - assert 'Test Plate' in str(loaded_labware['oldPlateId']) - - -def test_load_labware_v2(loop): - ctx = ProtocolContext(loop=loop) - # trough def with arbitrary ID - data = { - "labwareDefinitions": { - "someTroughDef": json.loads("""{"ordering":[["A1"],["A2"],["A3"],["A4"],["A5"],["A6"],["A7"],["A8"],["A9"],["A10"],["A11"],["A12"]],"otId":"THIS IS A CUSTOM ID","deprecated":false,"metadata":{"displayName":"CUSTOM 12 Channel Trough","displayVolumeUnits":"mL","displayCategory":"trough"},"cornerOffsetFromSlot":{"x":0,"y":0.32,"z":0},"dimensions":{"overallLength":127.76,"overallWidth":85.8,"overallHeight":44.45},"parameters":{"format":"trough","isTiprack":false,"isMagneticModuleCompatible":false,"loadName":"usa_scientific_12_trough_22_ml","quirks":["centerMultichannelOnWells"]},"wells":{"A1":{"shape":"rectangular","depth":42.16,"length":8.33,"width":71.88,"totalLiquidVolume":22000,"x":13.94,"y":42.9,"z":2.29},"A2":{"shape":"rectangular","depth":42.16,"length":8.33,"width":71.88,"totalLiquidVolume":22000,"x":23.03,"y":42.9,"z":2.29},"A3":{"shape":"rectangular","depth":42.16,"length":8.33,"width":71.88,"totalLiquidVolume":22000,"x":32.12,"y":42.9,"z":2.29},"A4":{"shape":"rectangular","depth":42.16,"length":8.33,"width":71.88,"totalLiquidVolume":22000,"x":41.21,"y":42.9,"z":2.29},"A5":{"shape":"rectangular","depth":42.16,"length":8.33,"width":71.88,"totalLiquidVolume":22000,"x":50.3,"y":42.9,"z":2.29},"A6":{"shape":"rectangular","depth":42.16,"length":8.33,"width":71.88,"totalLiquidVolume":22000,"x":59.39,"y":42.9,"z":2.29},"A7":{"shape":"rectangular","depth":42.16,"length":8.33,"width":71.88,"totalLiquidVolume":22000,"x":68.48,"y":42.9,"z":2.29},"A8":{"shape":"rectangular","depth":42.16,"length":8.33,"width":71.88,"totalLiquidVolume":22000,"x":77.57,"y":42.9,"z":2.29},"A9":{"shape":"rectangular","depth":42.16,"length":8.33,"width":71.88,"totalLiquidVolume":22000,"x":86.66,"y":42.9,"z":2.29},"A10":{"shape":"rectangular","depth":42.16,"length":8.33,"width":71.88,"totalLiquidVolume":22000,"x":95.75,"y":42.9,"z":2.29},"A11":{"shape":"rectangular","depth":42.16,"length":8.33,"width":71.88,"totalLiquidVolume":22000,"x":104.84,"y":42.9,"z":2.29},"A12":{"shape":"rectangular","depth":42.16,"length":8.33,"width":71.88,"totalLiquidVolume":22000,"x":113.93,"y":42.9,"z":2.29}},"brand":{"brand":"USA Scientific","brandId":["1061-8150"]}}""") # noqa - }, - "labware": { - "sourcePlateId": { - "slot": "10", - "definitionId": "someTroughDef", - "displayName": "Source (Buffer)" - }, - "destPlateId": { - "slot": "11", - "definitionId": "someTroughDef" - }, - } - } - loaded_labware = execute.load_labware_from_json_defs(ctx, data) - - # objects in loaded_labware should be same objs as labware objs in the deck - assert loaded_labware['sourcePlateId'] == ctx.loaded_labwares[10] - # use the displayName from protocol's labware.labwareId.displayName - assert 'Source (Buffer)' in str(loaded_labware['sourcePlateId']) - assert loaded_labware['destPlateId'] == ctx.loaded_labwares[11] - # use the metadata.displayName from embedded def - assert 'CUSTOM 12 Channel Trough' in str(loaded_labware['destPlateId']) - - -def test_load_labware_trash_v1(loop): - ctx = ProtocolContext(loop=loop) - data = { - "labware": { - "someTrashId": { - "slot": "12", - "model": "fixed-trash" - } - } - } - result = execute.load_labware_from_json_loadnames(ctx, data) - - assert result['someTrashId'] == ctx.fixed_trash - - -protocol_v1_data = { - "protocol-schema": "1.0.0", - "default-values": { - "aspirate-flow-rate": { - "p300_single_v1": 101 - }, - "dispense-flow-rate": { - "p300_single_v1": 102 - } - }, - "pipettes": { - "pipetteId": { - "mount": "left", - "model": "p300_single_v1" - } - }, - "procedure": [ - { - "subprocedure": [ - { - "command": "aspirate", - "params": { - "pipette": "pipetteId", - "labware": "sourcePlateId", - "well": "A1", - "volume": 5, - "flow-rate": 123 - } - }, - { - "command": "delay", - "params": { - "wait": 42 - } - }, - { - "command": "dispense", - "params": { - "pipette": "pipetteId", - "labware": "destPlateId", - "well": "B1", - "volume": 4.5 - } - }, - ] - } - ] -} - - -def test_dispatch_commands_v1(monkeypatch, loop): - ctx = ProtocolContext(loop=loop) - cmd = [] - flow_rates = [] - - def mock_sleep(minutes=0, seconds=0): - cmd.append(("sleep", minutes * 60 + seconds)) - - def mock_aspirate(volume, location): - cmd.append(("aspirate", volume, location)) - - def mock_dispense(volume, location): - cmd.append(("dispense", volume, location)) - - def mock_set_flow_rate(mount, aspirate=None, dispense=None): - flow_rates.append((aspirate, dispense)) - - insts = execute.load_pipettes_from_json(ctx, protocol_v1_data) - - source_plate = ctx.load_labware_by_name('generic_96_wellplate_380_ul', '1') - dest_plate = ctx.load_labware_by_name('generic_96_wellplate_380_ul', '2') - - loaded_labware = { - 'sourcePlateId': source_plate, - 'destPlateId': dest_plate - } - pipette = insts['pipetteId'] - monkeypatch.setattr(pipette, 'aspirate', mock_aspirate) - monkeypatch.setattr(pipette, 'dispense', mock_dispense) - monkeypatch.setattr(ctx._hw_manager.hardware._api, 'set_flow_rate', - mock_set_flow_rate) - monkeypatch.setattr(ctx, 'delay', mock_sleep) - - execute.dispatch_json_v1( - ctx, protocol_v1_data, insts, loaded_labware) - - assert cmd == [ - ("aspirate", 5, source_plate.wells_by_index()['A1'].bottom()), - ("sleep", 42), - ("dispense", 4.5, dest_plate.wells_by_index()['B1'].bottom()) - ] - - assert flow_rates == [ - (123, 102), - (101, 102) - ] - - -class MockPipette(object): - def __init__(self, command_log): - self.log = command_log - - def _make_logger(self, name): - def log_fn(*args, **kwargs): - if kwargs: - self.log.append((name, args, kwargs)) - else: - self.log.append((name, args)) - return log_fn - - def __getattr__(self, name): - if name == 'log': - return self.log - else: - return self._make_logger(name) - - def __setattr__(self, name, value): - if name == 'log': - super(MockPipette, self).__setattr__(name, value) - else: - self.log.append(("set: {}".format(name), value)) - - -def test_dispatch_commands_v3(monkeypatch, loop): - protocol_v3_data = { - "schemaVersion": "3", - "commands": [ - { - "command": "pickUpTip", - "params": { - "pipette": "pipetteId", - "labware": "tiprackId", - "well": "B1" - } - }, - { - "command": "aspirate", - "params": { - "pipette": "pipetteId", - "labware": "sourcePlateId", - "well": "A1", - "volume": 5, - "flowRate": 3, - "offsetFromBottomMm": 2 - } - }, - { - "command": "delay", - "params": { - "wait": 42 - } - }, - { - "command": "dispense", - "params": { - "pipette": "pipetteId", - "labware": "destPlateId", - "well": "B1", - "volume": 4.5, - "flowRate": 2.5, - "offsetFromBottomMm": 1 - } - }, - { - "command": "touchTip", - "params": { - "pipette": "pipetteId", - "labware": "destPlateId", - "well": "B1", - "offsetFromBottomMm": 11 - } - }, - { - "command": "blowout", - "params": { - "pipette": "pipetteId", - "labware": "destPlateId", - "well": "B1", - "flowRate": 2, - "offsetFromBottomMm": 12 - } - }, - { - "command": "moveToSlot", - "params": { - "pipette": "pipetteId", - "slot": "5", - "offset": { - "x": 1, - "y": 2, - "z": 3 - } - } - }, - { - "command": "dropTip", - "params": { - "pipette": "pipetteId", - "labware": "trashId", - "well": "A1" - } - } - ] - } - - command_log = [] - mock_pipette = MockPipette(command_log) - insts = {"pipetteId": mock_pipette} - - ctx = ProtocolContext(loop=loop) - - def mock_delay(seconds=0, minutes=0): - command_log.append(("delay", seconds + minutes * 60)) - - monkeypatch.setattr(ctx, 'delay', mock_delay) - - source_plate = ctx.load_labware_by_name('generic_96_wellplate_380_ul', '1') - dest_plate = ctx.load_labware_by_name('generic_96_wellplate_380_ul', '2') - tiprack = ctx.load_labware_by_name('opentrons_96_tiprack_10_ul', '3') - - loaded_labware = { - 'sourcePlateId': source_plate, - 'destPlateId': dest_plate, - 'tiprackId': tiprack, - 'trashId': ctx.fixed_trash - } - - execute.dispatch_json_v3( - ctx, protocol_v3_data, insts, loaded_labware) - - assert command_log == [ - ("pick_up_tip", (tiprack.wells_by_index()['B1'],)), - ("set: flow_rate", {"aspirate": 3, "dispense": 3}), - ("aspirate", (5, source_plate.wells_by_index()['A1'].bottom(2),)), - ("delay", 42), - ("set: flow_rate", {"aspirate": 2.5, "dispense": 2.5}), - ("dispense", (4.5, dest_plate.wells_by_index()['B1'].bottom(1),)), - ("touch_tip", (dest_plate.wells_by_index()['B1'],), - {"v_offset": 0.46000000000000085}), - ("set: flow_rate", {"aspirate": 2, "dispense": 2}), - ("blow_out", (dest_plate.wells_by_index()['B1'],)), - ("move_to", (ctx.deck.position_for('5').move(Point(1, 2, 3)),), - {"force_direct": None, "minimum_z_height": None}), - ("drop_tip", (ctx.fixed_trash.wells_by_index()['A1'],)) - ] diff --git a/api/tests/opentrons/protocol_api/test_execute_v1.py b/api/tests/opentrons/protocol_api/test_execute_v1.py new file mode 100644 index 00000000000..37331a3ef84 --- /dev/null +++ b/api/tests/opentrons/protocol_api/test_execute_v1.py @@ -0,0 +1,228 @@ +import pytest +from opentrons.types import Point +from opentrons.protocol_api import execute_v1, ProtocolContext + + +# TODO Ian 2018-11-07 when `model` is dropped, delete its test case +@pytest.mark.parametrize('protocol_data', + [ + # deprecated case + { + "pipettes": { + "leftPipetteHere": { + "mount": "left", + "model": "p10_single_v1.3" + } + } + }, + # future case + { + "pipettes": { + "leftPipetteHere": { + "mount": "left", + "name": "p10_single" + } + } + } + ]) +async def test_load_pipettes(loop, protocol_data): + + ctx = ProtocolContext(loop=loop) + + loaded_pipettes = execute_v1.load_pipettes_from_json(ctx, protocol_data) + assert 'leftPipetteHere' in loaded_pipettes + assert len(loaded_pipettes) == 1 + pip = loaded_pipettes['leftPipetteHere'] + assert pip.mount == 'left' + assert ctx.loaded_instruments['left'] == pip + + +@pytest.mark.parametrize('command_type', ['aspirate', 'dispense']) +def test_get_location_with_offset(loop, command_type): + ctx = ProtocolContext(loop=loop) + plate = ctx.load_labware_by_name("generic_96_wellplate_380_ul", 1) + well = "B2" + + default_values = { + 'aspirate-mm-from-bottom': 2, + 'dispense-mm-from-bottom': 3 + } + + loaded_labware = { + "someLabwareId": plate + } + + # test with nonzero and with zero command-specific offset + for offset in [5, 0]: + command_params = { + "labware": "someLabwareId", + "well": well, + "offsetFromBottomMm": offset + } + offs = execute_v1._get_bottom_offset( + command_type, command_params, default_values) + assert offs == offset + result = execute_v1._get_location_with_offset( + loaded_labware, command_type, command_params, default_values) + assert result.labware == plate.wells_by_index()[well] + assert result.point\ + == plate.wells_by_index()[well].bottom().point + Point(z=offset) + + command_params = { + "labware": "someLabwareId", + "well": well + } + + # no command-specific offset, use default + result = execute_v1._get_location_with_offset( + loaded_labware, command_type, command_params, default_values) + default = default_values['{}-mm-from-bottom'.format(command_type)] + assert execute_v1._get_bottom_offset( + command_type, command_params, default_values) == default + assert result.point\ + == plate.wells_by_index()[well].bottom().point + Point(z=default) + + +def test_load_labware(loop): + ctx = ProtocolContext(loop=loop) + data = { + "labware": { + "sourcePlateId": { + "slot": "10", + "model": "usa_scientific_12_trough_22_ml", + "display-name": "Source (Buffer)" + }, + "destPlateId": { + "slot": "11", + "model": "generic_96_wellplate_380_ul", + "display-name": "Destination Plate" + }, + "oldPlateId": { + "slot": "9", + "model": "96-flat", + "display-name": "Test Plate" + }, + } + } + loaded_labware = execute_v1.load_labware_from_json_loadnames(ctx, data) + + # objects in loaded_labware should be same objs as labware objs in the deck + assert loaded_labware['sourcePlateId'] == ctx.loaded_labwares[10] + assert 'Source (Buffer)' in str(loaded_labware['sourcePlateId']) + assert loaded_labware['destPlateId'] == ctx.loaded_labwares[11] + assert 'Destination Plate' in str(loaded_labware['destPlateId']) + assert loaded_labware['oldPlateId'].name == 'generic_96_wellplate_380_ul' + assert 'Test Plate' in str(loaded_labware['oldPlateId']) + + +def test_load_labware_trash(loop): + ctx = ProtocolContext(loop=loop) + data = { + "labware": { + "someTrashId": { + "slot": "12", + "model": "fixed-trash" + } + } + } + result = execute_v1.load_labware_from_json_loadnames(ctx, data) + + assert result['someTrashId'] == ctx.fixed_trash + + +protocol_data = { + "protocol-schema": "1.0.0", + "default-values": { + "aspirate-flow-rate": { + "p300_single_v1": 101 + }, + "dispense-flow-rate": { + "p300_single_v1": 102 + } + }, + "pipettes": { + "pipetteId": { + "mount": "left", + "model": "p300_single_v1" + } + }, + "procedure": [ + { + "subprocedure": [ + { + "command": "aspirate", + "params": { + "pipette": "pipetteId", + "labware": "sourcePlateId", + "well": "A1", + "volume": 5, + "flow-rate": 123 + } + }, + { + "command": "delay", + "params": { + "wait": 42 + } + }, + { + "command": "dispense", + "params": { + "pipette": "pipetteId", + "labware": "destPlateId", + "well": "B1", + "volume": 4.5 + } + }, + ] + } + ] +} + + +def test_dispatch_commands(monkeypatch, loop): + ctx = ProtocolContext(loop=loop) + cmd = [] + flow_rates = [] + + def mock_sleep(minutes=0, seconds=0): + cmd.append(("sleep", minutes * 60 + seconds)) + + def mock_aspirate(volume, location): + cmd.append(("aspirate", volume, location)) + + def mock_dispense(volume, location): + cmd.append(("dispense", volume, location)) + + def mock_set_flow_rate(mount, aspirate=None, dispense=None): + flow_rates.append((aspirate, dispense)) + + insts = execute_v1.load_pipettes_from_json(ctx, protocol_data) + + source_plate = ctx.load_labware_by_name('generic_96_wellplate_380_ul', '1') + dest_plate = ctx.load_labware_by_name('generic_96_wellplate_380_ul', '2') + + loaded_labware = { + 'sourcePlateId': source_plate, + 'destPlateId': dest_plate + } + pipette = insts['pipetteId'] + monkeypatch.setattr(pipette, 'aspirate', mock_aspirate) + monkeypatch.setattr(pipette, 'dispense', mock_dispense) + monkeypatch.setattr(ctx._hw_manager.hardware._api, 'set_flow_rate', + mock_set_flow_rate) + monkeypatch.setattr(ctx, 'delay', mock_sleep) + + execute_v1.dispatch_json( + ctx, protocol_data, insts, loaded_labware) + + assert cmd == [ + ("aspirate", 5, source_plate.wells_by_index()['A1'].bottom()), + ("sleep", 42), + ("dispense", 4.5, dest_plate.wells_by_index()['B1'].bottom()) + ] + + assert flow_rates == [ + (123, 102), + (101, 102) + ] diff --git a/api/tests/opentrons/protocol_api/test_execute_v3.py b/api/tests/opentrons/protocol_api/test_execute_v3.py new file mode 100644 index 00000000000..3f98bcb3771 --- /dev/null +++ b/api/tests/opentrons/protocol_api/test_execute_v3.py @@ -0,0 +1,182 @@ +import json +from opentrons.types import Point +from opentrons.protocol_api import execute_v3, ProtocolContext + + +def test_load_labware_v2(loop): + ctx = ProtocolContext(loop=loop) + # trough def with arbitrary ID + data = { + "labwareDefinitions": { + "someTroughDef": json.loads("""{"ordering":[["A1"],["A2"],["A3"],["A4"],["A5"],["A6"],["A7"],["A8"],["A9"],["A10"],["A11"],["A12"]],"otId":"THIS IS A CUSTOM ID","deprecated":false,"metadata":{"displayName":"CUSTOM 12 Channel Trough","displayVolumeUnits":"mL","displayCategory":"trough"},"cornerOffsetFromSlot":{"x":0,"y":0.32,"z":0},"dimensions":{"overallLength":127.76,"overallWidth":85.8,"overallHeight":44.45},"parameters":{"format":"trough","isTiprack":false,"isMagneticModuleCompatible":false,"loadName":"usa_scientific_12_trough_22_ml","quirks":["centerMultichannelOnWells"]},"wells":{"A1":{"shape":"rectangular","depth":42.16,"length":8.33,"width":71.88,"totalLiquidVolume":22000,"x":13.94,"y":42.9,"z":2.29},"A2":{"shape":"rectangular","depth":42.16,"length":8.33,"width":71.88,"totalLiquidVolume":22000,"x":23.03,"y":42.9,"z":2.29},"A3":{"shape":"rectangular","depth":42.16,"length":8.33,"width":71.88,"totalLiquidVolume":22000,"x":32.12,"y":42.9,"z":2.29},"A4":{"shape":"rectangular","depth":42.16,"length":8.33,"width":71.88,"totalLiquidVolume":22000,"x":41.21,"y":42.9,"z":2.29},"A5":{"shape":"rectangular","depth":42.16,"length":8.33,"width":71.88,"totalLiquidVolume":22000,"x":50.3,"y":42.9,"z":2.29},"A6":{"shape":"rectangular","depth":42.16,"length":8.33,"width":71.88,"totalLiquidVolume":22000,"x":59.39,"y":42.9,"z":2.29},"A7":{"shape":"rectangular","depth":42.16,"length":8.33,"width":71.88,"totalLiquidVolume":22000,"x":68.48,"y":42.9,"z":2.29},"A8":{"shape":"rectangular","depth":42.16,"length":8.33,"width":71.88,"totalLiquidVolume":22000,"x":77.57,"y":42.9,"z":2.29},"A9":{"shape":"rectangular","depth":42.16,"length":8.33,"width":71.88,"totalLiquidVolume":22000,"x":86.66,"y":42.9,"z":2.29},"A10":{"shape":"rectangular","depth":42.16,"length":8.33,"width":71.88,"totalLiquidVolume":22000,"x":95.75,"y":42.9,"z":2.29},"A11":{"shape":"rectangular","depth":42.16,"length":8.33,"width":71.88,"totalLiquidVolume":22000,"x":104.84,"y":42.9,"z":2.29},"A12":{"shape":"rectangular","depth":42.16,"length":8.33,"width":71.88,"totalLiquidVolume":22000,"x":113.93,"y":42.9,"z":2.29}},"brand":{"brand":"USA Scientific","brandId":["1061-8150"]}}""") # noqa + }, + "labware": { + "sourcePlateId": { + "slot": "10", + "definitionId": "someTroughDef", + "displayName": "Source (Buffer)" + }, + "destPlateId": { + "slot": "11", + "definitionId": "someTroughDef" + }, + } + } + loaded_labware = execute_v3.load_labware_from_json_defs(ctx, data) + + # objects in loaded_labware should be same objs as labware objs in the deck + assert loaded_labware['sourcePlateId'] == ctx.loaded_labwares[10] + # use the displayName from protocol's labware.labwareId.displayName + assert 'Source (Buffer)' in str(loaded_labware['sourcePlateId']) + assert loaded_labware['destPlateId'] == ctx.loaded_labwares[11] + # use the metadata.displayName from embedded def + assert 'CUSTOM 12 Channel Trough' in str(loaded_labware['destPlateId']) + + +class MockPipette(object): + def __init__(self, command_log): + self.log = command_log + + def _make_logger(self, name): + def log_fn(*args, **kwargs): + if kwargs: + self.log.append((name, args, kwargs)) + else: + self.log.append((name, args)) + return log_fn + + def __getattr__(self, name): + if name == 'log': + return self.log + else: + return self._make_logger(name) + + def __setattr__(self, name, value): + if name == 'log': + super(MockPipette, self).__setattr__(name, value) + else: + self.log.append(("set: {}".format(name), value)) + + +def test_dispatch_commands(monkeypatch, loop): + protocol_data = { + "schemaVersion": "3", + "commands": [ + { + "command": "pickUpTip", + "params": { + "pipette": "pipetteId", + "labware": "tiprackId", + "well": "B1" + } + }, + { + "command": "aspirate", + "params": { + "pipette": "pipetteId", + "labware": "sourcePlateId", + "well": "A1", + "volume": 5, + "flowRate": 3, + "offsetFromBottomMm": 2 + } + }, + { + "command": "delay", + "params": { + "wait": 42 + } + }, + { + "command": "dispense", + "params": { + "pipette": "pipetteId", + "labware": "destPlateId", + "well": "B1", + "volume": 4.5, + "flowRate": 2.5, + "offsetFromBottomMm": 1 + } + }, + { + "command": "touchTip", + "params": { + "pipette": "pipetteId", + "labware": "destPlateId", + "well": "B1", + "offsetFromBottomMm": 11 + } + }, + { + "command": "blowout", + "params": { + "pipette": "pipetteId", + "labware": "destPlateId", + "well": "B1", + "flowRate": 2, + "offsetFromBottomMm": 12 + } + }, + { + "command": "moveToSlot", + "params": { + "pipette": "pipetteId", + "slot": "5", + "offset": { + "x": 1, + "y": 2, + "z": 3 + } + } + }, + { + "command": "dropTip", + "params": { + "pipette": "pipetteId", + "labware": "trashId", + "well": "A1" + } + } + ] + } + + command_log = [] + mock_pipette = MockPipette(command_log) + insts = {"pipetteId": mock_pipette} + + ctx = ProtocolContext(loop=loop) + + def mock_delay(seconds=0, minutes=0): + command_log.append(("delay", seconds + minutes * 60)) + + monkeypatch.setattr(ctx, 'delay', mock_delay) + + source_plate = ctx.load_labware_by_name('generic_96_wellplate_380_ul', '1') + dest_plate = ctx.load_labware_by_name('generic_96_wellplate_380_ul', '2') + tiprack = ctx.load_labware_by_name('opentrons_96_tiprack_10_ul', '3') + + loaded_labware = { + 'sourcePlateId': source_plate, + 'destPlateId': dest_plate, + 'tiprackId': tiprack, + 'trashId': ctx.fixed_trash + } + + execute_v3.dispatch_json( + ctx, protocol_data, insts, loaded_labware) + + assert command_log == [ + ("pick_up_tip", (tiprack.wells_by_index()['B1'],)), + ("set: flow_rate", {"aspirate": 3, "dispense": 3}), + ("aspirate", (5, source_plate.wells_by_index()['A1'].bottom(2),)), + ("delay", 42), + ("set: flow_rate", {"aspirate": 2.5, "dispense": 2.5}), + ("dispense", (4.5, dest_plate.wells_by_index()['B1'].bottom(1),)), + ("touch_tip", (dest_plate.wells_by_index()['B1'],), + {"v_offset": 0.46000000000000085}), + ("set: flow_rate", {"aspirate": 2, "dispense": 2}), + ("blow_out", (dest_plate.wells_by_index()['B1'],)), + ("move_to", (ctx.deck.position_for('5').move(Point(1, 2, 3)),), + {"force_direct": None, "minimum_z_height": None}), + ("drop_tip", (ctx.fixed_trash.wells_by_index()['A1'],)) + ] From db4c2e2779877ccfd6945a602b4393a022cb5a73 Mon Sep 17 00:00:00 2001 From: IanLondon Date: Mon, 8 Apr 2019 13:03:24 -0400 Subject: [PATCH 4/9] fixup typo --- api/src/opentrons/protocol_api/execute.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/src/opentrons/protocol_api/execute.py b/api/src/opentrons/protocol_api/execute.py index 0c4387ad3df..eeda3afdc37 100644 --- a/api/src/opentrons/protocol_api/execute.py +++ b/api/src/opentrons/protocol_api/execute.py @@ -125,7 +125,7 @@ def get_protocol_schema_version(protocol_json: Dict[Any, Any]) -> int: raise RuntimeError(('No such schema version: "{}". Did you mean ' + '"1.0.0" or "2.0.0"?').format(legacyKebabVersion)) raise RuntimeError( - 'Could not determine schema version for protcol. ' + + 'Could not determine schema version for protocol. ' + 'Make sure there is a version number under "schemaVersion"') From e59529cc1ab638ad989b7bd604d108793bfe879b Mon Sep 17 00:00:00 2001 From: IanLondon Date: Mon, 8 Apr 2019 14:08:52 -0400 Subject: [PATCH 5/9] move json dispatch fixtures to files; do not use Dict.get on required keys --- api/src/opentrons/protocol_api/execute_v1.py | 23 +++-- api/src/opentrons/protocol_api/execute_v3.py | 86 ++++++++----------- .../protocol_api/data/v1_json_dispatch.json | 48 +++++++++++ .../protocol_api/data/v3_json_dispatch.json | 80 +++++++++++++++++ .../opentrons/protocol_api/test_execute_v1.py | 60 ++----------- .../opentrons/protocol_api/test_execute_v3.py | 84 +----------------- 6 files changed, 185 insertions(+), 196 deletions(-) create mode 100644 api/tests/opentrons/protocol_api/data/v1_json_dispatch.json create mode 100644 api/tests/opentrons/protocol_api/data/v3_json_dispatch.json diff --git a/api/src/opentrons/protocol_api/execute_v1.py b/api/src/opentrons/protocol_api/execute_v1.py index 6f17ab55b08..b29cc029968 100644 --- a/api/src/opentrons/protocol_api/execute_v1.py +++ b/api/src/opentrons/protocol_api/execute_v1.py @@ -14,11 +14,11 @@ def load_pipettes_from_json( ctx: ProtocolContext, protocol: Dict[Any, Any]) -> Dict[str, InstrumentContext]: - pipette_data = protocol.get('pipettes', {}) + pipette_data = protocol['pipettes'] pipettes_by_id = {} for pipette_id, props in pipette_data.items(): - model = props.get('model') - mount = props.get('mount') + model = props['model'] + mount = props['mount'] # NOTE: 'name' is only used by v1 and v2 JSON protocols name = props.get('name') @@ -148,19 +148,18 @@ def dispatch_json(context: ProtocolContext, # noqa(C901) instruments: Dict[str, InstrumentContext], loaded_labware: Dict[str, labware.Labware]): subprocedures = [ - p.get('subprocedure', []) - for p in protocol_data.get('procedure', [])] + p['subprocedure'] + for p in protocol_data['procedure']] - default_values = protocol_data.get('default-values', {}) + default_values = protocol_data['default-values'] flat_subs = itertools.chain.from_iterable(subprocedures) for command_item in flat_subs: - command_type = command_item.get('command') - params = command_item.get('params', {}) + command_type = command_item['command'] + params = command_item['params'] pipette = instruments.get(params.get('pipette')) - protocol_pipette_data = protocol_data\ - .get('pipettes', {})\ - .get(params.get('pipette'), {}) + protocol_pipette_data = protocol_data['pipettes'].get( + params.get('pipette'), {}) pipette_name = protocol_pipette_data.get('name') if (not pipette_name): @@ -170,7 +169,7 @@ def dispatch_json(context: ProtocolContext, # noqa(C901) pipette_name = protocol_pipette_data.get('model') if command_type == 'delay': - wait = params.get('wait') + wait = params['wait'] if wait is None: raise ValueError('Delay cannot be null') elif wait is True: diff --git a/api/src/opentrons/protocol_api/execute_v3.py b/api/src/opentrons/protocol_api/execute_v3.py index 50f328aee7f..31ce486017e 100644 --- a/api/src/opentrons/protocol_api/execute_v3.py +++ b/api/src/opentrons/protocol_api/execute_v3.py @@ -11,11 +11,11 @@ def load_pipettes_from_json( ctx: ProtocolContext, protocol: Dict[Any, Any]) -> Dict[str, InstrumentContext]: - pipette_data = protocol.get('pipettes', {}) + pipette_data = protocol['pipettes'] pipettes_by_id = {} for pipette_id, props in pipette_data.items(): - mount = props.get('mount') - name = props.get('name') + mount = props['mount'] + name = props['name'] instr = ctx.load_instrument(name, mount) pipettes_by_id[pipette_id] = instr @@ -26,23 +26,18 @@ def _get_well(loaded_labware: Dict[str, labware.Labware], params: Dict[str, Any]): labwareId = params['labware'] well = params['well'] - plate = loaded_labware.get(labwareId) - if not plate: - raise ValueError( - 'Command tried to use labware "{}", but that ID does not exist ' - 'in protocol\'s "labware" section'.format(labwareId)) + plate = loaded_labware[labwareId] return plate.wells_by_index()[well] # TODO (Ian 2019-04-05) once Pipette commands allow flow rate as an # absolute value (not % value) as an argument in # aspirate/dispense/blowout/air_gap fns, remove this -def _set_flow_rate( - pipette_name, pipette, command_type, params): +def _set_flow_rate(pipette, command_type, params): """ Set flow rate in uL/mm, to value obtained from command's params. """ - flow_rate_param = params.get('flowRate') + flow_rate_param = params['flowRate'] pipette.flow_rate = { 'aspirate': flow_rate_param, @@ -53,13 +48,13 @@ def _set_flow_rate( def load_labware_from_json_defs( ctx: ProtocolContext, protocol: Dict[Any, Any]) -> Dict[str, labware.Labware]: - protocol_labware = protocol.get('labware', {}) - definitions = protocol.get('labwareDefinitions', {}) + protocol_labware = protocol['labware'] + definitions = protocol['labwareDefinitions'] loaded_labware = {} for labware_id, props in protocol_labware.items(): - slot = props.get('slot') - definition = definitions.get(props.get('definitionId')) + slot = props['slot'] + definition = definitions[props['definitionId']] loaded_labware[labware_id] = ctx.load_labware( labware.Labware( definition, @@ -80,38 +75,28 @@ def _get_location_with_offset(loaded_labware: Dict[str, labware.Labware], if 'fixedTrash' in labware.quirks_from_any_parent(well): return well.top() - offset_from_bottom = params.get('offsetFromBottomMm') - if None is offset_from_bottom: - raise RuntimeError('"offsetFromBottomMm" is required for {}' - .format(command_type)) - + offset_from_bottom = params['offsetFromBottomMm'] bottom = well.bottom() return bottom.move(Point(z=offset_from_bottom)) def dispatch_json(context: ProtocolContext, # noqa(C901) - protocol_data: Dict[Any, Any], - instruments: Dict[str, InstrumentContext], - loaded_labware: Dict[str, labware.Labware]): - commands = protocol_data.get('commands', []) + protocol_data: Dict[Any, Any], + instruments: Dict[str, InstrumentContext], + loaded_labware: Dict[str, labware.Labware]): + commands = protocol_data['commands'] for command_item in commands: - command_type = command_item.get('command') - params = command_item.get('params', {}) - pipette = instruments.get(params.get('pipette')) - protocol_pipette_data = protocol_data\ - .get('pipettes', {})\ - .get(params.get('pipette'), {}) - pipette_name = protocol_pipette_data.get('name') - - if (not pipette_name): - # TODO: Ian 2018-11-06 remove this fallback to 'model' when - # backwards-compatability for JSON protocols with versioned - # pipettes is dropped (next JSON protocol schema major bump) - pipette_name = protocol_pipette_data.get('model') + command_type = command_item['command'] + params = command_item['params'] + + # all commands but 'delay' have 'pipette' param + if command_type != 'delay': + pipette_id = params['pipette'] + pipette = instruments[pipette_id] if command_type == 'delay': - wait = params.get('wait') + wait = params['wait'] if wait is None: raise ValueError('Delay cannot be null') elif wait is True: @@ -122,33 +107,30 @@ def dispatch_json(context: ProtocolContext, # noqa(C901) elif command_type == 'blowout': well = _get_well(loaded_labware, params) - _set_flow_rate( - pipette_name, pipette, command_type, params) - pipette.blow_out(well) # type: ignore + _set_flow_rate(pipette, command_type, params) + pipette.blow_out(well) elif command_type == 'pickUpTip': well = _get_well(loaded_labware, params) - pipette.pick_up_tip(well) # type: ignore + pipette.pick_up_tip(well) elif command_type == 'dropTip': well = _get_well(loaded_labware, params) - pipette.drop_tip(well) # type: ignore + pipette.drop_tip(well) elif command_type == 'aspirate': location = _get_location_with_offset( loaded_labware, 'aspirate', params) volume = params['volume'] - _set_flow_rate( - pipette_name, pipette, command_type, params) - pipette.aspirate(volume, location) # type: ignore + _set_flow_rate(pipette, command_type, params) + pipette.aspirate(volume, location) elif command_type == 'dispense': location = _get_location_with_offset( loaded_labware, 'dispense', params) volume = params['volume'] - _set_flow_rate( - pipette_name, pipette, command_type, params) - pipette.dispense(volume, location) # type: ignore + _set_flow_rate(pipette, command_type, params) + pipette.dispense(volume, location) elif command_type == 'touchTip': location = _get_location_with_offset( @@ -156,10 +138,10 @@ def dispatch_json(context: ProtocolContext, # noqa(C901) well = _get_well(loaded_labware, params) # convert mmFromBottom to v_offset v_offset = location.point.z - well.top().point.z - pipette.touch_tip(well, v_offset=v_offset) # type: ignore + pipette.touch_tip(well, v_offset=v_offset) elif command_type == 'moveToSlot': - slot = params.get('slot') + slot = params['slot'] if slot not in [str(s+1) for s in range(12)]: raise ValueError('Invalid "slot" for "moveToSlot": {}' .format(slot)) @@ -171,7 +153,7 @@ def dispatch_json(context: ProtocolContext, # noqa(C901) offset.get('y', 0), offset.get('z', 0)) - pipette.move_to( # type: ignore + pipette.move_to( slot_obj.move(offsetPoint), force_direct=params.get('forceDirect'), minimum_z_height=params.get('minimumZHeight')) diff --git a/api/tests/opentrons/protocol_api/data/v1_json_dispatch.json b/api/tests/opentrons/protocol_api/data/v1_json_dispatch.json new file mode 100644 index 00000000000..bd19c3fa7f6 --- /dev/null +++ b/api/tests/opentrons/protocol_api/data/v1_json_dispatch.json @@ -0,0 +1,48 @@ +{ + "protocol-schema": "1.0.0", + "default-values": { + "aspirate-flow-rate": { + "p300_single_v1": 101 + }, + "dispense-flow-rate": { + "p300_single_v1": 102 + } + }, + "pipettes": { + "pipetteId": { + "mount": "left", + "model": "p300_single_v1" + } + }, + "procedure": [ + { + "subprocedure": [ + { + "command": "aspirate", + "params": { + "pipette": "pipetteId", + "labware": "sourcePlateId", + "well": "A1", + "volume": 5, + "flow-rate": 123 + } + }, + { + "command": "delay", + "params": { + "wait": 42 + } + }, + { + "command": "dispense", + "params": { + "pipette": "pipetteId", + "labware": "destPlateId", + "well": "B1", + "volume": 4.5 + } + } + ] + } + ] +} diff --git a/api/tests/opentrons/protocol_api/data/v3_json_dispatch.json b/api/tests/opentrons/protocol_api/data/v3_json_dispatch.json new file mode 100644 index 00000000000..4b5430cd23e --- /dev/null +++ b/api/tests/opentrons/protocol_api/data/v3_json_dispatch.json @@ -0,0 +1,80 @@ +{ + "schemaVersion": "3", + "commands": [ + { + "command": "pickUpTip", + "params": { + "pipette": "pipetteId", + "labware": "tiprackId", + "well": "B1" + } + }, + { + "command": "aspirate", + "params": { + "pipette": "pipetteId", + "labware": "sourcePlateId", + "well": "A1", + "volume": 5, + "flowRate": 3, + "offsetFromBottomMm": 2 + } + }, + { + "command": "delay", + "params": { + "wait": 42 + } + }, + { + "command": "dispense", + "params": { + "pipette": "pipetteId", + "labware": "destPlateId", + "well": "B1", + "volume": 4.5, + "flowRate": 2.5, + "offsetFromBottomMm": 1 + } + }, + { + "command": "touchTip", + "params": { + "pipette": "pipetteId", + "labware": "destPlateId", + "well": "B1", + "offsetFromBottomMm": 11 + } + }, + { + "command": "blowout", + "params": { + "pipette": "pipetteId", + "labware": "destPlateId", + "well": "B1", + "flowRate": 2, + "offsetFromBottomMm": 12 + } + }, + { + "command": "moveToSlot", + "params": { + "pipette": "pipetteId", + "slot": "5", + "offset": { + "x": 1, + "y": 2, + "z": 3 + } + } + }, + { + "command": "dropTip", + "params": { + "pipette": "pipetteId", + "labware": "trashId", + "well": "A1" + } + } + ] + } diff --git a/api/tests/opentrons/protocol_api/test_execute_v1.py b/api/tests/opentrons/protocol_api/test_execute_v1.py index 37331a3ef84..8081a66969c 100644 --- a/api/tests/opentrons/protocol_api/test_execute_v1.py +++ b/api/tests/opentrons/protocol_api/test_execute_v1.py @@ -1,3 +1,5 @@ +import os +import json import pytest from opentrons.types import Point from opentrons.protocol_api import execute_v1, ProtocolContext @@ -6,7 +8,7 @@ # TODO Ian 2018-11-07 when `model` is dropped, delete its test case @pytest.mark.parametrize('protocol_data', [ - # deprecated case + # no name, use model { "pipettes": { "leftPipetteHere": { @@ -15,11 +17,12 @@ } } }, - # future case + # name over model { "pipettes": { "leftPipetteHere": { "mount": "left", + "model": "ignore this!!!", "name": "p10_single" } } @@ -130,57 +133,10 @@ def test_load_labware_trash(loop): assert result['someTrashId'] == ctx.fixed_trash -protocol_data = { - "protocol-schema": "1.0.0", - "default-values": { - "aspirate-flow-rate": { - "p300_single_v1": 101 - }, - "dispense-flow-rate": { - "p300_single_v1": 102 - } - }, - "pipettes": { - "pipetteId": { - "mount": "left", - "model": "p300_single_v1" - } - }, - "procedure": [ - { - "subprocedure": [ - { - "command": "aspirate", - "params": { - "pipette": "pipetteId", - "labware": "sourcePlateId", - "well": "A1", - "volume": 5, - "flow-rate": 123 - } - }, - { - "command": "delay", - "params": { - "wait": 42 - } - }, - { - "command": "dispense", - "params": { - "pipette": "pipetteId", - "labware": "destPlateId", - "well": "B1", - "volume": 4.5 - } - }, - ] - } - ] -} - - def test_dispatch_commands(monkeypatch, loop): + with open(os.path.join(os.path.dirname(__file__), 'data', + 'v1_json_dispatch.json'), 'r') as f: + protocol_data = json.load(f) ctx = ProtocolContext(loop=loop) cmd = [] flow_rates = [] diff --git a/api/tests/opentrons/protocol_api/test_execute_v3.py b/api/tests/opentrons/protocol_api/test_execute_v3.py index 3f98bcb3771..1a359f8621d 100644 --- a/api/tests/opentrons/protocol_api/test_execute_v3.py +++ b/api/tests/opentrons/protocol_api/test_execute_v3.py @@ -1,3 +1,4 @@ +import os import json from opentrons.types import Point from opentrons.protocol_api import execute_v3, ProtocolContext @@ -59,86 +60,9 @@ def __setattr__(self, name, value): def test_dispatch_commands(monkeypatch, loop): - protocol_data = { - "schemaVersion": "3", - "commands": [ - { - "command": "pickUpTip", - "params": { - "pipette": "pipetteId", - "labware": "tiprackId", - "well": "B1" - } - }, - { - "command": "aspirate", - "params": { - "pipette": "pipetteId", - "labware": "sourcePlateId", - "well": "A1", - "volume": 5, - "flowRate": 3, - "offsetFromBottomMm": 2 - } - }, - { - "command": "delay", - "params": { - "wait": 42 - } - }, - { - "command": "dispense", - "params": { - "pipette": "pipetteId", - "labware": "destPlateId", - "well": "B1", - "volume": 4.5, - "flowRate": 2.5, - "offsetFromBottomMm": 1 - } - }, - { - "command": "touchTip", - "params": { - "pipette": "pipetteId", - "labware": "destPlateId", - "well": "B1", - "offsetFromBottomMm": 11 - } - }, - { - "command": "blowout", - "params": { - "pipette": "pipetteId", - "labware": "destPlateId", - "well": "B1", - "flowRate": 2, - "offsetFromBottomMm": 12 - } - }, - { - "command": "moveToSlot", - "params": { - "pipette": "pipetteId", - "slot": "5", - "offset": { - "x": 1, - "y": 2, - "z": 3 - } - } - }, - { - "command": "dropTip", - "params": { - "pipette": "pipetteId", - "labware": "trashId", - "well": "A1" - } - } - ] - } + with open(os.path.join(os.path.dirname(__file__), 'data', + 'v3_json_dispatch.json'), 'r') as f: + protocol_data = json.load(f) command_log = [] mock_pipette = MockPipette(command_log) From a9fa1faeb29e27d8b76be22cd776c6487902d753 Mon Sep 17 00:00:00 2001 From: IanLondon Date: Mon, 8 Apr 2019 14:33:26 -0400 Subject: [PATCH 6/9] type cleanup; move trough def to fixture json --- api/src/opentrons/protocol_api/execute_v3.py | 9 +- .../protocol_api/data/custom_trough_def.json | 166 ++++++++++++++++++ .../opentrons/protocol_api/test_execute_v3.py | 6 +- 3 files changed, 177 insertions(+), 4 deletions(-) create mode 100644 api/tests/opentrons/protocol_api/data/custom_trough_def.json diff --git a/api/src/opentrons/protocol_api/execute_v3.py b/api/src/opentrons/protocol_api/execute_v3.py index 31ce486017e..bd1e01caf7e 100644 --- a/api/src/opentrons/protocol_api/execute_v3.py +++ b/api/src/opentrons/protocol_api/execute_v3.py @@ -23,7 +23,7 @@ def load_pipettes_from_json( def _get_well(loaded_labware: Dict[str, labware.Labware], - params: Dict[str, Any]): + params: Dict[str, Any]) -> labware.Well: labwareId = params['labware'] well = params['well'] plate = loaded_labware[labwareId] @@ -33,12 +33,15 @@ def _get_well(loaded_labware: Dict[str, labware.Labware], # TODO (Ian 2019-04-05) once Pipette commands allow flow rate as an # absolute value (not % value) as an argument in # aspirate/dispense/blowout/air_gap fns, remove this -def _set_flow_rate(pipette, command_type, params): +def _set_flow_rate(pipette, command_type, params) -> None: """ Set flow rate in uL/mm, to value obtained from command's params. """ flow_rate_param = params['flowRate'] + if not (flow_rate_param > 0): + raise RuntimeError('Positive flowRate param required') + pipette.flow_rate = { 'aspirate': flow_rate_param, 'dispense': flow_rate_param @@ -83,7 +86,7 @@ def _get_location_with_offset(loaded_labware: Dict[str, labware.Labware], def dispatch_json(context: ProtocolContext, # noqa(C901) protocol_data: Dict[Any, Any], instruments: Dict[str, InstrumentContext], - loaded_labware: Dict[str, labware.Labware]): + loaded_labware: Dict[str, labware.Labware]) -> None: commands = protocol_data['commands'] for command_item in commands: diff --git a/api/tests/opentrons/protocol_api/data/custom_trough_def.json b/api/tests/opentrons/protocol_api/data/custom_trough_def.json new file mode 100644 index 00000000000..010628f3cf7 --- /dev/null +++ b/api/tests/opentrons/protocol_api/data/custom_trough_def.json @@ -0,0 +1,166 @@ +{ + "ordering": [ + ["A1"], + ["A2"], + ["A3"], + ["A4"], + ["A5"], + ["A6"], + ["A7"], + ["A8"], + ["A9"], + ["A10"], + ["A11"], + ["A12"] + ], + "otId": "THIS IS A CUSTOM ID", + "deprecated": false, + "metadata": { + "displayName": "CUSTOM 12 Channel Trough", + "displayVolumeUnits": "mL", + "displayCategory": "trough" + }, + "cornerOffsetFromSlot": { + "x": 0, + "y": 0.32, + "z": 0 + }, + "dimensions": { + "overallLength": 127.76, + "overallWidth": 85.8, + "overallHeight": 44.45 + }, + "parameters": { + "format": "trough", + "isTiprack": false, + "isMagneticModuleCompatible": false, + "loadName": "custom_trough_def", + "quirks": ["centerMultichannelOnWells"] + }, + "wells": { + "A1": { + "shape": "rectangular", + "depth": 42.16, + "length": 8.33, + "width": 71.88, + "totalLiquidVolume": 22000, + "x": 13.94, + "y": 42.9, + "z": 2.29 + }, + "A2": { + "shape": "rectangular", + "depth": 42.16, + "length": 8.33, + "width": 71.88, + "totalLiquidVolume": 22000, + "x": 23.03, + "y": 42.9, + "z": 2.29 + }, + "A3": { + "shape": "rectangular", + "depth": 42.16, + "length": 8.33, + "width": 71.88, + "totalLiquidVolume": 22000, + "x": 32.12, + "y": 42.9, + "z": 2.29 + }, + "A4": { + "shape": "rectangular", + "depth": 42.16, + "length": 8.33, + "width": 71.88, + "totalLiquidVolume": 22000, + "x": 41.21, + "y": 42.9, + "z": 2.29 + }, + "A5": { + "shape": "rectangular", + "depth": 42.16, + "length": 8.33, + "width": 71.88, + "totalLiquidVolume": 22000, + "x": 50.3, + "y": 42.9, + "z": 2.29 + }, + "A6": { + "shape": "rectangular", + "depth": 42.16, + "length": 8.33, + "width": 71.88, + "totalLiquidVolume": 22000, + "x": 59.39, + "y": 42.9, + "z": 2.29 + }, + "A7": { + "shape": "rectangular", + "depth": 42.16, + "length": 8.33, + "width": 71.88, + "totalLiquidVolume": 22000, + "x": 68.48, + "y": 42.9, + "z": 2.29 + }, + "A8": { + "shape": "rectangular", + "depth": 42.16, + "length": 8.33, + "width": 71.88, + "totalLiquidVolume": 22000, + "x": 77.57, + "y": 42.9, + "z": 2.29 + }, + "A9": { + "shape": "rectangular", + "depth": 42.16, + "length": 8.33, + "width": 71.88, + "totalLiquidVolume": 22000, + "x": 86.66, + "y": 42.9, + "z": 2.29 + }, + "A10": { + "shape": "rectangular", + "depth": 42.16, + "length": 8.33, + "width": 71.88, + "totalLiquidVolume": 22000, + "x": 95.75, + "y": 42.9, + "z": 2.29 + }, + "A11": { + "shape": "rectangular", + "depth": 42.16, + "length": 8.33, + "width": 71.88, + "totalLiquidVolume": 22000, + "x": 104.84, + "y": 42.9, + "z": 2.29 + }, + "A12": { + "shape": "rectangular", + "depth": 42.16, + "length": 8.33, + "width": 71.88, + "totalLiquidVolume": 22000, + "x": 113.93, + "y": 42.9, + "z": 2.29 + } + }, + "brand": { + "brand": "USA Scientific", + "brandId": ["1061-8150"] + } +} diff --git a/api/tests/opentrons/protocol_api/test_execute_v3.py b/api/tests/opentrons/protocol_api/test_execute_v3.py index 1a359f8621d..e32852b0008 100644 --- a/api/tests/opentrons/protocol_api/test_execute_v3.py +++ b/api/tests/opentrons/protocol_api/test_execute_v3.py @@ -3,13 +3,17 @@ from opentrons.types import Point from opentrons.protocol_api import execute_v3, ProtocolContext +with open(os.path.join(os.path.dirname(__file__), 'data', + 'custom_trough_def.json'), 'r') as f: + custom_trough_def = json.load(f) + def test_load_labware_v2(loop): ctx = ProtocolContext(loop=loop) # trough def with arbitrary ID data = { "labwareDefinitions": { - "someTroughDef": json.loads("""{"ordering":[["A1"],["A2"],["A3"],["A4"],["A5"],["A6"],["A7"],["A8"],["A9"],["A10"],["A11"],["A12"]],"otId":"THIS IS A CUSTOM ID","deprecated":false,"metadata":{"displayName":"CUSTOM 12 Channel Trough","displayVolumeUnits":"mL","displayCategory":"trough"},"cornerOffsetFromSlot":{"x":0,"y":0.32,"z":0},"dimensions":{"overallLength":127.76,"overallWidth":85.8,"overallHeight":44.45},"parameters":{"format":"trough","isTiprack":false,"isMagneticModuleCompatible":false,"loadName":"usa_scientific_12_trough_22_ml","quirks":["centerMultichannelOnWells"]},"wells":{"A1":{"shape":"rectangular","depth":42.16,"length":8.33,"width":71.88,"totalLiquidVolume":22000,"x":13.94,"y":42.9,"z":2.29},"A2":{"shape":"rectangular","depth":42.16,"length":8.33,"width":71.88,"totalLiquidVolume":22000,"x":23.03,"y":42.9,"z":2.29},"A3":{"shape":"rectangular","depth":42.16,"length":8.33,"width":71.88,"totalLiquidVolume":22000,"x":32.12,"y":42.9,"z":2.29},"A4":{"shape":"rectangular","depth":42.16,"length":8.33,"width":71.88,"totalLiquidVolume":22000,"x":41.21,"y":42.9,"z":2.29},"A5":{"shape":"rectangular","depth":42.16,"length":8.33,"width":71.88,"totalLiquidVolume":22000,"x":50.3,"y":42.9,"z":2.29},"A6":{"shape":"rectangular","depth":42.16,"length":8.33,"width":71.88,"totalLiquidVolume":22000,"x":59.39,"y":42.9,"z":2.29},"A7":{"shape":"rectangular","depth":42.16,"length":8.33,"width":71.88,"totalLiquidVolume":22000,"x":68.48,"y":42.9,"z":2.29},"A8":{"shape":"rectangular","depth":42.16,"length":8.33,"width":71.88,"totalLiquidVolume":22000,"x":77.57,"y":42.9,"z":2.29},"A9":{"shape":"rectangular","depth":42.16,"length":8.33,"width":71.88,"totalLiquidVolume":22000,"x":86.66,"y":42.9,"z":2.29},"A10":{"shape":"rectangular","depth":42.16,"length":8.33,"width":71.88,"totalLiquidVolume":22000,"x":95.75,"y":42.9,"z":2.29},"A11":{"shape":"rectangular","depth":42.16,"length":8.33,"width":71.88,"totalLiquidVolume":22000,"x":104.84,"y":42.9,"z":2.29},"A12":{"shape":"rectangular","depth":42.16,"length":8.33,"width":71.88,"totalLiquidVolume":22000,"x":113.93,"y":42.9,"z":2.29}},"brand":{"brand":"USA Scientific","brandId":["1061-8150"]}}""") # noqa + "someTroughDef": custom_trough_def }, "labware": { "sourcePlateId": { From 6505a6bab1e145b0ec8b12698b929371951dedc1 Mon Sep 17 00:00:00 2001 From: IanLondon Date: Mon, 8 Apr 2019 14:47:44 -0400 Subject: [PATCH 7/9] use dispatcher map for v3 executor --- api/src/opentrons/protocol_api/execute_v3.py | 177 +++++++++++-------- 1 file changed, 105 insertions(+), 72 deletions(-) diff --git a/api/src/opentrons/protocol_api/execute_v3.py b/api/src/opentrons/protocol_api/execute_v3.py index bd1e01caf7e..16ab0c6e2bb 100644 --- a/api/src/opentrons/protocol_api/execute_v3.py +++ b/api/src/opentrons/protocol_api/execute_v3.py @@ -33,7 +33,7 @@ def _get_well(loaded_labware: Dict[str, labware.Labware], # TODO (Ian 2019-04-05) once Pipette commands allow flow rate as an # absolute value (not % value) as an argument in # aspirate/dispense/blowout/air_gap fns, remove this -def _set_flow_rate(pipette, command_type, params) -> None: +def _set_flow_rate(pipette, params) -> None: """ Set flow rate in uL/mm, to value obtained from command's params. """ @@ -70,7 +70,6 @@ def load_labware_from_json_defs( def _get_location_with_offset(loaded_labware: Dict[str, labware.Labware], - command_type: str, params: Dict[str, Any]) -> Location: well = _get_well(loaded_labware, params) @@ -83,83 +82,117 @@ def _get_location_with_offset(loaded_labware: Dict[str, labware.Labware], return bottom.move(Point(z=offset_from_bottom)) -def dispatch_json(context: ProtocolContext, # noqa(C901) +def _delay( + context, protocol_data, instruments, loaded_labware, params) -> None: + wait = params['wait'] + if wait is None or wait is False: + raise ValueError('Delay must be true, or a number') + elif wait is True: + message = params.get('message', 'Pausing until user resumes') + context.pause(msg=message) + else: + context.delay(seconds=wait) + + +def _blowout( + context, protocol_data, instruments, loaded_labware, params) -> None: + pipette_id = params['pipette'] + pipette = instruments[pipette_id] + well = _get_well(loaded_labware, params) + _set_flow_rate(pipette, params) + pipette.blow_out(well) + + +def _pick_up_tip( + context, protocol_data, instruments, loaded_labware, params) -> None: + pipette_id = params['pipette'] + pipette = instruments[pipette_id] + well = _get_well(loaded_labware, params) + pipette.pick_up_tip(well) + + +def _drop_tip( + context, protocol_data, instruments, loaded_labware, params) -> None: + pipette_id = params['pipette'] + pipette = instruments[pipette_id] + well = _get_well(loaded_labware, params) + pipette.drop_tip(well) + + +def _aspirate( + context, protocol_data, instruments, loaded_labware, params) -> None: + pipette_id = params['pipette'] + pipette = instruments[pipette_id] + location = _get_location_with_offset(loaded_labware, params) + volume = params['volume'] + _set_flow_rate(pipette, params) + pipette.aspirate(volume, location) + + +def _dispense( + context, protocol_data, instruments, loaded_labware, params) -> None: + pipette_id = params['pipette'] + pipette = instruments[pipette_id] + location = _get_location_with_offset(loaded_labware, params) + volume = params['volume'] + _set_flow_rate(pipette, params) + pipette.dispense(volume, location) + + +def _touch_tip( + context, protocol_data, instruments, loaded_labware, params) -> None: + pipette_id = params['pipette'] + pipette = instruments[pipette_id] + location = _get_location_with_offset(loaded_labware, params) + well = _get_well(loaded_labware, params) + # convert mmFromBottom to v_offset + v_offset = location.point.z - well.top().point.z + pipette.touch_tip(well, v_offset=v_offset) + + +def _move_to_slot( + context, protocol_data, instruments, loaded_labware, params) -> None: + pipette_id = params['pipette'] + pipette = instruments[pipette_id] + slot = params['slot'] + if slot not in [str(s+1) for s in range(12)]: + raise ValueError('Invalid "slot" for "moveToSlot": {}' + .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( + slot_obj.move(offsetPoint), + force_direct=params.get('forceDirect'), + minimum_z_height=params.get('minimumZHeight')) + + +def dispatch_json(context: ProtocolContext, protocol_data: Dict[Any, Any], instruments: Dict[str, InstrumentContext], loaded_labware: Dict[str, labware.Labware]) -> None: commands = protocol_data['commands'] - + dispatcher_map = { + "delay": _delay, + "blowout": _blowout, + "pickUpTip": _pick_up_tip, + "dropTip": _drop_tip, + "aspirate": _aspirate, + "dispense": _dispense, + "touchTip": _touch_tip, + "moveToSlot": _move_to_slot + } for command_item in commands: command_type = command_item['command'] params = command_item['params'] - # all commands but 'delay' have 'pipette' param - if command_type != 'delay': - pipette_id = params['pipette'] - pipette = instruments[pipette_id] - - if command_type == 'delay': - wait = params['wait'] - if wait is None: - raise ValueError('Delay cannot be null') - elif wait is True: - message = params.get('message', 'Pausing until user resumes') - context.pause(msg=message) - else: - context.delay(seconds=wait) - - elif command_type == 'blowout': - well = _get_well(loaded_labware, params) - _set_flow_rate(pipette, command_type, params) - pipette.blow_out(well) - - elif command_type == 'pickUpTip': - well = _get_well(loaded_labware, params) - pipette.pick_up_tip(well) - - elif command_type == 'dropTip': - well = _get_well(loaded_labware, params) - pipette.drop_tip(well) - - elif command_type == 'aspirate': - location = _get_location_with_offset( - loaded_labware, 'aspirate', params) - volume = params['volume'] - _set_flow_rate(pipette, command_type, params) - pipette.aspirate(volume, location) - - elif command_type == 'dispense': - location = _get_location_with_offset( - loaded_labware, 'dispense', params) - volume = params['volume'] - _set_flow_rate(pipette, command_type, params) - pipette.dispense(volume, location) - - elif command_type == 'touchTip': - location = _get_location_with_offset( - loaded_labware, 'dispense', params) - well = _get_well(loaded_labware, params) - # convert mmFromBottom to v_offset - v_offset = location.point.z - well.top().point.z - pipette.touch_tip(well, v_offset=v_offset) - - elif command_type == 'moveToSlot': - slot = params['slot'] - if slot not in [str(s+1) for s in range(12)]: - raise ValueError('Invalid "slot" for "moveToSlot": {}' - .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( - slot_obj.move(offsetPoint), - force_direct=params.get('forceDirect'), - minimum_z_height=params.get('minimumZHeight')) - else: + dispatcher = dispatcher_map[command_type] + if not dispatcher: raise RuntimeError( "Unsupported command type {}".format(command_type)) + dispatcher(context, protocol_data, instruments, loaded_labware, params) From 03c41c9e883c101e8d9bca8496f467258f8452b2 Mon Sep 17 00:00:00 2001 From: IanLondon Date: Mon, 8 Apr 2019 15:07:06 -0400 Subject: [PATCH 8/9] cleanup re: Laura PR comments --- api/src/opentrons/protocol_api/execute.py | 23 +++++++++++--------- api/src/opentrons/protocol_api/execute_v1.py | 2 +- api/src/opentrons/protocol_api/execute_v3.py | 6 ++--- 3 files changed, 17 insertions(+), 14 deletions(-) diff --git a/api/src/opentrons/protocol_api/execute.py b/api/src/opentrons/protocol_api/execute.py index eeda3afdc37..69402a4dd2b 100644 --- a/api/src/opentrons/protocol_api/execute.py +++ b/api/src/opentrons/protocol_api/execute.py @@ -111,19 +111,21 @@ def _run_python(proto: Any, context: ProtocolContext): def get_protocol_schema_version(protocol_json: Dict[Any, Any]) -> int: # v3 and above uses `schemaVersion: integer` version = protocol_json.get('schemaVersion') - if None is not version: + if version: return version # v1 uses 1.x.x and v2 uses 2.x.x legacyKebabVersion = protocol_json.get('protocol-schema') # No minor/patch schemas ever were released, # do not permit protocols with nonexistent schema versions to load - if (legacyKebabVersion == '1.0.0'): + if legacyKebabVersion == '1.0.0': return 1 - if (legacyKebabVersion == '2.0.0'): + elif legacyKebabVersion == '2.0.0': return 2 - if (legacyKebabVersion is not None): - raise RuntimeError(('No such schema version: "{}". Did you mean ' + - '"1.0.0" or "2.0.0"?').format(legacyKebabVersion)) + elif legacyKebabVersion: + raise RuntimeError( + f'No such schema version: "{legacyKebabVersion}". Did you mean ' + + '"1.0.0" or "2.0.0"?') + # no truthy value for schemaVersion or protocol-schema raise RuntimeError( 'Could not determine schema version for protocol. ' + 'Make sure there is a version number under "schemaVersion"') @@ -160,11 +162,12 @@ def run_protocol(protocol_code: Any = None, _run_python(protocol_code, true_context) elif None is not protocol_json: protocol_version = get_protocol_schema_version(protocol_json) - if (protocol_version) > 3: - raise RuntimeError('JSON Protocol version {} is not yet supported \ - in this version of the API'.format(protocol_version)) + if protocol_version > 3: + raise RuntimeError( + f'JSON Protocol version {protocol_version } is not yet ' + + 'supported in this version of the API') - if (protocol_version >= 3): + if protocol_version >= 3: ins = execute_v3.load_pipettes_from_json( true_context, protocol_json) lw = execute_v3.load_labware_from_json_defs( diff --git a/api/src/opentrons/protocol_api/execute_v1.py b/api/src/opentrons/protocol_api/execute_v1.py index b29cc029968..cfe118d84d1 100644 --- a/api/src/opentrons/protocol_api/execute_v1.py +++ b/api/src/opentrons/protocol_api/execute_v1.py @@ -162,7 +162,7 @@ def dispatch_json(context: ProtocolContext, # noqa(C901) params.get('pipette'), {}) pipette_name = protocol_pipette_data.get('name') - if (not pipette_name): + if not pipette_name: # TODO: Ian 2018-11-06 remove this fallback to 'model' when # backwards-compatability for JSON protocols with versioned # pipettes is dropped (next JSON protocol schema major bump) diff --git a/api/src/opentrons/protocol_api/execute_v3.py b/api/src/opentrons/protocol_api/execute_v3.py index 16ab0c6e2bb..16a9fd72e18 100644 --- a/api/src/opentrons/protocol_api/execute_v3.py +++ b/api/src/opentrons/protocol_api/execute_v3.py @@ -191,8 +191,8 @@ def dispatch_json(context: ProtocolContext, command_type = command_item['command'] params = command_item['params'] - dispatcher = dispatcher_map[command_type] - if not dispatcher: + if command_type not in dispatcher_map: raise RuntimeError( "Unsupported command type {}".format(command_type)) - dispatcher(context, protocol_data, instruments, loaded_labware, params) + dispatcher_map[command_type]( + context, protocol_data, instruments, loaded_labware, params) From e082c6da2b0d867ab116b2693a6269b2e14bf962 Mon Sep 17 00:00:00 2001 From: IanLondon Date: Tue, 9 Apr 2019 11:40:21 -0400 Subject: [PATCH 9/9] delete extra space --- api/src/opentrons/protocol_api/execute.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/src/opentrons/protocol_api/execute.py b/api/src/opentrons/protocol_api/execute.py index 69402a4dd2b..96a5edc0d28 100644 --- a/api/src/opentrons/protocol_api/execute.py +++ b/api/src/opentrons/protocol_api/execute.py @@ -164,7 +164,7 @@ def run_protocol(protocol_code: Any = None, protocol_version = get_protocol_schema_version(protocol_json) if protocol_version > 3: raise RuntimeError( - f'JSON Protocol version {protocol_version } is not yet ' + + f'JSON Protocol version {protocol_version} is not yet ' + 'supported in this version of the API') if protocol_version >= 3: