Source: EvowareConfigSpec.js

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

/**
 * Module for converting an EvowareConfigSpec to a general Roboliq configuration protocol.
 * @module
 */
const _ = require('lodash');
const assert = require('assert');
const math = require('mathjs');
const commandHelper = require('roboliq-processor/dist/commandHelper.js');
const expect = require('roboliq-processor/dist/expect.js');
const evowareEquipment = require('./equipment/evoware.js');
// For validate():
const Validator = require('jsonschema').Validator;
const YAML = require('yamljs');


/**
 * Convert an EvowareConfigSpec into the format of a Roboliq Protocol.
 * This function exists, because it is much simpler to write an
 * EvowareConfigSpec than the equivalent Protocol.
 * @param  {EvowareConfigSpec} spec - specification of an Evoware robot configuration
 * @param  {Object} [data] - (not currently used) protocol data loaded before this configuration
 * @return {Protocol} - an Evoware robot configuration in the format of a Protocol.
 */
function makeProtocol(spec, data = {objects: {}, predicates: []}) {
	// Validate
	const validation = validate(spec);
	if (!_.isEmpty(validation.errors)) {
		return {
			errors: {
				EvowareConfigSpec: validation.errors
			}
		};
	}

	const namespace = [spec.namespace, spec.name].join(".");
	const agent = [spec.namespace, spec.name, "evoware"].join(".");
	const predicates = [];
	const output = { roboliq: "v1", predicates, commandHandlers: evowareEquipment.getCommandHandlers() };

	function getAgentName() {
		return [namespace, "evoware"].join(".");
	}
	function getEquipmentName(equipmentName) {
		assert(equipmentName, "equipmentName is undefined");
		return [namespace, equipmentName].join(".");
	}
	function getSiteName(siteName) {
		assert(siteName, "siteName is undefined");
		return [namespace, "site", siteName].join(".");
	}
	// Return fully qualified model name - first lookup to see whether the
	// model is specific to this robot, and if so, use that name.  Otherwise
	// use the model name for lab, as defined by spec.namespace.
	function getModelName(base) {
		assert(base, "modelName is undefined");
		const model1 = [spec.namespace, spec.name, "model", base];
		const model2 = [spec.namespace, "model", base];
		const isOnlyOnRobot = _.has(output.objects, model1);
		return (isOnlyOnRobot) ? model1.join(".") : model2.join(".");
	}
	function lookupSyringe(base) {
		assert(base, "lookupSyringe: base name must be defined");
		const id1 = [spec.namespace, spec.name, "liha", "syringe", base.toString()];
		const id2 = base.toString();
		const id =
			_.has(output.objects, id1) ? id1 :
			_.has(data.objects, id2) ? id2 :
			undefined;
		assert(!_.isUndefined(id), "syringe not found: "+base);
		return id.join(".");
	}
	function lookupTipModel(base) {
		assert(base, "lookupTipModel: base name must be defined");
		const id1 = [spec.namespace, spec.name, "liha", "tipModel", base];
		const id2 = [spec.namespace, "tipModel", base];
		const id3 = base;
		// console.log({id1, id2, id3, b1: _.has(output.objects, id1), b2: _.has(data.objects, id2), b3: _.has(data.objects, id3)})
		const id =
			_.has(output.objects, id1) ? id1 :
			_.has(data.objects, id2) ? id2 :
			_.has(data.objects, id3) ? id3 :
			undefined;
		assert(!_.isUndefined(id), "tipModel not found: "+base);
		return id.join(".");
	}
	let siteModelCount = 0;
	function addSiteModelCompatibilities(siteModelCompatibilities, output) {
		if (_.isUndefined(output.predicates))
			output.predicates = [];
		// Add predicates for siteModelCompatibilities
		_.forEach(siteModelCompatibilities, compat => {
			siteModelCount++;
			const siteModel = `${namespace}.siteModel${siteModelCount}`;
			output.predicates.push({isSiteModel: {model: siteModel}});
			_.forEach(compat.sites, site => {
				output.predicates.push({siteModel: {site: helpers.getSiteName(site), siteModel}});
			});
			_.forEach(compat.models, labwareModel => {
				output.predicates.push({stackable: {below: siteModel, above: helpers.getModelName(labwareModel)}})
			});
		});
	}

	const helpers = {
		getAgentName,
		getEquipmentName,
		getSiteName,
		getModelName,
		lookupSyringe,
		lookupTipModel,
		addSiteModelCompatibilities,
	};

	output.schemas = evowareEquipment.getSchemas();

	_.set(output, ["roboliq"], "v1");
	_.set(output, ["objects", spec.namespace, "type"], "Namespace");
	_.set(output, ["objects", spec.namespace, "model", "type"], "Namespace");
	_.set(output, ["objects", spec.namespace, spec.name, "type"], "Namespace");
	_.set(output, ["objects", spec.namespace, spec.name, "evoware", "type"], "EvowareRobot");
	_.set(output, ["objects", spec.namespace, spec.name, "evoware", "config"], spec.config);
	_.set(output, ["objects", spec.namespace, spec.name, "site", "type"], "Namespace");
	_.set(output, ["objects", spec.namespace, spec.name, "liha", "type"], "Pipetter");

	// Add 5 timers
	_.forEach(_.range(5), i => {
		const equipment = [spec.namespace, spec.name, `timer${i+1}`].join(".");
		_.set(output.objects, equipment, {
			type: "Timer",
			evowareId: i+1
		});
		output.predicates.push({ "timer.canAgentEquipment": {agent, equipment} });
	});

	// Add bench sites (equipment sites will be added by the equipment modules)
	_.forEach(spec.sites, (value, key) => {
		_.set(output, ["objects", spec.namespace, spec.name, "site", key], _.merge({type: "Site"}, value));
	});

	// Add explicitly defined models to spec.namespace
	_.forEach(spec.models, (value, key) => {
		_.set(output, ["objects", spec.namespace, "model", key], value);
	});

	// Add predicates for siteModelCompatibilities
	addSiteModelCompatibilities(spec.siteModelCompatibilities, output);

	// Lid and plate stacking
	_.forEach(spec.lidStacking, lidsModels => {
		_.forEach(lidsModels.lids, lid => {
			_.forEach(lidsModels.models, model => {
				const below = getModelName(model);
				const above = getModelName(lid);
				predicates.push({stackable: {below, above}});
			});
		});
	});

	handleEquipment(spec, helpers, namespace, agent, output);

	handleRomas(spec, helpers, namespace, agent, output);

	handleLiha(spec, helpers, namespace, agent, output);

	output.objectToPredicateConverters = evowareEquipment.objectToPredicateConverters;

	if (spec.planAlternativeChoosers) {
		output.planAlternativeChoosers = spec.planAlternativeChoosers;
		// console.log({planAlternativeChoosers: output.planAlternativeChoosers})
	}

	// User-defined commandHandlers
	_.forEach(spec.commandHandlers, (fn, key) => {
		output.commandHandlers[key] = fn;
	});

	return output;
}

