Source: commandHelper.js

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

/**
 * A collection of helper functions for command handlers.
 * @module commandHelper
 */

var _ = require('lodash');
var assert = require('assert');
var expect = require('./expect.js');
var jmespath = require('jmespath');
import math from 'mathjs';
import naturalSort from 'javascript-natural-sort';
import tv4 from 'tv4';
const Design = require('./design.js');
import misc from './misc.js';
import roboliqSchemas from './roboliqSchemas.js';
import wellsParser from './parsers/wellsParser.js';

/**
 * Ensure that the value is an array.
 * If the value is already an array, return it directly.
 * If the value is undefined, return an empty array.
 * Otherwise, return the value wrapped in an array.
 *
 * @param  {any} x - value
 * @return {array} an array
 */
function asArray(x) {
	if (_.isArray(x)) return x;
	else if (_.isUndefined(x)) return [];
	else return [x];
}

/**
 * Create the 'data' object that gets passed into many commandHelper functions.
 *
 * TODO: Rather than calling it 'data', we should probably rename it to 'context'.
 *
 * @param  {Protocol} protocol
 * @param  {object} objects  =             {} - current objects
 * @param  {object} SCOPE    =             {} - current SCOPE
 * @param  {array} DATA     =             [] - current DATA table
 * @param  {array} path = [] - current processing path (usually a step ID, e.g. step 1.2 would be given by `[1, 2]`)
 * @param  {object} files = {} - map of filename to loaded filedata
 * @return {object} the 'data' object that gets passed into many commandHelper functions
 */
function createData(protocol, objects = {}, SCOPE = {}, DATA = [], path = [], files = {}, step = {}) {
	const updatedSCOPEDATA = updateSCOPEDATA(step, {objects: _.defaults({SCOPE, DATA}, objects)}, SCOPE, DATA);
	// console.log({step, SCOPE: updatedSCOPEDATA.SCOPE, DATA: updatedSCOPEDATA.DATA})

	// Process any directives in this step
	const objects2 = _.clone(objects);
	// TODO: consider changing this so that DATA and SCOPE are not a part of `objects`,
	// but are their own separate properties.
	objects2.DATA = updatedSCOPEDATA.DATA;
	objects2.SCOPE = _.defaults(
		{
			// access the raw objects
			__objects: objects2,
			// access the current raw data table
			__data: updatedSCOPEDATA.DATA,
			// access raw protocol parameters
			__parameters: protocol.parameters || {},
			// access parameters of the current step
			__step: step,
			// access parameters from any step in the current step stack (0 = current step)
			__stepStack: null
		},
		updatedSCOPEDATA.SCOPE,
		_.mapValues(protocol.parameters || {}, x => x.value)
	);

	const context = {
		objects: objects2,
		schemas: protocol.schemas,
		accesses: new Set(),
		files,
		protocol,
		path
	};
	// console.log("SCOPE:")
	// console.log(context.objects.SCOPE);

	return context;
}

function getDesignFactor(propertyName, DATA) {
	return _(DATA).map(propertyName).filter(x => !_.isUndefined(x)).value();
}

/**
 * Recursively replace $-SCOPE, $$-DATA, and template strings in `x`.
 *
 * The recursion has the following exceptions:
 * - skip objects with any of these properties: `data`, `@DATA`, `@SCOPE`
 * - skip `steps` properties
 * - skip directives
 *
 * @param  {any} x - the variable to perform substitutions on
 * @param  {object} data - protocol data
 * @return {any} the value with possible substitutions
 */
function substituteDeep(x, data, SCOPE, DATA, addCommonValuesToScope=true, depth=0) {
	// console.log("substituteDeep: "); console.log({x, SCOPE, DATA, x})
	let x2 = x;
	if (_.isString(x)) {
		/*// DATA substitution
		if (_.startsWith(x, "$$")) {
			if (_.isArray(DATA)) {
				const propertyName = x.substr(2);
				x2 = getDesignFactor(propertyName, DATA);
				assert(x2.length > 0 || DATA.length == 0, `factor ${x} not found in data`);
				// console.log({x2})
				// console.log("DATA: "+JSON.stringify(DATA, null, '\t'));
				// console.log({map: _(DATA).map(propertyName).value()});
			}
			else {
				assert(false, `invalid factor ${x}, because no data source is currently selected`);
			}
		}
		// Javascript
		else*/ if (_.startsWith(x, "${") && _.endsWith(x, "}")) {
			const safeEval = require('safe-eval');
			const code = x.substr(2, x.length - 3);
			const scope = _.defaults({_, math}, SCOPE, data.objects.PARAMS);
			// console.log({code, scope})
			x2 = safeEval(code, scope);
			// console.log({x2})
		}
		// Mathjs calculation
		else if (_.startsWith(x, "$(") && _.endsWith(x, ")")) {
			const expr = x.substr(2, x.length - 3);
			// console.log({expr})
			const context = _.defaults({}, SCOPE, data.objects.PARAMS)
			// console.log({expr, context})
			x2 = calculateWithMathjs(expr, context);
			// console.log({x2, expr})
		}
		// Mathjs calculation (deprecated)
		else if (x.length > 2 && _.startsWith(x, "$`") && _.endsWith(x, "`")) {
			const expr = x.substr(2, x.length - 3);
			// console.log({expr})
			// process.exit()
			x2 = Design.calculate(expr, SCOPE);
			// console.log({x2, expr})
		}
		// Variable substitution
		else if (_.startsWith(x, "$@")) {
			const propertyName = x.substr(2);
			x2 = _.get(data.objects, [propertyName, "value"], x);
		}
		// SCOPE substitution
		else if (_.startsWith(x, "$")) {
			const propertyName = x.substr(1);
			assert(_.has(SCOPE, propertyName), `${x} not in scope`);
			x2 = _.get(SCOPE, propertyName, x);
		}
		// Template substitution
		else if (_.startsWith(x, "`") && _.endsWith(x, "`")) {
			const template = x.substr(1, x.length - 2);
			const scope = SCOPE; //_.mapKeys(SCOPE, (value, name) => "$"+name);
			// console.log({x, template, scope})
			x2 = misc.renderTemplate(template, scope, data);
			// console.log({x2})
		}
	}
	else if (_.isArray(x)) {
		x2 = _.map(x, y => substituteDeep(y, data, SCOPE, DATA, addCommonValuesToScope, depth+1));
	}
	else if (_.isPlainObject(x)) {
		const updatedSCOPEDATA = updateSCOPEDATA(x, data, SCOPE, DATA, addCommonValuesToScope);
		// console.log({SCOPE, SCOPE2: updatedSCOPEDATA.SCOPE})
		x2 = _.mapValues(x, (value, name) => {
			// Skip over @DATA, @SCOPE, directives and 'steps' properties
			if (_.startsWith(name, "#") || _.endsWith(name, "()") || name === "data" || name === "@DATA" || name === "@SCOPE" || name === "steps" || (depth > 0 && _.startsWith(name, "lazy"))) { //(_.isPlainObject(value) && value.hasOwnProperty("command"))) {
				// console.log("lazy..."); console.trace(); // FIXME: for debug only
				return value;
			}
			else {
				return substituteDeep(value, data, updatedSCOPEDATA.SCOPE, updatedSCOPEDATA.DATA, addCommonValuesToScope, depth+1);
			}
		});
	}
	return x2;
}

