Source: parsers/wellsParser.js

/**
 * Module for parsing strings that represent wells and labware.
 * @module
 */

var _ = require('lodash');
var assert = require('assert');
var random = require('random-js');
var expect = require('../expectCore.js');
var misc = require('../misc.js');
var wellsParser0 = require('./wellsParser0.js');

/**
 * Take a well identifier (e.g. A01) and returns an integer array
 * representing the row and column of that well.
 * @param  {string} location - a well identifier starting with a capital letter and followed by a number (e.g. A01)
 * @return {array} an integer array of `[row, col]`.  The values are 1-based (i.e. row 1 is the first row)
 * @static
 */
function locationTextToRowCol(location) {
	var row = location.charCodeAt(0) - "A".charCodeAt(0) + 1;
	var col = parseInt(location.substr(1));
	return [row, col];
}

/**
 * Converts a row and column index to a string.
 * For example, `[1, 1] => A01`, and `[8, 12] => H12`.
 * @param  {number} row - row of well
 * @param  {number} col - column of well
 * @return {string} string representation of location of well on labware
 * @static
 */
function locationRowColToText(row, col) {
	var colText = col.toString();
	if (colText.length == 1) colText = "0"+colText;
	return String.fromCharCode("A".charCodeAt(0) + row - 1) + colText;
}

/**
 * Parses a text which should represent one or more labwares and wells.
 * If the `objects` parameter is passed, this function will return an array of the individual wells;
 * otherwise it will return the raw parser results.
 *
 * @param {string} text - text to parse
 * @param {object} objects - map of protocol objects, in order to find labwares and number of rows and columns on labware.
 * @param {object} [config] - optional object that contains properties for 'rows' and 'columns', in case we want to expand something like 'A1 down C3' without having specified a plate
 * @return {array} If the `objects` parameter is passed, this function will return an array of the individual wells; otherwise it will return the raw parser results.
 * @static
 */
function parse(text, objects, config) {
	assert(_.isString(text), "wellsParser.parse() expected a string, received: "+text)
	var result;
	try {
		result = wellsParser0.parse(text);
	} catch (e) {
		expect.rethrow(e);
	}
	if (!objects)
		return result;

	return processParserResult(result, objects, text, config);
}

/**
 * Take the raw parser results and return an array of location names,
 * one entry for each well.
 * @param {array} result - raw parser results.
 * @param {object} objects - map of protocol objects, in order to find labwares and number of rows and columns on labware.
 * @param {string} text - the original text that was parsed; this is merely used for error output.
 * @param {object} [config] - optional object that contains properties for 'rows' and 'columns', in case we want to expand something like 'A1 down C3' without having specified a plate
 * @return {array} array of names for each plate + well (e.g. `plate1(C04)`)
 */
