Source: roboliq.js

/**
 * Roboliq: Automation for liquid-handling robots
 * @copyright 2017, ETH Zurich, Ellis Whitehead
 * @license GPL-3.0
 */

/**
 * Roboliq's top module with functions for processing protocols.
 * @module roboliq
 */

/**
 * Protocol specification.
 * @typedef {Object} Protocol
 * @property {Object} objects
 * @property {Object} steps
 * @property {Object} effects
 * @property {Array} predicates
 * @property {Object} directiveHandlers
 * @property {Object} objectToPredicateConverters
 * @property {Object} commandHandlers
 * @property {Object} planHandlers
 * @property {Object} files
 * @property {Object} errors
 * @property {Object} warnings
 */

/**
 * Command handler result.
 * @typedef {Object} CommandHandlerResult
 * @property {Array} errors - array of error strings
 * @property {Array} warnings - array of warning strings
 * @property {Object|Array} expansion - an array or map of sub-steps
 * @property {Object} effects - a map of object property effects
 * @property {Object} alternatives - ???
 */

Error.stackTraceLimit = Infinity;
/**
 * Well contents.
 *
 * Well contents are encoded as an array.
 * The first element always holds the volume in the well.
 * If the array has exactly one element, the volume should be 0l.
 * If the array has exactly two elements, the second element is the name of the substance.
 * If the array has more than two elements, each element after the volume has the same
 * structure as the top array and they represent the mixture originally dispensed in the well.
 *
 *          objects:
 *              plate1:
 *                  contents:
 *                      A01: ["30ul", ["25ul", "water"], ["5ul", "reagent1"]]

 * @typedef {array} WellContents
 */

var _ = require('lodash');
var assert = require('assert');
var fs = require('fs');
import handlebars from 'handlebars';
var jiff = require('jiff');
var jsonfile = require('jsonfile');
import mkdirp from 'mkdirp';
import naturalSort from 'javascript-natural-sort';
var path = require('path');
var yaml = require('yamljs');
import commandHelper from './commandHelper.js';
var expect = require('./expect.js');
var misc = require('./misc.js');
import stripUndefined from './stripUndefined.js';
import * as WellContents from './WellContents.js';
var wellsParser = require('./parsers/wellsParser.js');
import * as Design from './design.js';

const version = "v1";

const nomnom = require('nomnom').options({
	infiles: {
		position: 0,
		help: 'input files, .json or .js',
		list: true
	},
	debug: {
		abbr: 'd',
		flag: true,
		help: 'Print debugging info'
	},
	evoware: {
		help: "Invoke evoware supplier and pass the comma-separated arguements"
	},
	fileData: {
		full: 'file-data',
		list: true,
		help: "Supply filedata on the command line in the form of 'filename:filedata'"
	},
	fileJson: {
		full: 'file-json',
		list: true,
		help: "Supply a JSON file on the command line in the form of 'filename:filedata'"
	},
	loadRoboliqConfig: {
		full: 'load-roboliq-config',
		flag: true,
		default: true
	},
	output: {
		abbr: 'o',
		help: 'specify output filename or "" for none; otherwise the default filename is used',
		metavar: 'FILE'
	},
	outputDir: {
		abbr: 'O',
		full: 'output-dir',
		help: 'specify output directory',
		metavar: 'DIR'
	},
	parentDir: {
		abbr: 'P',
		full: 'parent-dir',
		help: "specify output's parent directory, under which a new subdirectory will be created with the protocol's name",
		metavar: 'DIR'
	},
	print: {
		abbr: 'p',
		flag: true,
		help: 'print output'
	},
	printProtocol: {
		abbr: 'r',
		full: 'print-protocol',
		flag: true,
		help: 'print combined protocol'
	},
	printDesigns: {
		full: 'print-designs',
		flag: true,
		help: 'print design tables'
	},
	progress: {
		flag: true,
		help: 'print progress indicator while processing the protocol'
	},
	quiet: {
		flag: true,
		help: "suppress printing of information, erros, and warning"
	},
	subdir: {
		abbr: 'S',
		full: 'subdir',
		help: "specify an extra subdirectory beneath the parent directory; for use when grouping several protocols together.",
		metavar: 'DIR'
	},
	throw: {
		abbr: 'T',
		flag: true,
		help: 'throw error when errors encountered during processing (in order to get a backtrace)'
	},
	varset: {
		help: "Variable set to load",
		list: true
	},
	version: {
		flag: true,
		help: 'print version and exit',
		callback: function() {
			return "version "+version;
		}
	},
});

const protocolEmpty = {
	objects: {},
	steps: {},
	effects: {},
	predicates: [],
	directiveHandlers: {},
	objectToPredicateConverters: {},
	schemas: {},
	commandHandlers: {},
	planAlternativeChoosers: {},
	planHandlers: {},
	files: {},
	fillIns: {},
	reports: {},
	errors: {},
	warnings: {},
	COMPILER: {},
};

/**
 * Loads the raw content at the given URL.
 * Supported formats are: JSON, YAML, JavaScript, and pre-cached file data.
 *
 * @param  {string} url - URL to load.
 * @param  {object} filecache - map of cached file data, map from URL to data.
 * @return content at URL.
 */
function loadUrlContent(url, filecache) {
	url = path.posix.join(url);
	//if (!path.isAbsolute(url))
	if (!path.isAbsolute(url))
		url = "./" + url;
	//console.log("in cache:", filecache.hasOwnProperty(url))
	const absolutePath = path.resolve(url);
	if (filecache.hasOwnProperty(url))
		return filecache[url];
	else if (path.extname(url) === ".yaml")
		return yaml.load(url);
	else if (path.extname(url) === ".json")
		return jsonfile.readFileSync(url);
	else {
		let relativePath = path.relative(__dirname, absolutePath);
		if (!_.startsWith(relativePath, ".")) {
			relativePath = "./" + relativePath;
		}
		//console.log({url, absolutePath, relativePath, __dirname})
		return require(relativePath);
	}
}

/**
 * Finishing loading/processing an unprocessed protocol: handle imports, directives, and file nodes
 * @param  {Object} a - Previously loaded protocol data
 * @param  {Object} b - The protocol to pre-process
 * @param  {String} [url] - The url of the protocol
 * @return {Object} protocol with
 */