/**
 * Create the predictates to be added to Roboliq's robot
 * configuration for Evoware's RoMa relationships.
 *
 * Expect specs of this form:
 * ``{<transporter>: {<program>: [site names]}}``
 * @param {Object} spec
 * @param {Roma[]} spec.romas
 */
function handleRomas(spec, helpers, namespace, agent, output) {
	let siteCliqueId = 1;
	_.forEach(spec.romas, (roma, i) => {
		// Add the roma object
		_.set(output, ["objects", spec.namespace, spec.name, `roma${i+1}`], {type: "Transporter", evowareRoma: i});

		const equipment = [spec.namespace, spec.name, `roma${i+1}`].join(".");
		_.forEach(roma.safeVectorCliques, safeVectorClique => {
			const siteClique = `${namespace}.siteClique${siteCliqueId}`;
			siteCliqueId++;

			const program = safeVectorClique.vector;

			// Add the site clique predicates
			_.forEach(safeVectorClique.clique, base => {
				const site = helpers.getSiteName(base);
				output.predicates.push({"siteCliqueSite": {siteClique, site}});
			});

			// Add the transporter predicates
			output.predicates.push({
				"transporter.canAgentEquipmentProgramSites": {
					agent,
					equipment,
					program,
					siteClique
				}
			});
		});
	});
}