/**
 * Calculate `expr` using variables in `context`, with optional `spec` object specifying `units` and/or `decimals`
 */
function calculateWithMathjs(expr, context, spec={}) {
	const rx = /([_$a-zA-Z\xA0-\uFFFF][._$a-zA-Z0-9\xA0-\uFFFF]*)/g;
	const identifiers = expr.match(rx);
	const identifierValues = identifiers.map(key => {
		const x = _.get(context, key);
		if (_.isUndefined(x)) return undefined;
		// console.log({key, x});
		if (_.isString(x)) {
			return calculateWithMathjs_variable(x);
		}
		else if (_.isArray(x)) {
			return x.map(y => _.isString(y) ? calculateWithMathjs_variable(y) : y);
		}
		return x;
	});
	const identifierAndValues = _.zip(identifiers, identifierValues).filter(x => !_.isUndefined(x[1]));
	const scope = _.fromPairs(identifierAndValues);
	// console.log({expr, context, identifiers, identifierValues, scope})

	// console.log({expr, scope})
	// console.log("scope:"+JSON.stringify(scope, null, '\t'))
	let value = math.eval(expr, scope);
	// console.log({type: value.type, value})

	// We're going to temprarily force value to be an array - this tells us whether we need to convert it back to a single item later.
	const doUnArray = !_.isArray(value);
	const values = (doUnArray) ? [value] : value;

	const processedValues = values.map(value => {
		if (_.isString(value) || _.isNumber(value) || _.isBoolean(value)) {
			return value;
		}

		// Get units to use in the end, and the unitless value
		const {units0, units, unitless} = (() => {
			const result = {
				units0: undefined,
				units: spec.units,
				unitless: value
			};
			// If the result has units:
			if (value.type === "Unit") {
				result.units0 = value.formatUnits();
				if (_.isUndefined(result.units))
					result.units = result.units0;
				const conversionUnits = (_.isEmpty(result.units)) ? result.units0 : result.units;
				// If the units dissappeared, e.g. when dividing 30ul/1ul = 30:
				if (_.isEmpty(conversionUnits)) {
					// TODO: find a better way to get the unit-less quantity from `value`
					// console.log({spec})
					// console.log({result, conversionUnits});
					result.unitless = math.eval(value.format());
				}
				else {
					result.unitless = value.toNumeric(conversionUnits);
				}
			}
			return result;
		})();
		// console.log(`unitless: ${JSON.stringify(unitless)}`)

		// Restrict decimal places
		// console.log({unitless})
		const unitlessText = (_.isNumber(spec.decimals))
			? unitless.toFixed(spec.decimals)
			: _.isNumber(unitless) ? unitless : unitless.toNumber();

		// Set units
		const valueText = (!_.isEmpty(units))
			? unitlessText + " " + units
			: unitlessText;

		return valueText;
	});

	return (doUnArray) ? processedValues[0] : processedValues;
}

function calculateWithMathjs_variable(x) {
	// console.log({y: x})
	if (_.isString(x) && x.length > 0) {
		const c0 = x[0];
		const c1 = x[x.length - 1];
		// console.log({c0, c1})
		// If this might be a number with units
		if (
			// If it starts with a digit or sign
			((c0 >= "0" && c0 <= "9") || (c0 == "+" || c0 == "-")) &&
			// and ends with a letter
			(c1 >= "a" && c1 <= "z")
		) {
			try {
				const result = math.eval(x);
				// console.log({result})
				return result;
			}
			catch (e) {}
		}
	}
	return x;
}


/**
 * Parse command parameters according to a schema.
 *
 * If parsing fails, an exception will be thrown.
 *
 * Otherwise, the returned result contains two properties: `value` and `objectName`.
 * Both properties are maps that reflect the structure of the given schema.
 * The `value` map contains the parsed values -- object references are replaced
 * by the actual object (in `data`), quantities are replaced by mathjs objects,
 * well specifications are replaced by an array of well references, etc.
 *
 * The `objectName` map contains any object names that were referenced;
 * in contrast to the `value` map (which is a tree of properties like `params`),
 * `objectName` is a flat map, where the keys are string representations of the
 * object paths (separated by '.').
 * Any object names that were looked up will also be added to the `data.accesses`
 * list.
 *
 * @param  {object} params - the parameters passed to the command
 * @param  {object} data - protocol data
 * @param  {object} schema - JSON Schema description, with roboliq type extensions
 * @return {object} the parsed parameters, if successfully parsed.
 */
function parseParams(params, data, schema) {
	const substituted = _.merge(
		_.pick(params, schema.noSubstitution),
		substituteDeep(_.omit(params, schema.noSubstitution), data, data.objects.SCOPE, data.objects.DATA)
	);
	//console.log("SCOPE: "+JSON.stringify(data.objects.SCOPE, null, '\t'))
	const result = {orig: substituted, value: {}, objectName: {}, unknown: []};
	processParamsBySchema(result, [], substituted, schema, data);
	// Remove any unknowns that aren't in the parameteters (they might be in referenced objects)
	result.unknown = result.unknown.filter(name => _.has(params, name));
	// Remove unknown list if it's empty
	if (_.isEmpty(result.unknown)) {
		delete result.unknown;
	}
	return result;
}

/**
 * Try to process the given params with the given schema.
 *
 * Updates the `result` object.
 * Updates `data.accesses` if object lookups are performed.
 *
 * @param {object} result - the resulting object to return, containing objectName and value representations of params.
 * @param {array} path - path in the original params object
 * @param {object} params - the part of the original parameters refered to by `path`
 * @param {object} schema - JSON Schema description, with roboliq extensions
 * @param {object} data - protocol data
 */