function loadProtocol(a, b, url, filecache) {
	// Require 'roboliq' property
	expect.truthy({}, b.roboliq, "'roboliq' property must be specified with targetted version number for protocol at URL "+url);

	//console.log("loadProtocol:", url);
	//if (url.indexOf("roboliq") > 0)
	//	console.log(JSON.stringify(b))
	// Handle imports
	var imported = _.cloneDeep(protocolEmpty);
	if (b.imports) {
		var urls = _.map(_.flatten([b.imports]), function(imp) {
			// console.log("paths:", path.dirname(url), imp, path.join(path.dirname(url), imp))
			const path1 = path.posix.join(path.dirname(url), imp);
			const path2 = (_.startsWith(path1, "/")) ? path1 : `./${path1}`;
			//console.log({url, absolutePath, relativePath, __dirname})
			return path2;
		});
		var protocols2 = _.map(urls, function(url2) {
			// console.log("url:", url2)
			var protocol2 = loadUrlContent(url2, filecache);
			return loadProtocol(protocolEmpty, protocol2, url2, filecache);
		});
		imported = mergeProtocolList(protocols2);
	}

	if (_.isPlainObject(b.files) && !_.isEmpty(b.files)) {
		_.merge(filecache, b.files)
	}

	/*
	// Add variables to `objects`
	// TODO: Remove this in favor of  (ellis 2016-11-09)
	if (b.variables) {
		_.forEach(b.variables, (value, key) => {
			b.objects[key] = _.merge({}, {type: "Variable"}, value);
		});
	}*/

	// Add parameters to `objects.PARAMS`
	if (b.parameters) {
		// console.log("parameters")
		_.forEach(b.parameters, (param, key) => {
			// console.log("parameter: "+key)
			// If this parameter needs to be 'calculate'd
			const value0 = param.calculate || param.value;
			expect.try({path: key, paramName: "value"}, () => {
				const calculate = _.cloneDeep(value0);
				const data = {
					objects: {PARAMS: _.merge({}, _.get(a, ["objects", "PARAMS"]), _.get(b, ["objects", "PARAMS"]))},
					directiveHandlers: _.merge({}, a.directiveHandlers, b.directiveHandlers)
				};
				// console.log({data})
				const value = expandDirectivesDeep(calculate, data);
				// console.log({value0, calculate, value})
				param.value = value;
			});
			_.set(b.objects, ["PARAMS", key], param.value);
		});
	}

	// Create a clone keeping only valid protocol properties.
	var c = _.cloneDeep(_.pick(b,
		"description",
		"config",
		"parameters",
		"objects",
		"steps",
		"effects",
		"predicates",
		"directiveHandlers",
		"objectToPredicateConverters",
		"schemas",
		"commandHandlers",
		"planAlternativeChoosers",
		"planHandlers",
		"files",
		"errors",
		"warnings",
		"COMPILER"
	));
	if (_.isUndefined(c.errors)) {
		c.errors = {};
	}

	// Pre-process properties with ?-suffixes and !-suffixes.
	if (!c.fillIns)
		c.fillIns = {};
	preProcessQuestionMarks(c, c.objects, ['objects']);
	preProcessQuestionMarks(c, c.steps, ['steps']);
	// console.log("A: "+JSON.stringify(c.fillIns["objects.plate1.model"]))
	preProcessExclamationMarks(c, c.objects, ['objects']);
	preProcessExclamationMarks(c, c.steps, ['steps']);

	var data = {
		objects: _.merge({}, a.objects, imported.objects, c.objects),
		directiveHandlers: _.defaults({}, b.directiveHandlers, imported.directiveHandlers, a.directiveHandlers)
	};
	// Handle directives for predicates
	var l = [
		'predicates'
	];
	_.forEach(l, function(key) {
		// console.log({key, c: c[key]})
		misc.mutateDeep(c[key], function(x) { return misc.handleDirective(x, data); });
	});

	// Deep mutation for two modifications:
	// 1. Handle file nodes, resolve path relative to current directory, add to "files" key of protocol
	// 2. Substitute parameter values
	misc.mutateDeep(c, function(x) {
		//console.log("x: "+x)
		if (_.isString(x)) {
			// Return filename relative to current directory
			if (_.startsWith(x, "./") || _.startsWith(x, "../")) {
				var filename = "./" + path.posix.join(path.dirname(url), x);
				// If the file hasn't been loaded yet:
				if (!filecache.hasOwnProperty(filename)) {
					// console.log("try to load "+filename);
					try {
						var filedata = fs.readFileSync(filename);
						filecache[filename] = filedata;
						//console.log("filename: "+filename);
						//console.log(filedata);
						//console.log(filedata.toString('utf8'))
					} catch (e) {
						c.errors[url] = [`could not load file (${filename})`, e.toString()];
					}
				}
				return filename;
			}
			// Substitute parameter value
			else if (_.startsWith(x, "$#")) {
				// HACK: modified from misc.handleDirective
				const key = x.substr(2);
				const value = _.get(c, ["parameters", key, "value"]) || _.get(imported, ["parameters", key, "value"]);
				if (_.isUndefined(value)) {
					throw new Error("undefined parameter value: "+x);
				}
				return value;
			}
		}
		return x;
	});

	// Merge in the imports
	var d = mergeProtocols(imported, c);
	//if (url.indexOf("roboliq") > 0)
	//if (c.objects && !c.predicates)
	//	console.log(JSON.stringify(c, null, '\t'));

	// console.log("B: "+JSON.stringify(d.fillIns["objects.plate1.model"]))
	return d;
}

/**
 * Remove properties with '?'-suffix.  If the propery value has a 'value!' property,
 * add a new property to the object without the '?'-suffix and with the given value.
 * Mutates the object.
 * Also add the path to the property to the protocol's `fillIns`
 * @param  {Protocol} protocol
 * @param  {any} obj
 * @param  {array} path
 */
