Source: config/roboliqDirectiveHandlers.js

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

/**
 * Roboliq's default directives.
 * @module config/roboliqDirectiveHandlers
 */

var _ = require('lodash');
var assert = require('assert');
var math = require('mathjs');
var random = require('random-js');
import commandHelper from '../commandHelper.js';
var Design = require('../design.js');
var expect = require('../expect.js');
var misc = require('../misc.js');
var wellsParser = require('../parsers/wellsParser.js');

// TODO: analyze wells string (call wellsParser)
// TODO: zipMerge to merge destinations into mix items (but error somehow if not enough destinations)
// TODO: extractKey list of destinations from items
// TODO: extractValue list of destinations from items
// TODO: extract unique list of destinations, make it a well string?

function handleDirective(spec, data) {
	return misc.handleDirective(spec, data);
}

function directive_createWellAssignments(spec, data) {
	return expect.try("#createWellAssignments", () => {
		const parsed = commandHelper.parseParams(spec, data, {
			properties: {
				list: {type: "array"},
				wells: {type: "Wells"}
			},
			required: ["list", "wells"]
		});
		return _.take(parsed.value.wells, parsed.value.list.length);
	});
}

function directive_data(spec, data) {
	// console.log("directive_data: "+JSON.stringify(spec, null, '\t'))
	// console.log("DATA: "+JSON.stringify(data.objects.DATA, null, '\t'))
	return expect.try("data()", () => {
		const updatedSCOPEDATA = commandHelper.updateSCOPEDATA({data: spec}, data);
		// console.log({updateDATA: updatedSCOPEDATA.DATA})

		let result = Design.query(updatedSCOPEDATA.DATA, spec);
		// console.log("result0: "+JSON.stringify(result))
		if (spec.templateGroup) {
			result = _.map(result, DATA => {
				const SCOPE = _.merge({}, updatedSCOPEDATA.SCOPE, Design.getCommonValues(DATA));
				return commandHelper.substituteDeep(spec.templateGroup, data, SCOPE, DATA);
			});
			// console.log("result1: "+JSON.stringify(result))
		}
		else if (spec.hasOwnProperty("map") || spec.template) {
			const template = _.get(spec, "map", spec.template);
			// console.log("result: "+JSON.stringify(result, null, '\t'))
			result = _.map(result, DATA => _.map(DATA, row => {
				const SCOPE = _.defaults({}, row, updatedSCOPEDATA.SCOPE);
				// console.log("row: "+JSON.stringify(row, null, '\t'))
				// console.log("SCOPE: "); console.log(SCOPE);
				return commandHelper.substituteDeep(template, data, SCOPE, undefined);
			}));
			// console.log("result1: "+JSON.stringify(result))
		}

		// deprecated, use `map`
		if (spec.value) {
			result = _.map(result, DATA => _.map(DATA, row => {
				if (_.startsWith(spec.value, "$`")) {
					const SCOPE = _.merge({}, updatedSCOPEDATA.SCOPE, row);
					return commandHelper.substituteDeep(spec.value, data, SCOPE, DATA);
				}
				else {
					return row[spec.value];
				}
			}));
			// console.log("result1: "+JSON.stringify(result))
		}

		if (spec.hasOwnProperty("summarize")) {
			// console.log("result: "+JSON.stringify(result, null, '\t'))
			result = _.map(result, DATA => {
				const SCOPE = _.clone(updatedSCOPEDATA.SCOPE);
				const columns = getColumns(DATA);
				_.forEach(columns, key => {
					SCOPE[key] = _.map(DATA, key);
				});
				// console.log(SCOPE);
				// console.log("SCOPE: "+JSON.stringify(SCOPE, null, '\t'))
				return commandHelper.substituteDeep(spec.summarize, data, SCOPE, DATA, false);
			});
			// console.log("result1: "+JSON.stringify(result))
		}

		result = (spec.groupBy) ? result : _.flatten(result);
		// console.log("result2: "+JSON.stringify(result))

		if (spec.flatten) {
			result = _.flatten(result);
			// console.log("result3: "+JSON.stringify(result))
		}

		if (spec.head) {
			result = _.head(result);
			// console.log("result4: "+JSON.stringify(result))
		}
		else if (spec.unique) {
			result = _.uniq(result);
		}
		if (spec.join) {
			result = result.join(spec.join);
			// console.log("result5: "+JSON.stringify(result))
		}
		if (spec.orderBy) {
			result = _.orderBy(result, spec.orderBy);
		}
		if (spec.reverse) {
			result = _.reverse(result);
		}
		// console.log("result: "+JSON.stringify(result, null, '\t'))
		return result;
	});
}

