Source: WellContents.js

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

/**
 * A module of functions for querying and manipulating well contents.
 * @module WellContents
 */

import _ from 'lodash';
var assert = require('assert');
var math = require('mathjs');
import expect from './expectCore.js';
var misc = require('./misc.js');
var wellsParser = require('./parsers/wellsParser.js');

export const emptyVolume = math.unit(0, 'ul');
export const unknownVolume = math.eval('Infinity l');

/**
 * Validate well contents.  Throws an error if they aren't valid.
 *
 * @param  {array} contents - The well contents.
 */
export function checkContents(contents) {
	if (_.isUndefined(contents)) {
		// ok
	}
	else if (!_.isArray(contents)) {
		assert(false, "expected well contents to be represented by an array: "+JSON.stringify(contents));
	}
	else if (contents.length == 0) {
		// ok
	}
	else {
		//console.log(contents)
		var volume = math.eval(contents[0]);
		if (contents.length == 1) {
			// FIXME: remove 'false, ' from here!
			assert.equal(volume.toNumber('l'), 0, "when the contents array has only one element, that element must be 0: "+JSON.stringify(contents));
		}
		else if (contents.length == 2) {
			assert(_.isString(contents[1]), "second element of contents should be a string: "+JSON.stringify(contents));
		}
		else {
			for (var i = 1; i < contents.length; i++) {
				//try {
					checkContents(contents[i]);
				//} catch (e) {
				//
				//}
			}
		}
	}
}

/**
 * Tries to find the contents array for the given syringe.
 *
 * @param {string} syringeName name of the syringe
 * @param {object} data the data object passed to command handlers
 * @param {object} effects an optional effects object for effects which have taken place during the command handler and aren't in the data object
 * @return {WellContents} the contents array if found, otherwise null
 *//*
export function getSyringeContents(syringeName, data, effects) {
	//console.log({syringeName})
	const contentsName = `${syringeName}.contents`;
	// Check for well or labware contents in effects object
	if (!_.isEmpty(effects)) {
		if (effects.hasOwnProperty(contentsName))
			return effects[contentsName];
	}

	let contents = misc.findObjectsValue(contentsName, data.objects, effects);
	checkContents(contents);
	return contents;
}*/

/**
 * Tries to find the contents array for the given well.
 *
 * @param {string} wellName name of the well
 * @param {object} data the data object passed to command handlers
 * @param {object} effects an optional effects object for effects which have taken place during the command handler and aren't in the data object
 * @return {WellContents} the contents array if found, otherwise null
 */
export function getWellContents(wellName, data, effects) {
	//console.log({wellName})
	var wellInfo = wellsParser.parseOne(wellName);
	assert(wellInfo.wellId, "missing `wellId`: "+JSON.stringify(wellInfo));
	var labwareContentsName = wellInfo.labware+".contents";
	var wellContentsName = wellInfo.labware+".contents."+wellInfo.wellId;
	// Check for well or labware contents in effects object
	if (!_.isEmpty(effects)) {
		if (effects.hasOwnProperty(wellContentsName))
			return effects[wellContentsName];
		if (effects.hasOwnProperty(labwareContentsName))
			return effects[labwareContentsName];
	}

	var contents = misc.findObjectsValue(wellContentsName, data.objects, effects);
	if (!_.isEmpty(contents)) return contents;

	contents = misc.findObjectsValue(labwareContentsName, data.objects, effects);
	if (_.isArray(contents)) {
		expect.try({objectName: wellName}, () => checkContents(contents));
		return contents;
	}

	return [];
}

/**
 * Get the volume of the contents array.
 * @param {array} contents The well contents array
 * @return {object} the mathjs volume if found, otherwise 0ul
 */
export function getVolume(contents) {
	checkContents(contents);
	if (!_.isEmpty(contents)) {
		const volume = math.eval(contents[0]);
		if (math.unit('l').equalBase(volume)) return volume;
	}
	return emptyVolume;
}

