/** * 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 };