Source: design.js

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

'use babel';

/**
 * Functions for processing design specifications.
 * In particular, it can take concise design specifications and expand them
 * into a long table of factor values.
 * @module
 */
import _ from 'lodash';
import assert from 'assert';
// import Immutable, {Map, fromJS} from 'immutable';
import math from 'mathjs';
import naturalSort from 'javascript-natural-sort';
import Random from 'random-js';
import stableSort from 'stable';
//import yaml from 'yamljs';

import wellsParser from './parsers/wellsParser.js';


const DEBUG = false;

//import {locationRowColToText} from './parsers/wellsParser.js';
// FIXME: HACK: this function is included here temporarily, to make usage in react component easier for the moment
function locationRowColToText(row, col) {
	var colText = col.toString();
	if (colText.length == 1) colText = "0"+colText;
	return String.fromCharCode("A".charCodeAt(0) + row - 1) + colText;
}

/**
 * Print a text representation of the table
 * @param  {array}  rows - array of rows
 * @param  {Boolean} [hideRedundancies] - suppress printing of values that haven't changed from the previous row
 */
export function printRows(rows, hideRedundancies = false) {
	const data = _.flattenDeep(rows);

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

	// Convert data to array of lines (which are arrays of columns)
	const lines = [];
	_.forEach(data, group => {
		if (!_.isArray(group)) {
			group = [group];
		}
		else {
			lines.push(["---"]);
		}
		_.forEach(group, row => {
			// console.log(JSON.stringify(row))
			const line = _.map(columns, key => {
				const x1 = _.get(row, key, "");
				const x2 = (_.isNull(x1)) ? "" : x1;
				return x2.toString();
			});
			lines.push(line);
		});
	});

	// Calculate column widths
	const widths = _.map(columns, key => key.length);
	// console.log({widths})
	_.forEach(lines, line => {
		_.forEach(line, (s, i) => { if (!_.isEmpty(s)) widths[i] = Math.max(widths[i], s.length); });
	});
	// console.log({widths})

	console.log(columns.map((s, i) => _.padEnd(s, widths[i])).join("  "));
	console.log(columns.map((s, i) => _.repeat("=", widths[i])).join("  "));
	let linePrev;
	_.forEach(lines, line => {
		const s = line.map((s, i) => {
			const s2 = (s === "") ? "-"
				: (hideRedundancies && linePrev && s === linePrev[i]) ? ""
				: s;
			return _.padEnd(s2, widths[i]);
		}).join("  ");
		console.log(s);
		linePrev = line;
	});
	console.log(columns.map((s, i) => _.repeat("=", widths[i])).join("  "));
}

/**
 * Print a TAB-formatted representation of the table
 * @param  {array}  rows - array of rows
 */
export function printTAB(rows) {
	const hideRedundancies = false;
	const data = _.flattenDeep(rows);

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

	// Convert data to array of lines (which are arrays of columns)
	const lines = [];
	_.forEach(data, group => {
		if (!_.isArray(group)) {
			group = [group];
		}
		else {
			lines.push(["---"]);
		}
		_.forEach(group, row => {
			// console.log(JSON.stringify(row))
			const line = _.map(columns, key => {
				const x1 = _.get(row, key, "");
				const x2 = (_.isNull(x1)) ? "" : x1;
				return x2.toString();
			});
			lines.push(line);
		});
	});

	console.log(columns.join("\t"));
	_.forEach(lines, line => {
		const s = line.join("\t");
		console.log(s);
	});
}

/**
 * Print a markdown pipe table
 * @param  {array}  rows - array of rows
 */
export function printMarkdown(rows) {
	const hideRedundancies = false;
	const data = _.flattenDeep(rows);

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

	// Convert data to array of lines (which are arrays of columns)
	const lines = [];
	_.forEach(data, group => {
		if (!_.isArray(group)) {
			group = [group];
		}
		else {
			lines.push(["---"]);
		}
		_.forEach(group, row => {
			// console.log(JSON.stringify(row))
			const line = _.map(columns, key => {
				const x1 = _.get(row, key, "");
				const x2 = (_.isNull(x1)) ? "" : x1;
				return x2.toString();
			});
			lines.push(line);
		});
	});

	console.log(columns.join(" | "));
	console.log(columns.map(s => ":-----:").join(" | "));
	_.forEach(lines, line => {
		const s = line.join(" | ");
		console.log(s);
	});
	console.log();
}

/**
 * Turn a design specification into a design table.
 * @param {object} design - the design specification.
 */
export function flattenDesign(design, randomEngine) {
	if (_.isEmpty(design)) {
		return [];
	}

	randomEngine = randomEngine || Random.engines.mt19937();
	const randomSeed = _.isNumber(design.randomSeed) ? design.randomSeed : 0;
	randomEngine.seed(randomSeed);

	let children;
	if (_.isArray(design.children)) {
		children = design.children.map(child => flattenDesign(child, randomEngine));
	}
	else {
		const conditionsList = _.isArray(design.design) ? design.design : [design.design];
		children = conditionsList.map(conditions => expandConditions(conditions, randomEngine, design.initialRows));
	}

	let rows;
	if (children.length == 1) {
		rows = children[0];
	}
	// If there were multiple children, we'll need to join them: either merge columns or concat rows
	else {
		// console.log(JSON.stringify(children, null, '\t'))
		const joinMethod = design.join || "concat";
		if (joinMethod === "merge") {
			rows = _.merge.apply(_, [[]].concat(children));
		}
		else {
			rows = [].concat(...children);
		}
	}

	if (design.where) {
		rows = filterOnWhere(rows, design.where);
	}
	if (design.orderBy) {
		rows = _.orderBy(rows, design.orderBy);
	}
	if (design.select) {
		rows = rows.map(row => _.pick(row, design.select));
	}
	return rows;
}

export function getCommonValues(table) {
	if (_.isEmpty(table)) return {};
	assert(_.isArray(table), `required an array: ${JSON.stringify(table)}`);

	let common = _.clone(table[0]);
	for (let i = 1; i < table.length; i++) {
		// Remove any value from common which aren't shared with this row.
		_.forEach(table[i], (value, name) => {
			if (common.hasOwnProperty(name) && !_.isEqual(common[name], value)) {
				delete common[name];
			}
		});
	}

	return common;
}

export function getCommonValuesNested(nestedRows, rowIndexes, common) {
	if (_.isEmpty(rowIndexes)) return {};

	for (let i = 0; i < rowIndexes.length; i++) {
		const rowIndex = rowIndexes[i];
		const row = nestedRows[rowIndex];
		if (_.isArray(row)) {
			getCommonValuesNested(row, _.range(row.length), common);
		}
		else if (_.isUndefined(common)) {
			common = _.clone(row);
		}
		else {
			// Remove any value from common which aren't shared with this row.
			_.forEach(row, (value, name) => {
				if (common.hasOwnProperty(name) && !_.isEqual(common[name], value)) {
					delete common[name];
				}
			});
		}
	}

	return common;
}

/**
 * Is like _.flattenDeep, but it mutates the array in-place.
 *
 * @param  {array} rows - array to flatten
 */
export function flattenArrayM(rows) {
	let i = rows.length;
	while (i > 0) {
		i--;
		const item = rows[i];
		if (_.isArray(item)) {
			// Flatten the sub-array
			flattenArrayM(item);
			// Splice the original sub-array back into the parent array
			rows.splice(i, 1, ...item);
		}
	}
	return rows;
}

/**
 * Is like _.flattenDeep, but only for the given rows, and it mutates both the rows array and rowIndexes array in-place.
 *
 * @param {array} rows - array to flatten
 * @param {array} rowIndexes - array of row indexes to flatten
 * @param {array} [otherRowIndexes] - a second, optional array of row indexes that should have the same modifications made to it as rowIndexes
 * @param {integer} rowIndexesOffset - index in rowIndexes to start at
 */