/**
 * Check whether the contents are empty.
 * They are empty if the contents are undefined, an empty array,
 * or the array begins with a number that mathjs considers equal to 0.
 *
 * @param {WellContents} contents
 * @return {Boolean} true if the contents are empty
 */
export function isEmpty(contents) {
	const volume = getVolume(contents);
	return math.equal(volume.toNumber('l'), 0);
}

/**
 * Get the volume of the given well.
 * @param {string} wellName name of the well
 * @param {object} data the data object passed to command handlers
 * @param {object} effects an optional effects object for effects which have taken place during the command handler and aren't in the data object
 * @return {object} the mathjs volume if found, otherwise 0ul
 */
export function getWellVolume(wellName, data, effects) {
	var contents = getWellContents(wellName, data, effects);
	if (!_.isEmpty(contents)) {
		var volume = math.eval(contents[0]);
		if (math.unit('l').equalBase(volume)) return volume;
	}
	return emptyVolume;
}

/**
 * Get an object representing the effects of pipetting.
 * @param {string} wellName fully qualified object name of the well
 * @param {object} data The data object passed to command handlers.
 * @param {object} effects The effects object for effects which have taken place during the command handler and aren't in the data object
* @return {array} [content, contentName], where content will be null if not found
 */
export function getContentsAndName(wellName, data, effects) {
	//console.log("getContentsAndName", wellName)
	if (!effects) effects = {};

	//var i = wellName.indexOf('(');
	//var wellId = if (i >= 0) {}
	var wellInfo = wellsParser.parseOne(wellName);
	var labwareName;
	//console.log("wellInfo", wellInfo);
	if (wellInfo.source) {
		labwareName = wellInfo.source;
	}
	else {
		assert(wellInfo.wellId);
		labwareName = wellInfo.labware;
		// Check for contents of well
		var contentsName = labwareName+".contents."+wellInfo.wellId;
		//console.log("contentsName", contentsName, effects[contentsName], _.get(data.objects, contentsName))
		var contents = effects[contentsName] || misc.findObjectsValue(contentsName, data.objects, effects);
		checkContents(contents);
		if (contents)
			return [contents, contentsName];
	}

	// Check for contents of labware
	//console.log("labwareName", labwareName);
	var contentsName = labwareName+".contents";
	//console.log("contentsName", contentsName)
	var contents = effects[contentsName] || misc.findObjectsValue(contentsName, data.objects, effects);
	// If contents is an array, then we have the correct contents;
	// Otherwise, we have a map of well contents, but no entry for the current well yet
	if (_.isArray(contents)) {
		checkContents(contents);
		return [contents, contentsName];
	}

	return [undefined, wellInfo.labware+".contents."+wellInfo.wellId];
}

/**
 * Convert the contents array encoding to a map of substances to amounts
 * @param  {array} contents The well contents array
 * @return {object} map of substance name to the volume or amount of that substance in the well
 */
