liquid-data-handler
Version:
Transform plate layouts according to liquid handling instructions
454 lines (422 loc) • 15.7 kB
text/typescript
import { getPlateWells, wellIDFormatter } from "well-id-formatter";
import {
PlateRelationshipType,
ClashStrategyType,
TransferInstructionsRowType,
PlateMapRowType,
StrategyType,
SeparatorType,
// PlateSizeType,
WellType,
} from "./PlateTypes";
import _ from "lodash";
import {
PlateSizesType,
WellFormatsType,
} from "well-id-formatter/dist/wellTypes";
import { start } from "repl";
// transforms 1 source to 1 dest plate
interface anyObjectType {
[key: string]: string | number | null;
}
class PlateTransformer {
//class types
columnClashStrategies: ClashStrategyType[];
instructions: TransferInstructionsRowType[];
sourceMap: PlateMapRowType[];
destMap: PlateMapRowType[];
newDestMap: PlateMapRowType[];
defaultClashStragty: StrategyType;
defaultSeparator: SeparatorType;
newDestMapCols: ("Plate" | "Well" | string)[];
internalWellType: WellType;
clashedColumns: string[];
sourceRelationship: PlateRelationshipType;
destRelationship: PlateRelationshipType;
constructor(
sourceMap: PlateMapRowType[],
destMap: PlateMapRowType[],
instructions: TransferInstructionsRowType[],
sourceRelationship: PlateRelationshipType,
destRelationship: PlateRelationshipType,
columnClashStrategies: ClashStrategyType[] = []
) {
this.sourceMap = sourceMap;
this.destMap = destMap;
this.newDestMap = _.cloneDeep(destMap);
this.instructions = instructions;
this.sourceRelationship = sourceRelationship;
this.destRelationship = destRelationship;
this.defaultClashStragty = "suffix";
this.defaultSeparator = ", ";
this.internalWellType = "padded";
this.columnClashStrategies = columnClashStrategies;
this.newDestMapCols = Object.keys(this.newDestMap[0]);
this.clashedColumns = [];
this.validateAndFormatData();
}
validateLayoutKeys(): void {
const destKeysValid = this.destMap.every(
(well) => !!well["Well"] && !!well["Plate"]
);
const srcKeysValid = this.sourceMap.every(
(well) => !!well["Well"] && !!well["Plate"]
);
if (!(destKeysValid && srcKeysValid)) {
throw Error(
`PlateLayout Keys Invalid. Source and Destination Platelayouts both require "Plate" and "Well" keys.`
);
}
}
validateInstructionsKeys(): void {
const instructionKeysValid = this.instructions.every(
(t) =>
t["Source Plate"] &&
t["Destination Well"] &&
t["Source Well"] &&
t["Volume"] &&
t["Destination Plate"]
);
if (!instructionKeysValid) {
throw Error(
`Transfer Instruction Keys Invalid. Keys must be "Source Plate", "Destination Plate", "Source Well", "Destination Well", and "Volume"`
);
}
}
validateAndFormatData(): void {
this.validateInstructionsKeys();
this.validateLayoutKeys();
this.validateInstructionIDs();
this.validateMapIDs();
this.validateAndConvertWellFormats(this.internalWellType);
this.fillMissingWells();
if (this.sourceMap.length !== +this.sourceRelationship.plate_size) {
throw Error(
`Source Map must have ${this.sourceRelationship.plate_size} rows, not ${this.sourceMap.length}`
);
}
if (this.newDestMap.length !== this.destRelationship.plate_size) {
throw Error(
`Destination Map must have ${this.destRelationship.plate_size} rows, not ${this.newDestMap.length}`
);
}
}
setDefaultClashStrategy(strategy: StrategyType): void {
this.defaultClashStragty = strategy;
}
setClashConcatenationSeparator(separator: SeparatorType): void {
this.defaultSeparator = separator;
}
assigncolumnClashStrategies(
columnClashStrategies: ClashStrategyType[]
): void {
this.columnClashStrategies = columnClashStrategies;
}
fillMissingWells(): void {
// if well data are missing, (ex, missing object for well A4),
// insert them to make the plate complete, setting values to null
//internalWellType should be sortable, either 'padded' or 'number', not unpadded
// const sourceMapInternal: PlateMapRowType[] = _.cloneDeep(this.sourceMap);
// const destMapInternal: PlateMapRowType[] = _.cloneDeep(this.newDestMap);
//get complete array of source well ids
const sourcePlateWells = getPlateWells(
this.sourceRelationship.plate_size,
this.internalWellType
);
//get complete array of dest well ids
const destPlateWells = getPlateWells(
this.destRelationship.plate_size,
this.internalWellType
);
const sourceNewMapInternal: PlateMapRowType[] = [];
const destNewMapInternal: PlateMapRowType[] = [];
type NullPairsArr = [string, null];
//create an object of each variable with the value set to null
//from sourcemap
const sourceMapRowInternalArr: NullPairsArr[] = Object.keys(
this.sourceMap[0]
)
.filter((d) => d !== "Plate" && d !== "Well")
.map((d) => [d, null]);
const sourceMapRowInternalObj: anyObjectType = _.fromPairs(
sourceMapRowInternalArr
);
let idxCount = 0;
sourcePlateWells.forEach((well) => {
const srcMapRow = this.sourceMap[idxCount];
const srcMapRowWellIsCorrect = srcMapRow["Well"] === well;
if (!srcMapRowWellIsCorrect) {
// push an empty row into the new plate
sourceNewMapInternal.push({
Plate: this.sourceRelationship.plate_map_id,
Well: well,
...sourceMapRowInternalObj,
});
} else {
//if the well is correct
idxCount += 1;
sourceNewMapInternal.push({ ...srcMapRow });
}
});
//repeate steps for destination source
const destMapRowInternalArr: NullPairsArr[] = Object.keys(
this.newDestMap[0]
)
.filter((d) => d !== "Plate" && d !== "Well")
.map((d) => [d, null]);
const destMapRowInternalObj: anyObjectType = _.fromPairs(
destMapRowInternalArr
);
idxCount = 0;
destPlateWells.forEach((well) => {
const destMapRow = this.newDestMap[idxCount];
const destMapRowWell = destMapRow["Well"] === well;
if (!destMapRowWell) {
destNewMapInternal.push({
Plate: this.destRelationship.plate_map_id,
Well: well,
...destMapRowInternalObj,
});
} else {
idxCount += 1;
destNewMapInternal.push({ ...destMapRow });
}
});
//assign complete maps
this.sourceMap = sourceNewMapInternal;
this.newDestMap = destNewMapInternal;
}
validateAndConvertWellFormats(wellFormat: WellFormatsType): void {
let newWell;
const destPlateSize: PlateSizesType = this.destRelationship.plate_size;
const sourcePlateSize: PlateSizesType = this.sourceRelationship.plate_size;
this.newDestMap = this.newDestMap.map((row) => {
newWell = wellIDFormatter(row["Well"], wellFormat, destPlateSize);
if (typeof newWell === "string") {
row["Well"] = newWell;
return row;
} else {
throw Error(
`${row["Well"]} not recognized in ${destPlateSize} well plate`
);
}
});
this.sourceMap = this.sourceMap.map((row) => {
newWell = wellIDFormatter(row["Well"], wellFormat, sourcePlateSize);
if (typeof newWell === "string") {
row["Well"] = newWell;
return row;
} else {
throw Error(
`${row["Well"]} not recognized in ${sourcePlateSize} well plate`
);
}
});
}
transformPlates(wellformat: WellType | null): PlateMapRowType[] {
let localClashedCols: string[] = [];
if (this.columnClashStrategies.length) {
localClashedCols = this.columnClashStrategies.map((cc) => cc.column_name);
}
const sourceCols = Object.keys(this.sourceMap[0]).filter(
(d) => d !== "Plate" && d !== "Well"
);
const destCols = Object.keys(this.destMap[0]).filter(
(d) => d !== "Plate" && d !== "Well"
);
// add source source columns to the new destination map
//unless those columns already exist on the destination map.
//initiate new destColumns if they do not already exist
sourceCols.forEach((scol) => {
// if (!destCols.includes(scol) && localClashedCols.includes(scol)) {
if (!destCols.includes(scol) && !localClashedCols.includes(scol)) {
//add a null col if the destCol doesn't have the source col and if it isn't a clashign col
this.newDestMap = this.newDestMap.map((d) => {
return { ...d, [scol]: null };
});
}
});
//add the volumn column to destmap = the amount of volume transfered to dest plate from source plate
const volColname = `volume${this.sourceRelationship.plate_suffix}`;
sourceCols.forEach((scol) => {
if (!destCols.includes(scol)) {
this.newDestMap = this.newDestMap.map((d) => ({
...d,
[volColname]: 0,
}));
}
});
//transfer source values to dest values
this.instructions.forEach((inst) => {
const sourceMapRow = {
...this.sourceMap.filter(
(smap) => inst["Source Well"] === smap.Well
)[0],
};
const destMapRow = _.find(
this.newDestMap,
(dmap) => inst["Destination Well"] === dmap.Well
); //mutable row
if (sourceMapRow) {
//if we have a sourceMapRow
sourceCols.forEach((scol) => {
let isClashedCol = this.columnClashStrategies.some(
(strat) => strat.column_name === scol
);
if (destMapRow) {
if (destMapRow[scol] === null && !isClashedCol) {
//assign source col & value to destMap
destMapRow[scol] = sourceMapRow[scol];
} else {
console.log(sourceMapRow, destMapRow);
//if value clashes: destMapRow[scol] already has been assigned a value
this.mutateDestMapRowWithClashedValues(
destMapRow,
sourceMapRow,
scol
);
}
}
});
//add the volume from source to destination well
const instructionVolume = inst.Volume == null ? 0 : +inst.Volume;
if (destMapRow) {
let previousVolume = destMapRow[volColname];
previousVolume =
!(previousVolume == null) && typeof previousVolume === "number"
? previousVolume
: 0;
//assign updated volume
destMapRow[volColname] = instructionVolume + previousVolume;
}
}
});
if (!(wellformat == null) && wellformat !== this.internalWellType) {
//if there is a different well
this.validateAndConvertWellFormats(wellformat);
}
return this.newDestMap;
}
assignNewColnameToDestMap(newColname: string): void {
this.newDestMap.forEach((row) => {
row[newColname] = row[newColname] ? row[newColname] : null;
});
}
mutateDestMapRowWithClashedValues(
destMapRow: PlateMapRowType,
sourceMapRow: PlateMapRowType,
colname: string
): PlateMapRowType {
if (!this.clashedColumns.includes(colname)) {
this.clashedColumns.push(colname);
}
let newColname: string | null = null; //in case we add suffix to the colname
let stragety = this.defaultClashStragty;
let separator = this.defaultSeparator;
//adjust strategy and separator if we have a specific clashStrategy for this colname
const specificClashStrategy = this.columnClashStrategies.filter(
(cs) => cs.column_name === colname
);
if (specificClashStrategy.length) {
stragety = specificClashStrategy[0].strategy;
separator = specificClashStrategy[0].concatenation_separator;
}
switch (stragety) {
case "concatenate":
destMapRow[colname] = [destMapRow[colname], sourceMapRow[colname]].join(
separator
);
break;
case "keepDestination":
break;
case "keepSource":
destMapRow[colname] = sourceMapRow[colname];
break;
case "suffix": {
newColname = `${colname}${this.sourceRelationship.plate_suffix}`;
if (!this.newDestMapCols.includes(newColname)) {
this.assignNewColnameToDestMap(newColname);
this.newDestMapCols.push(newColname);
}
destMapRow[newColname] = sourceMapRow[colname];
break;
}
default:
throw Error(`${stragety} not recognized as a clash strategy`);
}
return destMapRow;
}
validateMapIDs(): void {
//ensures that the plate ids match
//get all the plate ids from the dest map
const mapDestIdArr = _.uniq(this.destMap.map((row) => row["Plate"]));
//get all the plate ids from the source map
const mapSourceIdArr = _.uniq(this.sourceMap.map((row) => row["Plate"]));
//we only transfer one plate at a time.
if (mapDestIdArr.length > 1) {
throw Error(`PlateTransformerClass Destination Plate Maps contains more than one 'Plate': "${mapDestIdArr.join(
" and "
)}".
Must only have one Plate.`);
}
if (mapSourceIdArr.length > 1) {
throw Error(`PlateTransformerClass Source Plate Maps contains more than one 'Plate': "${mapSourceIdArr.join(
" and "
)}".
Must only have one Plate.`);
}
//instructions and plate maps just have one plate id for source and destinatino
const mapDestId = mapDestIdArr[0];
const mapSourceId = mapSourceIdArr[0];
//make sure that destination plate map Plate ID is found in plate relationships
if (this.destRelationship.plate_map_id !== mapDestId) {
throw Error(
`Destination Plate Relationship Plate ID "${this.destRelationship.plate_map_id}" does NOT match Plate Map Plate ID "${mapDestId}"`
);
}
//make sure that source Plate Map Plate ID is found in plate relationships
if (this.sourceRelationship.plate_map_id !== mapSourceId) {
throw Error(
`Source Plate Relationship Plate ID "${this.sourceRelationship.plate_map_id}" does NOT match Plate Map Plate ID "${mapSourceId}"`
);
}
}
validateInstructionIDs(): void {
//throw error if there are more than one source and one destination in instructions
const instDestIdArr = _.uniq(
this.instructions.map((inst) => inst["Destination Plate"])
);
const instSourceIdArr = _.uniq(
this.instructions.map((inst) => inst["Source Plate"])
);
if (instDestIdArr.length > 1) {
throw Error(`PlateTransformerClass Instructions contains more than one 'Destination Plate': "${instDestIdArr.join(
" and "
)}".
Must only have one Destination Plate.`);
}
if (instSourceIdArr.length > 1) {
throw Error(`PlateTransformerClass Instructions contains more than one 'Source Plate': "${instSourceIdArr.join(
" and "
)}".
Must only have one Source Plate.`);
}
const instDestId = instDestIdArr[0];
const instSourceId = instSourceIdArr[0];
//make sure that instructions Destination Plate ID is found in destination plate relationships
if (this.destRelationship.plate_instruction_id !== instDestId) {
throw Error(
`Destination Plate Relationship Plate ID "${this.destRelationship.plate_instruction_id}"
does NOT match Destintation Plate in transfer instructions "${instDestId}"`
);
}
//make sure that instruction Source Plate ID is found in plate relationships
if (this.sourceRelationship.plate_instruction_id !== instSourceId) {
throw Error(
`Source Plate Relationship Plate ID "${this.sourceRelationship.plate_instruction_id}"
does NOT match Source Plate in transfer instructions "${instSourceId}"`
);
}
}
}
export default PlateTransformer;