function processParamsBySchema(result, path, params, schema, data) {
	// console.log(`processParamsBySchema: ${JSON.stringify(params)} ${JSON.stringify(schema)}`)
	const required_l = schema.required || [];
	const l0 = _.toPairs(schema.properties);
	// If no properties are schemafied, return the original parameters
	if (l0.length === 0) {
		_.set(result.value, path, params);
		return result;
	}
	// Add unknowns
	result.unknown.push(..._.difference(_.keys(params), ["description", "comment", "command", "data", "@DATA", "@SCOPE"].concat(_.keys(schema.properties))).map(x => path.concat(x).join(".")));
	// Otherwise, convert the parameters
	for (const [propertyName, p] of l0) {
		const type = p.type;
		const required = _.includes(required_l, propertyName);
		const defaultValue = p.default;
		const path1 = path.concat(propertyName);
		const value0 = _.cloneDeep(_.get(params, propertyName, defaultValue));

		if (type === "name") {
			// Normally, we don't want to process "name" parameters at all, but we
			// still need to dereference "$"-scope variables
			const value1
				= (_.startsWith(value0, "$@"))
						? _.get(data.objects, value0.substring(2), value0)
					: (_.startsWith(value0, "$"))
						? _.get(data.objects.SCOPE, value0.substring(1), value0)
						: value0;
			if (!_.isUndefined(value1)) {
				_.set(result.value, path1, value1);
			}
			// If not optional, require the variable's presence:
			if (required) {
				//console.log({propertyName, type, info, params})
				expect.truthy({paramName: propertyName}, !_.isUndefined(value1), "missing required value [CODE 95]");
			}
		}
		else if (_.startsWith(type, "nameOf ")) {
			const type1 = type.substr(7);
			const value1 = lookupValue0(result, path1, value0, data);
			expect.truthy({paramName: path1.join(".")}, value1.type === type1, `expect the name of an object of type ${type1}: ${JSON.stringify(value1)}`);
			_.set(result.value, path1, result.objectName[path1.join(".")]);
			// console.log("nameOf result: "+JSON.stringify(result));
		}
		else {
			const value1 = _.clone(lookupValue0(result, path1, value0, data));
			if (!_.isUndefined(value1) && !_.isNull(value1)) {
				processValue0BySchema(result, path1, value1, p, data, propertyName);
			}
			// If not optional, require the variable's presence:
			else if (required) {
				// console.log({propertyName, type, result, path, params, schema})
				expect.truthy({paramName: path1.join(".")}, false, "missing required value [CODE 106]");
			}
		}
	}

	return result;
}

/**
 * Try to convert value0 (a "raw" value, no yet looked up) to the given type.
 *
 * - If schema is undefined, return value.
 *
 * - If schema.enum: return processValue0AsEnum()
 *
 * - If schema.type is undefined but there are schema.properties, assume schema.type = "object".
 *
 * - If type is undefined or empty, return value.
 *
 * - If type is an array, try processing for each element of the array
 *
 * @param {object} result - the resulting object to return, containing objectName and value representations of params.
 * @param {array} path - path in the original params object
 * @param {any} value0 - the value to process
 * @param {object} schema - JSON Schema description, with roboliq extensions
 * @param {object} data - protocol data
 */
function processValue0BySchema(result, path, value0, schema, data) {
	// console.log(`processValue0BySchema(${path.join('.')}, ${JSON.stringify(value0)})`)
	//const valuePre = _.cloneDeep(value0);
	if (_.isUndefined(schema)) {
		_.set(result.value, path, value0);
	}

	else if (schema.hasOwnProperty('enum')) {
		return processValue0AsEnum(result, path, value0, schema, data);
	}

	else {
		const type = (_.isUndefined(schema.type) && !_.isEmpty(schema.properties))
			? "object"
			: schema.type;

		if (_.isEmpty(type)) {
			_.set(result.value, path, value0);
		}

		else if (_.isString(type)) {
			processValue0BySchemaType(result, path, value0, schema, type, data);
		}

		// Otherwise, we should have an array of types
		else {
			// Try each type alternative:
			const types = _.flatten([schema.type]);
			return processValue0OnTypes(result, path, value0, schema, types, data);
		}
	}
	/*if (!_.isEqual(value0, valuePre)) {
		console.log("VALUE CHANGED");
		throw "error";
	}*/
}

/**
 * Try to process the value as an enum.
 * @param  {object} result - result structure for values and objectNames
 * @param  {array} path - path in params
 * @param  {any} value0 - the value to process
 * @param  {object} schema - schema
 * @param  {object} data - protocol data
 */
function processValue0AsEnum(result, path, value0, schema, data) {
	const value1 = lookupValue0(result, path, value0, data);
	expect.truthy({paramName: path.join(".")}, _.includes(schema.enum, value1), "expected one of "+schema.enum+": "+JSON.stringify(value0));
	_.set(result.value, path, value1);
}

/**
 * A sub-function of processValue0BySchema().
 * Try to process the value as a named type.
 * @param  {object} result - result structure for values and objectNames
 * @param  {array} path - path in params
 * @param  {any} value0 - the value to process
 * @param  {object} schema - schema
 * @param  {object} data - protocol data
 */
function processValue0BySchemaType(result, path, value0, schema, type, data) {
	// console.log(`processValue0BySchemaType(${path.join('.')}, ${value0}, ${type})`)
	if (type === "name") {
		_.set(result.value, path, value0);
		return;
	}
	else if (_.startsWith(type, "nameOf ")) {
		// REFACTOR: this duplicates code in processValue0BySchema()
		const type1 = type.substr(7);
		const value1 = lookupValue0(result, path, value0, data);
		expect.truthy({paramName: path.join(".")}, value1.type === type1, `expect the name of an object of type ${type1}: ${JSON.stringify(value1)}`);
		_.set(result.value, path, result.objectName[path.join(".")]);
		// console.log("nameOf result : "+JSON.stringify(result));
		return;
	}

	const value = _.cloneDeep(lookupValue0(result, path, value0, data));
	// By default, set result.value@path = value
	_.set(result.value, path, value);

	const name = path.join(".");

	switch (type) {
		case "array": return processValueAsArray(result, path, value, schema.items, data);
		case "boolean": return processOneOfBasicType(result, path, value, _.isBoolean, "boolean");
		case "integer": return processOneOfBasicType(result, path, value, _.isInteger, "integer");
		case "markdown": return processString(result, path, value, data);
		case "number": return processOneOfBasicType(result, path, value, _.isNumber, "number");
		case "null": return processOneOfBasicType(result, path, value, _.isNull, "null");
		case "string": return processString(result, path, value, data);
		case "object": return processParamsBySchema(result, path, value, schema, data);
		case "Agent":
			// TODO: need to check a list of which types are Agent types
			expect.truthy({paramName: name}, _.isPlainObject(value), "expected object: "+value);
			return;
		case "Any": return;
		case "Duration": return processDuration(result, path, value, data);
		case "Equipment":
			// TODO: need to check a list of which types are Equipment types
			expect.truthy({paramName: name}, _.isPlainObject(value), "expected object: "+value);
			return;
		case "Labware": return processValue0OnTypes(result, path, value0, schema, ["Lid", "Plate", "Trough", "Tube"], data);
		case "Length": return processLength(result, path, value, data);
		case "Lid": return processObjectOfType(result, path, value, data, type);
		case "Plate": return processObjectOfType(result, path, value, data, type);
		case "Plates": return processOneOrArray(result, path, value, data, (result, path, x) => processObjectOfType(result, path, x, data, "Plate", false));
		case "Site": return processObjectOfType(result, path, value, data, type);
		case "SiteOrStay": return processSiteOrStay(result, path, value, data);
		case "Source": return processSource(result, path, value, data);
		case "Sources": return processSources(result, path, value, data);
		case "String": return processString(result, path, value, data);
		case "Temperature": return processTemperature(result, path, value, data);
		case "Temperatures": return processOneOrArray(result, path, value, data, (result, path, x) => processTemperature(result, path, x, data));
		case "Volume": return processVolume(result, path, value, data);
		case "Volumes": return processOneOrArray(result, path, value, data, (result, path, x) => processVolume(result, path, x, data));
		case "Well": return processWell(result, path, value, data);
		case "Wells": return processWells(result, path, value, data);
		case "File":
			var filename = value;
			var filedata = data.files[filename];
			if (_.isUndefined(filedata))
				filedata = defaultValue;
			if (_.isUndefined(filedata) && _.isUndefined(filename))
				return;
			expect.truthy({paramName: name, objectName: filename}, !_.isUndefined(filedata), "file not loaded: "+filename);
			//console.log({filedata})
			result.objectName[path.join('.')] = filename;
			_.set(result.value, path, filedata);
			return;
		default: {
			if (data.schemas.hasOwnProperty(type)) {
				const schema = data.schemas[type];
				// console.log({type, schema})
				processValue0BySchema(result, path, value, schema, data);
				// console.log("result: "+JSON.stringify(result, null, '\t'))
				return;
			}
			else {
				const schema = roboliqSchemas[type];
				if (!schema) console.log("known types: "+_.keys(data.schemas).concat(_.keys(roboliqSchemas)))
				expect.truthy({paramName: name}, schema, "unknown type: "+JSON.stringify(type));
				const isValid = tv4.validate(value, schema);
				expect.truthy({paramName: name}, isValid, tv4.toString());
				return;
			}
		}
	}
}