function preProcessQuestionMarks(protocol, obj, path) {
	// console.log("preProcessQuestionMarks")
	if (_.isPlainObject(obj)) {
		const pairs0 = _.toPairs(obj);
		let changed = false;
		const pairs1 = pairs0.map(pair => {
			const [name, value] = pair;
			if (_.endsWith(name, "?")) {
				// console.log("endsWith: "+name)
				changed = true;
				const name1 = name.slice(0, -1);
				if (value.hasOwnProperty('value!')) {
					return [name1, value['value!']];
				}
				else {
					protocol.fillIns[path.concat(name1).join('.')] = value || {};
					// console.log(`protocol.fillIns[${path.concat(name1).join('.')}] = ${JSON.stringify(value)}`)
					return null;
				}
			}
			else {
				preProcessQuestionMarks(protocol, value, path.concat(name));
				return [name, obj[name]];
			}
		});
		if (changed) {
			// Remove all properties
			pairs0.forEach(pair => delete obj[pair[0]]);
			// Add them all back in again, with new names/values
			_.compact(pairs1).forEach(pair => obj[pair[0]] = pair[1]);
		}
	}
	else if (_.isArray(obj)) {
		_.forEach(obj, (value, index) => {
			preProcessQuestionMarks(protocol, value, path.concat(index));
		});
	}
}

/**
 * Any properties that have a "!" suffix are renamed to not have that suffix,
 * overwritting an already existing property if necessary.
 * Mutates the object.
 * @param  {Protocol} protocol
 * @param  {any} obj
 * @param  {array} path
 */
function preProcessExclamationMarks(protocol, obj, path) {
	//console.log(JSON.stringify(obj));
	if (_.isPlainObject(obj)) {
		const pairs0 = _.toPairs(obj);
		let changed = false;
		const obj1 = [];
		for (var i = 0; i < pairs0.length; i++) {
			const [name, value] = pairs0[i];
			if (_.endsWith(name, "!")) {
				changed = true;
				const name1 = name.slice(0, -1);
				obj1[name1] = value;
			}
			// if an object has both ! and non ! properties, the ! property should take precedence
			else if (!obj1.hasOwnProperty(name)) {
				preProcessExclamationMarks(protocol, value, path.concat(name));
				obj1[name] = obj[name];
			}
		}
		if (changed) {
			// Remove all properties
			pairs0.forEach(pair => delete obj[pair[0]]);
			// Add them all back in again, with new names/values
			const pairs1 = _.toPairs(obj1);
			pairs1.forEach(pair => obj[pair[0]] = pair[1]);
		}
	}
	else if (_.isArray(obj)) {
		_.forEach(obj, (value, index) => {
			preProcessExclamationMarks(protocol, value, path.concat(index));
		});
	}
}

/**
 * Merge protocols A & B, returning a new protocol.
 *
 * @param  {Object} a   protocol representing the result of all previous mergeProtocols
 * @param  {Object} b   newly loaded protocol to merge into previous protocols
 * @return {Object}     result of merging protocol B into A.
 */
function mergeProtocols(a, b) {
	//console.log("BEFORE")
	//console.log("a.predicates: "+JSON.stringify(a.predicates));
	//console.log("b.predicates: "+JSON.stringify(b.predicates));

	var c = _.merge({}, _.omit(a, 'predicates'), _.omit(b, 'predicates'));
	//console.log("AFTER")
	//console.log("a.predicates: "+JSON.stringify(a.predicates));
	//console.log("b.predicates: "+JSON.stringify(b.predicates));
	c.predicates = a.predicates.concat(b.predicates || []);
	//console.log("c:", c);
	return c;
}

/**
 * Merge a list of protocols.
 *
 * @param  {array} protocols - list of protocols.
 * @return {Protocol} merged protocol.
 */
function mergeProtocolList(protocols) {
	var protocol = _.cloneDeep(protocolEmpty);
	_.forEach(protocols, function(b) {
		protocol = mergeProtocols(protocol, b);
	});
	return protocol;
}

/**
 * Post-process protocol: flatten predicate list, parse wells strings for Liquid objects.
 *
 * Mutates the passed protocol.
 *
 * @param  {Object} protocol A protocol.
 */
function postProcessProtocol(protocol, filecache) {
	// Make sure predicates is a flat list
	protocol.predicates = _.flattenDeep(protocol.predicates);

	// Calculate values for variables
	postProcessProtocol_variables(protocol, filecache);

	// For all liquids, if they specify source wells, make sure the source well
	// has a reference to the liquid in its contents (the contents will be added
	// if necessary).
	var liquids = misc.getObjectsOfType(protocol.objects, 'Liquid');
	_.forEach(liquids, function(liquid, name) {
		if (_.isString(liquid.wells)) {
			try {
				liquid.wells = wellsParser.parse(liquid.wells, protocol.objects);
				_.forEach(liquid.wells, function(well) {
					var pair = WellContents.getContentsAndName(well, protocol);
					// If well already has contents:
					if (pair[0]) {
						assert(_.isEqual(_.tail(pair[0]), [name]), "well "+well+" already contains different contents: "+JSON.stringify(pair[0]));
						// Don't need to set contents, leave as is with the given volume.
					}
					else {
						var path = pair[1];
						_.set(protocol.objects, path, ['Infinity l', name]);
					}
				});
			} catch (e) {
				protocol.errors[name+".wells"] = [e.toString(), e.stack];
				//console.log(e.toString());
			}
		}
	});
}

/**
 * For all variables that have a `calculate` property, handle the calculation and put the
 * result in the `value` property.
 * For 'Data' objects:
 *  if it doesn't have a value, call `Design.flattenDesign`;
 *  if its value is a filename, load the file into the value
 *
 * Mutates protocol.
 *
 * @param  {Protocol} protocol - The protocol to inspect.
 */
function postProcessProtocol_variables(protocol, filecache) {
	const data = _.clone(protocol);

	_.forEach(protocol.objects, (obj, key) => {
		expect.try({path: key, paramName: "calculate"}, () => {
			// console.log("postProcessProtocol_variables key: "+key);
			// If this is a variable with a 'calculate' property
			if (obj.type === "Variable" && obj.calculate) {
				const calculate = _.cloneDeep(obj.calculate);
				const value = expandDirectivesDeep(calculate, data);
				// console.log("postProcessProtocol_variables value: "+value);
				obj.value = value;
			}
		});
		expect.try({path: key, paramName: "valueFile"}, () => {
			if (obj.type === "Data" && obj.valueFile) {
				// console.log("postProcessProtocol_variables files: "+JSON.stringify(filecache[obj.valueFile]));
				assert(filecache.hasOwnProperty(obj.valueFile), "file not in cache: "+obj.valueFile);
				const filedata = filecache[obj.valueFile].toString('utf8');
				// console.log("filedata: "+filedata);
				const rows = filedata.split("\n").map(s => s.trim()).filter(s => s != "");
				// console.log("rows: "+rows);
				const value = rows.map(s => JSON.parse(s));
				// console.log({value});
				obj.value = value;
			}
		});
	});
}