function handleLiha(spec, helpers, namespace, agent, output) {
	if (!spec.liha) return;

	const equipment = [spec.namespace, spec.name, "liha"].join(".");

	const tipModelToSyringes = {};

	if (_.isPlainObject(spec.liha.tipModels)) {
		const tipModels = _.mapValues(spec.liha.tipModels, x => _.merge({type: "TipModel"}, x));
		// console.log(tipModels)
		_.set(output.objects, [spec.namespace, spec.name, "liha", "tipModel"], tipModels);
		// console.log({stuff: _.get(output, ["object", spec.namespace, spec.name, "liha", "tipModel"])});
	}

	output.schemas[`pipetter.cleanTips|${agent}|${equipment}`] = {
		description: "Clean the pipetter tips.",
		properties: {
			agent: {description: "Agent identifier", type: "Agent"},
			equipment: {description: "Equipment identifier", type: "Equipment"},
			program: {description: "Program identifier", type: "string"},
			items: {
				description: "List of which syringes to clean at which intensity",
				type: "array",
				items: {
					type: "object",
					properties: {
						syringe: {description: "Syringe identifier", type: "Syringe"},
						intensity: {description: "Intensity of the cleaning", type: "pipetter.CleaningIntensity"}
					},
					required: ["syringe", "intensity"]
				}
			}
		},
		required: ["agent", "equipment", "items"]
	};

	// Add syringes
	_.set(output.objects, [spec.namespace, spec.name, "liha", "syringe"], {});
	_.forEach(spec.liha.syringes, (syringeSpec, i) => {
		// console.log({syringeSpec})
		const syringe = [spec.namespace, spec.name, "liha", "syringe", (i+1).toString()].join(".");
		const syringeObj = {
			type: "Syringe",
			row: i + 1
		};
		// console.log({syringeObj})
		_.set(output.objects, syringe, syringeObj);

		// Handle permanent tips
		if (syringeSpec.tipModelPermanent) {
			const tipModel = helpers.lookupTipModel(syringeSpec.tipModelPermanent);
			syringeObj.tipModel = tipModel;
			syringeObj.tipModelPermanent = tipModel;

			tipModelToSyringes[tipModel] = (tipModelToSyringes[tipModel] || []).concat([syringe]);
		}
		else {
			assert(false, "roboliq-evoware currently only supports fixed tips; please contact the software developer to add support for disposable tips.")
		}
	});

	// Handle tipModelToSyringes mapping for non-permantent tips
	_.forEach(spec.liha.tipModelToSyringes, (syringes0, tipModel0) => {
		const tipModel = helpers.lookupTipModel(syringeSpec.tipModelPermanent);
		const syringes = syringes0.map(helpers.lookupSyringe);
		tipModelToSyringes[tipModel] = (tipModelToSyringes[tipModel] || []).concat(syringes);
	});
	// console.log({tipModelToSyringes})
	_.set(output.objects, [spec.namespace, spec.name, "liha", "tipModelToSyringes"], tipModelToSyringes);

	// Handle washPrograms
	if (spec.liha.washPrograms) {
		const washPrograms = _.merge({type: "Namespace"}, _.mapValues(spec.liha.washPrograms, x => _.merge({type: "EvowareWashProgram"}, x)));
		_.set(output.objects, [spec.namespace, spec.name, "washProgram"], washPrograms);
	}

	// Add system liquid
	const syringeCount = spec.liha.syringes.length;
	assert(syringeCount <= 8, "roboliq-evoware has only been configured to handle 8-syringe LiHas; please contact the software developer to accommodate your needs.");
	_.set(output.objects, [spec.namespace, spec.name, "systemLiquidLabwareModel"], {
		"type": "PlateModel",
		"description": "dummy labware model representing the system liquid source",
		"rows": syringeCount,
		"columns": 1,
		"evowareName": "SystemLiquid"
	});
	_.set(output.objects, [spec.namespace, spec.name, "systemLiquid"], {
		"type": "Liquid",
		"wells": _.map(_.range(syringeCount), i => `${spec.namespace}.${spec.name}.systemLiquidLabware(${String.fromCharCode(65 + i)}01)`)
	});
	_.set(output.objects, [spec.namespace, spec.name, "systemLiquidLabware"], {
		"type": "Plate",
		"description": "dummy labware representing the system liquid source",
		"model": `${namespace}.systemLiquidLabwareModel`,
		"location": helpers.getSiteName("SYSTEM"),
		"contents": ["Infinity l", "systemLiquid"]
	});

	// Equipment predicates
	output.predicates.push({
		"pipetter.canAgentEquipment": {
			agent,
			equipment
		}
	});
	// Syringe predicates
	_.forEach(spec.liha.syringes, (syringeSpec, i) => {
		output.predicates.push({
			"pipetter.canAgentEquipmentSyringe": {
				agent,
				equipment,
				syringe: `ourlab.mario.liha.syringe.${i+1}`
			}
		})
	});
	// Site predicates
	_.forEach(spec.liha.sites, site0 => {
		const site = helpers.getSiteName(site0);
		output.predicates.push({
			"pipetter.canAgentEquipmentSite": {
				agent,
				equipment,
				site
			}
		});
	});

	// Command handler for `pipetter.cleanTips`
	output.commandHandlers[`pipetter.cleanTips|${agent}|${equipment}`] = makeCleanTipsHandler(namespace);
}