/**
 * A sub-function of processValue0BySchema().
 * Try to process the value as a named type.
 * @param  {object} result - result structure for values and objectNames
 * @param  {array} path - path in params
 * @param  {any} value0 - the value to process
 * @param  {object} schema - schema
 * @param  {array} types - a list of types to try
 * @param  {object} data - protocol data
 */
function processValue0OnTypes(result, path, value0, schema, types, data) {
	// console.log({types})
	let es = [];
	for (const t of types) {
		try {
			// console.log({t, path, value0})
			return processValue0BySchemaType(result, path, value0, schema, t, data);
		}
		catch (e) {
			es.push(e);
		}
	}

	if (!_.isEmpty(es))
		// throw es[0];
		throw es.join("; ");
}

/**
 * Try to process a value as an array.
 * @param  {object} result - result structure for values and objectNames
 * @param  {array} path - path in params
 * @param  {any} value0 - the value to process
 * @param  {object} schema - schema of the array items
 * @param  {object} data - protocol data
 */
function processValueAsArray(result, path, list0, schema, data) {
	//console.log(`processValueAsArray(${path}, ${list0})`)
	// FIXME: for debug only
	// if (!_.isArray(list0)) {
	// 	console.trace();
	// 	process.exit();
	// }
	// ENDFIX
	expect.truthy({paramName: path.join(".")}, _.isArray(list0), "expected an array: "+list0);
	//console.log({t2})
	list0.forEach((x, index) => {
		//return processValueByType(x, t2, data, `${name}[${index}]`);
		processValue0BySchema(result, path.concat(index), x, schema, data);
		//console.log({x, t2, x2})
		//return x2;
	});
	//console.log({list1})
	//return list1;
}

/**
 * Try to get a value from data.objects with the given name.
 * @param  {object} data - Data object with 'objects' property
 * @param  {array|string} path - Name of the object value to lookup
 * @param  {any} dflt - default value to return
 * @return {Any} The value at the given path, if any
 */
function g(data, path, dflt) {
	const name = (_.isArray(path)) ? path.join('.') : path;

	if (_.isSet(data.accesses))
		data.accesses.add(name);
	else
		data.accesses = new Set([name]);

	return _.get(data.objects, path, dflt);
}

/**
 * Try to lookup value0 in objects set.
 * This function is recursive - if the value refers to a variable,
 * the variables value will also be dereferenced.
 * When a variable is looked up, its also added to result.objectName[path].
 *
 * @param {object} result - the resulting object to return, containing objectName and value representations of params.
 * @param {array} path - path in the original params object
 * @param {object} data - protocol data
 * @param {any} value0 - The value from the user.
 * @return {any} A new value, if value0 referred to something in data.objects.
 */
function lookupValue0(result, path, value0, data) {
	if (_.isString(value0) && !_.startsWith(value0, '"')) {
		const deref = dereferenceVariable(data, value0);
		if (deref) {
			result.objectName[path.join(".")] = deref.objectName;
			// FIXME: for debug only
			// if (path.join(".") === "plates2.0") {
			// 	console.trace();
			// 	const process = require('process');
			// 	process.exit();
			// }
			// ENDFIX
			return deref.value;
		}
	}

	return value0;
}

/**
 * Recursively lookup variable by name or path and return the final value.
 * @param {object} data - protocol data
 * @param {string} name - name or path of object to lookup in `data.objects`
 * @return {any} result of the lookup, if successful; otherwise undefined.
 */
function dereferenceVariable(data, name) {
	const result = {};

	// Query DATA
	if (_.startsWith(name, "$$")) {
		if (_.isArray(data.objects.DATA)) {
			const propertyName = name.substr(2);
			result.value = getDesignFactor(propertyName, data.objects.DATA);
			//console.log("data.objects.DATA: "+JSON.stringify(data.objects.DATA, null, '\t'));
			//console.log({map: _(data.objects.DATA).map(propertyName).value()});

			const accessName = "DATA."+propertyName;
			if (_.isSet(data.accesses))
				data.accesses.add(accessName);
			else
				data.accesses = new Set([accessName]);
		}
	}
	else {
		// Handle Variable reference
		if (_.startsWith(name, "$@")) {
			// console.log({name})
			name = name.substr(2);
		}
		// Handle SCOPE abbreviation
		else if (_.startsWith(name, "$")) {
			name = "SCOPE."+name.substr(1);
		}

		while (_.has(data.objects, name)) {
			const value = g(data, name);
			// console.log({value})
			if (!_.startsWith(name, "SCOPE.") && !_.startsWith(name, "DATA.")) {
				result.objectName = name;
			}
			//console.log({name, value})
			if (value.type === "Variable" || value.type === "Data") {
				result.value = value.value;
				name = value.value;
			}
			else {
				result.value = value;
				break;
			}
		}
	}
	return (_.isEmpty(result)) ? undefined : result;
}

/**
 * Accept either a single value whose type is checked with fnCheck(), or
 * an array with each element equal to the first - in that case,
 * set the result value to the first element of the array.
 * @param {object} result - the resulting object to return, containing objectName and value representations of params.
 * @param {array} path - path in the original params object
 * @param {any} value - the value to process
 * @param {Function} fnCheck - a function that returns true if the value has the correct type
 * @param {string} expectedTypeName - name of the expected type, for constructing the error message if fnCheck fails
 */