export function flattenArrayAndIndexes(rows, rowIndexes, otherRowIndexes = []) {
	if (DEBUG) {
		console.log(`flattenArrayAndIndexes:`);
		console.log(` otherRowIndexes: ${JSON.stringify(otherRowIndexes)}`);
		console.log(` rowIndexes: ${JSON.stringify(rowIndexes)}\n ${JSON.stringify(rows)}`)
	}
	let i = 0;
	while (i < rowIndexes.length) {
		const rowIndex = rowIndexes[i];
		const item = rows[rowIndex];
		if (_.isArray(item)) {
			// Flatten the sub-array
			flattenArrayM(item);
			// Splice the original sub-array back into the parent array
			rows.splice(rowIndex, 1, ...item);

			// Update rowIndexes
			for (let j = i + 1; j < rowIndexes.length; j++) {
				rowIndexes[j] += item.length - 1;
			}
			// console.log(` 1: ${rowIndexes.join(",")}`)
			const x = _.range(rowIndex, rowIndex + item.length);
			// console.log({x})
			rowIndexes.splice(i, 1, ...x);
			// console.log(` 2: ${rowIndexes.join(",")}`)

			for (let m = 0; m < otherRowIndexes.length; m++) {
				const rowIndexes2 = otherRowIndexes[m];
				let k = -1;
				for (let j = 0; j < rowIndexes2.length; j++) {
					if (rowIndexes2[j] === rowIndex) {
						k = j;
					}
					else if (rowIndexes2[j] > rowIndex) {
						rowIndexes2[j] += item.length - 1;
					}
				}
				if (k >= 0) {
					const x = _.range(rowIndex, rowIndex + item.length);
					// console.log({x})
					rowIndexes2.splice(k, 1, ...x);
				}
				// console.log({m, len: otherRowIndexes.length, k})
				// console.log(` 3 otherRowIndexes: ${JSON.stringify(otherRowIndexes)}`);
			}

			i += item.length;
		}
		else {
			i++;
		}
		// console.log(` 4 otherRowIndexes: ${JSON.stringify(otherRowIndexes)}`);
	}
	if (DEBUG) { console.log(" (flattenArrayAndIndexes) otherRowIndexes: "+JSON.stringify(otherRowIndexes)); }
}

/**
 * If conditions is an array, then each element will be processed individually and then the results will be merged together.
 * @param {object|array} conditions - an object of conditions or an array of such objects.
 * @param {array} table0 - the initial rows to start expanding conditions on (default `[{}]`)
 */
export function expandConditions(conditions, randomEngine, table0 = [{}]) {
	// console.log("expandConditions: "+JSON.stringify(conditions))

	const conditionsList = _.isArray(conditions) ? conditions : [conditions];
	const conditionsRows = conditionsList.map(conditions => {
		const table = _.cloneDeep(table0);
		expandRowsByObject(table, _.range(0, table.length), [], conditions, randomEngine);
		flattenArrayM(table);
		return table;
	});

	let table = (conditionsRows.length == 1)
		? conditionsRows[0]
		: _.merge.apply(_, [[]].concat(conditionsRows));  // should probably be `_.merge([], ...conditionsRows)`
	return table;
}

/**
 * expandRowsByObject:
 *   for each key/value pair, call expandRowsByNamedValue
 */
function expandRowsByObject(nestedRows, rowIndexes, otherRowIndexes, conditions, randomEngine) {
	if (DEBUG) {
		console.log("expandRowsByObject: "+JSON.stringify(conditions));
		console.log(` otherRowIndexes: ${JSON.stringify(otherRowIndexes)}`);
		console.log(` rowIndexes: ${rowIndexes}\n ${JSON.stringify(nestedRows)}`)
		assertNoDuplicates(otherRowIndexes);
	}
	for (let name in conditions) {
		expandRowsByNamedValue(nestedRows, rowIndexes, otherRowIndexes, name, conditions[name], randomEngine);
	}
}

/**
 * // REQUIRED by: expandRowsByObject
 * expandRowsByNamedValue:
 *   TODO: turn the name/value into an action in order to allow for more sophisticated expansion
 *   if has star-suffix, call branchRowsByNamedValue
 *   else call assignRowsByNamedValue
 */
export function expandRowsByNamedValue(nestedRows, rowIndexes, otherRowIndexes, name, value, randomEngine) {
	if (DEBUG) {
		console.log(`expandRowsByNamedValue: ${name}, ${JSON.stringify(value)}`);
		console.log(` otherRowIndexes: ${JSON.stringify(otherRowIndexes)}`);
		console.log(` rowIndexes: ${JSON.stringify(rowIndexes)}\n ${JSON.stringify(nestedRows)}`)
		assertNoDuplicates(otherRowIndexes);
		assertNoDuplicates(otherRowIndexes.concat([rowIndexes]));
	}
	// If an action is specified using the "=" symbol:
	const iEquals = name.indexOf("=");
	if (iEquals >= 0) {
		// Need to flatten the rows in case the action uses groupBy or sameBy
		flattenArrayAndIndexes(nestedRows, rowIndexes, otherRowIndexes);
		// console.log(` 'otherRowIndexes: ${JSON.stringify(otherRowIndexes)}`);
		// console.log(` 'rowIndexes: ${JSON.stringify(rowIndexes)}\n ${JSON.stringify(nestedRows)}`)
		const actionType = name.substr(iEquals + 1) || "assign";
		const actionHandler = actionHandlers[actionType];
		assert(actionHandler, `unknown action type: ${actionType} in ${name}`)
		name = name.substr(0, iEquals);

		const result = actionHandler(nestedRows, rowIndexes, otherRowIndexes, name, value, randomEngine);
		// If no result was returned, the action handled modified the rows directly:
		if (_.isUndefined(result)) {
			return;
		}
		// Otherwise, continue processing using the action's results
		else {
			value = result;
		}
	}

	const starIndex = name.indexOf("*");
	if (starIndex >= 0) {
		// Remove the branching suffix from the name
		name = name.substr(0, starIndex);
		// If the name is empty, automatically pick a dummy name that will be omitted
		if (_.isEmpty(name)) {
			name = ".HIDDEN";
		}
		// If the branching value is just a number, then assume it means the number of replicates
		if (_.isNumber(value)) {
			value = _.range(1, value + 1);
		}

		// console.log({loc: "A", rowIndexes, nestedRows})
		branchRowsByNamedValue(nestedRows, rowIndexes, otherRowIndexes, name, value, randomEngine);
		// console.log("B")
	}
	else {
		assignRowsByNamedValue(nestedRows, rowIndexes, otherRowIndexes, name, value, randomEngine, true);
	}
}

/*
 * // REQUIRED by: expandRowsByNamedValue, branchRowsByNamedValue
 * assignRowsByNamedValue: (REQUIRED FOR ASSIGNING ARRAY TO ROWS)
 *   if value is array:
 *     for i in count:
 *       rowIndex = rowIndexes[i]
 *       assignRowByNamedKeyItem(nestedRows, rowIndex, name, i+1, value[i])
 *   else if value is object:
 *     keys = _.keys(value)
 *     for each i in keys.length:
 *       key = keys[i]
 *       item = value[key]
 *       assignRowByNamedKeyItem(nestedRows, rowIndex, name, key, item)
 *   else:
 *     for each row:
 *       setColumnValue(row, name, value)
 */