// Recursively expand all directives
function expandDirectivesDeep(x, data) {
	if (_.isPlainObject(x)) {
		for (var key in x) {
			var value1 = x[key];
			if (_.isArray(value1)) {
				x[key] = _.map(value1, function(x2) { return misc.handleDirectiveDeep(x2, data); });
			}
			else {
				x[key] = expandDirectivesDeep(value1, data);
			}
		}
	}
	// Make sure this property exists in order to avoid an exception
	if (!data.hasOwnProperty("accesses")) {
		data.accesses = [];
	}
	return misc.handleDirective(x, data);
}

/**
 * Perorms a schema check, makes sure that all objects are valid.
 *
 * Throws an error if the protocol isn't valid.
 *
 * @param  {Protocol} protocol - The protocol to validate.
 */
function validateProtocol1(protocol, o, path) {
	// console.log({objects: protocol.objects})
	if (_.isUndefined(o)) {
		o = protocol.objects;
		path = [];
	}
	for (const [name, value] of _.toPairs(o)) {
		const path2 = path.concat(name);
		const fullName = path2.join(".");
		const doit = () => {
			//console.log({name, value, fullName})
			if (name !== 'type' && name !== "DATA" && name !== "SCOPE" && name !== "PARAMS") {
				assert(!_.isEmpty(value.type), "Missing `type` property: "+JSON.stringify(value));
				if (value.type === "Namespace") {
					validateProtocol1(protocol, value, path.concat(name));
				}
				else {
					const schema = protocol.schemas[value.type];
					assert(schema, "Unknown type: "+value.type);
					if (schema) {
						const data = {
							objects: protocol.objects,
							predicates: protocol.predicates,
							planAlternativeChoosers: protocol.planAlternativeChoosers,
							planHandlers: protocol.planHandlers,
							schemas: protocol.schemas,
							accesses: [],
							files: protocol.files, // or filecache?
							protocol,
							path: [fullName]
						};
						commandHelper.parseParams(value, data, schema);
					}
				}
			}
		}
		expect.context({objectName: fullName}, doit);
	}
}

function run(argv, userProtocol, loadRoboliqProcessorYaml = true) {
	argv = argv || process.argv.slice(2);

	if (loadRoboliqProcessorYaml && fs.existsSync("roboliq-processor.yaml")) {
		const env = yaml.load("roboliq-processor.yaml");
		if (env.preload) {
			argv = env.preload.concat(argv);
		}
		if (env.args) {
			argv = env.args.concat(argv);
		}
	}

	// Validate the command line arguments
	var opts = nomnom.parse(argv);
	if (_.isEmpty(opts.infiles) && !userProtocol) {
		console.log(nomnom.getUsage());
		if (require.main === module) {
			process.exit(0);
		}
	}
	else {
		return runWithOpts(opts, userProtocol);
	}
}

/**
 * Process a roboliq protocol.
 *
 * @param  {array} argv - command line options.
 * @param  {Protocol} [userProtocol] - an optional protocol that can be directly passed into the function rather than supplied via argv; currently this is only for testing purposes.
 * @return {object} Processing results with properties `output` (the final processed protocol) and `protocol` (the result of merging all input protocols).
 */
function runWithOpts(opts, userProtocol) {

	// Configure mathjs to use bignumbers
	require('mathjs').config({
		number: 'BigNumber', // Default type of number
		precision: 64        // Number of significant digits for BigNumbers
	});

	// Try to process the protocol
	var result = undefined;
	try {
		result = _run(opts, userProtocol);
	} catch (e) {
		// If _run throws an exception, we don't get any results,
		// so try to set `error` in the result or at least print
		// messages to the console.
		if (opts.debug || opts.throw) {
			console.log("RUN ERROR:")
			console.log(e);
			console.log(e.message);
			console.log(e.stack);
		}
		if (e.isRoboliqError) {
			result = {};
			const errors = expect.RoboliqError.getErrors(e);
			const path = e.path || "";
			_.set(result, `output.errors[${path}]`, errors);
			//console.log(JSON.stringify(errors))
		}
		else if (!opts.quiet) {
			console.log(JSON.stringify(e));
		}
	}

	// If processing finished without exceptions:
	if (result && result.output) {
		if (!opts.quiet) {
			// Print errors, if any:
			if (!_.isEmpty(result.output.errors)) {
				console.log();
				console.log("Errors:");

				if (_.isPlainObject(result.output.errors)) {
					// Find all sub-steps (properties that start with a digit)
					var keys = _.keys(result.output.errors);
					// Sort them in "natural" order
					keys.sort(naturalSort);

					_.forEach(keys, key => {
						const err = result.output.errors[key];
						console.log(key+": "+err.toString());
					});
				}
				else {
					_.forEach(result.output.errors, function(err, id) {
						if (id)
							console.log(id+": "+err.toString());
						else
							console.log(err.toString());
					});
				}
			}

			// Print warnings, if any:
			if (!_.isEmpty(result.output.warnings)) {
				console.log();
				console.log("Warnings:");
				_.forEach(result.output.warnings, function(err, id) {
					if (id)
						console.log(id+": "+err.toString());
					else
						console.log(err.toString());
				});
			}
		}

		if (opts.debug) {
			console.log();
			console.log("Output:");
		}
		var outputText = JSON.stringify(result.output, null, '\t');
		if (opts.debug || opts.print)
			console.log(outputText);

		// If compilation was suspended, crease a dumpfile for later continuation
		if (_.get(result, ["protocol", "COMPILER", "suspend"])) {
			result.dump = _.clone(result.protocol);
			// Resume where this compilation suspended
			result.dump.COMPILER = {
				resumeStepId: result.protocol.COMPILER.suspendStepId
			};
		}

		// If the output is not suppressed, write the protocol to an output file.
		if (opts.output !== '') {
			var inpath = _.last(opts.infiles);
			var basename = path.basename(inpath, path.extname(inpath));
			var dir
				= (opts.outputDir)
				? opts.outputDir
				: (opts.parentDir)
					? (opts.subdir)
						? path.join(opts.parentDir, opts.subdir, basename)
						: path.join(opts.parentDir, basename)
					: path.dirname(inpath);
			var outpath = opts.output || path.join(dir, basename+".out.json");
			if (!opts.quiet) {
				console.log("output written to: "+outpath);
			}

			// Write output protocol
			mkdirp.sync(path.dirname(outpath));
			fs.writeFileSync(outpath, JSON.stringify(result.output, null, '\t')+"\n");

			// Write extra files if parentDir or outputDir was specified
			// console.log({a: !_.isEmpty(result.output.simulatedOutput), b: opts.parentDir})
			if (opts.parentDir || opts.outputDir) {
				if (!_.isEmpty(result.output.simulatedOutput)) {
					writeSimulatedOutput(opts, dir, result);
				}

				writeHtml(opts, dir, result);
			}

			// Write dump data (2016-11-05 ELLIS: What's this for??)
			if (result.dump) {
				const dumppath = path.join(path.dirname(output), `${result.dump.COMPILER.resumeStepId}.dump.json`);
				if (!opts.quiet) {
					console.log("dump written to: "+dumppath);
				}
				fs.writeFileSync(dumppath, JSON.stringify(result.dump, null, '\t')+"\n");
			}

			// Send through the Evoware compiler
			if (opts.evoware) {
				const evowareArgs = _.clone(opts.evoware.split(","));
				assert(evowareArgs.length >= 3, "at least three arguments must be passed to --evoware options: carrier file, table file, and one or more agent names");
				// Insert
				const evowareRun = require("roboliq-evoware/dist/EvowareMain").run;
				evowareArgs.splice(2, 0, outpath);
				if (!opts.quiet) {
					console.log(`calling evoware: ${evowareArgs.join(" ")}`);
				}
				evowareRun({args: evowareArgs});
			}
		}
	}

	return result;
}

