UNPKG

liquid-data-handler

Version:

Transform plate layouts according to liquid handling instructions

656 lines (610 loc) 22.2 kB
import _, { sum } from "lodash"; import { arrayToObject, ObjectOfArraysType, objectToArray, } from "array-object-transformer"; // import PlateTransformer from "plate-data-transfer/dist/tsc/main"; import PlateTransformer from "./PlateTransformerClass"; import getGeneratorOfTransferInstructions from "./getNextPlateInstructions"; import { DEST_PLATE_KEY, DEST_WELL_KEY, SRC_PLATE_KEY, SRC_WELL_KEY, VOL_KEY, } from "./keys/InstructionKeys"; import { PLATE_ID_KEY, WELL_ID_KEY } from "./keys/LayoutKeys"; import { KeySetType, PlateIdConfigType, TransferInstructionsRowType, AnyStringObjectType, PlateMapRowType, ClashStrategyType, StrategyType, PlateColumnSuffixObjectType, SpecificColumnClashesType, } from "./mainTypes"; // import { TransferInstructionsType } from "plate-data-transfer"; import { getPlate } from "well-id-formatter"; import { PlateRelationshipType, SeparatorType } from "./PlateTypes"; const sufSep = "_suftag_"; function getAllIndexes(arr: string[], val: string) { var indexes = [], i = -1; while ((i = arr.indexOf(val, i + 1)) !== -1) { indexes.push(i); } return indexes; } interface ClashReportType { discoveredClashes: discoveredClashesType[]; defaultClashStrategy: string; clashColumnStrategies: SpecificColumnClashesType[]; } interface anystringarrayOT { [key: string]: string[]; } interface ColLayoutType { [key: string]: string[]; Plate: string[]; Well: string[]; } interface PlateTransformerParamsType { srcLayout: PlateMapRowType[]; destLayout: PlateMapRowType[]; srcPlateRel: PlateRelationshipType; destPlateRel: PlateRelationshipType; } interface discoveredClashesType { plate_id: string; colname: string; suffixColnames: string[]; isActualClash: boolean; clashingIndexes: number[]; } interface layoutGroup { plate_id: string; group: string; } class liquidDataHander { inputInstructions: AnyStringObjectType[] = []; inputLayouts: AnyStringObjectType[][] = []; //each plate in its own instructions: TransferInstructionsRowType[] = []; layouts: PlateMapRowType[][] = []; layoutsSuffix: PlateMapRowType[][] = []; plateInfo: PlateIdConfigType[]; keySet: KeySetType; clashStrategy: StrategyType; columnSuffixNames: PlateColumnSuffixObjectType; clashColumnStrategies: ClashStrategyType[]; finalDestPlates: PlateMapRowType[][] = []; discoveredClashes: discoveredClashesType[] = []; layoutGroups: layoutGroup[] = []; removeVolumes: boolean = false; constructor( inputLayouts: AnyStringObjectType[][], inputInstructions: AnyStringObjectType[], platesInfo: PlateIdConfigType[], keySet: KeySetType, clashColumnStrategies: ClashStrategyType[] = [] ) { this.inputLayouts = inputLayouts; this.inputInstructions = inputInstructions; this.plateInfo = platesInfo.map((plate) => ({ ...plate, suffix: `${sufSep}${plate.suffix}`, })); this.keySet = keySet; this.layouts = []; this.layoutsSuffix = []; //layouts but colnames all have plate suffix this.columnSuffixNames = {}; this.instructions = []; this.clashStrategy = "suffix"; this.clashColumnStrategies = clashColumnStrategies; // not implemented yet this.finalDestPlates = []; this.discoveredClashes = []; this.layoutGroups = []; this.removeVolumes = false; //if user doesn't care about volumes //TODO this.setup(); } setup() { this.formatLayouts(); this.addMissingPlateLayouts(); this.detectLayoutGroups(); this.formatInstructions(); this.detectUnusedPlates(); this.assertUniqueSuffixes(); this.setLayoutSuffix(); } detectLayoutGroups() { //if a user uploads multiple layout plates, some of them will have the same column headers //here we idenitfy which plates are grouped with which this.layouts.forEach((layout) => { let plate_id = layout[0].Plate; let colnames = Object.keys(layout[0]).filter( (colname) => ![PLATE_ID_KEY, WELL_ID_KEY].includes(colname) ); //get colnames let colnamesString = colnames.sort().join(""); this.layoutGroups.push({ plate_id, group: colnamesString }); }); } fillMissingColumnsInPlateLayoutGroups() { //if 2+ destination plates have the same column names as input, //we should ensure they have the same column names as output //user will likely concatenate them. const volumeRegex = new RegExp(`volume${sufSep}`); let groupObj: anystringarrayOT = {}; this.layoutGroups.forEach((origGroup) => { if (groupObj.hasOwnProperty(origGroup.group)) { groupObj[origGroup.group].push(origGroup.plate_id); } else if (origGroup.group !== "") { //don't add empty plates groupObj[origGroup.group] = [origGroup.plate_id]; } }); let plateGroups = Object.values(groupObj); plateGroups.forEach((plateGroup) => { let allGroupColnamesSet = new Set<string>(); plateGroup.forEach((plateid) => { const layout = this.layouts.filter((d) => d[0].Plate === plateid)[0]; layout.forEach((row) => { [...Object.keys(row)].reduce((a, e) => a.add(e), allGroupColnamesSet); }); }); //all the colnames required for each plate in the group let allGroupColnamesArray: string[] = Array.from(allGroupColnamesSet); plateGroup.forEach((plateid) => { //iterate over plate in layout, and add on the colname if they do not exist this.layouts.forEach((layout) => { if (layout[0].Plate === plateid) { layout.forEach((row) => { allGroupColnamesArray.forEach((colname) => { if (!row.hasOwnProperty(colname)) { row[colname] = colname.match(volumeRegex) ? 0 : null; } }); }); } }); }); }); } fillMissingColumnsWithinPlateLayout() { //collect all the column names in this layout const volumeRegex = new RegExp(`volume${sufSep}`); this.layouts.forEach((layout) => { let allColnamesSet = new Set<string>(); layout.forEach((row) => { [...Object.keys(row)].reduce((a, e) => a.add(e), allColnamesSet); let allColnamesArray: string[] = Array.from(allColnamesSet); //make sure each row has all column names layout.forEach((row) => { allColnamesArray.forEach((colname) => { if (colname.match(volumeRegex)) { //missing volumes should be 0, not null row[colname] = row.hasOwnProperty(colname) ? row[colname] : 0; } else { row[colname] = row.hasOwnProperty(colname) ? row[colname] : null; } }); }); }); }); } getClashReport(): ClashReportType { this.runLiquidDataHandlerAllSuffix(); // this is resetting the source map this.discoverClashes(); return { discoveredClashes: this.discoveredClashes, defaultClashStrategy: this.clashStrategy, clashColumnStrategies: this.clashColumnStrategies, }; } getFinalPlateLayouts( clashColumnStrategies: ClashStrategyType[] = [], defaultClashStrategy: StrategyType = "suffix" ) { this.runLiquidDataHandlerWithClashStrategies( clashColumnStrategies, defaultClashStrategy ); this.fillMissingColumnsWithinPlateLayout(); this.fillMissingColumnsInPlateLayoutGroups(); this.removeSufTags(); // if (this.removeVolumes) { // //take out volumes if user doesn't care // //TODO // } return this.layouts; } removeSufTags() { //not removing user's suffix //removing internal suffix tag this.layouts.forEach((layout, i) => { let output: ColLayoutType = { Plate: [], Well: [] }; let collayout = arrayToObject(layout); Object.entries(collayout).forEach((arr) => { let newplatename = arr[0].replace(sufSep, ""); output[newplatename] = arr[1]; }); let newArray: any = objectToArray(output); this.layouts[i] = newArray; }); } runLiquidDataHandlerWithClashStrategies( clashColumnStrategies: ClashStrategyType[], defaultClashStrategy: StrategyType, defaultSeparator: SeparatorType = " + " ) { let plateInstructionChunk = getGeneratorOfTransferInstructions( this.instructions ); let stillRunning: boolean = true; while (stillRunning) { let nextChunk: any = plateInstructionChunk.next(); if (!nextChunk.done) { //get sourceLayout, getDestLayout, chunkInstructions sourcePlateInfo, destPlateInf let chunkInstructions: TransferInstructionsRowType[] = nextChunk.value; let { srcLayout, destLayout, srcPlateRel, destPlateRel } = this.getPlateTransformerParams(chunkInstructions, "layouts"); let transformer = new PlateTransformer( srcLayout, destLayout, chunkInstructions, srcPlateRel, destPlateRel, clashColumnStrategies ); //get new destination plate layout transformer.assigncolumnClashStrategies(clashColumnStrategies); transformer.defaultClashStragty = defaultClashStrategy; transformer.defaultSeparator = defaultSeparator; let newDestLayout = transformer.transformPlates(null); //now update the destPlate with newDestLayout this.layouts.forEach((layout, i) => { if (layout[0]["Plate"] === newDestLayout[0]["Plate"]) { //the new layout is updates to include transfered wells this.layouts[i] = newDestLayout; } }); } else { //no more instructions stillRunning = false; } } } runLiquidDataHandlerAllSuffix() { let plateInstructionChunk = getGeneratorOfTransferInstructions( this.instructions ); let stillRunning: boolean = true; while (stillRunning) { let nextChunk: any = plateInstructionChunk.next(); if (!nextChunk.done) { //get sourceLayout, getDestLayout, chunkInstructions sourcePlateInfo, destPlateInf let chunkInstructions: TransferInstructionsRowType[] = nextChunk.value; let { srcLayout, destLayout, srcPlateRel, destPlateRel } = this.getPlateTransformerParams(chunkInstructions, "layoutsSuffix"); let transformer = new PlateTransformer( srcLayout, destLayout, chunkInstructions, srcPlateRel, destPlateRel ); //get new destination plate layout let newDestLayout = transformer.transformPlates(null); //now update the destPlate with newDestLayout this.layoutsSuffix.forEach((layout, i) => { if (layout[0]["Plate"] === newDestLayout[0]["Plate"]) { //the new layout is updates to include transfered wells this.layoutsSuffix[i] = newDestLayout; } }); } else { //no more instructions stillRunning = false; } } } resolveClashes() { // if repeated columns do not have clashed values // then ignore // if } getClashingIndexes( repeatedSuffixColnames: string[], columnLayout: ObjectOfArraysType ) { let arraysOfValuesFromRepeatedCols = repeatedSuffixColnames.map( (cn) => columnLayout[cn] ); //get an array of rows counting the number of non-null values. //clashing values have 2 or more. let numberValuesArray = _.zip(...arraysOfValuesFromRepeatedCols).map( (arr) => sum(arr.map((val) => val !== null)) ); let clashingIndexes = numberValuesArray.reduce<number[]>( (acc, cur, idx) => { if (cur > 1) { acc.push(idx); } return acc; }, [] ); return clashingIndexes; } discoverClashes() { //after layoutSuffix is run, discover where there are clashes //then create a table for user to this.layoutsSuffix.forEach((layout) => { let plateid = layout[0]["Plate"]; let suffix = this.plateInfo.filter( (plate) => plate.layoutId === plateid )[0]["suffix"]; if (suffix) { let columnLayout = arrayToObject(layout); let suffixColnames = Object.keys(columnLayout); let suffixRegEx = new RegExp(`^(.*)${sufSep}(.*)$`); let noSuffixColnames = suffixColnames.map((sufcol) => { let match = suffixRegEx.exec(sufcol); return match && match[1] ? match[1] : sufcol; }); let uniqNoSuffColnames = _.uniq(noSuffixColnames); if (uniqNoSuffColnames.length + 1 === noSuffixColnames.length) { } else { //create an object specifiy the potentially clashing column names uniqNoSuffColnames.forEach((noSuffixColname) => { if (noSuffixColname !== "volume_") { let indexes = getAllIndexes(noSuffixColnames, noSuffixColname); if (indexes.length > 1) { //the column name is present more than one time //get the repeated equivalents of the colnams with suffixes let repeatedSuffixColnames = indexes.map( (idx) => suffixColnames[idx] ); //just because it is repeated doesn't mean there are clashes in the actual wells //it could be that different plates transfer different into non-overlapping wells. //see if the actual values clash - if one has a value while the other is null, it isn't a real clash; let clashingIndexes = this.getClashingIndexes( repeatedSuffixColnames, columnLayout ); this.discoveredClashes.push({ plate_id: plateid, colname: noSuffixColname, suffixColnames: repeatedSuffixColnames, isActualClash: clashingIndexes.length > 1, clashingIndexes, }); } } }); } } else { throw Error(`No suffix error on plate ${plateid}`); } }); } getPlateTransformerParams( chunkInstructions: TransferInstructionsRowType[], layoutChoice: "layoutsSuffix" | "layouts" ): PlateTransformerParamsType { //get source plate info let srcInstructionsPlateID = chunkInstructions[0]["Source Plate"]; let srcPlateIDArr = this.plateInfo.filter( (info) => info["instructionId"] === srcInstructionsPlateID ); if (srcPlateIDArr.length === 0) { throw Error( `Report Program Error: instruction plate ${srcInstructionsPlateID} not in plateInfo` ); } let srcInfo = srcPlateIDArr[0]; let srcLayoutPlateID = srcInfo["layoutId"]; let srcLayoutArr = this[layoutChoice].filter( (layout) => layout[0]["Plate"] === srcLayoutPlateID ); if (!srcLayoutArr.length) { throw Error(`Source Plate ${srcLayoutPlateID} not in PlateInfo`); } let srcLayout = srcLayoutArr[0]; //get destination plate info let destInstructionsPlateID = chunkInstructions[0]["Destination Plate"]; let destPlateIDarr = this.plateInfo.filter( (info) => info["instructionId"] === destInstructionsPlateID ); if (destPlateIDarr.length === 0) { throw Error( `Report Program Error: instruction plate ${destInstructionsPlateID} not in plateInfo` ); } let destInfo = destPlateIDarr[0]; let destLayoutPlateID = destInfo["layoutId"]; let destLayoutArr = this[layoutChoice].filter( (layout) => layout[0]["Plate"] === destLayoutPlateID ); if (!destLayoutArr) { throw Error(`Destination plate ${destLayoutPlateID} not in plateInfo`); } let destLayout = destLayoutArr[0]; let srcPlateRel = { plate_map_id: srcInfo.layoutId, plate_instruction_id: srcInfo.instructionId, plate_size: srcInfo.size, plate_suffix: srcInfo.suffix, }; let destPlateRel = { plate_map_id: destInfo.layoutId, plate_instruction_id: destInfo.instructionId, plate_size: destInfo.size, plate_suffix: destInfo.suffix, }; return { srcLayout, destLayout, srcPlateRel, destPlateRel, }; } detectUnusedPlates() { //throw errors for following senarios. let configLayoutPlates = this.plateInfo.map((plt) => plt.layoutId); let configInstructionPlates = this.plateInfo.map( (plt) => plt.instructionId ); // let configPlates = [...configInstructionPlates, ...configLayoutPlates]; let sourceInstructionPlates = _.uniq( this.instructions.map((i) => i["Source Plate"]) ); let destinationInstructionPlates = _.uniq( this.instructions.map((i) => i["Destination Plate"]) ); let instructionPlates = _.uniq([ ...sourceInstructionPlates, ...destinationInstructionPlates, ]); let layoutPlates = _.uniq( this.layouts.map((layout) => layout[0][PLATE_ID_KEY]) ); //a plate in config is not present in instructions configInstructionPlates.forEach((plate_name) => { if (!instructionPlates.includes(plate_name)) { throw Error( `plate id ${plate_name} is missing from transfer instructions.` ); } }); //a plate in config is not present in layouts configLayoutPlates.forEach((plate_name) => { if (!layoutPlates.includes(plate_name)) { throw Error(`plate id "${plate_name}" is missing from plate layouts.`); } }); //a layout is not present in config layoutPlates.forEach((plate_name) => { if (!configLayoutPlates.includes(plate_name)) { throw Error( `layout plate id "${plate_name}" is missing from plate configuration object.` ); } }); //an instructions plate is not present in config instructionPlates.forEach((plate_name) => { if (!configInstructionPlates.includes(plate_name)) { throw Error( `transfer instructions plate id "${plate_name}" is missing from plate configuration object.` ); } }); } assertUniqueSuffixes() { let suffixes = this.plateInfo.map((p) => p.suffix); let uniq_suffixes = _.uniq(suffixes); if (uniq_suffixes.length < suffixes.length) { throw Error("Error: All plates must have a unique suffix."); } } setLayoutSuffix() { //create a copy of plate layouts with changed column names to contain plate suffix in all columns _.cloneDeep(this.layouts).forEach((layout) => { let columnLayout = arrayToObject(layout); let suffixArr = this.plateInfo.filter( (plate) => plate.layoutId === columnLayout[PLATE_ID_KEY][0] ); if (suffixArr.length === 0) { throw Error( `A plate layout is defined in config, but not : ${columnLayout[PLATE_ID_KEY][0]}` ); } let suffix = this.plateInfo.filter( (plate) => plate.layoutId === columnLayout[PLATE_ID_KEY][0] )[0].suffix; if (suffix) { let colnames = Object.keys(columnLayout).map((col) => { let col_suff = [PLATE_ID_KEY, WELL_ID_KEY].includes(col) ? col : `${col}${suffix}`; return [col, col_suff]; }); //record the columns and column_suffixes this.columnSuffixNames[columnLayout[PLATE_ID_KEY][0]] = colnames; //change the column names to include the suffix colnames.forEach((colArr) => { columnLayout[colArr[1]] = columnLayout[colArr[0]]; if (![PLATE_ID_KEY, WELL_ID_KEY].includes(colArr[0])) { delete columnLayout[colArr[0]]; } }); let rowLayout: any = objectToArray(columnLayout); if (rowLayout[0][PLATE_ID_KEY] && rowLayout[0][WELL_ID_KEY]) { this.layoutsSuffix.push(rowLayout); } else { throw Error( "Report This Error to www.github.com/svgpubs: Missing Plate and Well : this is a problem with the program. " ); } } }); } get columnClashes() { //runLiquidDataHandler and records clashes return this.clashColumnStrategies; } set columnClashStragtegies(strats: ClashStrategyType[]) { this.clashColumnStrategies = strats; } addMissingPlateLayouts() { this.plateInfo.forEach((plate) => { if (plate.isEmpty && plate.layoutId === "") { let newPlate = getPlate(plate.size).map((wellrow) => { return { Plate: plate.instructionId, Well: wellrow["padded"] }; }); plate.layoutId = plate.instructionId; //now it has a layout id //add the empty plate layout to this.layouts this.layouts.push(newPlate); } else if (plate.isEmpty || plate.layoutId === "") { throw Error( `Error: Plate Config plate "isEmpty = ${plate.isEmpty}"" but layoutId = ${plate.layoutId}. This is an error.` ); } }); } formatLayouts() { // create empty plates for destinatios that are missing plate layouts var inputLayoutsCopy = _.cloneDeep(this.inputLayouts); //ensure well data has correct Plate and Well keys inputLayoutsCopy.forEach((layout, layoutIndex) => { this.layouts.push([]); layout.forEach((wellRow) => { let plateID = wellRow[this.keySet["plateIdKey"]]; let wellId = wellRow[this.keySet["wellIdKey"]]; delete wellRow[this.keySet["plateIdKey"]]; delete wellRow[this.keySet["wellIdKey"]]; this.layouts[layoutIndex].push({ ...wellRow, [PLATE_ID_KEY]: plateID, [WELL_ID_KEY]: wellId, }); }); }); } formatInstructions() { var inputInstructionsCopy = _.cloneDeep(this.inputInstructions); let newRow; inputInstructionsCopy.forEach((inst) => { newRow = { [SRC_PLATE_KEY]: inst[this.keySet["sourePlateKey"]], [SRC_WELL_KEY]: inst[this.keySet["sourceWellKey"]], [VOL_KEY]: +inst[this.keySet["volumeKey"]], [DEST_PLATE_KEY]: inst[this.keySet["destPlateKey"]], [DEST_WELL_KEY]: inst[this.keySet["destWellKey"]], }; this.instructions.push(newRow); }); } } export default liquidDataHander;