function assignRowsByNamedValue(nestedRows, rowIndexesGroups, otherRowIndexes, name, value, randomEngine, doUnnest = true) {
	const l = (_.every(rowIndexesGroups, l => _.isArray(l))) ? rowIndexesGroups : [rowIndexesGroups];
	if (DEBUG) {
		console.log(`assignRowsByNamedValue: ${name}, ${JSON.stringify(value)}`);
		console.log(` otherRowIndexes: ${JSON.stringify(otherRowIndexes)}`);
		console.log(` rowIndexesGroups: ${JSON.stringify(rowIndexesGroups)}\n ${JSON.stringify(nestedRows)}`)
		assertNoDuplicates(otherRowIndexes);
		assertNoDuplicates(otherRowIndexes.concat(l));
		//printRows(nestedRows)
	}
	const otherRowIndexes2 = otherRowIndexes.concat(l);
	for (let il = 0; il < l.length; il++) {
		const rowIndexes = _.clone(l[il]);
		// console.log({il, rowIndexes, l})
		let valueIndex = 0;
		const isSpecial = value instanceof Special;
		if (isSpecial) {
			value.reset();
		}
		/*// If value is an array of objects
		if (_.isArray(value) && _.every(value, x => _.isObject(x))) {
			// Assign indexes
			const valueIndexes = _.range(1, value.length + 1);
			assignRowsByNamedValue(nestedRows, rowIndexes, otherRowIndexes, name, valueIndexes, randomEngine, doUnnest);
			// Assign objects
			expandRowsByObject(nestedRows, rowIndexes, otherRowIndexes, item, randomEngine);
		}
		else*/ if (isSpecial || _.isArray(value)) {
			for (let i = 0; i < rowIndexes.length; i++) {
				const rowIndex = rowIndexes[i];
				const rowIndexes2 = [rowIndex];
				const n = assignRowByNamedValuesKey(nestedRows, rowIndex, otherRowIndexes.concat([rowIndexes, rowIndexes2]), name, value, valueIndex, undefined, randomEngine, doUnnest);
				valueIndex += n;
				i += rowIndexes2.length - 1;
				if (DEBUG) {
					console.log({rowIndex, dRowIndex: rowIndexes2.length, dValueIndex: n, valueIndex, i, rowIndexes})
					console.log(` (assignRowsByNamedValue:) rowIndexes: ${rowIndexes.join(", ")}`)
				}
			}
		}
		else if (_.isPlainObject(value)) {
			let valueIndex = 0;
			const keys = _.keys(value);
			for (let i = 0; i < rowIndexes.length; i++) {
				const rowIndex = rowIndexes[i];
				const rowIndexes2 = [rowIndex];
				const n = assignRowByNamedValuesKey(nestedRows, rowIndex, otherRowIndexes.concat([rowIndexes, rowIndexes2]), name, value, valueIndex, keys, randomEngine, doUnnest);
				valueIndex += n;
				i += rowIndexes2.length - 1;
			}
		}
		else {
			for (let i = 0; i < rowIndexes.length; i++) {
				const rowIndex = rowIndexes[i];
				setColumnValue(nestedRows[rowIndex], name, value);
				// console.log(JSON.stringify(nestedRows))
			}
		}
	}
}

/*
 * // REQUIRED by: assignRowsByNamedValue
 * assignRowByNamedValuesKey:
 *   if item is array:
 *     setColumnValue(row, name, key)
 *     branchRowByArray(nestdRows, rowIndex, item)
 *   else if item is object:
 *     setColumnValue(row, name, key)
 *     expandRowsByObject(nestedRows, [rowIndex], item)
 *   else:
 *     setColumnValue(row, name, item)
 * Returns number of values actually assigned (may be more than one for nested rows)
 */
function assignRowByNamedValuesKey(nestedRows, rowIndex, otherRowIndexes, name, values, valueKeyIndex, valueKeys, randomEngine, doUnnest) {
	assert(!_.isUndefined(doUnnest));
	if (DEBUG) {
		console.log(`assignRowByNamedValuesKey: ${name}, ${JSON.stringify(values)}, ${valueKeyIndex}, ${valueKeys}`);
		console.log(` otherRowIndexes: ${JSON.stringify(otherRowIndexes)}`);
		console.log(` rowIndex: ${rowIndex}\n ${JSON.stringify(nestedRows)}`)
		assertNoDuplicates(otherRowIndexes);
	}
	const row = nestedRows[rowIndex];
	let n = (doUnnest) ? 0 : 1;
	if (_.isArray(row)) {
		// console.log("0")
		const otherRowIndexes2 = otherRowIndexes.concat([_.range(row.length)]);
		for (let i = 0; i < row.length; i++) {
			// console.log(` (assignRowByNamedValuesKey) #${i} of ${row.length}`)
			const n2 = assignRowByNamedValuesKey(row, i, otherRowIndexes2, name, values, valueKeyIndex, valueKeys, randomEngine, doUnnest);
			if (doUnnest) {
				n += n2;
				valueKeyIndex += n2;
			}
			// console.log({i, n2, n, valueKeyIndex, row})
		}
		flattenArrayAndIndexes(nestedRows, [rowIndex], otherRowIndexes);
	}
	else {
		// Error.stackTraceLimit = Infinity;

		// console.log("A")
		let item, key;
		if (values instanceof Special) {
			// console.log("B: "+rowIndex)
			// console.log(nestedRows[rowIndex])
			const result = values.next(nestedRows, rowIndex);
			// console.log({result})
			key = result[0];
			item = result[1];
			// [key, item] = result;
		}
		else {
			// console.log("C")
			assert(valueKeyIndex < _.size(values), `fewer values (${_.size(values)}) than rows: `+JSON.stringify({name, values}));
			const valueKey = (valueKeys) ? valueKeys[valueKeyIndex] : valueKeyIndex;
			key = (valueKeys) ? valueKey : valueKey + 1;
			item = values[valueKey];
		}

		// console.log("D")
		// console.log({item})
		const rowIndexes2 = [rowIndex];
		if (_.isArray(item)) {
			setColumnValue(row, name, key);
			branchRowByArray(nestedRows, rowIndex, otherRowIndexes, item, randomEngine);
		}
		else if (_.isPlainObject(item)) {
			setColumnValue(row, name, key);
			expandRowsByObject(nestedRows, rowIndexes2, otherRowIndexes, item, randomEngine);
		}
		else {
			setColumnValue(row, name, item);
		}
		// n = rowIndexes2.length
		n = 1;
	}
	if (DEBUG) {
		console.log(` (assignRowByNamedValuesKey): ${JSON.stringify(nestedRows)}`)
	}
	return n;
}

/*
 * // REQUIRED by: expandRowsByNamedValue
 * branchRowsByNamedValue:
 *   size
 *     = (value is array) ? value.length
 *     : (value is object) ? _.size(value)
 *     : 1
 *   row0 = nestedRows[rowIndex];
 *   rows2 = Array(size)
 *   for each rowIndex2 in _.range(size):
 *     rows2[rowIndex] = _.cloneDeep(row0)
 *
 *   expandRowsByNamedValue(rows2, _.range(size), name, value);
 *   nestedRows[rowIndex] = _.flattenDeep(rows2);
 */