function makeCleanTipsHandler(namespace) {
	return function cleanTips(params, parsed, data) {
		//console.log("pipetter.cleanTips|ourlab.mario.evoware|ourlab.mario.liha")
		//console.log(JSON.stringify(parsed, null, '  '))

		const cleaningIntensities = data.schemas["pipetter.CleaningIntensity"].enum;
		const syringeNameToItems = _.map(parsed.value.items, (item, index) => [parsed.objectName[`items.${index}.syringe`], item]);
		//console.log(syringeNameToItems);

		const expansionList = [];
		const sub = function(syringeNames, volume) {
			const syringeNameToItems2 = _.filter(syringeNameToItems, ([syringeName, ]) =>
				_.includes(syringeNames, syringeName)
			);
			//console.log({syringeNameToItems2})
			if (!_.isEmpty(syringeNameToItems2)) {
				const value = _.max(_.map(syringeNameToItems2, ([, item]) => cleaningIntensities.indexOf(item.intensity)));
				if (value >= 0) {
					const intensity = cleaningIntensities[value];
					const syringes = _.map(syringeNameToItems2, ([syringeName, ]) => syringeName);
					expansionList.push({
						command: "pipetter._washTips",
						agent: parsed.objectName.agent,
						equipment: parsed.objectName.equipment,
						program: `${namespace}.washProgram.${intensity}_${volume}`,
						intensity: intensity,
						syringes: syringeNames
					});
				}
			}
		}
		// Get list of syringes on the liha
		const syringesName = `${namespace}.liha.syringe`;
		const syringesObj = _.get(data.objects, syringesName);
		assert(syringesObj, "didn't find LiHa syringes "+syringesName);
		// Lists of [syringeName, tipModelName, programCode]
		const l = _.map(syringesObj, (syringeObj, syringeName0) => {
			const syringeName = `${namespace}.liha.syringe.${syringeName0}`;
			// console.log({syringeObj})
			const tipModelName = syringeObj.tipModel;
			const tipModelObj = _.get(data.objects, tipModelName);
			assert(tipModelObj, "didn't find tipModel "+tipModelName);
			return [syringeName, tipModelName, tipModelObj.programCode];
		});
		// console.log({l})
		// Group by program code, and call `sub()`
		const m = _.groupBy(l, x => x[2]);
		_.forEach(m, (l, programCode) => {
			sub(l.map(x => x[0]), programCode);
		})
		return {expansion: expansionList};
	};
}

