/** * Roboliq: Automation for liquid-handling robots * @copyright 2017, ETH Zurich, Ellis Whitehead * @license GPL-3.0 */ /** * Module for the Tecan InfiniteM200 reader. * @module */ import _ from 'lodash'; import assert from 'assert'; import math from 'mathjs'; import Handlebars from 'handlebars'; import path from 'path'; import commandHelper from 'roboliq-processor/dist/commandHelper.js'; import expect from 'roboliq-processor/dist/expect.js'; import wellsParser from 'roboliq-processor/dist/parsers/wellsParser.js'; import {makeEvowareExecute, makeEvowareFacts} from './evoware.js'; const templateAbsorbance = `<TecanFile xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="tecan.at.schema.documents Main.xsd" fileformat="Tecan.At.Measurement" fileversion="2.0" xmlns="tecan.at.schema.documents"> <FileInfo type="" instrument="infinite 200Pro" version="" createdFrom="localadmin" createdAt="{{createdAt}}" createdWith="Tecan.At.XFluor.ReaderEditor.XFluorReaderEditor" description="" /> <TecanMeasurement id="1" class="Measurement"> <MeasurementManualCycle id="2" number="1" type="Standard"> <CyclePlate id="3" file="{{plateFile}}" plateWithCover="{{plateWithCover}}"> <PlateRange id="4" range="{{wells}}" auto="false"> {{#if doShakeBefore}}<Shaking id="{{shakeBeforeId}}" mode="Orbital" time="{{shakeBeforeTime}}" frequency="0" amplitude="{{shakeBeforeAmplitude}}" maxDeviation="PT0S" settleTime="PT0S" />{{/if}} {{#if doSettleBefore}}<WaitTime id="{{settleBeforeId}}" timeSpan="{{settleBeforeTime}}" maxDeviation="PT0S" refTimeID="0" ignoreInLastCycle="False" />{{/if}} {{#if doMeasure}}<MeasurementAbsorbance id="{{measureId}}" mode="Normal" type="" name="ABS" longname="" description=""> <Well id="6" auto="true"> <MeasurementReading id="7" name="" beamDiameter="{{beamDiameter}}" beamGridType="{{beamGridType}}" beamGridSize="{{beamGridSize}}" beamEdgeDistance="{{beamEdgeDistance}}"> <ReadingLabel id="8" name="Label1" scanType="{{scanType}}" refID="0"> <ReadingSettings number="25" rate="25000" /> <ReadingTime integrationTime="0" lagTime="0" readDelay="{{readDelay}}" flash="0" dark="0" excitationTime="0" /> <ReadingFilter id="0" type="Ex" wavelength="{{excitationWavelength}}" bandwidth="{{excitationBandwidth}}" attenuation="0" usage="ABS" /> </ReadingLabel> </MeasurementReading> </Well> </MeasurementAbsorbance>{{/if}} </PlateRange> </CyclePlate> </MeasurementManualCycle> <MeasurementInfo id="0" description=""> <ScriptTemplateSettings id="0"> <ScriptTemplateGeneralSettings id="0" Title="" Group="" Info="" Image="" /> <ScriptTemplateDescriptionSettings id="0" Internal="" External="" IsExternal="False" /> </ScriptTemplateSettings> </MeasurementInfo> </TecanMeasurement> </TecanFile>`; // TODO: continue working on this with the goal of allowing shaking in the reader const templateShake = `<TecanFile xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="tecan.at.schema.documents Main.xsd" fileformat="Tecan.At.Measurement" fileversion="2.0" xmlns="tecan.at.schema.documents"> <FileInfo type="" instrument="infinite 200Pro" version="" createdFrom="localadmin" createdAt="{{createdAt}}" createdWith="Tecan.At.XFluor.ReaderEditor.XFluorReaderEditor" description="" /> <TecanMeasurement id="1" class="Measurement"> <MeasurementManualCycle id="2" number="1" type="Standard"> <CyclePlate id="3" file="{{plateFile}}" plateWithCover="{{plateWithCover}}"> <PlateRange id="4" range="{{wells}}" auto="false"> {{#items}} <Shaking id="{{id}}" mode="Orbital" time="{{time}}" frequency="0" amplitude="{{amplitude}}" maxDeviation="PT0S" settleTime="PT0S" /> {{/items}} </PlateRange> </CyclePlate> </MeasurementManualCycle> <MeasurementInfo id="0" description=""> <ScriptTemplateSettings id="0"> <ScriptTemplateGeneralSettings id="0" Title="" Group="" Info="" Image="" /> <ScriptTemplateDescriptionSettings id="0" Internal="" External="" IsExternal="False" /> </ScriptTemplateSettings> </MeasurementInfo> </TecanMeasurement> </TecanFile>`; function locationRowColToText(row, col) { var colText = col.toString(); return String.fromCharCode("A".charCodeAt(0) + row - 1) + colText; } function getTemplateAbsorbanceParams(parsed, data) { const program = parsed.value.program || {}; const output = parsed.value.output || {}; // const labwareModelName = parsed.objectName["object.model"]; const labwareModelName = parsed.value.object.model; // console.log({labwareModelName}) const labwareModel = _.get(data.objects, labwareModelName); // console.log({labwareModel}) const modelToPlateFile = parsed.value.equipment.modelToPlateFile; // assert(modelToPlateFile, `please define ${parsed.objectName.equipment}.modelToPlateFile`); expect.truthy({paramName: "equipment"}, modelToPlateFile, `please define ${parsed.objectName.equipment}.modelToPlateFile`); const plateFile = modelToPlateFile[labwareModelName]; // assert(plateFile, `please define ${parsed.objectName.equipment}.modelToPlateFile."${labwareModelName}"`); expect.truthy({paramName: "equipment"}, plateFile, `please define ${parsed.objectName.equipment}.modelToPlateFile."${labwareModelName}"`); let wells; const wells0 = (program.wells) ? commandHelper.asArray(program.wells) : (output.joinKey) ? commandHelper.getDesignFactor(output.joinKey, data.objects.DATA) : undefined; if (wells0) { // console.log({program}) // Get well list const wells1 = _.flatMap(wells0, s => wellsParser.parse(s, data.objects)); // console.log({wells0}) const rx = /\(([^)]*)\)/; const wells2 = wells1.map(s => { const match = s.match(rx); return (match) ? match[1] : s; }); // console.log({wells1}) const rowcols = wells2.map(s => wellsParser.locationTextToRowCol(s)); // console.log({rowcols}) // rowcols.sort(); rowcols.sort((a, b) => (a[0] == b[0]) ? a[1] - b[1] : a[0] - b[0]); // console.log({rowcols}) if (_.isEmpty(rowcols)) { wells = ""; } else { let prev = rowcols[0]; let indexOnPlatePrev = (prev[0] - 1) * labwareModel.columns + prev[1]; wells = locationRowColToText(prev[0], prev[1])+":"; for (let i = 1; i < rowcols.length; i++) { const rowcol = rowcols[i]; const indexOnPlate = (rowcol[0] - 1) * labwareModel.columns + rowcol[1]; // If continuity is broken or we've changed rows: // TODO: do something smarter than starting over on each row; // but it can be tricky, because the reader expects square blocks // of wells. if (indexOnPlate !== indexOnPlatePrev + 1 || rowcol[0] != prev[0]) { wells += locationRowColToText(prev[0], prev[1])+"|"+locationRowColToText(rowcol[0], rowcol[1])+":"; } prev = rowcol; indexOnPlatePrev = indexOnPlate; } wells += locationRowColToText(prev[0], prev[1]); } } // If not specified, read all wells on plate else { wells = "A1:"+locationRowColToText(labwareModel.rows, labwareModel.columns); } // console.log({wells}) let isScan = false; let excitationWavelength; let excitationBandwidth; // This will normally be true if program data was passed to the absorbanceReader command, // but it may be empty if `programFileTemplate` was passed. if (program.excitationWavelength || program.excitationWavelengthMax) { isScan = (program.excitationWavelengthMin && program.excitationWavelengthMax); if (isScan) { const excitationWavelengthMin = program.excitationWavelengthMin.toNumber("nm"); const excitationWavelengthStep0 = program.excitationWavelengthStep || math.unit(2, "nm"); const excitationWavelengthStep = excitationWavelengthStep0.toNumber("nm"); const excitationWavelengthMax0 = program.excitationWavelengthMax.toNumber("nm"); const stepCount = Math.floor((excitationWavelengthMax0 - excitationWavelengthMin) / excitationWavelengthStep); const excitationWavelengthMax = excitationWavelengthMin + excitationWavelengthStep * stepCount; excitationWavelength = `${excitationWavelengthMin*10}~${excitationWavelengthMax*10}:${excitationWavelengthStep*10}`; } else { // console.log({program}) excitationWavelength = program.excitationWavelength.toNumber("nm") * 10; } const excitationBandwidth0 = program.excitationBandwidth || math.unit(9, "nm"); excitationBandwidth = excitationBandwidth0.toNumber("nm") * 10; } else { assert(parsed.value.programFileTemplate, "You must supply either `program.excitationWavelength` or `programFileTemplate`"); } const doMeasure = true; let nextId = 5; const shakeBeforeParams = (_.has(program, "shakerProgramBefore.duration")) ? { doShakeBefore: true, shakeBeforeId: nextId++, shakeBeforeTime: "PT"+program.shakerProgramBefore.duration.toNumber("s")+"S", shakeBeforeAmplitude: 4000 } : {}; const settleBeforeParams = (_.has(program, "shakerProgramBefore.settleDuration")) ? { doSettleBefore: true, settleBeforeId: nextId++, settleBeforeTime: "PT"+program.shakerProgramBefore.settleDuration.toNumber("s")+"S", shakeBeforeAmplitude: 4000 } : {}; const measureId = (doMeasure) ? nextId++ : null; const params = _.defaults( { //createdAt: moment().format("YYYY-MM-DDTHH:mm:ss.SSSSSSS")+"Z", createdAt: "2016-01-01T00:00:00.0000000Z", plateFile, plateWithCover: (parsed.value.object.isSealed || parsed.value.object.isCovered) ? "True" : "False", wells, doMeasure, measureId, beamDiameter: (isScan) ? 0 : 500, beamGridType: "Single", beamGridSize: (isScan) ? 0 : 1, beamEdgeDistance: (isScan) ? "" : "auto", scanType: (isScan) ? "ScanEX" : "ScanFixed", readDelay: (isScan) ? 0 : 10000, excitationWavelength, excitationBandwidth, }, shakeBeforeParams, settleBeforeParams ); // console.log({params, excitationWavelength0, excitationWavelength, excitationBandwidth0, excitationBandwidth}); return params; } /** * @typedef ReaderInfiniteM200ProConfig * @type {object} * @property {!string} evowareId - the Evoware ID of this equipment * @property {!string} evowareCarrier - the carrier that the equipment is on * @property {!string} evowareGrid - the grid that the equipment is on * @property {!number} evowareSite - the evoware site index of the equipment site * @param {!string} site - the equipment's site name (just the base part, without namespace) * @param {!Object.<string, string>} modelToPlateFile - a map from labware model to equipment's plate filename * @example * ``` * evowareId: "ReaderNETwork", * evowareCarrier: "Infinite M200", * evowareGrid: 61, * evowareSite: 1, * site: "READER", * modelToPlateFile: { * "plateModel_96_round_transparent_nunc": "NUN96ft", * "plateModel_384_square": "GRE384fw", * "EK_384_greiner_flat_bottom": "GRE384fw", * "EK_96_well_Greiner_Black": "GRE96fb_chimney" * } * ``` */ function configure(config, equipmentName, params) { const agent = config.getAgentName(); const equipment = config.getEquipmentName(equipmentName); const site = config.getSiteName(params.site); const objects = {}; // Add equipment _.set(objects, equipment, { type: "Reader", evowareId: params.evowareId, sitesInternal: [site], modelToPlateFile: _.fromPairs(_.map(_.toPairs(params.modelToPlateFile), ([model0, file]) => [config.getModelName(model0), file])) }); // Add site _.set(objects, site, { type: "Site", evowareCarrier: params.evowareCarrier, evowareGrid: params.evowareGrid, evowareSite: params.evowareSite, closed: true }); const predicates = _.flatten([ _.flatten(_.map(Object.keys(params.modelToPlateFile), model0 => [ { "absorbanceReader.canAgentEquipmentModelSite": { agent, equipment, model: config.getModelName(model0), site } }, { "fluorescenceReader.canAgentEquipmentModelSite": { agent, equipment, model: config.getModelName(model0), site } }, ])), /*{ "shaker.canAgentEquipmentSite": { agent, equipment, site } },*/ ]); predicates.push(...exports.getPredicates(agent, equipment, site)); // console.log({planHandlers: exports.getPlanHandlers(agent, equipment, site)}) const protocol = { schemas: exports.getSchemas(agent, equipment), objects, predicates, planHandlers: exports.getPlanHandlers(agent, equipment, site), commandHandlers: exports.getCommandHandlers(agent, equipment), }; return protocol; } const exports = { getSchemas: (agentName, equipmentName) => ({ [`equipment.close|${agentName}|${equipmentName}`]: { properties: { agent: {description: "Agent identifier", type: "Agent"}, equipment: {description: "Equipment identifier", type: "Equipment"}, }, required: ["agent", "equipment"] }, [`equipment.open|${agentName}|${equipmentName}`]: { properties: { agent: {description: "Agent identifier", type: "Agent"}, equipment: {description: "Equipment identifier", type: "Equipment"}, }, required: ["agent", "equipment"] }, [`equipment.openSite|${agentName}|${equipmentName}`]: { properties: { agent: {description: "Agent identifier", type: "Agent"}, equipment: {description: "Equipment identifier", type: "Equipment"}, site: {description: "Site identifier", type: "Site"} }, required: ["agent", "equipment", "site"] }, [`equipment.run|${agentName}|${equipmentName}`]: { description: "Run measurement on Infinite M200 Pro reader", properties: { agent: {description: "Agent identifier", type: "Agent"}, equipment: {description: "Equipment identifier", type: "Equipment"}, measurementType: {description: "Type of measurement, i.e fluorescence or absorbance", enum: ["fluorescence", "absorbance"]}, program: { description: "Program definition", properties: { shakerProgramBefore: { description: "Program for shaker.", properties: { rpm: {description: "Rotations per minute (RPM)", type: "number"}, duration: {description: "Duration of shaking", type: "Duration"}, settleDuration: {description: "Duration to settle after shaking", type: "Duration"} } }, wells: {description: "Array of wells to read", type: "Wells"}, excitationWavelength: {description: "Excitation wavelength", type: "Length"}, excitationBandwidth: {description: "Excitation bandwidth", type: "Length"}, excitationWavelengthMin: {description: "Minimum excitation wavelength for a scan", type: "Length"}, excitationWavelengthMax: {description: "Maximum excitation wavelength for a scan", type: "Length"}, excitationWavelengthStep: {description: "Size of steps for a scan", type: "Length"} } }, programFileTemplate: {description: "Program template; well information will be substituted into the template automatically.", type: "File"}, programFile: {description: "Program filename", type: "File"}, programData: {description: "Program data"}, object: {description: "The labware being measured", type: "Plate"}, output: { description: "Output definition for where and how to save the measurements", properties: { joinKey: {description: "The key used to left-join the measurement values with the current DATA", type: "string"}, writeTo: {description: "Filename to write measurements to as JSON", type: "string"}, appendTo: {description: "Filename to append measurements to as newline-delimited JSON", type: "string"}, userValues: {description: "User-specificed values that should be included in the output table", type: "object"}, simulated: {description: "An expression to evaluate with mathjs", type: "string"}, units: {description: "Map of factor names to unit type; converts the factor values to plain numbers in the given units."} } } }, required: ["measurementType"] }, // [`shaker.run|${agentName}|${equipmentName}`]: { // properties: { // agent: {description: "Agent identifier", type: "Agent"}, // equipment: {description: "Equipment identifier", type: "Equipment"}, // program: { // description: "Program for shaking", // properties: { // amplitude: {description: "Amplitude", enum: ["min", "low", "high", "max"]}, // duration: {description: "Duration of shaking", type: "Duration"} // }, // required: ["duration"] // } // }, // required: ["agent", "equipment", "program"] // }, }), getCommandHandlers: (agentName, equipmentName) => ({ // Reader [`equipment.close|${agentName}|${equipmentName}`]: function(params, parsed, data) { return {expansion: [makeEvowareFacts(parsed, data, "Close")]}; }, [`equipment.open|${agentName}|${equipmentName}`]: function(params, parsed, data) { return {expansion: [makeEvowareFacts(parsed, data, "Open")]}; }, [`equipment.openSite|${agentName}|${equipmentName}`]: function(params, parsed, data) { var carrier = commandHelper.getParsedValue(parsed, data, "equipment", "evowareId"); var sitesInternal = commandHelper.getParsedValue(parsed, data, "equipment", "sitesInternal"); var siteIndex = sitesInternal.indexOf(parsed.objectName.site); expect.truthy({paramName: "site"}, siteIndex >= 0, "site must be one of the equipments internal sites: "+sitesInternal.join(", ")); return {expansion: [makeEvowareFacts(parsed, data, "Open")]}; }, [`equipment.run|${agentName}|${equipmentName}`]: function(params, parsed, data) { // console.log("reader-InfiniteM200Pro-equipment.run: "+JSON.stringify(parsed, null, '\t')); const hasProgram = (parsed.value.program) ? 1 : 0; const hasProgramFile = (parsed.value.programFile) ? 1 : 0; const hasProgramData = (parsed.value.programData) ? 1 : 0; expect.truthy({}, hasProgram + hasProgramFile + hasProgramData >= 1, "either `program`, `programFile` or `programData` must be specified."); expect.truthy({}, hasProgram + hasProgramFile + hasProgramData <= 1, "only one of `program`, `programFile` or `programData` may be specified."); let content = (hasProgramData) ? parsed.value.programData.toString('utf8') : (hasProgramFile) ? parsed.value.programFile.toString('utf8') : (!_.isEmpty(parsed.value.programFileTemplate)) ? parsed.value.programFileTemplate.toString('utf8') : undefined; // If program: // if hasProgramFile: take programFile as template and modify it for substituting in wells and whatnot // otherwise use the template // do substitutions if (hasProgram) { // If programFile is not supplied, use the default template: if (_.isUndefined(content)) { assert(parsed.value.measurementType === "absorbance", "MISSING FUNCTIONALITY: programmer needs to supply template for fluorescence programs"); content = templateAbsorbance; } // Otherwise, modify the programFile/programData to allow for substitutions: else { // substitute the wells into the program file if (!_.isEmpty(parsed.value.program.wells) || !_.isEmpty(parsed.value.output.joinKey)) { content = content.replace(/<PlateRange id="(\d+)" range="[^"]+"/, '<PlateRange id="$1" range="{{wells}}"'); } } assert(!_.isEmpty(content), "Program content is empty"); // Substitute in relevant values: const templateParams = getTemplateAbsorbanceParams(parsed, data); const template = Handlebars.compile(content); content = template(templateParams); } var start_i = content.indexOf("<TecanFile"); if (start_i < 0) start_i = 0; var programData = content.substring(start_i). replace(/[\r\n]/g, ""). replace(/&/g, "&"). // "&" is probably not needed, since I didn't find it in the XML files replace(/=/g, "&equal;"). replace(/"/g, ""e;"). replace(/~/, "˜"). replace(/>[ \t]+</g, "><"); // Save the file to the agent-configured TEMPDIR, if no absolute path is given const writeTo = _.get(parsed.value, "output.writeTo") || parsed.value.outputFile; const outputFile0 = (writeTo) ? path.join(path.dirname(writeTo), path.basename(writeTo, ".xml") + ".xml") : parsed.value.measurementType+".xml"; const outputFile = (path.win32.isAbsolute(outputFile0)) ? outputFile0 : "%{TEMPDIR}\\" + path.win32.basename(outputFile0); const value = outputFile + "|" + programData; const args = ["TecanInfinite", "%{SCRIPTFILE}", data.path.join("."), outputFile]; const expansion = [ makeEvowareFacts(parsed, data, "Measure", value, parsed.objectName.object), makeEvowareExecute(parsed.objectName.agent, "%{ROBOLIQ}", args, true) ]; return { expansion, reports: (_.isEmpty(data.objects.DATA)) ? undefined : { measurementFactors: data.objects.DATA } }; }, // [`shaker.run|${agentName}|${equipmentName}`]: function(params, parsed, data) { }), getPredicates: (agentName, equipmentName, siteName) => [ // Open READER {"method": {"description": `generic.openSite-${siteName}`, "task": {"generic.openSite": {"site": "?site"}}, "preconditions": [{"same": {"thing1": "?site", "thing2": siteName}}], "subtasks": {"ordered": [{[`${equipmentName}.open`]: {}}]} }}, {"action": {"description": `${equipmentName}.open: open the reader`, "task": {[`${equipmentName}.open`]: {}}, "preconditions": [], "deletions": [{"siteIsClosed": {"site": siteName}}], "additions": [] }}, // Close READER {"method": {"description": `generic.closeSite-${siteName}`, "task": {"generic.closeSite": {"site": "?site"}}, "preconditions": [{"same": {"thing1": "?site", "thing2": siteName}}], "subtasks": {"ordered": [{[`${equipmentName}.close`]: {}}]} }}, {"action": {"description": `${equipmentName}.close: close the reader`, "task": {[`${equipmentName}.close`]: {}}, "preconditions": [], "deletions": [], "additions": [ {"siteIsClosed": {"site": siteName}} ] }}, ], getPlanHandlers: (agentName, equipmentName, siteName) => ({ [`${equipmentName}.close`]: function(params, parentParams, data) { return [{ command: "equipment.close", agent: agentName, equipment: equipmentName }]; }, [`${equipmentName}.open`]: function(params, parentParams, data) { return [{ command: "equipment.openSite", agent: agentName, equipment: equipmentName, site: siteName }]; }, }) }; module.exports = _.merge({configure}, exports);