function branchRowsByNamedValue(nestedRows, rowIndexes, otherRowIndexes, name, value, randomEngine) {
	if (DEBUG) {
		console.log(`branchRowsByNamedValue: ${name}, ${JSON.stringify(value)}`);
		console.log(` otherRowIndexes: ${JSON.stringify(otherRowIndexes)}`);
		console.log(` rowIndexes: ${JSON.stringify(rowIndexes)}\n ${JSON.stringify(nestedRows)}`)
	}
	const isSpecial = (value instanceof Special);
	const size
		= (_.isArray(value)) ? value.length
		: (_.isPlainObject(value)) ? _.size(value)
		: (isSpecial) ? value.valueCount
		: 1;

	//flattenArrayAndIndexes(nestedRows, rowIndexes, otherRowIndexes);

	// Create 'size' copies of each row in rowIndexes.
	const rows2 = Array(size * rowIndexes.length);
	const rowIndexesGroups2 = Array(rowIndexes.length);
	const rowIndexesGroups2Transposed = _.range(size).map(i => Array(rowIndexes.length));
	for (let j = 0; j < rowIndexes.length; j++) {
		const rowIndex = rowIndexes[j];
		rowIndexesGroups2[j] = Array(size);
		for (let i = 0; i < size; i++) {
			const k = j * size + i;
			rowIndexesGroups2[j][i] = k;
			rows2[k] = _.cloneDeep(nestedRows[rowIndex]);
			rowIndexesGroups2Transposed[i][j] = k;
		}
	}
	// console.log({rows2, rowIndexesGroups2, rowIndexesGroups2Transposed});

	if (_.isArray(value) && _.every(value, x => _.isPlainObject(x))) {
		// Assign indexes
		const valueIndexes = _.range(1, value.length + 1);
		assignRowsByNamedValue(rows2, rowIndexesGroups2, rowIndexesGroups2Transposed, name, valueIndexes, randomEngine, false);
		// Assign objects
		const otherRowIndexes3 = rowIndexesGroups2Transposed.concat(rowIndexesGroups2);
		for (let i = 0; i < size; i++) {
			const rowIndexes3 = _.clone(rowIndexesGroups2Transposed[i]);
			expandRowsByObject(rows2, rowIndexes3, otherRowIndexes3, value[i], randomEngine);
		}
	}
	else if (_.isPlainObject(value)) {
		// Assign indexes
		const valueIndexes = _.keys(value);
		assignRowsByNamedValue(rows2, rowIndexesGroups2, rowIndexesGroups2Transposed, name, valueIndexes, randomEngine, false);
		// Assign objects
		const otherRowIndexes3 = rowIndexesGroups2Transposed.concat(rowIndexesGroups2);
		for (let i = 0; i < size; i++) {
			const key = valueIndexes[i];
			const rowIndexes3 = _.clone(rowIndexesGroups2Transposed[i]);
			expandRowsByObject(rows2, rowIndexes3, otherRowIndexes3, value[key], randomEngine);
		}
	}
	else {
		// Assign to those copies
		assignRowsByNamedValue(rows2, rowIndexesGroups2, [], name, value, randomEngine, false);
	}
	// console.log({rows2, rowIndexesGroups2, rowIndexesGroups2Transposed});
	flattenArrayAndIndexes(rows2, [], rowIndexesGroups2);
	// console.log({rows2, rowIndexesGroups2});

	// console.log({nestedRows})

	// Transpose back into nestedRows
	for (let j = 0; j < rowIndexes.length; j++) {
		const rowIndexes3 = rowIndexesGroups2[j];
		const rows3 = rowIndexes3.map(i => rows2[i]);
		const rowIndex = rowIndexes[j];
		// console.log({rows3, rowIndex})
		nestedRows[rowIndex] = rows3;
	}
	// console.log({nestedRows})

	// console.log({loc: "B", otherRowIndexes})
	// console.log(JSON.stringify(nestedRows))
	flattenArrayAndIndexes(nestedRows, rowIndexes, otherRowIndexes);
	// console.log({loc: "C", otherRowIndexes})
}

function branchRowByArray(nestedRows, rowIndex, otherRowIndexes, values, randomEngine) {
	if (DEBUG) {
		console.log(`branchRowByArray: ${JSON.stringify(values)}`);
		console.log(` otherRowIndexes: ${JSON.stringify(otherRowIndexes)}`);
		console.log(` rowIndex: ${rowIndex}\n ${JSON.stringify(nestedRows)}`)
		assertNoDuplicates(otherRowIndexes);
	}
	const size = values.length;
	// Make replicates of row
	const row0 = nestedRows[rowIndex];
	const rows2 = Array(size);
	for (let rowIndex2 = 0; rowIndex2 < size; rowIndex2++) {
		const value = values[rowIndex2];
		rows2[rowIndex2] = _.cloneDeep(row0);
		expandRowsByObject(rows2, [rowIndex2], [], values[rowIndex2], randomEngine);
	}

	nestedRows[rowIndex] = _.flattenDeep(rows2);
	flattenArrayAndIndexes(nestedRows, [rowIndex], otherRowIndexes);
	if (DEBUG) {
		console.log(` (branchRowByArray): ${JSON.stringify(nestedRows)}`)
	}
}

// Set the given value, but only if the name doesn't start with a period
function setColumnValue(row, name, value) {
	if (DEBUG) { console.log(`setColumnValue: ${name}, ${JSON.stringify(value)}`); console.log("row: "+JSON.stringify(row)); }
	if (name.length >= 1 && name[0] != ".") {
		// Recurse into sub-rows
		if (_.isArray(row)) {
			// console.log("isArray")
			for (let i = 0; i < row.length; i++) {
				setColumnValue(row[i], name, value);
			}
		}
		// Set the value in the row
		else {
			row[name] = value;
			// console.log(`row[name] = ${row[name]}`)
		}
	}
}

class Special {
	constructor({action, draw, reuse, randomEngine}, next, initGroup) {
		this.action = action;
		this.draw = draw;
		this.reuse = reuse;
		this.randomEngine = randomEngine;
		this.next = (next || this.defaultNext);
		this.initGroup = (initGroup || this.defaultInitGroup);
		this.reset = this.defaultReset;
	}

	defaultInitGroup(nestedRows, rowIndexes) {
		this.nextIndex = 0;
		this.valueCount = _.size(this.action.values);
		// Initialize this.indexes
		switch (this.draw) {
			case "direct":
				this.indexes = _.range(this.valueCount);
				break;
			case "shuffle":
				if (this.action.shuffleOnce !== true || !this.indexes) {
					this.indexes = Random.sample(this.randomEngine, _.range(this.valueCount), this.valueCount);
				} else {
					// FIXME: if this.valueCount is now larger than this.indexes.length, then generate more indexes
				}
				break;
		}
	}

	defaultNext() {
		// if (DEBUG) { console.log("defaultNext: "+)}
		// console.log({this});
		if (this.nextIndex >= this.valueCount) {
			// console.log(`next: this.nextIndex >= this.valueCount, ${this.nextIndex} >= ${this.valueCount}`)
			switch (this.reuse) {
				case "repeat":
					this.nextIndex = 0;
					break;
				case "reverse":
					this.indexes = _.reverse(this.indexes);
					this.nextIndex = 0;
					break;
				case "reshuffle":
					this.indexes = Random.sample(this.randomEngine, _.range(this.valueCount), this.valueCount);
					this.nextIndex = 0;
					// console.log("shuffled indexes: "+this.indexes)
					break;
				default:
					assert(false, "not enough values supplied to fill the rows: "+JSON.stringify(this.action));
			}
			// console.log("this.nextIndex = "+this.nextIndex)
		}

		let index, key;
		switch (this.draw) {
			case "direct":
				index = this.indexes[this.nextIndex];
				key = index + 1;
				break;
			case "shuffle":
				index = this.indexes[this.nextIndex];
				key = index + 1;
				break;
			case "sample":
				index = Random.integer(0, this.valueCount - 1)(this.randomEngine);
				key = index + 1;
				break;
			default:
				assert(false, "unknown 'draw' value: "+JSON.stringify(this.draw)+" in "+JSON.stringify(this.action));
		}
		// console.log({index, key})

		const value = this.action.values[index];
		if (this.draw !== "sample") {
			this.nextIndex++;
		}

		return [key, value];
	}

	defaultReset() {
		if (this.nextIndex) {
			this.nextIndex = 0;
		}
	}
}

/*
function countRows(nestedRows, rowIndexes) {
	let sum = 0;
	for (let i = 0; i < rowIndexes.length; i++) {
		const rowIndex = rowIndexes[i];
		const row = nestedRows[rowIndex];
		if (_.isPlainObject(row)) {
			sum++;
		}
		else {
			sum += countRows(row, _.range(row.length));
		}
	}
	return sum;
}*/

/**
 * If an action handler return 'undefined', it means that the handler took care of the action already.
 * @type {Object}
 */