function processOneOfBasicType(result, path, value, fnCheck, expectedTypeName) {
	if (fnCheck(value)) {
		return;
	}
	if (_.isArray(value)) {
		const one = value[0];
		// console.log("processOneOfBasicType:"); console.log({value, one, ok: _.map(value, x => _.isEqual(x, one))})
		if (_.every(value, x => _.isEqual(x, one))) {
			_.set(result.value, path, one);
			return;
		}
	}
	expect.truthy({paramName: path.join(".")}, false, "expected "+expectedTypeName+": "+value);
	return;
}

/**
 * If value is an array and every element of the array is the same,
 * return the first value of the array.  Otherwise just return the value.
 * @param  {any} value - value to inspect
 */
function getCommon(value) {
	if (_.isArray(value) && value.length > 0 && _.every(value, x => _.isEqual(x, value[0]))) {
		return value[0];
	}
	return value;
}

/**
 * Try to call fn on value0.  If that works, return the value is made into
 * a singleton array.  Otherwise try to process value0 as an array.
 * fn should accept parameters (result, path, value0) and set the value in
 * result.value at the given path.

 * @param {object} result - the resulting object to return, containing objectName and value representations of params.
 * @param {array} path - path in the original params object
*/
function processOneOrArray(result, path, value0, data, fn) {
	// console.log("processOneOrArray:")
	// console.log({path, value0})
	// Try to process value0 as a single value, then turn it into an array
	try {
		const path1 = path.concat(0);
		_.unset(result.value, path, undefined);
		fn(result, path1, value0);
		// If we reach this point, then value0 was able to be processed as an object,
		// so use it as a singleton array.
		// const value1 = _.get(result.value, path);
		// _.set(result.value, path1, value1);
		if (result.objectName.hasOwnProperty(path.join("."))) {
			result.objectName[path1.join(".")] = result.objectName[path.join(".")];
			delete result.objectName[path.join(".")];
		}
		// console.log(JSON.stringify(result));
		// console.log(JSON.stringify(_.get(result.value, path)));
		// const x = _.get(result.value, path2);
		// console.log({path2, x: JSON.stringify(x)})
		// _.set(result.value, path2, x);
		// console.log(JSON.stringify(result, null, '\t'))
		return;
	} catch (e) {
		// console.log(e)
	}

	expect.truthy({paramName: path.join('.')}, _.isArray(value0), "expected an array: "+JSON.stringify(value0));
	value0.forEach((x0, i) => {
		const path1 = path.concat(i)
		const x1 = _.cloneDeep(lookupValue0(result, path1, x0, data));
		_.set(result.value, path1, x1);
		fn(result, path1, x1);
	});
}

/**
 * Try to process a value as a length.
 *
 * @param {object} result - the resulting object to return, containing objectName and value representations of params.
 * @param {array} path - path in the original params object
 * @param {object} x - the value to process
 * @param {object} data - protocol data
 */
function processLength(result, path, value0, data) {
	let x = getCommon(value0);
	if (_.isString(x)) {
		x = math.eval(x);
	}
	//console.log({function: "processLength", path, x})
	expect.truthy({paramName: path.join('.')}, math.unit('m').equalBase(x), "expected a volume with meter units (m, mm, nm, etc.): "+JSON.stringify(value0));
	_.set(result.value, path, x);
}

/**
 * Try to process a value as a string.
 *
 * @param {object} result - the resulting object to return, containing objectName and value representations of params.
 * @param {array} path - path in the original params object
 * @param {object} params - the part of the original parameters refered to by `path`
 * @param {object} data - protocol data
 */
function processString(result, path, value0, data) {
	// Follow de-references:
	var references = [];
	var objectName = undefined;
	let value1 = getCommon(value0);
	while (_.isString(value1) && _.startsWith(value1, "${") && references.indexOf(value1) < 0) {
		references.push(value1);
		objectName = value1.substring(2, value1.length - 1);
		if (_.has(data.objects, objectName)) {
			var type2 = g(data, objectName+".type");
			if (type2 === "Variable") {
				value1 = g(data, objectName+".value");
			}
			else {
				value1 = g(data, objectName);
			}
		}
	}

	if (!_.isNull(value1))
		_.set(result.value, path, value1.toString());
}

/**
 * Tries to process and object with the given type,
 * whereby this simply means checking that the value
 * is a plain object with a property `type` whose value
 * is the given type.
 *
 * @param {object} result - the resulting object to return, containing objectName and value representations of params.
 * @param {array} path - path in the original params object
 * @param {object} x - the value to process
 * @param {object} data - protocol data
 * @param {string} type - type of object expected
 * @param {boolean} allowArray - false if we should not look into an array for an object
 */
function processObjectOfType(result, path, value0, data, type, allowArray = true) {
	// console.log("processObjectOfType:")
	// console.log({result, path, value0, type})
	let x = value0;
	if (allowArray && _.isArray(value0) && value0.length > 0 && _.every(value0, x => _.isEqual(x, value0[0]))) {
		x = _.cloneDeep(lookupValue0(result, path, value0[0], data));
	}
	const paramName = path.join(".");
	expect.truthy({paramName}, _.isPlainObject(x), `expected an object of type ${type}: `+JSON.stringify(value0));
	expect.truthy({paramName}, _.get(x, 'type') === type, `expected an object of type ${type}: `+JSON.stringify(value0));
	_.set(result.value, path, x);
}

/**
 * Try to process a value as the keyword "stay" or as a Site reference.
 *
 * @param {object} result - the resulting object to return, containing objectName and value representations of params.
 * @param {array} path - path in the original params object
 * @param {object} x - the value to process
 * @param {object} data - protocol data
 */
function processSiteOrStay(result, path, value0, data) {
	const x = getCommon(value0);
	if (x === "stay") {
		// do nothing, leave the value as "stay"
	}
	else {
		processObjectOfType(result, path, x, data, "Site");
	}
}

/**
 * Try to process a value as a source reference.
 *
 * @param {object} result - the resulting object to return, containing objectName and value representations of params.
 * @param {array} path - path in the original params object
 * @param {object} x - the value to process
 * @param {object} data - protocol data
 */
function processSource(result, path, value0, data) {
	const x = getCommon(value0);
	// console.log(`processSource: ${JSON.stringify(path)}, ${JSON.stringify(x)}`)
	const l = processSources(result, path, x, data);
	expect.truthy({paramName: path.join('.')}, _.isArray(l) && l.length === 1, "expected a single liquid source: "+JSON.stringify(x));
	_.set(result.value, path, l[0]);
}

/**
 * Try to process a value as an array of source references.
 *
 * @param {object} result - the resulting object to return, containing objectName and value representations of params.
 * @param {array} path - path in the original params object
 * @param {object} x - the value to process
 * @param {object} data - protocol data
 */