function getColumns(DATA) {
	// Get column names
	const columnMap = {};
	_.forEach(DATA, row => _.forEach(_.keys(row), key => { columnMap[key] = true; } ));
	const columns = _.keys(columnMap);
	// console.log({columns})
	return columns;
}

function directive_destinationWells(spec, data) {
	expect.truthy({}, _.isString(spec), "#destinationWells: expected string, received "+spec);
	return directive_wells(spec, data);
}

function directive_for(spec, data) {
	// console.log({spec})
	expect.paramsRequired(spec, ['factors', 'output']);
	var views = directive_factorialCols(spec.factors, data);
	//console.log("views:", views);
	return _.map(views, function(view) {
		var rendered = misc.renderTemplate(spec.output, view, data);
		//console.log("rendered:", rendered);
		var rendered2 = misc.handleDirectiveDeep(rendered, data);
		//console.log("rendered2:", rendered2);
		return rendered2;
	});
}

function directive_factorialArrays(spec, data) {
	//console.log("genList:", spec);
	assert(_.isArray(spec));
	var lists = _.map(spec, function(elem) { return handleDirective(elem, data); });
	return combineLists(lists);
}

function combineLists(elem) {
	//console.log("combineLists: ", elem);
	var list = [];
	if (_.isArray(elem) && !_.isEmpty(elem)) {
		list = elem[0];
		if (!_.isArray(list))
			list = [list];
		for (var i = 1; i < elem.length; i++) {
			//console.log("list@"+i, list);
			list = _(list).map(function(x) {
				//console.log("x", x);
				if (!_.isArray(x))
					x = [x];
				return elem[i].map(function(y) { return x.concat(y); });
			}).flatten().value();
		}
	}
	return list;
}

function directive_factorialCols(spec, data) {
    //console.log("genFactorialCols:", spec);
    assert(_.isPlainObject(spec) || _.isArray(spec));
	var variables = (_.isPlainObject(spec))
		? _.toPairs(spec)
		: _(spec).map(_.toPairs).flatten().value();

    var lists = _.map(variables, function(pair) {
		var key = pair[0];
		var values = pair[1];
        if (!_.isArray(values)) {
            var obj1 = {};
            obj1[key] = values;
            return [obj1];
        }
        else {
            return values.map(function(value) {
                var obj1 = {};
                obj1[key] = value;
                return obj1;
            });
        }
    })
    var result = directive_factorialMerge(lists, data);
    //console.log("genFactorialCols result:", result);
    return result;
}

function directive_gradient(data, data_) {
    if (!_.isArray(data))
        data = [data];

    var list = [];
    _.forEach(data, function(data) {
        assert(data.volume);
        assert(data.count);
        assert(data.count >= 2);
        var decimals = _.isNumber(data.decimals) ? data.decimals : 2;
        //console.log("decimals:", decimals);
        var volumeTotal = math.round(math.eval(data.volume).toNumber('ul'), decimals);
        // Pick volumes
        var volumes = Array(data.count);
        for (var i = 0; i < data.count / 2; i++) {
            volumes[i] = math.round(volumeTotal * i / (data.count - 1), decimals);
            volumes[data.count - i - 1] = math.round(volumeTotal - volumes[i], decimals);
        }
        if ((data.count % 2) === 1)
            volumes[Math.floor(data.count / 2)] = math.round(volumeTotal / 2, decimals);
        //console.log("volumes:", volumes);
        // Create items
        for (var i = 0; i < data.count; i++) {
            var volume2 = volumes[i];
            var volume1 = math.round(volumeTotal - volume2, decimals);
            var l = [];
            l.push((volume1 > 0) ? {source: data.source1, volume: math.unit(volume1, 'ul').format({precision: 14})} : null);
        	l.push((volume2 > 0) ? {source: data.source2, volume: math.unit(volume2, 'ul').format({precision: 14})} : null);
            //if (l.length > 0)
            list.push(l);
        }
    });
    return list;
}