function processParserResult(result, objects, text, config = {}) {
	var commandHelper = require('../commandHelper.js');
	//console.log("text", text)
	//console.log("result", result)
	//console.log("result:\n"+JSON.stringify(result, null, '  '));
	var ll = _.map(result, function(clause) {
		const data = {objects};
		// Get source or labware objects
		//console.log({commandHelper})
		const parsed = commandHelper.parseParams(clause, data, {
			properties: {
				source: {},
				labware: {},
				//subject: 'Any?'
			}
		});
		// console.log({clause, parsed});
		if (parsed.value.source) {
			// If this was a source, return its wells
			if (parsed.value.source.type === 'Source' || parsed.value.source.type === 'Liquid')
				return [parsed.value.source.wells];
			// Handle the case of when a variable is used and here we can substitute in its value
			else if (_.isString(parsed.value.source) && parsed.value.source !== clause.source)
				return parse(parsed.value.source, objects);
			// Else
			else {
				// console.log({clause, parsed})
				expect.throw({}, "unrecognized source specifier: "+JSON.stringify(parsed.value.source));
			}
		}
		else if (parsed.value.labware || (config.rows && config.columns)) {
			// Get number of rows and columns
			let rows, columns;
			let labwareName;
			if (parsed.value.labware) {
				const labware = parsed.value.labware;
				labwareName = parsed.objectName.labware;
				var modelName = labware.model;
				assert(modelName, "`"+labwareName+".model` missing");
				var model = misc.getObjectsValue(modelName, objects);
				assert(model.rows, "`"+modelName+".rows` missing");
				assert(model.columns, "`"+modelName+".columns` missing");
				rows = model.rows;
				columns = model.columns;
			}
			else {
				rows = config.rows;
				columns = config.columns;
			}

			var l = [];
			if (clause.subject === 'all') {
				for (var col = 1; col <= columns; col++) {
					for (var row = 1; row <= rows; row++) {
						l.push([row, col]);
					}
				}
			}
			else {
				l.push(locationTextToRowCol(clause.subject));
			}
			if (clause.phrases) {
				_.forEach(clause.phrases, function(phrase) {
					switch (phrase[0]) {
						case "down":
							assert(l.length == 1, "`down` can only be used with a single well");
							var rc0 = l[0];
							var n = phrase[1];
							assert(n >= 1, "`down n` must be positive: "+text);
							var row = rc0[0];
							var col = rc0[1];
							for (var i = 1; i < n; i++) {
								row++;
								if (row > rows) {
									row = 1;
									col++;
									if (col > columns) {
										throw {name: "RangeError", message: "`"+text+"` extends beyond range of labware `"+labwareName+"`"};
									}
								}
								l.push([row, col])
							}
							break;
						case "down-to":
							assert(l.length == 1, "`down` can only be used with a single well");
							var rc0 = l[0];
							var rc1 = locationTextToRowCol(phrase[1]);
							assert(rc0[1] < rc1[1] || rc0[0] <= rc1[0], "invalid target for `down`: "+text)
							assert(rc0[1] <= rc1[1], "column of `"+phrase[1]+"` must be equal or greater than origin: "+text);
							var row = rc0[0];
							var col = rc0[1];
							while (row !== rc1[0] || col != rc1[1]) {
								row++;
								if (row > rows) {
									row = 1;
									col++;
									if (col > columns) {
										throw {name: "RangeError", message: "`"+text+"` extends beyond range of labware `"+labwareName+"`"};
									}
								}
								l.push([row, col])
							}
							break;
						case "down-block":
							assert(l.length == 1, "`block` can only be used with a single well");
							var rc0 = l[0];
							var rc1 = locationTextToRowCol(phrase[1]);
							assert(rc0[0] <= rc1[0], "row of `"+phrase[1]+"` must be equal or greater than origin: "+text);
							assert(rc0[1] <= rc1[1], "column of `"+phrase[1]+"` must be equal or greater than origin: "+text);
							var row = rc0[0];
							var col = rc0[1];
							while (row !== rc1[0] || col != rc1[1]) {
								row++;
								if (row > rc1[0]) {
									row = rc0[0];
									col++;
								}
								l.push([row, col])
							}
							break;
						case "right":
							assert(l.length == 1, "`right` can only be used with a single well");
							var rc0 = l[0];
							var n = phrase[1];
							assert(n >= 1, "`right n` must be positive: "+text);
							var row = rc0[0];
							var col = rc0[1];
							for (var i = 1; i < n; i++) {
								col++;
								if (col > columns) {
									row++;
									col = 1;
									if (row > rows) {
										throw {name: "RangeError", message: "`"+text+"` extends beyond range of labware `"+labwareName+"`"};
									}
								}
								l.push([row, col])
							}
							break;
						case "right-to":
							assert(l.length == 1, "`right` can only be used with a single well");
							var rc0 = l[0];
							var rc1 = locationTextToRowCol(phrase[1]);
							assert(rc0[0] < rc1[0] || rc0[1] <= rc1[1], "invalid target for `right`: "+text)
							assert(rc0[0] <= rc1[0], "row of `"+phrase[1]+"` must be equal or greater than origin: "+text);
							var row = rc0[0];
							var col = rc0[1];
							while (row !== rc1[0] || col != rc1[1]) {
								col++;
								if (col > columns) {
									col = 1;
									row++;
									if (row > rows) {
										throw {name: "RangeError", message: "`"+text+"` extends beyond range of labware `"+labwareName+"`"};
									}
								}
								l.push([row, col])
							}
							break;
						case "right-block":
							assert(l.length == 1, "`block` can only be used with a single well");
							var rc0 = l[0];
							var rc1 = locationTextToRowCol(phrase[1]);
							assert(rc0[0] <= rc1[0], "row of `"+phrase[1]+"` must be equal or greater than origin: "+text);
							assert(rc0[1] <= rc1[1], "column of `"+phrase[1]+"` must be equal or greater than origin: "+text);
							var row = rc0[0];
							var col = rc0[1];
							while (row !== rc1[0] || col != rc1[1]) {
								col++;
								if (col > rc1[1]) {
									col = rc0[1];
									row++;
								}
								l.push([row, col])
							}
							break;
						case "random":
							// Initialize randomizing engine
							var mt = random.engines.mt19937();
							if (phrase.length == 2) {
								mt.seed(phrase[1]);
							}
							else {
								mt.autoSeed();
							}
							// Randomize the list
							var rest = _.clone(l);
							random.shuffle(mt, rest);
							//console.log("rest:", rest);
							// Now try to not repeated pick the sames rows or columns
							var l2 = [];
							var choices = [];
							while (rest.length > 0) {
								if (choices.length == 0)
									choices = _.clone(rest);
								//console.log("choices:", JSON.stringify(choices));
								// Pick the first choice in list
								var rc = _.pullAt(choices, 0)[0];
								// Add it to our new list
								l2.push(rc);
								// Remove it from 'rest'
								rest = _.without(rest, rc);
								// Remove all items from choices with the same row or column
								_.remove(choices, function(rc2) {
									return (rc2[0] == rc[0] || rc2[1] == rc[1]);
								});
							}
							l = l2;
							break;
						case "take":
							var n = phrase[1];
							assert(n >= 0);
							l = _.take(l, n);
							break;
						case "row-jump":
							// Number of rows of space to leave between rows
							var n = phrase[1];
							expect.truthy(null, n >= 0, "row-jump value must be >= 0");
							var cycleLen = n + 1;

							var l2 = [];
							while (l.length > 0) {
								// Get consecutive rc's that are in the same col
								var col = l[0][1];
								var sameCol = _.takeWhile(l, function(rc) { return rc[1] == col; });
								l = _.drop(l, sameCol.length);

								//console.dir(sameCol);
								while (sameCol.length > 0) {
									var row = sameCol[0][0];
									var l3 = _.remove(sameCol, function(rc) {
										return (((rc[0] - row) % cycleLen) === 0);
									});
									//console.log(row, l3, sameCol);
									l2 = l2.concat(l3);
								}
							}
							l = l2;
							break;
						default:
							assert(false, "unhandled verb: "+phrase[0]);
					}
				});
			}
			// Convert the list of row/col back to text
			return _.map(l, function(rc) {
				var location = locationRowColToText(rc[0], rc[1]);
				return (labwareName) ? labwareName+'('+location+')' : location;
			});
		}
		else if (clause.subject) {
			assert(_.isEmpty(clause.phrases));
			return clause.subject;
		}
		else {
			assert(false);
		}
	});
	//console.log("ll:")
	//console.log(ll);
	return _.flatten(ll);
}

/**
 * Parses a string which should represent a single well, and return the raw
 * parser results.
 *
 * @param {string} text - text to parse
 * @return {object} an object representing the raw parser results.
 * @static
 */
function parseOne(text) {
	assert(_.isString(text), "wellsParser.parseOne() expected a string, received: "+JSON.stringify(text))
	try {
		return wellsParser0.parse(text, {startRule: 'startOne'});
	} catch (e) {
		throw e;
		//throw Error(e.toString());
	}

}

module.exports = {
	locationRowColToText,
	locationTextToRowCol,
	parse,
	parseOne,
	processParserResult,
};