function processSources(result, path, x, data) {
	//console.log({before: x, paramName})
	if (_.isString(x)) {
		x = wellsParser.parse(x, data.objects);
		//console.log({x})
		expect.truthy({paramName: path.join('.')}, _.isArray(x), "expected a liquid source: "+JSON.stringify(x));
		//x = [x];
	}
	else if (_.isPlainObject(x) && x.type === 'Liquid') {
		x = [x.wells];
	}
	else if (_.isArray(x)) {
		x = x.map((x2, index) => {
			const path2 = path.concat(index)
			return expect.try({paramName: path2.join('.')}, () => {
				// console.log({x2})
				if (_.isPlainObject(x2) && x2.type === 'Liquid') {
					return [x.wells];
				}
				else {
					const result2 = {value: {}, objectName: {}};
					// console.log({result2, path2, x2})
					processSource(result2, path2, x2, data);
					// console.log(`result2: ${JSON.stringify(result2)}`)
					return _.get(result2.value, path2);
				}
			});
		});
	}
	// console.log({x})
	_.set(result.value, path, x);
	return x;
}

/**
 * Try to process a value as a temperature.
 *
 * @param {object} result - the resulting object to return, containing objectName and value representations of params.
 * @param {array} path - path in the original params object
 * @param {object} x - the value to process
 * @param {object} data - protocol data
 */
function processTemperature(result, path, value0, data) {
	let x = getCommon(value0);
	if (_.isString(x)) {
		x = math.eval(x);
	}
	//console.log({function: "processVolume", path, x})
	expect.truthy({paramName: path.join('.')}, math.unit('degC').equalBase(x), "expected a temperature with units degC, degF, or K: "+JSON.stringify(value0));
	_.set(result.value, path, x);
	//console.log("set in result.value")
}

/**
 * Try to process a value as a volume.
 *
 * @param {object} result - the resulting object to return, containing objectName and value representations of params.
 * @param {array} path - path in the original params object
 * @param {object} x - the value to process
 * @param {object} data - protocol data
 */
function processVolume(result, path, value0, data) {
	let x = getCommon(value0);
	if (_.isString(x)) {
		x = math.eval(x);
	}
	//console.log({function: "processVolume", path, x})
	expect.truthy({paramName: path.join('.')}, math.unit('l').equalBase(x), "expected a volume with liter units (l, ul, etc.): "+JSON.stringify(value0));
	_.set(result.value, path, x);
	//console.log("set in result.value")
}

/**
 * Try to process a value as a well reference.
 *
 * @param {object} result - the resulting object to return, containing objectName and value representations of params.
 * @param {array} path - path in the original params object
 * @param {object} x - the value to process
 * @param {object} data - protocol data
 */
function processWell(result, path, value0, data) {
	let x = getCommon(value0);
	if (_.isString(x)) {
		//console.log("processWell:")
		//console.log({result, path, x})
		x = wellsParser.parse(x, data.objects);
	}
	expect.truthy({paramName: path.join('.')}, _.isArray(x) && x.length === 1, "expected a single well indicator: "+JSON.stringify(value0));
	_.set(result.value, path, x[0]);
}

/**
 * Try to process a value as an array of wells.
 *
 * @param {object} result - the resulting object to return, containing objectName and value representations of params.
 * @param {array} path - path in the original params object
 * @param {object} x - the value to process
 * @param {object} data - protocol data
 */
function processWells(result, path, x, data) {
	if (_.isString(x)) {
		x = wellsParser.parse(x, data.objects);
	}
	expect.truthy({paramName: path.join('.')}, _.isArray(x), "expected a list of wells: "+JSON.stringify(x));
	_.set(result.value, path, x);
}

/**
 * Try to process a value as a time duration.
 *
 * @param {object} result - the resulting object to return, containing objectName and value representations of params.
 * @param {array} path - path in the original params object
 * @param {object} x0 - the value to process
 * @param {object} data - protocol data
 */
function processDuration(result, path, value0, data) {
	let x = getCommon(value0);
	if (_.isNumber(x)) {
		x = math.unit(x, 's');
	}
	else if (_.isString(x)) {
		x = math.eval(x);
	}
	//console.log({a: math.unit('s'), value: x, x0})
	expect.truthy({paramName: path.join('.')}, math.unit('s').equalBase(x), "expected a value with time units (s, second, seconds, minute, minutes, h, hour, hours, day, days): "+JSON.stringify(value0));
	_.set(result.value, path, x);
}

/**
 * Get a property value from an object in the parsed parameters.
 * If no value could be found (and no default was given) then an exception
 * will be thrown.
 *
 * @param {object} parsed - the parsed parameters object, as passed into a command handler
 * @param {object} data - protocol data
 * @param {string} paramName - parameter name (which should reference an object)
 * @param {string} propertyName - name of the object's property to retrieve
 * @param {any} defaultValue - default value if property not found
 * @return {any} the property value
 */
function getParsedValue(parsed, data, paramName, propertyName, defaultValue) {
	const value = _.get(parsed.value[paramName], propertyName, defaultValue);
	const objectName = parsed.objectName[paramName];
	//console.log({parsed, x: parsed[paramName], paramName, propertyName})
	if (!_.isUndefined(value)) {
		const objectName1 = (objectName) ? objectName+"."+propertyName : paramName+"/"+propertyName;
		//console.trace();
		expect.truthy({objectName1}, !_.isUndefined(value), "missing value");
		return value;
	}
	else {
		expect.truthy({paramName: paramName}, !_.isUndefined(defaultValue), "missing parameter value");
		return defaultValue;
	}
}

/**
 * Query the logic database with the given predicates and return the values
 * of interest.
 * @param  {Object} data         Command data
 * @param  {Array} predicates    Array of llpl predicates
 * @param  {String} queryExtract A jmespath query string to extract values of interest from the llpl result list
 * @return {Array}               Array of objects holding valid values
 */
function queryLogicGeneral(data, predicates, queryExtract) {
	var llpl = require('./HTN/llpl.js').create();
	llpl.initializeDatabase(data.predicates);

	fixPredicateUndefines(predicates);
	var query = {"and": predicates};
	var resultList = llpl.query(query);
	//console.log("resultList:\n"+JSON.stringify(resultList, null, '  '));

	if (_.isEmpty(resultList)) {
		var predicates2 = [];
		_.forEach(predicates, function(p, index) {
			var p2 = _.mapValues(p, function(value, name) { return "?"+name; });
			predicates2.push(p2);
			var query2 = {"and": predicates2};
			var resultList2 = llpl.query(query);
			expect.truthy({}, !_.isEmpty(resultList2), "logical query found no result for predicate "+(index+1)+" in: "+JSON.stringify(query));
		});
	}

	if (queryExtract) {
		var alternatives = jmespath.search(resultList, queryExtract);
		return alternatives;
	}
	else {
		return resultList;
	}
}

/**
 * Query the logic database with the given predicates.  If solutions are found,
 * choose one of the alternatives.
 *
 * @param  {Object} data         Command data
 * @param  {Array} predicates    Array of llpl predicates
 * @param  {String} predicateName Name of the predicate we're interested in
 * @return {Array} - an array where the first item is the chosen solution, and the second item includes all alternatives.  If no solution was found, then both items will be undefined.
 */