/**
 * Merge an array of objects, or combinatorially merge an array of arrays of objects.
 *
 * @param  {Array} spec The array of objects or arrays of objects to merge.
 * @return {Object|Array} The object or array resulting from combinatorial merging.
 */
function directive_factorialMerge(spec, data) {
	//console.log("#merge", spec);
	if (_.isEmpty(spec)) return spec;

	//console.log("genMerge lists:", lists);
	var result = genMerge2(spec, data, {}, 0, []);
	// If all elements were objects rather than arrays, return an object:
	/*if (_.every(spec, _.isPlainObject)) {
		assert(result.length == 1);
		result = result[0];
	}*/
	//console.log("genMerge result:", result);
	return result;
}

/**
 * Helper function for factorial merging of arrays of objects.
 * For example, the first element of the first array is merged with the first element of the second array,
 * added to the `acc` list, then the first element of the first array is merged with the second element of the second array, and so on.
 * @param  {array} spec  array of objects, may be nested arbitrarily deep, i.e. array of arrays of objects
 * @param  {object} data  [description]
 * @param  {object} obj0  accumulated result of the current merge, will be added to `acc` once the end of the `spec` list is reached
 * @param  {number} index index of current item in `spec`
 * @param  {array} acc   accumulated list of merged objects
 * @return {array} Returns a factorial list of merged objects.
 */
function genMerge2(spec, data, obj0, index, acc) {
	//console.log("genMerge2", spec, obj0, index, acc);
	assert(_.isArray(spec));

	var list = _.map(spec, function(elem) {
		if (!_.isArray(elem))
			elem = [elem];
		return handleDirective(elem, data);
	});

	if (index >= spec.length) {
		acc.push(obj0);
		return acc;
	}

	var elem = spec[index];
	if (!_.isArray(elem))
		elem = [elem];

	for (var j = 0; j < elem.length; j++) {
		var elem2 = handleDirective(elem[j], data);
		//console.log("elem2:", elem2)
		if (_.isArray(elem2)) {
			genMerge2(elem2, data, obj1, 0, acc);
		}
		else if (elem2 !== null) {
			assert(_.isPlainObject(elem2));
			var obj1 = _.merge({}, obj0, elem[j]);
			genMerge2(list, data, obj1, index + 1, acc);
		}
	}

	return acc;
}

//function expandArrays

function directive_factorialMixtures(spec, data) {
	// Get mixutre items
	var items;
	if (_.isArray(spec))
		items = spec;
	else {
		expect.paramsRequired(spec, ['items']);
		items = misc.getVariableValue(spec.items, data.objects);
	}

	assert(_.isArray(items));
	var items2 = _.map(items, function(item) {
		return (_.isPlainObject(item))
			? directive_factorialCols(item, data)
			: item;
	});
	var combined = combineLists(items2);

	if (spec.replicates && spec.replicates > 1) {
		combined = directive_replicate({count: spec.replicates, value: combined}, data);
	}
	return combined;
}

// function directive_include_jsonl(spec, data) {
// 	assert(_.isString(spec));
// 	const filename = spec;
// 	console.log("files: "+JSON.stringify(Object.keys(data)))
// 	assert(data.files.hasOwnProperty(filename));
// 	const filedata = data.files[spec];
// 	console.log(JSON.stringify(filename));
// 	assert(false);
// }

function directive_length(spec, data) {
	if (_.isArray(spec))
		return spec.length;
	else if (_.isString(spec)) {
		var value = misc.getObjectsValue(spec, data.objects)
		 if (_.isPlainObject(value) && value.hasOwnProperty('value'))
			value = value.value;
		expect.truthy({}, _.isArray(value), '#length expected an array, received: '+spec);
		return value.length;
	}
	else {
		expect.truthy({}, false, '#length expected an array, received: '+spec);
	}
}

function directive_merge(spec, data) {
	assert(_.isArray(spec));
	var list = _.map(spec, function(x) { return handleDirective(x, data); });
	return _.merge.apply(null, [{}].concat(list));
}

