From 988407d275a282f03a0d43a3ccbb6b19827605c2 Mon Sep 17 00:00:00 2001 From: Ian London Date: Tue, 9 Apr 2019 11:59:32 -0400 Subject: [PATCH] feat(api): define & execute v3 json protocols (#3312) * define v3 json protocol schema * support v3 JSON protocol execution in APIv2 executor Closes #3110 --- api/src/opentrons/protocol_api/execute.py | 272 ++----------- api/src/opentrons/protocol_api/execute_v1.py | 233 +++++++++++ api/src/opentrons/protocol_api/execute_v3.py | 198 +++++++++ .../protocol_api/data/custom_trough_def.json | 166 ++++++++ .../protocol_api/data/v1_json_dispatch.json | 48 +++ .../protocol_api/data/v3_json_dispatch.json | 80 ++++ .../opentrons/protocol_api/test_execute.py | 232 ----------- .../opentrons/protocol_api/test_execute_v1.py | 184 +++++++++ .../opentrons/protocol_api/test_execute_v3.py | 110 +++++ .../labware-json-schema/labware-schema.json | 1 + .../protocolSchemaV3.json | 383 ++++++++++++++++++ 11 files changed, 1445 insertions(+), 462 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/data/custom_trough_def.json 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 create mode 100644 api/tests/opentrons/protocol_api/test_execute_v1.py create mode 100644 api/tests/opentrons/protocol_api/test_execute_v3.py 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..96a5edc0d28 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,227 +108,27 @@ 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') - - # 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) - 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( - ctx: ProtocolContext, - protocol: Dict[Any, Any]) -> Dict[str, labware.Labware]: - 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(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 - - -# 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 dispatch_json(context: ProtocolContext, # noqa(C901) - protocol_data: Dict[Any, Any], - instruments: Dict[str, InstrumentContext], - 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(labware, params) - pipette.blow_out(well) # type: ignore - - elif command_type == 'pick-up-tip': - well = _get_well(labware, params) - pipette.pick_up_tip(well) # type: ignore - - elif command_type == 'drop-tip': - well = _get_well(labware, params) - pipette.drop_tip(well) # type: ignore - - elif command_type == 'aspirate': - location = _get_location_with_offset( - 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( - 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(labware, params) - offset = default_values.get('touch-tip-mm-from-top', -1) - pipette.touch_tip(location, v_offset=offset) # type: ignore - - elif command_type == 'move-to-slot': - slot = params.get('slot') - if slot not in [str(s+1) for s in range(12)]: - raise ValueError('Invalid "slot" for "move-to-slot": {}' - .format(slot)) - slot_obj = context.deck.position_for(slot) - - offset = params.get('offset', {}) - offsetPoint = Point( - offset.get('x', 0), - offset.get('y', 0), - offset.get('z', 0)) - - pipette.move_to( # type: ignore - slot_obj.move(offsetPoint), - force_direct=params.get('force-direct'), - minimum_z_height=params.get('minimum-z-height')) - else: - MODULE_LOG.warning("Bad command type {}".format(command_type)) +def get_protocol_schema_version(protocol_json: Dict[Any, Any]) -> int: + # v3 and above uses `schemaVersion: integer` + version = protocol_json.get('schemaVersion') + 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': + return 1 + elif legacyKebabVersion == '2.0.0': + return 2 + 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"') def run_protocol(protocol_code: Any = None, @@ -364,8 +161,23 @@ 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) - ins = load_pipettes_from_json(true_context, protocol_json) - dispatch_json(true_context, protocol_json, ins, lw) + protocol_version = get_protocol_schema_version(protocol_json) + 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: + 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: + 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..cfe118d84d1 --- /dev/null +++ b/api/src/opentrons/protocol_api/execute_v1.py @@ -0,0 +1,233 @@ +# 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['pipettes'] + pipettes_by_id = {} + for pipette_id, props in pipette_data.items(): + model = props['model'] + mount = props['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['subprocedure'] + for p in protocol_data['procedure']] + + default_values = protocol_data['default-values'] + flat_subs = itertools.chain.from_iterable(subprocedures) + + for command_item in flat_subs: + command_type = command_item['command'] + params = command_item['params'] + pipette = instruments.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: + # 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['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..16a9fd72e18 --- /dev/null +++ b/api/src/opentrons/protocol_api/execute_v3.py @@ -0,0 +1,198 @@ +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['pipettes'] + pipettes_by_id = {} + for pipette_id, props in pipette_data.items(): + mount = props['mount'] + name = props['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]) -> labware.Well: + labwareId = params['labware'] + well = params['well'] + 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, 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 + } + + +def load_labware_from_json_defs( + ctx: ProtocolContext, + protocol: Dict[Any, Any]) -> Dict[str, labware.Labware]: + protocol_labware = protocol['labware'] + definitions = protocol['labwareDefinitions'] + loaded_labware = {} + + for labware_id, props in protocol_labware.items(): + slot = props['slot'] + definition = definitions[props['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], + 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['offsetFromBottomMm'] + bottom = well.bottom() + return bottom.move(Point(z=offset_from_bottom)) + + +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'] + + if command_type not in dispatcher_map: + raise RuntimeError( + "Unsupported command type {}".format(command_type)) + dispatcher_map[command_type]( + context, protocol_data, instruments, loaded_labware, params) 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/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.py b/api/tests/opentrons/protocol_api/test_execute.py index 9571f052bf6..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 - -from opentrons.types import Point from opentrons.protocol_api import execute, ProtocolContext @@ -82,233 +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(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( - command_type, command_params, default_values) - assert offs == offset - result = execute._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._get_location_with_offset( - loaded_labware, command_type, command_params, default_values) - default = default_values['{}-mm-from-bottom'.format(command_type)] - assert execute._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.load_labware_from_json(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.load_labware_from_json(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 = { - "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", 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_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( - 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_v1.py b/api/tests/opentrons/protocol_api/test_execute_v1.py new file mode 100644 index 00000000000..8081a66969c --- /dev/null +++ b/api/tests/opentrons/protocol_api/test_execute_v1.py @@ -0,0 +1,184 @@ +import os +import json +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', + [ + # no name, use model + { + "pipettes": { + "leftPipetteHere": { + "mount": "left", + "model": "p10_single_v1.3" + } + } + }, + # name over model + { + "pipettes": { + "leftPipetteHere": { + "mount": "left", + "model": "ignore this!!!", + "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 + + +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 = [] + + 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..e32852b0008 --- /dev/null +++ b/api/tests/opentrons/protocol_api/test_execute_v3.py @@ -0,0 +1,110 @@ +import os +import json +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": custom_trough_def + }, + "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): + 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) + 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'],)) + ] 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" + } + } +}