const actionHandlers = {
	"allocatePlates": (rows, rowIndexes, otherRowIndexes, name, action, randomEngine) => {
		const action2 = _.cloneDeep(action);
		return assign(rows, rowIndexes, otherRowIndexes, name, action2, randomEngine, undefined, assign_allocatePlates_initGroup);
	},
	"allocateWells": (_rows, rowIndexes, otherRowIndexes, name, action, randomEngine) => {
		const rows = action.rows;
		const cols = action.columns;
		const rowJump = action.rowJump || 0; // whether to jump over rows (and then return to the skipped ones), and by how much
		assert(_.isNumber(rows) && rows > 0, "missing required positive number `rows`");
		assert(_.isNumber(cols) && cols > 0, "missing required positive number `columns`");
		assert(_.isNumber(rowJump) && rowJump >= 0, "`rowJump`, if specified, must be a number >= 0");
		const from0 = action.from || 1;
		let iFrom;
		if (_.isInteger(from0)) {
			iFrom = from0 - 1;
		}
		else {
			const [rowFrom, colFrom] = wellsParser.locationTextToRowCol(from0);
			iFrom = (colFrom - 1) * rows + (rowFrom - 1);
			// console.log({from0, rowFrom, colFrom, rows, iFrom})
		}

		let values;
		if (action.wells) {
			values = wellsParser.parse(action.wells, {}, {rows, columns: cols});
			// TODO: handle `from` for both cases of well name or for integer
		}
		else {
			const byColumns = _.get(action, "byColumns", true);
			values = _.range(iFrom, rows * cols).map(i => {
				const [row0, col] = (byColumns) ? [i % rows, Math.floor(i / rows)] : [Math.floor(i / cols), i % cols];
				// Calculate row when jumping
				const row1 = row0 * (rowJump + 1);
				const rowLayer = Math.floor(row1 / rows);
				const row = (row1 + rowLayer) % rows;
				const s = locationRowColToText(row + 1, col + 1);
				// console.log({row, col, s});
				return s;
			});
		}

		// console.log({values})
		const action2 = _.cloneDeep(action);
		action2.values = values;
		// console.log({values})
		return assign(_rows, rowIndexes, otherRowIndexes, name, action2, randomEngine);
	},
	"assign": assign,
	"case": (rows, rowIndexes, otherRowIndexes, name, action, randomEngine) => {
		if (DEBUG) {
			console.log(`=case: ${name}=${JSON.stringify(action)}`);
			console.log(` otherRowIndexes: ${JSON.stringify(otherRowIndexes)}`);
			console.log(` rowIndexes: ${JSON.stringify(rowIndexes)}\n ${JSON.stringify(rows)}`)
			assertNoDuplicates(otherRowIndexes);
			assertNoDuplicates(otherRowIndexes.concat([rowIndexes]));
		}
		const cases = _.isArray(action) ? action : action.cases;
		const caseMap = _.isArray(cases)
			? cases.map((v, i) => [i + 1, v])
			: _.toPairs(cases);
		// Group the rows according to the first case they satisfy
		const rowIndexesGroups = caseMap.map(x => []);
		for (let j = 0; j < rowIndexes.length; j++) {
			const rowIndex = rowIndexes[j];
			const row = rows[rowIndex];
			const table1 = [row];
			for (let i = 0; i < caseMap.length; i++) {
				const [caseName, caseSpec] = caseMap[i];
				if (!caseSpec.where || filterOnWhere(table1, caseSpec.where).length === 1) {
					rowIndexesGroups[i].push(rowIndex);
					break;
				}
			}
		}
		if (DEBUG) { console.log({caseMap, rowIndexesGroups}); }

		const otherRowIndexes2 = otherRowIndexes.concat([rowIndexes]).concat(rowIndexesGroups);
		for (let i = 0; i < caseMap.length; i++) {
			const [caseName, caseSpec] = caseMap[i];
			const rowIndexes2 = _.clone(rowIndexesGroups[i]);
			expandRowsByNamedValue(rows, rowIndexes2, otherRowIndexes2, name, caseName, randomEngine)
			expandRowsByObject(rows, rowIndexes2, otherRowIndexes2, caseSpec.design, randomEngine);
		}
	},
	"calculate": (rows, rowIndexes, otherRowIndexes, name, action, randomEngine) => {
		const expr = _.isString(action) ? action : action.expression;
		const action2 = _.isString(action) ? {} : action;
		return assign(rows, rowIndexes, otherRowIndexes, name, {}, randomEngine, assign_calculate_next(expr, action2));
	},
	// TODO: consider renaming to `calculateWellColumn`
	"calculateColumn": (rows, rowIndexes, otherRowIndexes, name, action, randomEngine) => {
		return assign(rows, rowIndexes, otherRowIndexes, name, {}, randomEngine, assign_calculateColumn_next(action, {}));
	},
	"calculateRow": (rows, rowIndexes, otherRowIndexes, name, action, randomEngine) => {
		return assign(rows, rowIndexes, otherRowIndexes, name, {}, randomEngine, assign_calculateRow_next(action, {}));
	},
	"calculateWell": (rows, rowIndexes, otherRowIndexes, name, action, randomEngine) => {
		return assign(rows, rowIndexes, otherRowIndexes, name, {}, randomEngine, assign_calculateWell_next(action, {}));
	},
	"concat": (rows, rowIndexes, otherRowIndexes, name, action, randomEngine) => {
		if (DEBUG) {
			console.log(`=concat: ${name}=${JSON.stringify(action)}`);
			console.log(` otherRowIndexes: ${JSON.stringify(otherRowIndexes)}`);
			console.log(` rowIndexes: ${JSON.stringify(rowIndexes)}\n ${JSON.stringify(rows)}`)
			assertNoDuplicates(otherRowIndexes);
			assertNoDuplicates(otherRowIndexes.concat([rowIndexes]));
		}

		// find groups (either from `groupBy` or just use everything in rowIndexes)
		const groupBy = _.isArray(action.groupBy)
			? action.groupBy
			: _.isEmpty(action.groupBy)
				? [] : [action.groupBy];
		const rowIndexesGroups = (!_.isEmpty(groupBy))
			? query_groupBy(rows, rowIndexes, groupBy)
			: [_.clone(rowIndexes)];

		// for each group, create a temporary row with the group variables and then expand the conditions on that row
		for (let i = 0; i < rowIndexesGroups.length; i++) {
			// create a temporary row with the group variables
			const rowIndexesGroup = rowIndexesGroups[i];
			const row0 = (_.isEmpty(groupBy)) ? {} : _.pick(rows[rowIndexesGroup[0]], groupBy);
			const rows2 = [row0];
			const rowIndexes2 = [0];
			// console.log({groupBy, rowIndexesGroup, row0})
			// expand the conditions on that row
			expandRowsByObject(rows2, rowIndexes2, [], action.design, randomEngine);
			// Add the rows back to the original table
			rows.push(...rows2);
			rowIndexes.push(..._.range(rowIndexes.length, rowIndexes.length + rows2.length));
		}
		return undefined;
	},
	"range": (rows, rowIndexes, otherRowIndexes, name, action, randomEngine) => {
		const action2 = _.cloneDeep(_.isNull(action) ? {} : action);
		_.defaults(action2, {from: 1, step: 1});
		return assign(rows, rowIndexes, otherRowIndexes, name, action2, randomEngine, undefined, assign_range_initGroup);
	},
	"rotateColumn": (rows, rowIndexes, otherRowIndexes, name, action, randomEngine) => {
		const action2 = _.isString(action) ? ({column: action, n: 1}) : _.cloneDeep(action);
		return assign(rows, rowIndexes, otherRowIndexes, name, action2, randomEngine, undefined, assign_rotateColumn_initGroup);
	},
	// "sample": {
	//
	// }
}