function writeSimulatedOutput(opts, dir, result) {
	const simulatedDir = path.join(dir, "simulated");
	// console.log({simulatedDir})
	mkdirp.sync(simulatedDir);
	_.forEach(result.output.simulatedOutput, (value, filename) => {
		const simulatedFile = path.join(simulatedDir, filename);
		// console.log({filename, simulatedFile})
		if (!opts.quiet) {
			console.log("saving simulated output: "+simulatedFile);
		}
		const ext = path.extname(simulatedFile);
		if (ext === ".json") {
			fs.writeFileSync(simulatedFile, JSON.stringify(value, null, "\t")+"\n");
		}
		else if (ext === ".jsonl") {
			const contents = value.map(x => JSON.stringify(x)).join("\n") + "\n";
			fs.writeFileSync(simulatedFile, contents);
		}
		else {
			fs.writeFileSync(simulatedFile, value);
		}
	});
}

function writeHtml(opts, dir, result) {
	const source = fs.readFileSync(__dirname + "/html/index.html", "utf8");
	const template = handlebars.compile(source);
  const html = template(result.output);

	const filename = path.join(dir, "index.html");
	if (!opts.quiet) {
		console.log("saving HTML output: "+filename);
	}
	fs.writeFileSync(filename, html);
}


/**
 * Process the protocol(s) given by the command line options and an optional
 * userProtocol passed in separately to the API (currently this is just for testing).
 *
 * @param  {object} opts - command line arguments as processed by nomnom.
 * @param  {Protocol} [userProtocol] - an optional protocol that can be directly passed into the function rather than supplied via argv; currently this is only for testing purposes.
 * @return {object} Processing results with properties `output` (the final processed protocol) and `protocol` (same as output, but without tables).
 */