function directive_pipetteMixtures(spec, data) {
	// Get mixutre items
	var items;
	if (_.isArray(spec))
		items = spec;
	else {
		expect.paramsRequired(spec, ['items']);
		items = misc.getVariableValue(spec.items, data.objects);
	}

	assert(_.isArray(items));
	var items2 = _.map(items, function(item) {
		return (_.isPlainObject(item))
			? directive_factorialCols(item, data)
			: item;
	});
	var combined = combineLists(items2);

	if (spec.replicates && spec.replicates > 1) {
		combined = directive_replicate({count: spec.replicates, value: combined}, data);
	}

	//console.log(1)
	//console.log(JSON.stringify(combined, null, '\t'))

	// Check and set volumes
	const volumePerMixture = (spec.volume)
		? math.eval(spec.volume) : undefined;
	//console.log({volumePerMixture})
	//console.log({combined})
	combined.forEach(l => {
		let volumeTotal = math.unit(0, 'l');
		let missingVolumeIndex = -1;
		//console.log({l})
		// Find total volume of components and whether any components are missing the volume parameter
		_.forEach(l, (x, i) => {
			//console.log({x, i, and: x.hasOwnProperty('volume')})
			if (x) {
				if (!x.hasOwnProperty('volume')) {
					assert(volumePerMixture, "missing volume parameter: "+JSON.stringify(x));
					assert(missingVolumeIndex < 0, "only one mixture element may omit the volume parameter: "+JSON.stringify(l));
					missingVolumeIndex = i;
					//console.log({missingVolumeIndex})
				}
				else {
					volumeTotal = math.add(volumeTotal, math.eval(x.volume));
				}
			}
		});
		// If one of the components needs to have its volume set:
		if (missingVolumeIndex >= 0) {
			assert(volumePerMixture);
			//console.log({volumePerMixture, volumeTotal, subtract: math.subtract(volumePerMixture, volumeTotal).format({precision: 10})})
			l[missingVolumeIndex] = _.merge({}, l[missingVolumeIndex], {
				volume: math.subtract(volumePerMixture, volumeTotal).format({precision: 13})
			});
		}
		else if (!_.isUndefined(volumePerMixture)) {
			const t1 = volumePerMixture.format({precision: 14});
			const t2 = volumeTotal.format({precision: 14});
			if (t1 !== t2) {
				console.log("WARNING: volume in mixture should sum to "+t1+" rather than "+t2+": "+JSON.stringify(l));
			}
		}
	});

	//console.log(2)
	//console.log(JSON.stringify(combined, null, '\t'))

	_.forEach(spec.transformations || [], t => {
		if (t.name === 'shuffle') {
			var mt = random.engines.mt19937();
			assert(_.isNumber(t.seed), "`shuffle` requires a numeric `seed` paramter");
			mt.seed(t.seed);
			random.shuffle(mt, combined);
		}
	});

	//console.log(3)
	//console.log(JSON.stringify(combined, null, '\t'))

	_.forEach(spec.transformationsPerWell || [], t => {
		if (t.name === 'sortByVolumeMax') {
			combined = _.map(combined, l => {
				const offset = 0;
				const count = t.count || l.length - offset;
				return _.take(l, offset)
					.concat(_.sortBy(_.take(l, count), x => -math.eval(x.volume).toNumber('l')))
					.concat(_.drop(l, offset + count));
			});
		}
	});

	//console.log(4)
	//console.log(JSON.stringify(combined, null, '\t'))

	return combined;
}

function directive_replaceLabware(spec, data) {
	expect.paramsRequired(spec, ['list', 'new']);
	var list = misc.getVariableValue(spec.list, data.objects);
	//if (!_.isArray(list)) console.log("list:", list)
	assert(_.isArray(list), "expected a list, received: "+JSON.stringify(list));
	assert(_.isString(spec.new));
	var l1 = _.flatten(_.map(list, function(s) {
		var l2 = wellsParser.parse(s);
		return _.map(l2, function(x) {
			//console.log("s:", s, "x:", x);
			//console.log(x.hasOwnProperty('labware'), !spec.old, x.labware === spec.old);
			if (x.hasOwnProperty('labware') && (!spec.old || x.labware === spec.old)) {
				x.labware = spec.new;
			}
			return x;
		});
	}));
	var l2 = wellsParser.processParserResult(l1, data.objects);
	return l2;
}