function queryLogic(data, predicates, predicateName) {
	const resultList = queryLogicGeneral(data, predicates, undefined);
	if (_.isEmpty(resultList)) {
		return [undefined, undefined];
	}

	const queryExtract = `[].and[]."${predicateName}"`
	const alternatives = jmespath.search(resultList, queryExtract);
	assert(!_.isEmpty(alternatives), `${predicateName} not found in resultList ${JSON.stringify(resultList)} for predicates ${JSON.stringify(predicates)}`);

	// Pick a plan
	let chosen = alternatives[0];
	if (data.planAlternativeChoosers.hasOwnProperty(predicateName)) {
		chosen = data.planAlternativeChoosers[predicateName](alternatives, data);
		// console.log({chosen})
	}

	return [chosen, alternatives];
}

/**
 * Helper function for queryLogic() that replaces undefined property values with
 * the name of the property prefixed by '?'.
 * @param  {Array} predicates    Array of llpl predicates
 */
function fixPredicateUndefines(predicate) {
	if (_.isArray(predicate)) {
		_.forEach(predicate, function(p) { fixPredicateUndefines(p); });
	}
	else if (_.isPlainObject(predicate)) {
		_.forEach(predicate, function(value, name) {
			if (_.isUndefined(value))
				predicate[name] = "?"+name;
			else if (_.isPlainObject(value)) {
				fixPredicateUndefines(value);
			}
		})
	}
}


/**
 * Lookup nested paths.
 *
 * @example
 * This example will first lookup `object` in `params`,
 * then lookup the result in `data.objects`,
 * then get the value of `model`,
 * then lookup it value for `evowareName`:
 *
 * ```
 * [["@object", "model"], "evowareName"]
 * ```
 *
 * @param  {array} path   [description]
 * @param  {object} params [description]
 * @param  {object} data   [description]
 * @return {any}        [description]
 */
function lookupPath(path, params, data) {
	//console.log({path, params, data})
	let prev;
	_.forEach(path, elem => {
		//console.log({elem})
		let current = elem;
		if (_.isArray(elem))
			current = lookupPath(elem, params, data);
		else {
			assert(_.isString(current));
			if (_.startsWith(current, "@")) {
				//console.log({current, tail: current.substring(1)})
				current = current.substring(1);
				//console.log({current})
				assert(_.has(params, current));
				current = _.get(params, current);
			}
		}

		//console.log({prev, current})
		if (_.isUndefined(prev)) {
			if (_.isString(current)) {
				const result = {value: {}, objectName: {}};
				const path2 = []; // FIXME: figure out a sensible path in case of errors
				current = lookupValue0(result, path2, current, data);
			}
			prev = current;
		}
		else {
			assert(_.isString(current));
			assert(_.has(prev, current));
			prev = _.get(prev, current);
		}
	});
	return prev;
}

function lookupPaths(paths, params, data) {
	return _.mapValues(paths, path => lookupPath(path, params, data));
}

/**
 * Parse input spec and return object with the same properties as the spec,
 * but with values looked up.
 */
function parseInputSpec(inputSpec, parsed, data) {
	return _.mapValues(inputSpec, (item, key) => {
		return lookupInputPath(item, parsed, data);
	});
}

/**
 * Lookup nested paths.
 *
 * @example
 *
 * * "object": gets parameter value.
 * * "?object": optionally gets parameter value.
 * * "object*": looks up object.
 * * "object*location": looks up object, gets `location` property.
 * * "object*location*": looks up object, gets `location` property, looks up location.
 * * "object*location*type": looks up object, looks up its `location` property, gets type property.
 * * "something**": double de-reference
 * * "object*(someName)": looks up object, gets someName value, gets object's property with that value. (this is not currently implemented)
  *
 * @param  {array} path   [description]
 * @param  {object} parsed [description]
 * @param  {object} data   [description]
 * @return {any}        [description]
 */
function lookupInputPath(path, parsed, data) {
	// console.log("lookupInputPath:"); console.log({path, parsed})
	assert(_.isString(path) && !_.isEmpty(path));

	// Check whether success is required
	let required = true;
	if (path[0] == "?") {
		required = false;
		path = path.substring(1);
	}

	const elems = _.filter(path.split(/([*])/), s => !_.isEmpty(s));
	// console.log({elems});

	let current;
	try {
		for (let i = 0; i < elems.length; i++) {
			const elem = elems[i];
			if (elem == "*") {
				assert(_.isString(current), "cannot dereference: "+JSON.stringify({path, i, elem, current}));
				current = lookupInputPath_dereference(current, data);
			}
			else {
				if (_.isUndefined(current)) {
					current = parsed.objectName[elem] || parsed.orig[elem];
				}
				else {
					current = _.get(current, elem);
				}
			}
			assert(current, `${elem} not found in path ${path}`);
		}
	} catch (e) {
		if (!required)
			return undefined;
		throw e;
	}

	return current;
}

function lookupInputPath_dereference(current, data) {
	const result = {value: {}, objectName: {}};
	const path2 = []; // FIXME: figure out a sensible path in case of errors
	const current2 = lookupValue0(result, path2, current, data);
	return current2;
}

/**
 * Return array of step keys in order.
 * Any keys that begin with a number will be included,
 * and they will be sorted in natural order.
 *
 * @param  {object|array} o - an object or array of steps
 * @return {array} an ordered array of keys that represent steps
 */
function getStepKeys(steps) {
	if (_.isPlainObject(steps)) {
		// Find all sub-steps (properties that start with a digit)
		const rx = /^[0-9]/;
		const keys = _.keys(steps).filter(x => rx.test(x));
		// Sort them in "natural" order
		keys.sort(naturalSort);
		return keys;
	}
	else if (_.isArray(steps)) {
		return _.range(steps.length);
	}
	else {
		return [];
	}
}

/**
 * Return an object that conforms to the expected format for steps.
 *
 * @param  {array|object} steps - input in format of an array of steps, a single step, or propertly formatted steps.
 * @return {object} an object with only numeric keys, representing a sequence of steps.
 */
function stepify(steps) {
	if (_.isPlainObject(steps)) {
		const rx = /^[0-9]/;
		const hasOnlyStepKeys = _.keys(steps).every(x => rx.test(x));
		if (hasOnlyStepKeys) {
			return steps;
		}
		else {
			return {"1": steps};
		}
	}
	else if (_.isArray(steps)) {
		steps = _.compact(_.flattenDeep(steps));
		return _.zipObject(_.range(1, steps.length+1), steps);
	}
	else {
		assert(false, "expected an array or a plain object: "+JSON.stringify(steps));
	}
}

/**
 * Process '@DATA', '@SCOPE', and 'data' properties for a step,
 * The returned data table will be the first to exist of '@DATA', 'DATA', and 'objects.DATA'
 * The returned scope will be the merger of data.objects.SCOPE, SCOPE, '@SCOPE', and common DATA values.
 * and return updated {DATA, SCOPE}.
 */