function assign(rows, rowIndexes, otherRowIndexes, name, action, randomEngine, next, initGroup) {
	assert(_.isPlainObject(action), "expect an object for assignment")
	// Handle order in which to assign values
	let draw = "direct";
	let reuse = "none";
	if (!_.isEmpty(action.order)) {
		switch (action.order) {
			case "direct": case "direct/none": break;
			case "direct/repeat": case "repeat": draw = "direct"; reuse = "repeat"; break;
			case "direct/reverse": case "reverse": draw = "direct"; reuse = "reverse"; break;
			case "shuffle": draw = "shuffle"; reuse = "none"; break;
			case "reshuffle": draw = "shuffle"; reuse = "reshuffle"; break;
			case "shuffle/reshuffle": draw = "shuffle"; reuse = "reshuffle"; break;
			case "shuffle/repeat": draw = "shuffle"; reuse = "repeat"; break;
			case "shuffle/reverse": draw = "shuffle"; reuse = "reverse"; break;
			case "sample": draw = "sample"; break;
			default: assert(false, "unrecognized 'order' value: "+action.order);
		}
	}

	if (_.isString(action.calculate)) {
		next = assign_calculate_next(action.calculate, action)
	}

	const randomEngine2 = (_.isNumber(action.randomSeed))
		? Random.engines.mt19937().seed(action.randomSeed)
		: randomEngine;
	const value2 = ((_.isArray(action.values)) && draw === "direct" && reuse === "none" && !initGroup)
		? action.values
		: new Special({action, draw, reuse, randomEngine: randomEngine2}, next, initGroup);

	return handleAssignmentWithQueries(rows, rowIndexes, otherRowIndexes, name, action, randomEngine2, value2);
}

function handleAssignmentWithQueries(rows, rowIndexes0, otherRowIndexes, name, action, randomEngine, value0) {
	if (DEBUG) {
		console.log(`handleAssignmentWithQueries: ${JSON.stringify({name, action, value0})}`);
		console.log(` otherRowIndexes: ${JSON.stringify(otherRowIndexes)}`)
		console.log(` rowIndexes: ${JSON.stringify(rowIndexes0)}\n ${JSON.stringify(rows)}`)
		assertNoDuplicates(otherRowIndexes);
		assertNoDuplicates(otherRowIndexes.concat([rowIndexes0]));
	}
	const isSpecial = value0 instanceof Special;
	const hasGroupOrSame = action.groupBy || action.sameBy;

	if (!action.groupBy && !action.sameBy && !action.orderBy) {
		if (isSpecial) {
			// console.log({name})
			value0.initGroup(rows, rowIndexes0);
		}
		return value0;
	}

	const rowIndexesGroups = (action.groupBy)
		? query_groupBy(rows, rowIndexes0, action.groupBy)
		: [_.clone(rowIndexes0)];

	// console.log({rowIndexesGroups})
	for (let i = 0; i < rowIndexesGroups.length; i++) {
		let rowIndexes = _.clone(rowIndexesGroups[i]);

		let value = value0;
		// If 'orderBy' is set, we should re-order the values (if just returning 'value') or rowIndexes (otherwise)
		if (action.orderBy) {
			// console.log("A")
			if (_.isArray(value)) {
				// This is a copy of 'makeComparer', but with more indirection in assignment of row1 and row2
				const propertyNames = action.orderBy;
				function comparer(i1, i2) {
					const row1 = rows[rowIndexes[i1]];
					const row2 = rows[rowIndexes[i2]];
					const l = (_.isArray(propertyNames)) ? propertyNames : [propertyNames];
					for (let j = 0; j < l.length; j++) {
						const propertyName = l[j];
						const value1 = row1[propertyName];
						const value2 = row2[propertyName];
						const cmp = naturalSort(value1, value2);
						if (cmp !== 0)
							return cmp;
					}
					return 0;
				};
				const is1 = stableSort(_.range(rowIndexes.length), comparer);
				// console.log({is1});

				// Allocate a new value array
				const value1 = new Array(rowIndexes.length);
				// Insert values into the new array according to the new desired order
				for (let i = 0; i < rowIndexes.length; i++) {
					const j = is1[i];
					value1[j] = value[i];
				}
				// console.log({is1, rowIndexes, rows, value, value1});
				value = value1;
			}
			//if (hasGroupOrSame)
			else {
				const rowIndexes2 = query_orderBy(rows, rowIndexes, action.orderBy);
				// console.log({orderBy: action.orderBy, rowIndexes, rowIndexes2})
				// console.log({rowIndexes})
				rowIndexes = rowIndexes2;
			}
		}

		// const rowIndexes2 = _.clone(rowIndexes);

		// console.log({rowIndexes, rowIndexesGroups})
		const otherRowIndexes2 = otherRowIndexes.concat([rowIndexes0]).concat(rowIndexesGroups);
		if (DEBUG) {
			assertNoDuplicates(otherRowIndexes2);
		}
		// console.log({otherRowIndexes, rowIndexes, rowIndexesGroups, otherRowIndexes2})
		if (action.sameBy) {
			assignSameBy(rows, rowIndexes, otherRowIndexes2, name, action, randomEngine, value);
		}
		else {
			if (isSpecial) {
				// console.log({rows, rowIndexes2})
				value.initGroup(rows, rowIndexes);
			}
			// console.log({rows, rowIndexes2, otherRowIndexes2, name, value})
			expandRowsByNamedValue(rows, rowIndexes, otherRowIndexes2, name, value, randomEngine);
		}
	}

	return undefined;
}

function assignSameBy(rows, rowIndexes, otherRowIndexes, name, action, randomEngine, value) {
	if (DEBUG) {
		console.log(`assignSameBy: ${JSON.stringify({name, action, value})}`);
		console.log(` rowIndexes: ${JSON.stringify(rowIndexes)}\n ${JSON.stringify(rows)}`)
		// assertNoDuplicates(otherRowIndexes);
		assertNoDuplicates(otherRowIndexes.concat([rowIndexes]));
	}
	const isArray = _.isArray(value);
	const isObject = _.isPlainObject(value);
	const isSpecial = value instanceof Special;
	const rowIndexesSame = query_groupBy(rows, rowIndexes, action.sameBy);
	// console.log({rowIndexesSame})

	/*
	for (let i = 0; i < rowIndexesSame.length; i++) {
		const rowIndexes2 = rowIndexesSame[i];
		const rowIndex = rowIndexes2[0];
		const rows2 = rowIndexes2.map(i => rows[i]);
		rows.splice(rowIndex, 1, ..rows2);
		for (let j = 0; j < rowIndexesSame.length;)
	}
	*/
	if (isSpecial) {
		const rowIndexesFirst = rowIndexesSame.map(l => l[0]);
		value.initGroup(rows, rowIndexesFirst);
	}

	const keys = (isObject) ? _.keys(value) : 0;
	const table2 = _.zip.apply(_, table2);
	for (let i = 0; i < rowIndexesSame.length; i++) {
		const rowIndexes3 = rowIndexesSame[i];
		// if (isSpecial) {
		// 	value.nextIndex = i;
		// }
		const value2
			= (isArray) ? value[i]
			: (isObject) ? value[keys[i]]
			: (isSpecial) ? value.next(rows, [rowIndexes3[0]])[1]
			: value;
		// console.log({i, rowIndexes3, value2, value})
		for (let i = 0; i < rowIndexes3.length; i++) {
			const rowIndex = rowIndexes3[i];
			expandRowsByNamedValue(rows, [rowIndex], otherRowIndexes, name, value2, randomEngine);
		}
	}
}