function _run(opts, userProtocol) {

	if (opts.debug) {
		console.log("opts:", opts);
	}

	const filecache = {};

	_.forEach(opts.fileData, function(s) {
		var pair = splitInlineFile(s);
		var data = pair[1];
		filecache[pair[0]] = data;
	});
	_.forEach(opts.fileJson, function(s) {
		var pair = splitInlineFile(s);
		var data = JSON.parse(pair[1]);
		//console.log("fileJson:", s, data);
		filecache[pair[0]] = data;
	});

	// Add config/roboliq.js to URLs by default.
	const urls = _.uniq(_.compact(
		_.compact([
			(opts.loadRoboliqConfig) ? __dirname+'/config/roboliq.js' : undefined
		]).concat(opts.infiles)
	));
	if (opts.debug) {
		console.log("urls:", urls);
	}

	// Load all the protocols in unprocessed form
	var urlToProtocol_l = _.map(urls, function(url) {
		return [url, loadUrlContent(url, filecache)];
	});
	// Append the optional user protocol to the list
	// (this lets unit tests pass in JSON protocols rather than loading them from files).
	if (userProtocol)
		urlToProtocol_l.push([undefined, userProtocol]);

	// Load varsets
	// console.log({opts})
	_.forEach(opts.varset, varsetString => {
		// console.log({varsetString})
		let url;
		let varset;
		if (_.isPlainObject(varsetString)) { // This is strange, apparently nomnom automatically converted the string to an object!
			varset = varsetString;
		}
		else if (_.startsWith(varsetString, "{")) {
			varset = JSON.parse(varsetString);
		}
		else {
			url = varsetString;
			varset = loadUrlContent(url, filecache);
		}
		const varsetProtocol = {
			roboliq: version,
			objects: {
				SCOPE: varset
			}
		};
		// console.log({varsetProtocol})
		urlToProtocol_l.push([url, varsetProtocol]);
	});

	// Reduce the list of URLs by merging or patching them together, starting
	// with the empty protocol.
	var protocol = _.reduce(
		urlToProtocol_l,
		(protocol, [url, raw]) => {
			if (_.isArray(raw)) {
				return jiff.patch(raw, protocol);
			}
			else {
				var b = loadProtocol(protocol, raw, url || "", filecache);
				return mergeProtocols(protocol, b);
			}
		},
		protocolEmpty
	);
	/*if (opts.debug) {
		console.log(protocol);
	}*/

	// Add command line options
	//console.log({opts})
	protocol.COMPILER.roboliqOpts = opts;
	protocol.COMPILER.filecache = filecache;

	try {
		postProcessProtocol(protocol, filecache);
		//console.log("A")
		validateProtocol1(protocol);
	} catch(e) {
		if (opts.debug || opts.throw) {
			console.log("Error type = "+(typeof e).toString());
		}
		if (e.isRoboliqError) {
			const prefix = expect.getPrefix(e.context);
			protocol.errors["_"] = _.map(e.errors, s => prefix+s);
		}
		else if (_.has(e, "errors")) {
			protocol.errors["_"] = e.errors;
		}
		else {
			protocol.errors["_"] = _.compact([JSON.stringify(e), e.stack]);
		}
		if (opts.throw) {
			if (_.isPlainObject(e))
				console.log("e:\n"+JSON.stringify(e));
			expect.rethrow(e);
		}
		return {protocol: protocol, output: protocol};
	}
	//console.log("B")

	var objectToPredicateConverters = protocol.objectToPredicateConverters;

	// If initial processing didn't result in any errors,
	//  expand steps and get final objects.
	const objectsFinal = (_.isEmpty(protocol.errors))
		? expandProtocol(opts, protocol)
		: protocol.objects;

	if (opts.debug || opts.printProtocol) {
		console.log();
		console.log("Protocol:");
		console.log(JSON.stringify(protocol, null, '\t'));
		/*console.log();
		console.log("Steps:")
		console.log(JSON.stringify(protocol.steps, null, '\t'));
		console.log();
		console.log("Effects:")
		console.log(JSON.stringify(effects, null, '\t'));
		*/
	}

	if (opts.debug || opts.printDesigns) {
		const designs = misc.getObjectsOfType(protocol.objects, "Data");
		_.forEach(designs, (data, name) => {
			console.log();
			console.log(`Data "${name}":`);
			// console.log(JSON.stringify(design, null, '\t'))
			let table;
			if (data.hasOwnProperty("value")) {
				table = data.value;
			}
			else {
				let design = misc.handleDirectiveDeep(data, protocol);
				design = commandHelper.substituteDeep(design, protocol, {}, []);
				table = Design.flattenDesign(design);
			}
			Design.printRows(table);
		});
	}

	// If there were errors,
	if (!_.isEmpty(protocol.errors)) {
		//return {protocol: protocol, output: _.pick(protocol, 'errors', 'warnings')};
		console.log("WITH ERRORS")
		return {protocol: protocol, output: protocol};
	}
	// Otherwise create tables
	else {
		const output = _.merge(
			{roboliq: version},
			_.pick(protocol, "description", "config", "parameters", "objects", "schemas", "steps", "effects", "reports", "simulatedOutput", "warnings", "errors", "fillIns")
		);
		// Handle protocol.COMPILER
		if (!_.isEmpty(protocol.COMPILER)) {
			output.COMPILER = _.pick(protocol.COMPILER, "resumeStepId", "suspendStepId");
		}
		// console.log("SIMULATED OUTPUT")
		// console.log(JSON.stringify(protocol.simulatedOutput))
		// process.exit(-1);

		const tables = {
			labware: [],
			sourceWells: [],
			wellContentsFinal: []
		};
		// Construct labware table
		const labwares = misc.getObjectsOfType(objectsFinal, ['Plate', 'Tube']);
		_.forEach(labwares, function(labware, name) {
			tables.labware.push(_.merge({}, {
				labware: name,
				type: labware.type,
				model: labware.model,
				locationInitial: expect.objectsValue({}, name+'.location', protocol.objects),
				locationFinal: labware.location
			}));
		});
		// Construct sourceWells table
		var tabulateWELLSSource = function(o, id) {
			//console.log("tabulateWELLSSource", o, id)
			if (o.isSource) {
				/* Example:
				- source: water
		          well: plate1(A01)
		          volume: 0ul
		          volumeRemoved: 60ul
				*/
				var wellName = (id.indexOf(".contents.") >= 0)
					? id.replace('.contents.', '(')+')'
					: id.replace('.contents', '()');
				var contents = expect.objectsValue({}, id, objectsFinal);
				var source = (contents.length == 2 && _.isString(contents[1]))
					? contents[1]
					: wellName;
				var volumeInitial = misc.findObjectsValue(id, protocol.objects, null, ["0ul"])[0];
				var volumeFinal = contents[0];
				tables.sourceWells.push({source: source, well: wellName, volumeInitial: volumeInitial, volumeFinal: volumeFinal, volumeRemoved: o.volumeRemoved || "0"});
			}
		};
		// For each well in object.__WELLS__, add to the appropriate table
		var tabulateWELLS = function(objects, prefix) {
			//console.log("tabulateWELLS", prefix)
			_.forEach(objects, function(x, field) {
				if (field === 'isSource') {
					tabulateWELLSSource(objects, prefix.join('.'));
				}
				else if (_.isPlainObject(x)) {
					tabulateWELLS(x, prefix.concat([field]));
				}
			});
		};
		tabulateWELLS(objectsFinal['__WELLS__'] || {}, []);
		// Construct wellContentsFinal table
		var tabulateWellContents = function(contents, labwareName, wellName) {
			//console.log("tabulateWellContents:", JSON.stringify(contents), labwareName, wellName);
			if (_.isArray(contents)) {
				var map = WellContents.flattenContents(contents);
				var wellName2 = (wellName) ? labwareName+"("+wellName+")" : labwareName;
				tables.wellContentsFinal.push(_.merge({well: wellName2}, map));
			}
			else if (_.isPlainObject(contents)) {
				_.forEach(contents, function(contents2, name2) {
					var wellName2 = _.compact([wellName, name2]).join('.');
					tabulateWellContents(contents2, labwareName, wellName2);
				});
			}
		};
		_.forEach(labwares, function(labware, name) {
			if (labware.contents) {
				tabulateWellContents(labware.contents, name);
			}
		});

		// Get tables for all designs
		const designs = misc.getObjectsOfType(protocol.objects, "Data");
		const designTables = _.mapValues(designs, design => {
			design = misc.handleDirectiveDeep(design, protocol);
			design = commandHelper.substituteDeep(design, protocol, {}, []);
			return Design.flattenDesign(design);
		});
		if (!_.isEmpty(designTables))
			tables.designs = designTables;

		output.tables = tables;

		return {protocol: protocol, output: output};
	}
}