function updateSCOPEDATA(step, data, SCOPE = undefined, DATA = undefined, addCommonValuesToScope=true) {
	// console.log("updateSCOPEDATA");
	// console.log(JSON.stringify(step));
	// FIXME: Debug only
	// if (step.value) {
	// 	assert(false);
	// }
	// ENDFIX
	// console.log("data2: "+JSON.stringify(data));
	// console.log({SCOPE})
	const overwriteCommon = !_.isEmpty(DATA);
	DATA
		= (step.hasOwnProperty("@DATA")) ? step["@DATA"]
		: (!_.isUndefined(DATA)) ? DATA
		: data.objects.DATA || [];
	// console.log({DATA})

	// Handle `data` parameter by loading Design data SCOPE and possibly
	// repeating the command for each group or each row
	if (step.hasOwnProperty("data")) {
		const dataInfo = misc.handleDirectiveDeep(step.data, data);
		// console.log({dataInfo})
		let table = DATA;
		if (_.isString(dataInfo) || dataInfo.source) {
			const dataId = _.isString(dataInfo) ? dataInfo : dataInfo.source;
			const source = _.get(data.objects, dataId);
			// console.log({source})
			// console.log("data.objects:")
			// console.log(data.objects)
			assert(source, `Data source not found: ${dataId}`);

			if (_.isArray(source)) {
				table = source;
			}
			else if (source.type === "Data") {
				if (!_.isUndefined(source.value)) {
					table = source.value;
				}
				else {
					const design = substituteDeep(source, data, SCOPE, DATA);
					table = Design.flattenDesign(design);
				}
			}
			else {
				assert(false, "unrecognized data source: "+JSON.stringify(dataId)+" -> "+JSON.stringify(source));
			}
		}
		else if (_.isPlainObject(dataInfo.design)) {
			const design = substituteDeep(dataInfo.design, data, SCOPE, DATA);
			// console.log({design0: dataInfo.design, design1: design})
			table = Design.flattenDesign({design});
			// console.log({table})
		}

		if (_.isPlainObject(dataInfo)) {
			const SCOPE2 = _.defaults({}, SCOPE, data.SCOPE);
			const query = _.clone(dataInfo);
			if (query.where) {
				query.where = substituteDeep(query.where, data, SCOPE, DATA);
			}
			// console.log({dataInfo, table})
			// console.log({SCOPE2});
			table = _.flatten(Design.query(table, query, SCOPE2));
			// console.log({dataInfo, table})
		}
		DATA = table;
	}
	//console.log("DATAs: "+JSON.stringify(DATAs, null, '\t'));

	const always = {
		// access the raw objects
		__objects: data.objects,
		// access the current raw data table
		__data: DATA,
		// access raw protocol parameters
		__parameters: _.get(data, ["protocol", "parameters"], {}),
		// access parameters from any step in the current step stack (0 = current step)
		// __stepStack: null,
	};
	if (step.hasOwnProperty("command")) {
		// access parameters of the current step
		always.__step = step;
	}
	// console.log({isEmpty: _.isEmpty(DATA)})
	const columns = (!overwriteCommon || _.isEmpty(DATA))
		? {}
		: _.fromPairs(_.map(_.keys(DATA[0]), key => [key, _.map(DATA, key)]));
	// console.log({DATA, columns, strange: _.map(DATA, "n")});
	const common = overwriteCommon
		? _.mapKeys(
				(addCommonValuesToScope) ? Design.getCommonValues(DATA) : {},
				(value, key) => key + "_ONE"
			)
		: {};
	const ATSCOPE = (step.hasOwnProperty("@SCOPE")) ? step["@SCOPE"] : {};
	SCOPE = _.defaults(always, columns, common, ATSCOPE, SCOPE, data.objects.SCOPE);

	return {DATA, SCOPE};
}

function copyItemsWithDefaults(items, defaults) {
	// console.log("copyItemsWithDefaults: "+JSON.stringify(items)+", "+JSON.stringify(defaults))
	if (_.isArray(items)) {
		items = _.cloneDeep(items);
	}
	// Create a new array with the appropriate size
	else {
		const defaultCounts = _.mapValues(defaults, (value) => (_.isArray(value)) ? value.length : 1);
		let counts = _.uniq(_.values(defaultCounts));
		counts.sort();
		let size;
		if (counts.length === 1) {
			size = counts[0];
		}
		else {
			counts = _.filter(counts, n => n != 1);
			assert(counts.length === 1, "unequal array sizes: "+JSON.stringify({items, defaults}));
			size = counts[0];
		}
		items = _.map(_.range(size), () => ({}));
	}

	for (let i = 0; i < items.length; i++) {
		const item = items[i];
		_.forEach(defaults, (value, name) => {
			if (_.isUndefined(item[name]) && !_.isUndefined(value)) {
				if (_.isArray(value)) {
					if (value.length === 1) {
						item[name] = value[0];
					}
					else {
						assert(i < value.length, "value array not long enough for target: "+JSON.stringify({name, i, value, target: items}));
						item[name] = value[i];
					}
				}
				else {
					item[name] = value;
				}
			}
		});
	}

	return items;
}

function splitItemsAndDefaults(items, keysToSkip) {
	// console.log("splitItemsAndDefaults: "+JSON.stringify(items)+", "+JSON.stringify(keysToSkip))
	let defaults = {};

	if (_.size(items) > 1) {
		defaults = Design.getCommonValues(items);
		if (_.isArray(keysToSkip) && !_.isEmpty(keysToSkip)) {
			defaults = _.omit(defaults, keysToSkip);
		}
		// console.log({defaults})

		if (_.size(defaults) > 0) {
			const keysToOmit = _.keys(defaults);
			items = _.map(items, item => _.omit(item, keysToOmit));
		}
	}

	return {items, defaults};
}

/*
function setDefaultInArrayOfObjects(name, value, l) {
	assert(_.isArray(l), "expected and array: "+JSON.stringify(l));
	for (let i = 0; i < l.length; i++) {
		const item = l[i];
		if (_.isUndefined(item[name])) {
			if (_.isArray(value)) {
				assert(i < value.length, "value array not long enough for target: "+JSON.stringify({value, target: l}));
				item[name] = value[i];
			}
			else {
				item[name] = value;
			}
		}
	}
}
*/

module.exports = {
	asArray,
	copyItemsWithDefaults,
	createData,
	getDesignFactor,
	_dereferenceVariable: dereferenceVariable,
	_g: g,
	// getCommonValues: Design.getCommonValues,
	getParsedValue,
	getStepKeys,
	lookupPath,
	lookupPaths,
	parseInputSpec,
	_lookupInputPath: lookupInputPath,
	parseParams,
	queryLogic,
	// setDefaultInArrayOfObjects,
	splitItemsAndDefaults,
	stepify,
	substituteDeep,
	updateSCOPEDATA,
}