function assign_allocatePlates_initGroup(rows, rowIndexes) {
	if (DEBUG) {
		console.log(`assign_allocatePlates_initGroup: ${rows}, ${rowIndexes}`)
	}
	const action = this.action;
	if (_.isUndefined(this.plateIndex))
		this.plateIndex = 0;
	if (_.isUndefined(this.wellsUsed))
		this.wellsUsed = 0;

	// If the wells on the plate should be segmented by some grouping
	if (action.groupBy) {
		assert(rowIndexes.length <= action.wellsPerPlate, `too many positions in group for plate to accomodate: ${rowIndexes.length} rows, ${action.wellsPerPlate} wells per plate`);

		// If they fit on the current plate:
		if (this.wellsUsed + rowIndexes.length <= action.wellsPerPlate) {
			this.wellsUsed += rowIndexes.length;
		}
		// Otherwise, skip to next plate
		else {
			this.plateIndex++;
			assert(this.plateIndex < action.plates.length, `require more plates than the ${action.plates.length} supplied: ${action.plates.join(", ")}`);
			this.wellsUsed = rowIndexes.length;
		}

		// TODO: allow for rotating plates for each group rather than assigning each plate until its full
		// console.log()
		// console.log({this})

		this.action.values = _.fill(Array(rowIndexes.length), action.plates[this.plateIndex]);
		// console.log({this_action_values: this.action.values});
	}
	else {
		// assert(rowIndexes.length <= action.plates.length * action.wellsPerPlate, `too many row for plates to accomodate: ${rowIndexes.length} rows, ${action.wellsPerPlate} wells per plate, ${action.plates.length} plates`);

		// If all the rows can be assigned to the current plate:
		if (this.wellsUsed + rowIndexes.length <= action.wellsPerPlate) {
			this.wellsUsed += rowIndexes.length;
			this.action.values = _.fill(Array(rowIndexes.length), action.plates[this.plateIndex]);
		}
		// Otherwise, just allocate each plate until its full
		else {
			this.action.values = Array(rowIndexes.length);

			let i = 0;
			while (i < rowIndexes.length) {
				const n = Math.min(rowIndexes.length - i, action.wellsPerPlate - this.wellsUsed);
				if (n == 0) {
					this.plateIndex++;
					assert(this.plateIndex < action.plates.length, `require more plates than the ${action.plates.length} supplied: ${action.plates.join(", ")}`);
					this.wellsUsed = 0;
				}
				else {
					for (let j = 0; j < n; j++) {
						this.action.values[i + j] = action.plates[this.plateIndex];
					}
					this.wellsUsed += n;
				}
				i += n;
			}
		}

		// TODO: allow for rotating plates for each group rather than assigning each plate until its full
		// console.log()
		// console.log({this})

		// console.log({this_action_values: this.action.values});
	}

	this.defaultInitGroup(rows, rowIndexes);
}

/**
 * Calculate `expr` using variables in `row`, with optional `action` object specifying `units` and/or `decimals`
 */
export function calculate(expr, row, action = {}) {
	const scope = _.mapValues(row, x => {
		// console.log({x})
		try {
			const result = math.eval(x);
			// If evaluation succeeds, but it was just a unit name, then set value as string instead
			if (result.type === "Unit" && result.value === null)
				return x;
			else {
				return result;
			}
		}
		catch (e) {}
		return x;
	});

	assert(!_.isUndefined(expr), "`expression` property must be specified");
	// console.log({expr, scope})
	// console.log("scope:"+JSON.stringify(scope, null, '\t'))
	let value = math.eval(expr, scope);
	// console.log({type: value.type, 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: action.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({action})
				// 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(action.decimals))
		? unitless.toFixed(action.decimals)
		: _.isNumber(unitless) ? unitless : unitless.toNumber();

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

	return valueText;
}

function assign_calculate_next(expr, action) {
	return function(nestedRows, rowIndex) {
		const row0 = nestedRows[rowIndex];
		const row = (_.isArray(row0)) ? _.head(_.flattenDeep(row0)) : row0;
		// Build the scope for evaluating the math expression from the current data row

		const valueText = calculate(expr, row, action);

		this.nextIndex++;
		return [this.nextIndex, valueText];
	}
}

function assign_calculateColumn_next(expr, action) {
	return function(nestedRows, rowIndex) {
		const row0 = nestedRows[rowIndex];
		const row = (_.isArray(row0)) ? _.head(_.flattenDeep(row0)) : row0;
		// Build the scope for evaluating the math expression from the current data row

		const valueText = _.get(row, expr, expr);
		const [r, c] = wellsParser.locationTextToRowCol(valueText);

		this.nextIndex++;
		return [this.nextIndex, c];
	}
}

function assign_calculateRow_next(expr, action) {
	return function(nestedRows, rowIndex) {
		const row0 = nestedRows[rowIndex];
		const row = (_.isArray(row0)) ? _.head(_.flattenDeep(row0)) : row0;
		// Build the scope for evaluating the math expression from the current data row

		const valueText = _.get(row, expr, expr);
		const [r, c] = wellsParser.locationTextToRowCol(valueText);

		this.nextIndex++;
		return [this.nextIndex, r];
	}
}

function assign_calculateWell_next(action) {
	return function(nestedRows, rowIndex) {
		const row0 = nestedRows[rowIndex];
		const row = (_.isArray(row0)) ? _.head(_.flattenDeep(row0)) : row0;
		// Build the scope for evaluating the math expression from the current data row
		const scope = _.mapValues(row, x => {
			// console.log({x})
			try {
				const result = math.eval(x);
				// If evaluation succeeds, but it was just a unit name, then set value as string instead
				if (result.type === "Unit" && result.value === null)
					return x;
				else {
					return result;
				}
			}
			catch (e) {}
			return x;
		});

		// console.log("scope:"+JSON.stringify(scope, null, '\t'))
		let wellRow = math.eval(action.row, scope).toNumber();
		let wellCol = math.eval(action.column, scope).toNumber();
		const wellName = locationRowColToText(wellRow, wellCol);
		// console.log({wellRow, wellCol, wellName})

		this.nextIndex++;
		return [this.nextIndex, wellName];
	}
}

function assign_range_initGroup(rows, rowIndexes) {
	// console.log(`assign_range_initGroup:`, {rows, rowIndexes})
	const commonHolder = []; // cache for common values, if needed
	const from = getOrCalculateNumber(this.action, "from", rowIndexes.length, rows, rowIndexes, commonHolder);
	const till = getOrCalculateNumber(this.action, "till", rowIndexes.length, rows, rowIndexes, commonHolder);
	const end = till + 1;

	let values;
	if (_.isNumber(this.action.count)) {
		const diff = till - from;
		values = _.range(this.action.count).map(i => {
			const d = diff * i / (this.action.count - 1);
			return from + d;
		});
	}
	else {
		values = _.range(from, end, this.action.step);
	}
	if (values) {
		if (_.isNumber(this.action.decimals)) {
			values = values.map(n => Number(n.toFixed(this.action.decimals)));
		}
		if (_.isString(this.action.units)) {
			values = values.map(n => `${n} ${this.action.units}`);
		}
	}
	this.action.values = values;
	// console.log({this_action_values: this.action.values});

	this.defaultInitGroup(rows, rowIndexes);
}

function assign_rotateColumn_initGroup(rows, rowIndexes) {
	const l = rowIndexes.map(i => rows[i][this.action.column]);
	if (this.action.n > 0) {
		for (let i = 0; i < this.action.n; i++) {
			const x = l.pop();
			l.unshift(x);
		}
	}
	else {
		for (let i = 0; i < -this.action.n; i++) {
			const x = l.shift();
			l.push(x);
		}
	}
	this.action.values = l;

	this.defaultInitGroup(rows, rowIndexes);
}