// Handle fileData and fileJson options, where file data is passed on the command line.
function splitInlineFile(s) {
	var i = s.indexOf(':');
	assert(i > 0);
	var name = "./" + path.posix.join(s.substr(0, i));
	var data = s.substr(i + 1);
	return [name, data];
}

/**
 * This function recurively iterates through all objects, and for each
 * object whose type has an entry in protocol.objectToPredicateConverters,
 * it generates the logical predicates and appends them to stateList.
 *
 * Mutates stateList.
 *
 * @param  {string} name - name of current object
 * @param  {object} o - current object
 * @param  {array} stateList - array of logical predicates
 */
function createStateItems(objectToPredicateConverters, o, name = "", stateList = []) {
	//console.log("name: "+name);
	if (o.hasOwnProperty("type")) {
		//console.log("type: "+o.type);
		const type = o['type'];
		if (objectToPredicateConverters.hasOwnProperty(type)) {
			const predicates = objectToPredicateConverters[type](name, o);
			if (!_.isEmpty(predicates)) {
				stateList.push(...predicates);
			}
		}
	}

	var prefix = _.isEmpty(name) ? "" : name + ".";
	_.forEach(o, function(value, name2) {
		//console.log(name2, value);
		if (_.isPlainObject(value)) {
			createStateItems(objectToPredicateConverters, value, prefix + name2, stateList);
		}
	});

	return stateList;
}

/**
 * Expand the protocol's steps.
 * This means that commands are passed to command handlers to possibly
 * be expanded to lower-level sub-commands.
 *
 * Mutates protocol.
 *
 * @param  {Protocol} The protocol.
 * @return {object} The final state of objects.
 */
function expandProtocol(opts, protocol) {
	var objects0 = _.cloneDeep(protocol.objects);
	_.merge(protocol, {effects: {}, cache: {}, warnings: {}, errors: {}});
	// If we should resume expansion at a particular step:
	delete protocol.COMPILER.suspend;
	// console.log({COMPILER: protocol.COMPILER})
	if (protocol.COMPILER.resumeStepId) {
		protocol.COMPILER.skipTo = protocol.COMPILER.resumeStepId; // HACKy...
	}
	expandStep(opts, protocol, [], protocol.steps, objects0);
	return objects0;
}

/**
 * Expand the given step by passing a command to its command handler
 * and recursively expanding sub-steps.
 *
 * Mutates protocol.  However, since protocol.objects should still hold the
 * *initial* objects after processing, rather than mutating protocol.objects
 * during processing, a separate `objects` variable is mutated, which
 * starts out as a deep copy of protocol.objects.
 *
 * @param  {Protocol} protocol - the protocol
 * @param  {array} prefix - array of string representing the current step ID (initially []).
 * @param  {object} step - the current step (initially protocol.steps).
 * @param  {object} objects - a mutable copy of the protocol's objects.
 */
function expandStep(opts, protocol, prefix, step, objects, SCOPE = {}, DATA = []) {
	// If protocol.COMPILER.suspend is set, compiling should be suspended and continued later
	if (protocol.COMPILER.suspend) {
		return;
	}

	//console.log("expandStep: "+prefix+JSON.stringify(step))
	var commandHandlers = protocol.commandHandlers;
	var id = prefix.join('.');
	// console.log({id, RESUME: protocol.COMPILER})
	if (opts.progress) {
		console.log(_.compact(["step "+id, step.command, step.description]).join(": "));
	}

	const accesses = [];
	// TODO: we should create the context further up in the call chain and
	// pass that around instead of passing protocol, objects, etc to all these
	// functions.
	const data0 = commandHelper.createData(protocol, objects, SCOPE, DATA, prefix, protocol.COMPILER.filecache, step);
	// console.log("step "+prefix)
	// console.log(_.get(data0, "objects.DATA"))
	// console.log(_.get(data0, "objects.SCOPE"))
	// console.log(" data0: "+JSON.stringify(data0, null, '\t'))

	// Check for command and its handler
	const commandName = step.command;
	const handler = (commandName) ? commandHandlers[commandName] : undefined;
	if (commandName && !handler) {
		protocol.warnings[id] = ["unknown command: "+step.command];
		return;
	}

	const step0 = _.omit(step, "data");
	{
		// const prefix2 = prefix.concat([groupIndex + 1]);
		// const DATA = DATA1;
		// const SCOPE = SCOPE1;
		// const data = commandHelper.createData(protocol, objects, SCOPE, DATA, prefix2, protocol.COMPILER.filecache, step0);
		// const SCOPE2 = data.objects.SCOPE;
		const params = misc.handleDirectiveDeep(step0, data0);
		// console.log({params})

		// If we're skipping to a specific step
		// console.log({COMPILER: protocol.COMPILER})
		if (protocol.COMPILER.skipTo) {
			// If the step has been reached:
			if (protocol.COMPILER.skipTo === id) {
				protocol.COMPILER.skipTo = undefined;
				assert(commandName === "system.runtimeLoadVariables", "Roboliq can only resume compiling at a `system.runtimeLoadVariables` command");
			}
			else {
				expandSubsteps(opts, protocol, prefix, step, objects, data0.objects.SCOPE, data0.objects.DATA);
			}
		}
		else if (commandName === "system.runtimeLoadVariables") {
			protocol.COMPILER.suspend = true;
			protocol.COMPILER.suspendStepId = id;
		}
		else {
			if (commandName) {
				expandCommand(protocol, prefix, step, objects, data0.objects.SCOPE, params, commandName, handler, data0.objects.DATA, id);
			}
			expandSubsteps(opts, protocol, prefix, step, objects, data0.objects.SCOPE, data0.objects.DATA);
		}
	}
}

function expandSubsteps(opts, protocol, prefix, step, objects, SCOPE, DATA) {
	// Find all sub-steps (properties that start with a digit)
	const keys = commandHelper.getStepKeys(step);
	// Try to expand the substeps
	for (const key of keys) {
		expandStep(opts, protocol, prefix.concat(key), step[key], objects, SCOPE, DATA);
	}
}