function directive_replicate(spec) {
    assert(_.isPlainObject(spec));
    assert(_.isNumber(spec.count));
	assert(spec.value);
	var depth = spec.depth || 0;
	assert(depth >= 0);

	var step = function(x, count, depth) {
		//console.log("step:", x, count, depth);
		if (_.isArray(x) && depth > 0) {
			if (depth === 1)
				return _.flatten(_.map(x, function(y) { return step(y, count, depth - 1); }));
			else
				return _.map(x, function(y) { return step(y, count, depth - 1); });
		}
		else {
			return _.flatten(_.fill(Array(count), x));
		}
	}
	return step(spec.value, spec.count, depth);
}

function directive_tableCols(table, data) {
    //console.log("genTableCols:", table)
    assert(_.isPlainObject(table));
	assert(!_.isEmpty(table));

	var ns1 = _.uniq(_.map(table, function(x) { return _.isArray(x) ? x.length : 1; }));
	var ns = _.filter(ns1, function(n) { return n > 1; });
	assert(ns1.length > 0);
	assert(ns.length <= 1);
	var n = (ns.length === 1) ? ns[0] : 1;

    var list = Array(n);
	for (var i = 0; i < n; i++) {
		list[i] = _.mapValues(table, function(value) {
			return (_.isArray(value)) ? value[i] : value;
		});
	}
    return list;
}

function directive_tableRows(table, data) {
    //console.log("genTableRows:", table)
    assert(_.isArray(table));

    var list = [];
    var names = [];
    var defaults = {};
    _.forEach(table, function(row) {
        // Object are for default values that will apply to the following rows
        if (_.isArray(row)) {
            // First array in table is the column names
            if (names.length === 0) {
                names = row;
            }
            else {
                assert(row.length === names.length);
                var obj = _.clone(defaults);
                for (var i = 0; i < names.length; i++) {
                    obj[names[i]] = row[i];
                }
                list.push(obj);
            }
        }
        else {
            assert(_.isPlainObject(row));
            defaults = _.merge(defaults, row);
            //console.log("defaults:", defaults)
        }
    });
    return list;
}

function directive_take(spec, data) {
	//console.log("#take:", spec);
	expect.paramsRequired(spec, ['list', 'count']);
	var list = misc.getVariableValue(spec.list, data.objects);
	var count = misc.getVariableValue(spec.count, data.objects);
	return _.take(list, count);
}

function directive_wells(spec, data) {
	return wellsParser.parse(spec, data.objects);
}

function directive_zipMerge(spec, data) {
	assert(_.isArray(spec));
	if (spec.length <= 1)
		return spec;
	//console.log('spec:', spec);
	var zipped = _.zip.apply(null, spec);
	//console.log('zipped:', zipped);
	var merged = _.map(zipped, function(l) { return _.merge.apply(null, [{}].concat(l)); });
	//console.log('spec:', spec);
	return merged;
}

//
// from design.js
//

function directive_allocateWells(spec, data) {
	assert(spec.N, "You must specify a positive value for parameter `N` in `allocateWells()`")
	// console.log("directive_allocateWells: "+JSON.stringify(spec))
	const design = {
		design: {
			".*": spec.N,
			"x=allocateWells": spec
		}
	};
	const table = Design.flattenDesign(design);
	return _.map(table, "x");
}

module.exports = {
	"createPipetteMixtureList": directive_pipetteMixtures,
	"createWellAssignments": directive_createWellAssignments,
	"data": directive_data,
	"destinationWells": directive_destinationWells,
	"factorialArrays": directive_factorialArrays,
	"factorialCols": directive_factorialCols,
	"factorialMerge": directive_factorialMerge,
	"factorialMixtures": directive_factorialMixtures,
	"for": directive_for,
	"gradient": directive_gradient,
	// "include_jsonl": directive_include_jsonl,
	"length": directive_length,
	"merge": directive_merge,
	"replaceLabware": directive_replaceLabware,
	"replicate": directive_replicate,
	"tableCols": directive_tableCols,
	"tableRows": directive_tableRows,
	"take": directive_take,
	//"#wells": genWells,
	"undefined": function() { return undefined; },
	"zipMerge": directive_zipMerge,

	// From design.js:
	"allocateWells": directive_allocateWells,
};