/*
function assign_range_next(nestedRows, rowIndex) {
	const commonHolder = []; // cache for common values, if needed
	const from = getOrCalculateNumber(this.action, "from", rowIndexes.length, nestedRows, [rowIndex], commonHolder);
	const till = getOrCalculateNumber(this.action, "till", rowIndexes.length, nestedRows, [rowIndex], commonHolder);
	const end = till + 1;

	let values;
	if (_.isNumber(this.action.count)) {
		const diff = till - from;
		values = _.range(this.action.count).map(i => {
			const d = diff * i / (this.action.count - 1);
			return from + d;
		});
	}
	else {
		values = _.range(from, end, this.action.step);
	}
	if (values) {
		if (_.isNumber(this.action.decimals)) {
			values = values.map(n => Number(n.toFixed(this.action.decimals)));
		}
		if (_.isString(this.action.units)) {
			values = values.map(n => `${n} ${this.action.units}`);
		}
	}
	console.log({values});

	this.nextIndex++;
	return [this.nextIndex, values];
}
*/
function getOrCalculateNumber(action, propertyName, dflt, nestedRows, rowIndexes, commonHolder) {
	const value = _.get(action, propertyName);
	if (_.isUndefined(value)) {
		return dflt;
	}
	else if (_.isNumber(value)) {
		return value;
	}
	else if (_.isString(value)) {
		const options = {};
		const next = assign_calculate_next(value, options);
		const fakethis = {nextIndex: 0};
		if (commonHolder.length === 0) {
			// console.log({nestedRows, rowIndexes})
			commonHolder.push(commonHolder.push(getCommonValuesNested(nestedRows, rowIndexes)));
		}
		const common = commonHolder[0];
		// console.log({common})
		const [dummyIndex, result] = next.bind(fakethis)([common], [0]);
		return result;
	}
}

/*
const assign_range_next = (expr, action) => function(nestedRows, rowIndex) {
	console.log("assign_range_next: "+JSON.stringify(this));
	const n = this.action.from + this.nextIndex * this.action.step;
	assert(!_.isNumber(this.action.till) || n < this.action.till, "range could not fill rows");
	this.nextIndex++;
	return [this.nextIndex, n];
}*/

export function query_groupBy(rows, rowIndexes, groupBy) {
	const groupKeys = (_.isArray(groupBy)) ? groupBy : [groupBy];
	// console.log({groupBy, groupKeys, rowIndexes, rows});
	return _.values(_.groupBy(rowIndexes, rowIndex => _.map(groupKeys, key => rows[rowIndex][key])));
}

/**
 * Return an array of rowIndexes which are ordered by the `orderBy` criteria.
 * @param  {array} rows - a flat array of row objects
 * @param  {array} rowIndexes - array of row indexes to consider
 * @param  {string|array} orderBy - the column(s) to order by
 * @return {array} a sorted ordering of rowIndexes
 */
export function query_orderBy(rows, rowIndexes, orderBy) {
	// console.log({rows, rowLen: rows.length, rowIndexes, orderBy})
	// console.log(rowIndexes.map(i => _.values(_.pick(rows[i], orderBy))))
	return stableSort(rowIndexes, makeComparer(rows, orderBy));
}

function makeComparer(rows, propertyNames) {
	return function(i1, i2) {
		const row1 = rows[i1];
		const row2 = rows[i2];
		if (!row1 || !row2) {
			console.log({i1, i2, row1, row2})
		}
		const l = (_.isArray(propertyNames)) ? propertyNames : [propertyNames];
		for (let j = 0; j < l.length; j++) {
			const propertyName = l[j];
			const value1 = row1[propertyName];
			const value2 = row2[propertyName];
			const cmp = naturalSort(value1, value2);
			if (cmp !== 0)
				return cmp;
		}
		return 0;
	};
}

export function query(table, q, SCOPE = undefined) {
	let table2 = _.clone(table);

	if (q.where) {
		// console.log({where: q.where})
		table2 = filterOnWhere(table2, q.where, SCOPE);
	}

	if (q.shuffle) {
		table2 = _.shuffle(table);
	}

	if (q.orderBy) {
		table2 = _.orderBy(table2, q.orderBy);
	}

	if (q.distinctBy) {
		const groupKeys = (_.isArray(q.distinctBy)) ? q.distinctBy : [q.distinctBy];
		const groups = _.map(_.groupBy(table2, row => _.map(groupKeys, key => row[key])), _.identity);
		//console.log({groupsLength: groups.length})
		table2 = _.flatMap(groups, group => {
			const first = group[0];
			// Find the properties that are the same for all items in the group
			const uniqueKeys = [];
			_.forEach(first, (value, key) => {
				const isUnique = _.every(group, row => _.isEqual(row[key], value));
				if (isUnique) {
					uniqueKeys.push(key);
				}
			});
			return _.pick(first, uniqueKeys);
		});
	}
	else if (q.unique) {
		table2 = _.uniqWith(table2, _.isEqual);
	}

	if (q.groupBy) {
		const groupKeys = (_.isArray(q.groupBy)) ? q.groupBy : [q.groupBy];
		table2 = _.map(_.groupBy(table2, row => _.map(groupKeys, key => row[key])), _.identity);
	}
	else {
		table2 = [table2];
	}

	if (q.select) {
		table2 = table2.map(rows => rows.map(row => _.pick(row, q.select)));
	}

	if (q.transpose) {
		table2 = _.zip.apply(_, table2);
	}

	return table2;
}

const compareFunctions = {
	"eq": _.isEqual,
	"gt": _.gt,
	"gte": _.gte,
	"lt": _.lt,
	"lte": _.lte,
	"ne": (a, b) => !_.isEqual(a, b),
	"in": _.includes
};
function filterOnWhere(table, where, SCOPE = undefined) {
	let table2 = table;
	if (_.isPlainObject(where)) {
		_.forEach(where, (value, key) => {
			if (_.isPlainObject(value)) {
				_.forEach(value, (value2, op) => {
					// Get compare function
					assert(compareFunctions.hasOwnProperty(op), `unrecognized operator: ${op} in ${JSON.stringify(value)}`);
					const fn = compareFunctions[op];
					// console.log({op, fn})
					table2 = filterOnWhereOnce(table2, key, value2, fn);
				});
			}
			else {
				table2 = filterOnWhereOnce(table2, key, value, _.isEqual);
			}
		});
	}
	else if (_.isString(where)) {
		// console.log({where})
		table2 = _.filter(table, row => {
			const scope1 = _.mapValues(row, x => {
				// console.log({x})
				try {
					const result = math.eval(x);
					// If evaluation succeeds, but it was just a unit name, then set value as string instead
					if (result.type === "Unit" && result.value === null)
						return x;
					else {
						return result;
					}
				}
				catch (e) {}
				return x;
			});
			const scope = _.defaults({}, scope1, SCOPE);
			// console.log({where, row, scope})
			try {
				const result = math.eval(where, scope);
				// console.log({result});
				return result;
			} catch (e) {
				console.log("WARNING: "+e);
				console.log("where: "+JSON.stringify(where));
				// console.log({scope})
				process.exit()
			}
			return false;
		});
	}
	return table2;
}

/**
 * Sub-function that filters table on a single criterion.
 * @param  {array}   table - table to filter
 * @param  {string}   key - key of column in table
 * @param  {any}   x - value to compare to
 * @param  {Function} fn - comparison function
 * @return {array} filtered table
 */
function filterOnWhereOnce(table, key, x, fn) {
	// console.log("filterOnWhereOnce: "); console.log({key, x, fn})
	// If x is an array, do an array comparison
	if (_.isArray(x)) {
		return _.filter(table, (row, i) => fn(row[key], x[i]));
	}
	// If we need to
	else if (_.isString(x)) {
		if (_.startsWith(x, "\"")) {
			const text = x.substr(1, x.length - 2);
			return table.filter(row => fn(row[key], text));
		}
		else {
			const key2 = x.substr(1);
			return table.filter(row => fn(row[key], row[key2]));
		}
	}
	else {
		return table.filter(row => fn(row[key], x));
	}
}

/**
 * Check whether the same underlying array shows up more than once in otherRowIndexes.
 * This should never be the case, because if we modify one, the "other" will also be modified.
 */
function assertNoDuplicates(otherRowIndexes) {
	for (let i = 0; i < otherRowIndexes.length - 1; i++) {
		for (let j = i + 1; j < otherRowIndexes.length; j++) {
			assert(otherRowIndexes[i] != otherRowIndexes[j], `same underlying array appears in 'otherRowIndexs' at both ${i} and ${j}: ${JSON.stringify(otherRowIndexes)}`)
		}
	}
}