function handleEquipment(spec, helpers, namespace, agent, output) {
	_.forEach(spec.equipment, (value, key) => {
		// console.log({key})
		const module = require(__dirname+"/equipment/"+value.module);
		const protocol = module.configure(helpers, key, value.params);
		// console.log(key+": "+JSON.stringify(protocol, null, '\t'))
		// console.log(key+".objects: "+JSON.stringify(protocol.objects, null, '\t'))
		_.merge(output, _.omit(protocol, "predicates"));
		// console.log("output.objects: "+JSON.stringify(output.objects, null, '\t'))
		if (!_.isEmpty(protocol.predicates))
			output.predicates.push(...protocol.predicates);
	});
}

/*
function test() {
	const evowareSpec = require('/Users/ellisw/src/roboliq/config/bsse-mario-new.js');
	const orig = require('/Users/ellisw/src/roboliq/config/bsse-mario.js');

	const protocol = process(evowareSpec);
	// console.log(JSON.stringify(protocol, null, '\t'));
	const diff = require('deep-diff');
	// console.log("isSiteModel predicates: "+JSON.stringify(_.filter(protocol.predicates, x => Object.keys(x)[0] == "isSiteModel")));
	// console.log("siteCliqueSite predicates: "+JSON.stringify(_.filter(protocol.predicates, x => Object.keys(x)[0] == "siteCliqueSite"), null, '\t'));
	protocol.predicates = _.fromPairs(_.sortBy(protocol.predicates.map(x => [JSON.stringify(x), x]), x => x[0]));
	orig.predicates = _.fromPairs(_.sortBy(orig.predicates.map(x => [JSON.stringify(x), x]), x => x[0]));
	const diffs = diff(_.omit(orig, "objectToPredicateConverters"), _.omit(protocol, "objectToPredicateConverters"));
	const diffs2 = _.filter(diffs, d => (
		(d.kind == "E" && d.path[0] == "commandHandlers") ? false
		: (d.kind == "E" && d.path[0] == "planHandlers") ? false
		: true
	));
	console.log(JSON.stringify(diffs2, null, '\t'));
}
*/

/**
 * Validates a EvowareConfigSpec against the JSON schema.
 * @param  {EvowareConfigSpec} evowareSpec - evoware config spec
 * @return {object} - returns the validation results from the npm package `jsonschema`
 */
function validate(evowareSpec) {
	const v = new Validator();

	const schemas = YAML.load(__dirname+"/schemas/EvowareConfigSpec.yaml");
	// console.log(JSON.stringify(schemas, null, '\t'));
	_.forEach(schemas, (schema, name) => {
		const id = "/"+name;
		v.addSchema(_.merge({id}, schema), id);
	});

	// console.log(JSON.stringify(evowareSpec, null, '\t'));
	// console.log(evowareSpec);

	// See: http://json-schema.org/example2.html
	// See: https://spacetelescope.github.io/understanding-json-schema/structuring.html
	// TODO: raise error on unknown type
	// TODO: add some extra types, such as `function`, see
	//  https://www.npmjs.com/package/jsonschema
	//  https://www.npmjs.com/package/jsonschema-extra
	const result = v.validate(evowareSpec, schemas.EvowareConfigSpec);
	// console.log(result);
	return result;
}

module.exports = {
	makeProtocol,
	validate
};