/**
* Roboliq: Automation for liquid-handling robots
* @copyright 2017, ETH Zurich, Ellis Whitehead
* @license GPL-3.0
*/
/**
* Namespace for the ``pipetter`` commands.
* @namespace pipetter
* @version v1
*/
/**
* Pipetter commands module.
* @module commands/pipetter
* @return {Protocol}
* @version v1
*/
const _ = require('lodash');
const assert = require('assert');
const math = require('mathjs');
import yaml from 'yamljs';
const commandHelper = require('../commandHelper.js');
const expect = require('../expect.js');
const misc = require('../misc.js');
const groupingMethods = require('./pipetter/groupingMethods.js');
const pipetterUtils = require('./pipetter/pipetterUtils.js');
import * as simulatedHelpers from './simulatedHelpers.js';
const sourceMethods = require('./pipetter/sourceMethods.js');
const wellsParser = require('../parsers/wellsParser.js');
import * as WellContents from '../WellContents.js';
const intensityToValue = {
"none": 0,
"flush": 1,
"light": 2,
"thorough": 3,
"decontaminate": 4
};
const valueToIntensity = ["none", "flush", "light", "thorough", "decontaminate"];
/**
* Takes a labware name and a well and returns a fully specified well.
* If the wells is undefined, return undefined.
* @param {string} [labwareName] - name of labware for wells that don't have specified labware.
* @param {array} [well] - well identifier, with or without labware explicitly specified.
* @return {array} fully specified well (e.g. on labware).
*/
function getLabwareWell(labwareName, well) {
if (_.isString(well) && _.isString(labwareName) && !_.isEmpty(labwareName)) {
return (_.includes(well, "(")) ? well : `${labwareName}(${well})`;
}
return well;
}
/**
* Takes a labware name and a list of wells and returns a list of wells.
* If the list of wells is empty or undefined, an empty array is returned.
* @param {string} [labwareName] - name of labware for wells that don't have specified labware.
* @param {array} [wells] - list of wells, with or without labware explicitly specified.
* @return {array} a list of wells on labware.
*/
function getLabwareWellList(labwareName, wells) {
const wells1 = wells || [];
assert(_.isArray(wells1));
const wells2 = (_.isString(labwareName) && !_.isEmpty(labwareName))
? _.map(wells1, w => (_.includes(w, "(")) ? w : `${labwareName}(${w})`)
: wells1;
return wells2;
}
function pipette(params, parsed, data, options={}) {
const llpl = require('../HTN/llpl.js').create();
llpl.initializeDatabase(data.predicates);
// console.log("pipette: "+JSON.stringify(parsed, null, '\t'))
// let items = (_.isUndefined(parsed.value.items))
// ? []
// : _.flatten(parsed.value.items);
//console.log("items: "+JSON.stringify(items));
let agent = parsed.objectName.agent || "?agent";
let equipmentName = parsed.objectName.equipment || "?equipment";
//const tipModels = params.tipModels;
//const syringes = params.syringes;
// const sourcesTop = getLabwareWellList(parsed.objectName.sourceLabware, parsed.value.sources);
// //console.log({sourcesTop})
// const destinationsTop = getLabwareWellList(parsed.objectName.destinationLabware, parsed.value.destinations);
// const wellsTop = getLabwareWellList(parsed.objectName.wellsLabware, parsed.value.wells);
// const volumesTop = parsed.value.volumes || [];
// const syringesTop = (parsed.value.syringes || []).map((x, i) => {
// const syringe = parsed.value.syringes[i];
// if (_.isNumber(syringe))
// return syringe;
// else
// return _.get(parsed.objectName, `syringes.${i}`, syringe);
// });
//console.log({sourceLabware})
const items0 = (parsed.value.items) ? _.flatten(parsed.value.items) : undefined;
let items = commandHelper.copyItemsWithDefaults(items0, {
source: parsed.value.sources,
destination: parsed.value.destinations,
well: parsed.value.wells,
volume: parsed.value.volumes,
syringe: parsed.value.syringes,
program: parsed.value.program,
tipModel: parsed.value.tipModel, // TODO: Create a TipModel schema, and then set the tipModel properties in schemas to the "TipModel" type instead of "string"
distance: parsed.value.distances,
sourceMixing: parsed.value.sourceMixing,
destinationMixing: parsed.value.destinationMixing
});
// console.log("items: "+JSON.stringify(items))
if (items.length == 0) {
return {};
}
// 1) Add labware to well properties
// 2) Fixup mixing specs
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item.source && parsed.objectName.sourceLabware) {
item.source = getLabwareWell(parsed.objectName.sourceLabware, item.source);
}
if (item.destination && parsed.objectName.destinationLabware) {
item.destination = getLabwareWell(parsed.objectName.destinationLabware, item.destination);
}
if (item.well && parsed.objectName.wellLabware) {
item.well = getLabwareWell(parsed.objectName.wellLabware, item.well);
}
if (item.hasOwnProperty("sourceMixing")) {
item.sourceMixing = processMixingSpecs([item.sourceMixing]);
}
if (item.hasOwnProperty("destinationMixing")) {
item.destinationMixing = processMixingSpecs([item.destinationMixing]);
}
}
// Calculate volumes from calibrators
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item.hasOwnProperty("volume")) {
// Ignore other volume properties
}
else if (item.hasOwnProperty("volumeTotal")) {
// Ignore other volume properties
}
else if (item.hasOwnProperty("volumeCalibrated")) {
const spec = item.volumeCalibrated;
const calibratorName = spec.calibrator;
const targetValue = math.eval(spec.value);
const calibratorVariable = _.get(parsed.orig, ["calibrators", calibratorName, "calibratorVariable"]);
assert(_.isString(calibratorVariable), "expected calibratorVariable to be a string: "+JSON.stringify(calibratorVariable));
const calibratorData0 = _.get(parsed.orig, ["calibrators", calibratorName, "calibratorData"]);
assert(_.isArray(calibratorData0), "expected calibratorData to be an array");
const calibratorData = _.sortBy(calibratorData0, calibratorVariable);
const dataLE = _.last(_.filter(calibratorData, x => x[calibratorVariable] <= targetValue));
const dataGE = _.first(_.filter(calibratorData, x => x[calibratorVariable] >= targetValue));
const valueLE = math.eval(dataLE[calibratorVariable]);
const valueGE = math.eval(dataGE[calibratorVariable]);
const volumeLE = math.eval(dataLE.volume);
const volumeGE = math.eval(dataGE.volume);
if (math.equal(valueLE, targetValue)) {
item.volume = volumeLE;
}
else if (math.equal(valueGE, targetValue)) {
item.volume = volumeGE;
}
else {
const d = math.subtract(valueGE, valueLE);
const p = math.divide(math.subtract(targetValue, valueLE), d);
// console.log({d, p})
// console.log(math.multiply(math.subtract(1, p), volumeLE))
// console.log(math.multiply(p, volumeGE))
item.volume = math.add(math.multiply(math.subtract(1, p), volumeLE), math.multiply(p, volumeGE));
}
// console.log({spec, dataLE, dataGE, volume: item.volume})
}
}
// In order to handle 'volumeTotal',
// perform an initial calculation of well volumes, but this will skip source
// liquids, and therefore need to be performed again later after choosing source wells.
calculateWellVolumes(items, data);
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item.hasOwnProperty("volume")) {
// Ignore other volume properties
}
else if (item.hasOwnProperty("volumeTotal")) {
item.volume = math.subtract(item.volumeTotal, item.volumeBefore);
// console.log({item})
}
}
// console.log(JSON.stringify(items, null, ' '))
// Find all wells, both sources and destinations
const wellName_l = _(items).map(function (item) {
//console.log({item})
// TODO: allow source to refer to a set of wells, not just a single well
// TODO: create a function getSourceWells()
return [item.source, item.destination, item.well]
}).flattenDeep().compact().uniq().value();
// wellName_l = _.uniq(_.compact(_.flattenDeep([wellName_l, sourcesTop, destinationsTop])));
// console.log("wellName_l", JSON.stringify(wellName_l))
// Find all labware
const labwareName_l = _(wellName_l).map(function (wellName) {
//console.log({wellName})
const i = wellName.indexOf('(');
return (i >= 0) ? wellName.substr(0, i) : wellName;
}).uniq().value();
const labware_l = _.map(labwareName_l, function (name) { return _.merge({name: name}, expect.objectsValue({}, name, data.objects)); });
// console.log({labwareName_l, labware_l})
// Check whether labwares are on sites that can be pipetted
const query2_l = [];
_.forEach(labware_l, function(labware) {
if (!labware.location) {
return {errors: [labware.name+".location must be set"]};
}
const query = {
"pipetter.canAgentEquipmentSite": {
"agent": agent,
"equipment": equipmentName,
"site": labware.location
}
};
const queryResults = llpl.query(query);
// console.log("queryResults: "+JSON.stringify(queryResults, null, '\t'));
if (_.isEmpty(queryResults)) {
throw {name: "ProcessingError", errors: [labware.name+" is at site "+labware.location+", which hasn't been configured for pipetting; please move it to a pipetting site."]};
}
query2_l.push(query);
});
// console.log({query2_l})
// Check whether the same agent and equipment can be used for all the pipetting steps
if (!_.isEmpty(query2_l)) {
const query2 = {"and": query2_l};
//console.log("query2: "+JSON.stringify(query2, null, '\t'));
const queryResults2 = llpl.query(query2);
//console.log("query2: "+JSON.stringify(query2, null, '\t'));
//console.log("queryResults2: "+JSON.stringify(queryResults2, null, '\t'));
if (_.isEmpty(queryResults2)) {
return {errors: ["unable to find an agent/equipment combination that can pipette at all required locations: "+_.map(labware_l, function(l) { return l.location; }).join(', ')]}
}
// Arbitrarily pick first listed agent/equipment combination
else {
const x = queryResults2[0]["and"][0]["pipetter.canAgentEquipmentSite"];
agent = x.agent;
equipmentName = x.equipment;
}
}
// Load equipment object
const equipment = _.get(data.objects, equipmentName);
assert(equipment, "could not find equipment: "+equipmentName);
const sourceToItems = _.groupBy(items, 'source');
// Only keep items that have a positive volume (will need to adapt this for pipetter.punctureSeal)
if (options.keepVolumelessItems !== true) {
items = _.filter(items, item => item.volume && item.volume.toNumber('l') > 0);
// console.log({items})
}
if (items.length === 0) {
return {expansion: []};
}
// Any items which have a syringe assigned, if they have a permanent tip model, then set item's tipModel
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (!_.isUndefined(item.syringe)) {
const syringeName = pipetterUtils.getSyringeName(item.syringe, equipmentName, data);
const syringe = _.get(data.objects, syringeName);
if (syringe && syringe.tipModelPermanent)
item.tipModel = syringe.tipModelPermanent;
}
}
// console.log("A: "+JSON.stringify(_.first(items)))
// Make sure all items have a 'tipModel' property
{
// Try to find tipModel, first for all items
// Restrict settings to items without tipModel properties
const items2 = items.filter(x => _.isUndefined(x.tipModel));
// console.log({items2})
if (items2.length > 0 && !setTipModel(items2, equipment, equipmentName)) {
// TODO: Try to find tipModel for each layer
// Try to find tipModel for each source
_.forEach(sourceToItems, function(items) {
const items2 = items.filter(x => _.isUndefined(x.tipModel));
if (items2.length > 0 && !setTipModel(items2, equipment, equipmentName)) {
// Try to find tipModel for each item for this source
_.forEach(items2, function(item) {
if (!setTipModel([item], equipment, equipmentName)) {
throw {name: "ProcessingError", message: "no tip model available for item: "+JSON.stringify(item)};
}
});
}
});
}
}
// console.log("B: "+JSON.stringify(_.first(items)))
// Make sure all items have a 'program' property
{
// Try to find program, first for all items
const items2 = items.filter(x => _.isUndefined(x.program));
if (items2.length > 0 && !assignProgram(items2, data)) {
// Try to find program for each source
_.forEach(sourceToItems, function(items) {
const items2 = items.filter(x => _.isUndefined(x.program));
if (items2.length > 0 && !assignProgram(items, data)) {
// Try to find program for each item for this source
_.forEach(items2, function(item) {
if (!assignProgram([item], data)) {
throw {name: "ProcessingError", message: "could not automatically choose a program for item: "+JSON.stringify(item)};
}
});
}
});
}
}
// console.log("C: "+JSON.stringify(_.first(items)))
// TODO: Limit syringe choices based on params
const syringesAvailable = _.map(_.keys(equipment.syringe), s => `${equipmentName}.syringe.${s}`) || [];
const tipModelToSyringes = equipment.tipModelToSyringes;
// Group the items
const groups = groupingMethods.groupingMethod3(items, syringesAvailable, tipModelToSyringes);
// console.log("groups:\n"+JSON.stringify(groups, null, '\t'));
// Pick syringe for each item
// For each group assign syringes, starting with the first available one
_.forEach(groups, function(group) {
const tipModelToSyringesAvailable = _.cloneDeep(tipModelToSyringes);
_.forEach(group, function(item) {
const tipModel = item.tipModel;
assert(tipModelToSyringesAvailable[tipModel].length >= 1);
if (_.isUndefined(item.syringe)) {
item.syringe = tipModelToSyringesAvailable[tipModel].splice(0, 1)[0];
}
// TODO: do we need to remove item.syringe from tipModelToSyringesAvailable, it item.syringe was already provided? -- ellis, 2016-03-30
});
});
// Pick source well for items, if the source has multiple wells
// Rotate through source wells in order of max volume
for (const group of groups) {
sourceMethods.sourceMethod3(group, data, effects);
}
// Add properties `volumeBefore` and `volumeAfter` to the items.
calculateWellVolumes(items, data);
// Calculate when tips need to be washed
// Create pipetting commands
const syringeToSource = {};
// How clean is the syringe/tip currently?
const syringeToCleanValue = _.fromPairs(_.map(syringesAvailable, s => [s, 5]));
const expansionList = [];
/*
cleanBegin: intensity of first cleaning at beginning of pipetting, before first aspiration.
Priority: item.cleanBefore || params.cleanBegin || params.clean || source.cleanBefore || "thorough"
cleanBetween: intensity of cleaning between groups.
Priority: max(previousCleanAfter, (item.cleanBefore || params.cleanBetween || params.clean || source.cleanBefore || "thorough"))
previousCleanAfter = item.cleanAfter || if (!params.cleanBetween) source.cleanAfter
cleanEnd: intensity of cleaning after pipetting is done.
Priority: max(previousCleanAfter, params.cleanEnd || params.clean || "thorough")
*/
// Find the cleaning intensity required before the first aspiration
const syringeToCleanBeginValue = {};
_.forEach(groups, function(group) {
_.forEach(group, function(item) {
const syringe = item.syringe;
if (!syringeToCleanBeginValue.hasOwnProperty(syringe)) {
// TODO: handle source's cleanBefore
const intensity = item.cleanBefore || parsed.value.cleanBegin || parsed.value.clean || "thorough";
const intensityValue = intensityToValue[intensity];
syringeToCleanBeginValue[syringe] = intensityValue;
}
});
});
// Add cleanBegin commands
expansionList.push.apply(expansionList, createCleanActions(syringeToCleanBeginValue, agent, equipmentName, data, true));
//console.log("expansionList:")
//console.log(JSON.stringify(expansionList, null, ' '));
// console.log("D: "+JSON.stringify(_.first(groups)))
const syringeToCleanAfterValue = {};
let doCleanBefore = false
_.forEach(groups, function(group) {
assert(group.length > 0);
// What cleaning intensity is required for the tip before aspirating?
const syringeToCleanBeforeValue = _.clone(syringeToCleanAfterValue);
//console.log({syringeToCleanBeforeValue, syringeToCleanAfterValue})
_.forEach(group, function(item) {
const source = item.source || item.well;
const syringe = item.syringe;
const isSameSource = (source === syringeToSource[syringe]);
// Find required clean intensity
// Priority: max(previousCleanAfter, (item.cleanBefore || params.cleanBetween || params.clean || source.cleanBefore || "thorough"))
// FIXME: ignore isSameSource if tip has been contaminated by 'Wet' pipetting position
// FIXME: also take the source's and destination's "cleanBefore" into account
const intensity = (!isSameSource)
? item.cleanBefore || parsed.value.cleanBetween || parsed.value.clean || "thorough"
: item.cleanBefore || parsed.value.cleanBetweenSameSource || parsed.value.cleanBetween || parsed.value.clean || "thorough";
expect.truthy({}, intensityToValue.hasOwnProperty(intensity), `unrecognized intensity value: ${intensity}`);
let intensityValue = intensityToValue[intensity];
if (syringeToCleanAfterValue.hasOwnProperty(syringe))
intensityValue = Math.max(syringeToCleanAfterValue[syringe], intensityValue);
//console.log({source, syringe, isSameSource, intensityValue})
// Update cleaning value required before current aspirate
if (!syringeToCleanBeforeValue.hasOwnProperty(syringe) || intensityValue > syringeToCleanBeforeValue[syringe]) {
syringeToCleanBeforeValue[syringe] = intensityValue;
}
// Set the aspirated source and indicate that the tip is no longer clean
syringeToSource[syringe] = source;
syringeToCleanValue[syringe] = 0;
// FIXME: also consider the source's cleanAfter
if (item.hasOwnProperty('cleanAfter'))
syringeToCleanAfterValue[syringe] = item.cleanAfter;
else
delete syringeToCleanAfterValue[syringe];
//console.log({syringeToCleanAfterValue, syringe})
// TODO: if wet contact, indicate tip contamination
});
// Add cleanBefore commands for this group (but not for the first group, because of the cleanBegin section above)
if (doCleanBefore) {
expansionList.push.apply(expansionList, createCleanActions(syringeToCleanBeforeValue, agent, equipmentName, data));
}
doCleanBefore = true;
// console.log("E: "+JSON.stringify(_.first(group)))
// _PipetteItems
const items2 = _.map(group, function(item) {
const item2 = _.pick(item, ["syringe", "source", "destination", "well", "volume", "count", "distance"]);
if (!_.isUndefined(item2.volume)) { item2.volume = item2.volume.format({precision: 14}); }
if (!_.isUndefined(item2.distance)) { item2.distance = item2.distance.format({precision: 14}); }
// Mix the source well
if (item.sourceVolumeBefore && item.sourceMixing) {
const mixing = item.sourceMixing;
const volume0 = item.sourceVolumeBefore;
const volume = calculateMixingVolume(volume0, mixing.amount);
const mixing2 = {
count: mixing.count,
volume: volume.format({precision: 14})
};
item2.sourceMixing = mixing2;
}
// Mix the destination well
if (item.volumeAfter && item.destinationMixing) {
const mixing = item.destinationMixing;
const volume0 = item.volumeAfter;
// console.log({mixing, volume0: (volume0) ? volume0 : item})
const volume = calculateMixingVolume(volume0, mixing.amount);
const mixing2 = {
count: mixing.count,
volume: volume.format({precision: 14})
};
item2.destinationMixing = mixing2;
}
return item2;
});
// console.log("Z: "+JSON.stringify(_.first(items2)))
// _pipette instruction
expansionList.push(_.merge({}, {
"command": "pipetter._pipette",
"agent": agent,
"equipment": equipmentName,
"program": group[0].program,
"sourceProgram": parsed.value.sourceProgram,
"items": items2,
}));
});
// cleanEnd
// Priority: max(previousCleanAfter, params.cleanEnd || params.clean || "thorough")
const syringeToCleanEndValue = {};
// console.log({syringeToCleanValue})
_.forEach(syringeToCleanValue, function (value, syringe) {
const intensity = parsed.value.cleanEnd || parsed.value.clean || "thorough";
assert(intensityToValue.hasOwnProperty(intensity), "unknown clean intensity: "+intensity);
let intensityValue = intensityToValue[intensity];
if (syringeToCleanAfterValue.hasOwnProperty(syringe))
intensityValue = Math.max(syringeToCleanAfterValue[syringe], intensityValue);
if (value < intensityValue)
syringeToCleanEndValue[syringe] = intensityValue;
});
//console.log({syringeToCleanEndValue})
expansionList.push.apply(expansionList, createCleanActions(syringeToCleanEndValue, agent, equipmentName, data));
// Create the effets object
// TODO: set final tip clean values
const effects = {};
return {
expansion: expansionList,
effects: effects
};
}
// const NOMIXING = {count: 0, amount: 0};
const MIXINGDEFAULT = {count: 3, amount: 0.7};
function processMixingSpecs(l) {
const mixing = _.reduce(
l,
(acc, mixing) => {
if (_.isUndefined(mixing) || mixing === false) return undefined;
else if (mixing === true) return {};
else if (_.isPlainObject(mixing)) return _.merge(acc || {}, mixing);
return undefined;
},
undefined
);
_.defaults(mixing, MIXINGDEFAULT);
return mixing;
}
// Try to find a tipModel for the given items
function findTipModel(items, equipment, equipmentName) {
/*if (_.size(equipment.tipModel) === 1) {
const tipModelName = _.keys(equipment.tipModel)[0];
return `${equipmentName}.tipModel.${tipModelName}`;
}
else {*/
const tipModelName = _.findKey(equipment.tipModel, (tipModel) => {
return _.every(items, item => {
const volume = item.volume;
// Only if the item has a volume, then we'll need a tipModel
if (!_.isUndefined(volume) && math.compare(volume, math.unit(0, "ul")) > 0) {
assert(math.unit('l').equalBase(volume), "expected units to be in liters");
if (math.compare(volume, math.eval(tipModel.min)) < 0 || math.compare(volume, math.eval(tipModel.max)) > 0) {
return false;
}
// TODO: check whether the labware is sealed
// TODO: check whether the well has cells
}
return true;
});
});
return (!_.isEmpty(tipModelName))
? `${equipmentName}.tipModel.${tipModelName}`
: undefined;
// }
}
function setTipModel(items, equipment, equipmentName) {
assert(!_.isEmpty(items));
// FIXME: allow for overriding tipModel via top pipetter params
const tipModelName = findTipModel(items, equipment, equipmentName);
// console.log({tipModelName, items})
if (tipModelName) {
_.forEach(items, function(item) {
if (!item.tipModel) item.tipModel = tipModelName;
});
return true;
}
else {
return false;
}
}
// Calculate volume for each well or destination,
// adding properties `volumeBefore` and `volumeAfter` to the items.
function calculateWellVolumes(items, data) {
const wellVolumes = {};
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (_.isString(item.source)) {
const well = item.source;
const volume0 = (wellVolumes.hasOwnProperty(well)) ? wellVolumes[well] : WellContents.getWellVolume(well, data)
const volume1 = math.subtract(volume0, item.volume);
item.sourceVolumeBefore = volume0;
item.sourceVolumeAfter = volume1;
wellVolumes[well] = volume1;
}
const well = item.well || item.destination;
if (well) {
const volume0 = (wellVolumes.hasOwnProperty(well)) ? wellVolumes[well] : WellContents.getWellVolume(well, data)
const volume1 = (item.destination && item.volume)
? math.add(volume0, item.volume)
: volume0;
item.volumeBefore = volume0;
item.volumeAfter = volume1;
wellVolumes[well] = volume1;
// console.log({well, volume: wellVolumes[well]})
}
}
}
function extractLiquidNamesFromContents(contents) {
if (_.isEmpty(contents) || contents.length < 2) return [];
if (contents.length === 2 && _.isString(contents[1])) return [contents[1]];
else {
return _(contents).tail().map(function(contents2) {
return extractLiquidNamesFromContents(contents2);
}).flatten().value();
}
}
// Try to find a pipettingClass for the given items
function findPipettingClass(items, data) {
// Pick liquid properties by inspecting source contents
const pipettingClasses0 = items.map(item => {
let pipettingClass = "Water";
const source0 = item.source || item.well || item.destination; // If no source is provided, then use well or destination
const source = commandHelper.asArray(source0);
// FIXME: for debug only
if (!source || _.isEmpty(source)) {
console.log({item});
}
// ENDFIX
//console.log({source})
if (source.length > 0) {
//console.log({source})
const contents = WellContents.getWellContents(source[0], data);
if (contents) {
const liquids = extractLiquidNamesFromContents(contents);
const pipettingClasses = _(liquids).map(function(name) {
return misc.findObjectsValue(name+".pipettingClass", data.objects, null, "Water");
}).uniq().value();
// FIXME: should pick "Water" if water-like liquids have high enough concentration
// Use "Water" if present
if (!_.includes(pipettingClasses, "Water")) {
if (pipettingClasses.length === 1) {
pipettingClass = pipettingClasses[0];
}
else if (pipettingClasses.length > 1) {
pipettingClass = null;
}
}
}
}
return pipettingClass;
});
const pipettingClasses = _.uniq(pipettingClasses0);
if (pipettingClasses.length === 1) {
return pipettingClasses[0];
}
else {
return null;
}
}
// Pick position (wet or dry) by whether there are already contents in the destination well
function findPipettingPosition(items, data) {
const pipettingPositions = _(items).map(item => item.destination || item.well).compact().map(function(well) {
const i = well.indexOf('(');
const labware = well.substr(0, i);
const wellId = well.substr(i + 1, 3); // FIXME: parse this instead, allow for A1 as well as A01
const contents = misc.findObjectsValue(labware+".contents."+wellId, data.objects);
const liquids = extractLiquidNamesFromContents(contents);
return _.isEmpty(liquids) ? "Dry" : "Wet";
}).uniq().value();
if (pipettingPositions.length === 1) {
return pipettingPositions[0];
}
else {
return null;
}
}
function assignProgram(items0, data) {
// console.log("assignProgram: "+JSON.stringify(items))
// items0.forEach(x => console.log(JSON.stringify(x)))
// console.log({items0})
const items = items0.filter(item => item.volume && math.larger(item.volume, math.unit(0, "l")));
if (items.length > 0) {
// console.log({items})
const pipettingClass = findPipettingClass(items, data);
if (!pipettingClass) return false;
const pipettingPosition = findPipettingPosition(items, data);
if (!pipettingPosition) return false;
const tipModels = _(items).map('tipModel').compact().uniq().value();
if (tipModels.length !== 1) return false;
const tipModelName = tipModels[0];
assert(tipModelName, `missing value for tipModelName: `+JSON.stringify(tipModels));
const tipModelCode = misc.getObjectsValue(tipModelName+".programCode", data.objects);
//console.log({equipment})
assert(tipModelCode, `missing value for ${tipModelName}.programCode`);
const program = "\"Roboliq_"+pipettingClass+"_"+pipettingPosition+"_"+tipModelCode+"\"";
_.forEach(items0, function(item) { item.program = program; });
}
return true;
}
// Create clean commands before pipetting this group
function createCleanActions(syringeToCleanValue, agent, equipmentName, data, compareToOriginalState = false) {
// console.log("createCleanActions: "+JSON.stringify(syringeToCleanValue))
const items = _(syringeToCleanValue).toPairs().map(([syringeName0, n]) => {
if (n > 0) {
const syringeName = pipetterUtils.getSyringeName(syringeName0, equipmentName, data);
const syringe = commandHelper._g(data, syringeName);
if (compareToOriginalState) {
const intensity = syringe.cleaned;
const syringeCleanedValue = intensityToValue[syringe.cleaned] || 0;
// console.log({syringeName0, n, syringeName, intensity, syringeCleanedValue, syringe})
if (n > syringeCleanedValue)
return {syringe: syringeName, intensity: valueToIntensity[n]};
}
else {
return {syringe: syringeName, intensity: valueToIntensity[n]};
}
}
}).compact().value();
// console.log({cleanItems: items})
if (_.isEmpty(items)) return [];
return [{
command: "pipetter.cleanTips",
agent: agent,
equipment: equipmentName,
items
}];
}
/*
// Mix destination after dispensing?
function addMixing(parsed, agent, equipmentName, group, mixPropertyName, wellPropertyName, volumePropertyName) {
let mixItems = [];
_.forEach(group, function(item) {
const well = item[wellPropertyName];
const doMixing = !_.isUndefined(well) && _.get(item, mixPropertyName, _.get(parsed.value, mixPropertyName, false));
if (doMixing) {
const mixing = _.defaults({count: 3, amount: 0.7}, item[mixPropertyName], parsed.value[mixPropertyName]);
const volume0 = item[volumePropertyName];
const volume = calculateMixingVolume(volume0, mixing.amount);
const mixItem = _.merge({}, {
syringe: item.syringe,
well,
count: mixing.count,
volume: volume.format({precision: 14})
});
mixItems.push(mixItem);
}
});
if (mixItems.length > 0) {
const mixCommand = {
command: "pipetter._mix",
agent,
equipment: equipmentName,
program: group[0].program, // FIXME: even if we used Air dispense for the dispense, we need to use Wet or Bot here
items: mixItems
};
return mixCommand;
}
return undefined;
}
*/
function calculateMixingVolume(volume0, amount) {
amount = _.isString(amount) ? math.eval(amount) : amount;
// console.log("amount: "+JSON.stringify(amount))
// console.log("type: "+math.typeof(amount))
switch (math.typeof(amount)) {
case "number":
case "BigNumber":
case "Fraction":
// assert(amount >= 0 && amount < 1, "amount must be between 0 and 1: "+JSON.stringify(item));
return math.multiply(volume0, amount);
case "Unit":
return amount;
}
assert(false, "expected amount to be a volume or a number: "+JSON.amount);
}
/**
* Handlers for {@link pipetter} commands.
* @static
*/
const commandHandlers = {
"pipetter._aspirate": function(params, parsed, data) {
// console.log("params", JSON.stringify(params, null, ' '))
const effects = pipetterUtils.getEffects_pipette(parsed, data);
// console.log("effects:", JSON.stringify(effects, null, ' '))
return {effects};
},
"pipetter._dispense": function(params, parsed, data) {
//console.log("params", JSON.stringify(params, null, ' '))
const effects = pipetterUtils.getEffects_pipette(parsed, data);
//console.log("effects:", JSON.stringify(effects, null, ' '))
return {effects};
},
"pipetter._measureVolume": function(params, parsed, data) {
// console.log("pipetter._punctureSeal: "+JSON.stringify(parsed, null, '\t'))
const effects = pipetterUtils.getEffects_pipette(parsed, data);
const result = {
effects,
reports: (_.isEmpty(data.objects.DATA)) ? undefined : {
measurementFactors: data.objects.DATA
}
};
if (_.has(parsed.value, ["output", "simulated"])) {
const wells = parsed.value.items.map(item => item.well);
simulatedHelpers.simulatedByWells(parsed, data, wells, result);
}
return result;
},
"pipetter._mix": function(params, parsed, data) {
// console.log("pipetter._mix: "+JSON.stringify(parsed, null, '\t'))
parsed.value.items = commandHelper.copyItemsWithDefaults(parsed.value.items, parsed.value.itemDefaults);
//console.log("params", JSON.stringify(params, null, ' '))
//console.log("effects:", JSON.stringify(pipetterUtils.getEffects_pipette(params, data), null, ' '))
return {
effects: pipetterUtils.getEffects_pipette(parsed, data)
};
},
"pipetter._pipette": function(params, parsed, data) {
// console.log("params", JSON.stringify(params, null, ' '))
const effects = pipetterUtils.getEffects_pipette(parsed, data);
// console.log("effects:", JSON.stringify(effects, null, ' '))
return {effects};
},
"pipetter._punctureSeal": function(params, parsed, data) {
// console.log("pipetter._punctureSeal: "+JSON.stringify(parsed, null, '\t'))
const effects = pipetterUtils.getEffects_pipette(parsed, data);
// Add effects for seal punctures
_.forEach(parsed.value.items, item => {
const wellInfo = wellsParser.parseOne(item.well);
const labwareName = wellInfo.source || wellInfo.labware;
const id = `${labwareName}.sealPunctures.${wellInfo.wellId}`;
if (_.get(data.objects, id) !== true) {
effects[id] = true;
}
});
return { effects };
},
"pipetter._washTips": function(params, parsed, data) {
//console.log("_washTips:");
//console.log(JSON.stringify(parsed, null, '\t'))
const effects = {};
parsed.value.syringes.forEach((syringe, index) => {
const syringeName = parsed.objectName[`syringes.${index}`];
if (!_.isUndefined(syringe.contaminants))
effects[`${syringeName}.contaminants`] = null;
// Remove contents property
if (!_.isUndefined(syringe.contents))
effects[`${syringeName}.contents`] = null;
// Set cleaned property
if (syringe.cleaned !== parsed.value.intensity)
effects[`${syringeName}.cleaned`] = parsed.value.intensity;
});
return {effects};
},
"pipetter.cleanTips": function(params, parsed, data) {
// console.log("pipetter.cleanTips:")
// console.log(JSON.stringify(parsed, null, '\t'));
const syringes0 = (params.syringes)
? commandHelper.asArray(params.syringes)
: (!params.items && parsed.value.equipment && parsed.value.equipment.syringe)
? _.keys(parsed.value.equipment.syringe).map(s => parsed.objectName.equipment + ".syringe." + s)
: [];
const n = _.max([syringes0.length, commandHelper.asArray(params.items).length])
const itemsToMerge = [
syringes0.map(syringe => { return {syringe} }),
(params.intensity) ? _.times(n, () => ({intensity: params.intensity})) : []
];
const items = _.merge([], itemsToMerge[0], itemsToMerge[1], params.items);
//console.log("items: "+JSON.stringify(params.items))
//console.log(JSON.stringify(itemsToMerge, null, '\t'));
//console.log("items: "+JSON.stringify(items))
// Ensure fully qualified names for the syringes
_.forEach(items, item => {
if (_.isInteger(item.syringe)) {
item.syringe = `${parsed.objectName.equipment}.syringe.${item.syringe}`;
}
});
// Get list of valid agent/equipment/syringe combinations for all syringes
const nodes = _.flatten(items.map(item => {
const predicates = [
{"pipetter.canAgentEquipmentSyringe": {
"agent": parsed.objectName.agent,
"equipment": parsed.objectName.equipment,
syringe: item.syringe
}}
];
//console.log(predicates)
const [, alternatives] = commandHelper.queryLogic(data, predicates, "pipetter.canAgentEquipmentSyringe");
expect.truthy({paramName: "items"}, !_.isEmpty(alternatives), `could not find agent and equipment to clean syring ${item.syringe}`);
return alternatives;
}));
//console.log(nodes);
// Group by agent+equipment
const equipToNodes = _.groupBy(nodes, x => `${x.agent}|${x.equipment}`);
//console.log(equipToNodes);
// Group by syringe
const syringeToNodes = _.groupBy(nodes, x => x.syringe);
// console.log({syringeToNodes});
// Desired intensity for each syringe
const syringeToItem = _.groupBy(items, item => item.syringe);
// Sub-command list
let expansion = [];
// Get list of syringes
let syringesRemaining = _.uniq(items.map(item => item.syringe));
//console.log({nodes, syringeToNodes, syringeToItem})
// Generate sub-commands until all syringes have been taken care of
while (!_.isEmpty(syringesRemaining)) {
const syringe = syringesRemaining[0];
const nodes = syringeToNodes[syringe];
// console.log({syringe, nodes})
// Arbitrarily pick the first possible agent/equipment combination
const {agent, equipment} = nodes[0];
const equipNodes = equipToNodes[`${agent}|${equipment}`];
const syringes = _.intersection(syringesRemaining, equipNodes.map(x => x.syringe));
// Create cleanTips items
const items = _.flatten(syringes.map(syringe => syringeToItem[syringe]));
//console.log({syringes, syringeToItem, items})
// Add the sub-command
expansion.push({
command: `pipetter.cleanTips|${agent}|${equipment}`,
agent,
equipment,
items
});
// Remove those syringes from the remaining list
syringesRemaining = _.difference(syringesRemaining, syringes);
}
//console.log(expansion);
return {expansion};
},
"pipetter.measureVolume": function(params, parsed, data) {
// console.log("pipetter.measureVolume: "+JSON.stringify(parsed))
const items = commandHelper.copyItemsWithDefaults(parsed.value.items, {
well: parsed.value.wells,
});
// console.log("items: "+JSON.stringify(items))
// Add labware to well properties
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item.well && parsed.objectName.wellLabware) {
item.well = getLabwareWell(parsed.objectName.wellLabware, item.well);
}
}
const parsed2 = _.cloneDeep(parsed);
// _.merge(parsed2.value, defaults3);
parsed2.value.items = items;
const result = pipette(params, parsed2, data, {keepVolumelessItems: true});
_.forEach(result.expansion, step => {
if (step.command === "pipetter._pipette") {
step.command = "pipetter._measureVolume";
if (parsed.orig.output)
step.output = _.clone(parsed.orig.output);
}
});
return result;
},
"pipetter.mix": function(params, parsed, data) {
// console.log("pipetter.mix: "+JSON.stringify(parsed, null, '\t'))
const items = commandHelper.copyItemsWithDefaults(parsed.value.items, {
well: parsed.value.wells,
count: parsed.value.counts,
amount: parsed.value.amounts
});
// console.log("items: "+JSON.stringify(items))
// Add labware to well properties
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item.well && parsed.objectName.wellLabware) {
item.well = getLabwareWell(parsed.objectName.wellLabware, item.well);
}
}
const items2 = _.map(items, (item, i) => {
assert(item.well, `missing well for mix item ${i}: ${JSON.stringify(item)}`);
const volume0 = WellContents.getWellVolume(item.well, data);
assert(math.compare(volume0, math.unit(0, 'l')) > 0, "cannot mix empty wells");
const item2 = _.omit(item, ["amount"]);
item2.volume = calculateMixingVolume(volume0, item.amount);
return item2;
});
const parsed2 = _.cloneDeep(parsed);
// _.merge(parsed2.value, defaults3);
parsed2.value.items = items2;
const result = pipette(params, parsed2, data);
_.forEach(result.expansion, step => {
if (step.command === "pipetter._pipette") {
step.command = "pipetter._mix";
const {items: items3, defaults: defaults3} = commandHelper.splitItemsAndDefaults(step.items, ["syringe", "well"]);
// console.log({items3, defaults3});
if (!_.isEmpty(defaults3))
step.itemDefaults = defaults3;
step.items = items3;
}
});
return result;
},
"pipetter.pipette": pipette,
"pipetter.pipetteDilutionSeries": function(params, parsed, data) {
// console.log("pipetter.pipetteDilutionSeries: "+JSON.stringify(parsed, null, '\t'))
const destinationLabware = parsed.objectName.destinationLabware;
const dilutionMethod = parsed.value.dilutionMethod;
// Fill all destination wells with diluent
const diluentItems = [];
const items = [];
_.forEach(parsed.value.items, (item, itemIndex) => {
if (_.isEmpty(item.destinations)) return;
// FIXME: handle `source`
assert(_.isUndefined(item.source), "`source` property not implemented yet");
const destinations1 = item.destinations.map(s => getLabwareWell(destinationLabware, s));
const destination0 = destinations1[0];
const destinations2 = _.tail(destinations1);
const syringeName = parsed.objectName[`items.${itemIndex}.syringe`] || item.syringe;
// console.log({destination0, destinations2, syringeName})
let dilutionFactorPrev = 1;
// get volume of destination0
const volume0 = WellContents.getWellVolume(destination0, data);
assert(math.compare(volume0, math.unit(0, 'l')) > 0, "first well in dilution series shouldn't be empty");
// The target volume of the dilution wells (or take the volume of the first 'destination')
const volumeFinal = parsed.value.volume || volume0;
// Dilute the destination0, if necessary
if (math.smaller(volume0, volumeFinal)) {
assert(parsed.objectName.diluent, "missing 'diluent'");
const diluentVolume2 = math.subtract(volumeFinal, volume0);
const item2 = {
source: parsed.objectName.diluent,
destination: getLabwareWell(destinationLabware, destination0),
volume: diluentVolume2.format({precision: 4}),
syringe: syringeName,
};
diluentItems.push(item2);
}
// Calculate volume to transfer from one well to the next, and the diluent volume
const sourceVolume = (dilutionMethod === "source")
? volumeFinal : math.divide(volumeFinal, parsed.value.dilutionFactor);
const diluentVolume = (dilutionMethod === "source")
? volumeFinal : math.subtract(volumeFinal, sourceVolume);
// console.log({volume0: volume0.format(), sourceVolume: sourceVolume.format(), diluentVolume: diluentVolume.format()})
// If we want to pre-dispense the diluent:
if (dilutionMethod === "begin") {
// Distribute diluent to all destination wells
// If 'lastWellHandling == none', don't dilute the last well
const destinations3 = (parsed.value.lastWellHandling !== "none") ? destinations2 : _.initial(destinations2);
_.forEach(destinations3, (destinationWell, index) => {
const wellContents = WellContents.getWellContents(destinationWell, data);
const wellVolume = WellContents.getVolume(wellContents);
if (math.smaller(wellVolume, diluentVolume)) {
assert(parsed.objectName.diluent, "missing 'diluent'");
const diluentVolume2 = math.subtract(diluentVolume, wellVolume);
const item2 = {
layer: index+1,
source: parsed.objectName.diluent,
destination: getLabwareWell(destinationLabware, destinationWell),
volume: diluentVolume2.format({precision: 4}),
syringe: syringeName,
};
diluentItems.push(item2);
}
});
}
// console.log({diluentItems})
// Pipette the dilutions
let source = destination0;
_.forEach(destinations2, (destinationWell, index) => {
const destination = getLabwareWell(destinationLabware, destinationWell);
// Dilute the source first?
if (dilutionMethod === "source") {
const layer = (index + 1) * 2 - 1;
const volume = math.subtract(math.multiply(volumeFinal, parsed.value.dilutionFactor), volumeFinal);
const item2 = {
layer,
source: parsed.objectName.diluent,
destination: source,
volume: volume.format({precision: 4}),
syringe: syringeName,
sourceMixing: false,
destinationMixing: false
};
items.push(item2);
}
// Transfer to destination
{
const layer = (dilutionMethod !== "begin") ? (index + 1) * 2 : index + 1;
// console.log({dilutionMethod, index, layer})
const item2 = {
layer, source, destination,
volume: sourceVolume.format({precision: 4}),
syringe: syringeName
};
// Mix before aspirating from first dilution well
if (index === 0 || dilutionMethod === "source") {
item2.sourceMixing = _.get(parsed.value, "sourceMixing", true);
}
items.push(item2);
}
source = destination;
});
// May need to extract aliquot from the final destination well in order to
// get it to the proper volume
if (dilutionMethod !== "source") {
// If disposal wells are specified, transfer extra volume from last well to the disposal
// FIXME: implement sending last aspirate to TRASH!
// Create final aspiration
items.push({
layer: (dilutionMethod === "begin") ? destinations2.length + 1 : (destinations2.length + 1) * 2 - 1,
source: getLabwareWell(destinationLabware, _.last(destinations2)),
volume: sourceVolume.format({precision: 4}),
syringe: syringeName
});
}
/*const source = (firstItemIsSource) ? dilution0.destination : dilution0.source;
_.forEach(series, dilution => {
// If the first item doesn't define a source, but it's dilutionFactor = 1, then treat the destination well as the source.
assert(!_.isUndefined(source), "dilution item requires a source");
diluentItems.push({source: parsed.objectName.diluent, destination})
});
*/
});
//const items = [];
const expansion = [];
if (diluentItems.length > 0) {
// Cleaning:
// if 'items' is empty,
const params1 = _.pick(parsed.orig, ["destinationLabware", "sourceLabware", "syringes"]);
params1.command = "pipetter.pipette";
params1.items = diluentItems;
if (parsed.value.cleanBegin) params1.cleanBegin = parsed.value.cleanBegin;
params1.cleanBetweenSameSource = "none";
if (items.length > 0) params1.cleanEnd = "none";
else if (parsed.value.cleanEnd) params1.cleanEnd = parsed.value.cleanEnd;
_.merge(params1, parsed.orig.diluentParams);
expansion.push(params1);
}
if (items.length > 0) {
const params2 = _.pick(parsed.orig, ["destinationLabware", "sourceLabware", "syringes"]);
params2.command = "pipetter.pipette";
params2.items = items;
if (diluentItems.length > 0) params2.cleanBegin = "none";
else if (parsed.value.cleanBegin) params2.cleanBegin = parsed.value.cleanBegin;
params2.cleanBetweenSameSource = "none";
if (parsed.value.cleanEnd) params2.cleanEnd = parsed.value.cleanEnd;
const destinationMixing = (dilutionMethod === "begin") ? true : false;
_.defaults(params2, parsed.value.dilutionParams, {cleanBetween: "none", destinationMixing});
expansion.push(params2);
// console.log({params1, params2})
}
return { expansion };
},
"pipetter.pipetteMixtures": function(params, parsed, data) {
// console.log("pipetter.pipetteMixtures: "+JSON.stringify(parsed, null, '\t'));
// Obtain a matrix of mixtures (rows for destinations, columns for layers)
const mixtures0 = parsed.value.mixtures.map((item, index1) => {
//console.log({index1, destination: item.destination || _.get(parsed.value.destinations, index1), destinations: _.get(parsed.value.destinations, index1)})
const {destination, syringe, sources} = (_.isPlainObject(item))
? {destination: item.destination || _.get(parsed.value.destinations, index1), syringe: item.syringe, sources: item.sources}
: {destination: _.get(parsed.value.destinations, index1), syringe: undefined, sources: item};
return sources.map((subitem, index2) => {
// If the layer is empty, ignore it
if (!_.isEmpty(subitem)) {
// Fill in destination and syringe defaults for current destination+layer
const item2 = _.merge({},
{destination, syringe},
subitem,
{index: index2}
);
//console.log({item2})
return item2;
}
else {
return [];
}
});
});
//const destinations = parsed.value.destinations;
//console.log("params:", params);
//console.log("data.objects.mixtures:", data.objects.mixtures);
//console.log("mixtures:\n"+JSON.stringify(mixtures0));
//console.log("A:", misc.getVariableValue(params.destinations, data.objects))
//console.log("data.objects.mixtureWells:", data.objects.mixtureWells);
//console.log("destinations:", destinations);
//expect.truthy({}, destinations.length >= params.mixtures.length, "length of destinations array must be equal or greater than length of mixtures array.");
const mixtures = _.compact(mixtures0);
//console.log("mixtures:", mixtures);
const params2 = _.omit(params, ['mixtures', 'destinations', 'order']);
let order = parsed.value.order || ["index"];
if (!_.isArray(order)) order = [order];
//console.log("A:", params2.items)
params2.items = _(mixtures).flatten().compact().sortBy(order).map(item => _.omit(item, 'index')).value();
// console.log("B:", params2.items)
params2.command = "pipetter.pipette";
return {
expansion: {
"1": params2
}
};
},
"pipetter.punctureSeal": function(params, parsed, data) {
const result = pipette(params, parsed, data, {keepVolumelessItems: true});
_.forEach(result.expansion, step => {
if (step.command === "pipetter._pipette") {
step.command = "pipetter._punctureSeal";
}
delete step.program;
});
return result;
},
};
module.exports = {
roboliq: "v1",
schemas: yaml.load(__dirname+'/../schemas/pipetter.yaml'),
commandHandlers: commandHandlers
};