function expandCommand(protocol, prefix, step, objects, SCOPE, params, commandName, handler, DATA, id) {
	// Take the initial predicates and append predicates for the current state
	// REFACTOR: this might be a time-consuming process, which could perhaps be
	// sped up by using Immutablejs and checking which objects have changed
	// rather than regenerating predicates for all objects.
	const predicates = protocol.predicates.concat(createStateItems(protocol.objectToPredicateConverters, objects));
	const opts = protocol.COMPILER.roboliqOpts || {};
	let result = {};
	const objects2 = _.merge({}, objects, {SCOPE});
	if (!_.isUndefined(DATA))
		objects2.DATA = DATA;
	const data = {
		objects: objects2,
		predicates,
		planAlternativeChoosers: protocol.planAlternativeChoosers,
		planHandlers: protocol.planHandlers,
		schemas: protocol.schemas,
		accesses: [],
		files: protocol.COMPILER.filecache,
		protocol,
		path: prefix,
		simulatedOutput: protocol.simulatedOutput || {}
	};
	const warnings = [];
	try {
		//if (!_.isEmpty(data.objects.SCOPE)) { console.log({SCOPE: data.objects.SCOPE})}
		// If a schema is given for the command, parse its parameters
		const schema = protocol.schemas[commandName];
		// console.log("params: "+JSON.stringify(params))
		const parsed = (schema)
			? commandHelper.parseParams(params, data, schema)
			: undefined;
		if (!_.isEmpty(parsed.unknown)) {
			warnings.push(...parsed.unknown.map(x => `unknown parameter ${x}`));
		}
		// console.log("parsed: "+JSON.stringify(parsed, null, '\t'));
		// If the handler has an input specification, parse it
		if (_.isPlainObject(handler.inputSpec)) {
			const input = commandHelper.parseInputSpec(handler.inputSpec, parsed, data);
			parsed.input = input;
		}
		// Try to run the command handler
		//console.log("A")
		//console.log(handler)
		// function isCyclic (obj) {
		// 	var seenObjects = [];
		//
		// 	function detect (obj) {
		// 	  if (obj && typeof obj === 'object') {
		// 	    if (seenObjects.indexOf(obj) !== -1) {
		// 	      return true;
		// 	    }
		// 	    seenObjects.push(obj);
		// 	    for (var key in obj) {
		// 	      if (obj.hasOwnProperty(key) && detect(obj[key])) {
		// 					// console.log(obj, 'cycle at ' + key);
		// 					console.log('cycle at ' + key);
		// 	        return true;
		// 	      }
		// 	    }
		// 	  }
		// 	  return false;
		// 	}
		//
		// 	return detect(obj);
		// }
		// console.log("A")
		result = handler(params, parsed, data) || {};
		// console.log("B")
		// console.log(Object.keys(result))
		// isCyclic(result);

		// console.log("result:"); console.log(result)
		// console.log("result: "+JSON.stringify(result))
		result = stripUndefined(result);
		// console.log("C")
		//console.log("B")
		//console.log("result: "+JSON.stringify(result))
	} catch (e) {
		// console.log("Some Error:");
		// console.log(JSON.stringify(e, null, "\t"))
		if (opts.debug || opts.throw) {
			console.log("Error type = "+(typeof e).toString());
		}
		if (e.isRoboliqError) {
			// console.log("RoboliqError:");
			// console.log(JSON.stringify(e, null, "\t"))
			const prefix = expect.getPrefix(e.context);
			result = {errors: _.map(e.errors, s => prefix+s)};
		}
		else if (_.has(e, "errors")) {
			result = {errors: e.errors};
		}
		else {
			result = {errors: _.compact([JSON.stringify(e), e.stack])};
		}
		console.log(`ERROR: `+result.errors.join("\n"));
		if (opts.throw) {
			if (_.isPlainObject(e))
				console.log("e:\n"+JSON.stringify(e));
			expect.rethrow(e, {stepName: id});
		}
	}
	// If debugging, store the result verbatim
	if (protocol.COMPILER.roboliqOpts.debug)
		protocol.cache[id] = result;

	// If there were errors:
	if (!_.isEmpty(result.errors)) {
		protocol.errors[id] = result.errors;
		// Abort expansion of protocol
		return false;
	}
	// If there were warnings
	if (!_.isEmpty(result.warnings)) {
		warnings.push(...result.warnings);
	}
	if (!_.isEmpty(warnings)) {
		const suppress = _.get(protocol, "config.suppressWarnings", []);
		const warnings2 = warnings.filter(s => _.every(suppress, code => !s.startsWith(`[W#${code}]`)));
		// console.log({config: protocol.config, warnings2})
		if (!_.isEmpty(warnings2))
			protocol.warnings[id] = warnings2;
	}
	// If the command was expanded, merge the expansion into the protocol as substeps:
	if (!_.isEmpty(result.expansion)) {
		// If an array was returned rather than an object, put it in the proper form
		//console.log({expansion: result.expansion, stepified: commandHelper.stepify(result.expansion)})
		result.expansion = commandHelper.stepify(result.expansion);
		result.expansion = commandHelper.substituteDeep(result.expansion, data, data.objects.SCOPE, data.objects.DATA);
		//console.log({expansion: result.expansion})
		_.merge(step, result.expansion);
	}
	// If the command has effects
	if (!_.isEmpty(result.effects)) {
		//console.log(result.effects);
		// Add effects to protocol's record of effects
		protocol.effects[id] = result.effects;
		//console.log("mixPlate.contents.C01 #0: "+_.get(objects, "mixPlate.contents.C01"));
		// Update object states
		_.forEach(result.effects, (value, key) => _.set(objects, key, value));
		//console.log("mixPlate.contents.C01 #1: "+_.get(objects, "mixPlate.contents.C01"));
	}
	// If the command has reports
	if (!_.isEmpty(result.reports)) {
		_.set(protocol, ["reports", id], result.reports);
	}
	// If the command has simulated output
	if (!_.isEmpty(result.simulatedOutput)) {
		_.forEach(result.simulatedOutput, (value, key) => {
			_.set(protocol, ["simulatedOutput", key], value);
		});
	}
}

module.exports = {
	run,
	runWithOpts,
}

if (require.main === module) {
	run();
}