export function flattenContents(contents) {
	if (_.isUndefined(contents)) {
		return {};
	}

	checkContents(contents);
	//console.log("flattenContents:", contents);
	assert(_.isArray(contents), "Expected 'contents' to be an array: "+JSON.stringify(contents));
	// The first element always holds the volume in the well.
	// If the array has exactly one element, the volume should be 0l.
	if (contents.length <= 1) {
		return {};
	}
	// If the array has exactly two elements, the second element is the name of the substance.
	else if (contents.length == 2) {
		assert(_.isString(contents[1]), "second element of contents should be a string: "+JSON.stringify(contents));
		var volume = math.eval(contents[0]).format({precision: 14});
		return _.fromPairs([[contents[1], volume]]);
	}
	// If the array has more than two elements, each element after the volume has the same
	// structure as the top array and they represent the mixture originally dispensed in the well.
	else {
		/*const maps = _(contents).tail().map(contents2 => {
			const flattened = flattenContents(contents2);
			//console.log({flattened});
			const x = _.mapValues(flattened, value => math.eval(value))
			//console.log({x});
			return x;
		}).value();*/
		var maps = _.map(_.tail(contents), contents => _.mapValues(flattenContents(contents), value => math.eval(value)));
		//console.log("maps: "+JSON.stringify(maps));
		var merger = function(a, b) { return (_.isUndefined(a)) ? b : math.add(a, b); };
		var mergeArgs = _.flatten([{}, maps, merger]);
		//console.log("mergeArgs: "+mergeArgs);
		var merged = _.mergeWith.apply(_, mergeArgs);
		//console.log("merged: "+JSON.stringify(merged));
		var total = math.eval(contents[0]);
		var subTotal = _.reduce(merged, function(total, n) { return math.add(total, n); }, emptyVolume);
		//console.log("total: "+total);
		//console.log("subTotal: "+subTotal);
		//var factor = math.fraction(total, subTotal);
		try {
			const result = _.mapValues(merged, function(v) {
				//console.log({v, totalNumber: total.toNumber("l")})
				const numerator = math.multiply(v, total.toNumber("l"));
				const result = math.divide(numerator, subTotal.toNumber("l"))
				return result.format({precision: 4});
			});
			return result;
		} catch (e) {
			console.log({total: total.toNumber("l"), subTotal: subTotal.toNumber("l"), factor})
			console.log(JSON.stringify(contents))
			throw e;
		}
	}
}

export function mergeContents(contents) {
	const flat = flattenContents(contents);
	const pairs = _.toPairs(flat);
	if (pairs.length === 0) {
		return [];
	}
	else if (pairs.length === 1) {
		const l = pairs[0];
		return [l[1], l[0]];
	}
	else {
		const volumes1 = _.values(flat);
		const volumes2 = volumes1.map(s => math.eval(s));
		const sum = math.sum(volumes2);
		const contents2 = [sum.format({precision: 14})].concat(pairs.map(l => [l[1], l[0]]));
		return contents2;
	}
}

/**
 * Add source contents to destination contents at the given volume.
 * @param {array} srcContents - current contents of the source well
 * @param {array} dstContents - current contents of the destination well
 * @param {string} volume - a string representing the volume to transfer
 * @return {array} an array whose first element is the new source contents and whose second element is the new destination contents.
 */
export function transferContents(srcContents, dstContents, volume) {
	assert(_.isArray(srcContents));
	checkContents(srcContents);

	if (_.isString(volume))
		volume = math.eval(volume);

	const volumeText = volume.format({precision: 14});

	//console.log({dstContents})
	if (_.isUndefined(dstContents) || _.isEmpty(dstContents) || !_.isArray(dstContents))
		dstContents = [];
	//console.log({dstContents})
	checkContents(dstContents);

	const srcContentsToAppend = [volumeText].concat(_.tail(srcContents));
	let dstContents2;
	// If the destination is empty:
	if (dstContents.length <= 1) {
		dstContents2 = srcContentsToAppend;
	}
	else {
		const dstVolume = math.eval(dstContents[0]);
		const totalVolumeText = math.add(dstVolume, volume).format({precision: 14});
		// If the destination currently only contains one substance:
		if (dstContents.length === 2) {
			dstContents2 = [totalVolumeText, dstContents, srcContentsToAppend];
		}
		// Otherwise add source to destination contents
		else {
			const dstSumOfComponents = math.sum(_.map(_.tail(dstContents), l => math.eval(l[0])));
			if (math.equal(dstVolume, dstSumOfComponents)) {
				dstContents2 = _.flatten([totalVolumeText, _.tail(dstContents), [srcContentsToAppend]]);
			}
			else {
				dstContents2 = _.flatten([totalVolumeText, [dstContents], [srcContentsToAppend]]);
			}
		}
	}
	//console.log("dstContents", dstContents);

	// Decrease volume of source
	const srcVolume0 = math.eval(srcContents[0]);
	const srcVolume1 = math.chain(srcVolume0).subtract(volume).done();
	const srcContents2 = [srcVolume1.format({precision: 14})].concat(_.tail(srcContents));

	checkContents(srcContents2);
	checkContents(dstContents2);

	return [srcContents2, dstContents2];
}