-
Notifications
You must be signed in to change notification settings - Fork 10
/
Copy pathfcs.js
668 lines (562 loc) · 19.2 KB
/
fcs.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
/**
* @author Morgan Conrad
* Copyright(c) 2018
* This software is released under the MIT license (http://opensource.org/licenses/MIT)
*/
const FCSWriteStream = require("./fcswritestream");
/**
* Constructor
* @param options options, {}, optional argument
* @param buffer if present, read it. (Otherwise, call readFCS() later)
* @constructor
*/
function FCS(/* optional */ options, buffer) {
// allow "static" usage, save user from misuse...
if (!(this instanceof FCS)) return new FCS(options, buffer);
// important options to always have in meta so they get remembered
this.meta = {
dataFormat: FCS.DEFAULT_VALUES.dataFormat,
groupBy: FCS.DEFAULT_VALUES.groupBy,
};
this.header = {};
this.text = {};
this.analysis = {};
this.bytesRead = 0;
this.dataAsStrings = null;
this.dataAsNumbers = null;
this.options(options);
if (buffer) {
if (Buffer.isBuffer(buffer))
this.readBuffer(buffer);
else
throw Error("only Buffers supported for now");
}
// override the toJSON() method
this.toJSON = function() {
// collect meta, header, text, and analysis
let segmentVals = Object.keys(FCS.SEGMENT).map((key) => {
let segmentName = FCS.SEGMENT[key];
return '"' + segmentName + '" :' + JSON.stringify(this[segmentName], null, 2);
});
let json = '{\n ' + segmentVals.join(',\n ');
json += ',\n "data": \n';
if (this.dataAsStrings) {
// for clarity, an extra CRLF after groupByParam data
let delim =
FCS.OPTION_VALUES.byParam === this.meta.groupBy ? ",\n\n" : ",\n";
json += "[";
json += this.dataAsStrings.join(delim);
json += "]";
} else if (this.dataAsNumbers)
json += JSON.stringify(this.dataAsNumbers, null, 2);
json += "\n}"; // close
return json;
};
}
/*
* Constants for possible incoming option/meta values
* Also see the defaults below in FCS.DEFAULT_VALUES
*/
module.exports.OPTION_VALUES = FCS.OPTION_VALUES = {
// .dataFormat should hold one of the following:
asNumber: "asNumber", // collect data in large numeric arrays
asString: "asString", // default, just collect data as a String (e.g. all you want is JSON back)
asBoth: "asBoth", // both
asNone: "asNone", // skip all the data
// .groupBy should hold one of the following:
byEvent: "byEvent", // data values for each event are grouped together
byParam: "byParam", // data values for each parameter are grouped together
/*
Other option keys that we use are
.decimalsToPrint 0 means "all events", default = 1000
.eventsToLoad 0 means none, negative means all
.maxPerLine affects printing in byParam for readability
.encoding should always be 'utf8' for FCS format
All other options are ignored by this code, but will be placed into meta.
So you will see them in the JSON. In this way you can add your own metadata
such as the date, laboratory, the filename, error status, etc...
*/
};
/*
* Default values for the options we use. In general, you should treat these as constants,
* but if you really want to change the default behavior I can't stop you...
*/
module.exports.DEFAULT_VALUES = FCS.DEFAULT_VALUES = {
decimalsToPrint: 2, // 0 means "all events"
encoding: "utf8",
eventsToRead: 1000, // an integer, 0 means "all events"
maxPerLine: 10, // only applies in byParam mode
dataFormat: "asString", // alternatives are 'asNumber', 'asBoth', 'asNone'
groupBy: "byEvent", // alternative is 'byParam'
};
module.exports.SEGMENT = FCS.SEGMENT = {
META: "meta",
HEADER: "header",
TEXT: "text",
ANALYSIS: "analysis",
};
/**
* Adds properties from options to our meta data, overwriting if necessary
* @param options if absent nothing happens.
* @returns {FCS} for convenience
*/
FCS.prototype.options = function(options) {
Object.assign(this.meta, options);
return this;
};
/**
* Main method at creation, reads an FCS format file from a databuf
*
* @param databuf required
* @param moreOptions optional
* @returns {FCS} for convenience
*/
FCS.prototype.readBuffer = function(databuf, /* optional */ moreOptions) {
// add any moreOptions, meta is now "complete"
this.options(moreOptions);
let encoding = this.meta.encoding || FCS.DEFAULT_VALUES.encoding;
this.header = this._readHeader(databuf, encoding);
let textSegment = databuf.toString(
encoding,
this.header.beginText,
this.header.endText,
);
this.text = this._readTextOrAnalysis(textSegment);
this._adjustHeaderBasedUponText(this.text);
if (this.header.beginAnalysis) {
let analysisSegment = databuf.toString(
encoding,
this.header.beginAnalysis,
this.header.endAnalysis,
);
this.analysis = this._readTextOrAnalysis(analysisSegment);
}
// TODO supplemental text, e.g. $BEGINSTEXT, $ENDSTEXT
this.dataAsNumbers = null;
this.dataAsStrings = null;
this._readData(databuf);
return this;
};
// here follow the public "getters/accessor" methods
/**
* All purpose get, called by the other methods
* @param segment one of FCS.SEGMENT (typically 'text',analysis', more rarely 'meta','header')
* @param keys if none, returns the entire segment
* otherwise, return first property match
* @returns {} if no-arg, else String, null if none were found.
*/
FCS.prototype.get = function(segment, ...keys) {
let theSegment = this[segment];
if (!keys.length) return theSegment;
let firstMatchingKey = keys.find((key) => theSegment[key]);
return firstMatchingKey ?
theSegment[firstMatchingKey] :
null;
};
/**
* If no arguments are provided, returns *all* the ANALYSIS segment (may be {})
* Otherwise, returns a single value from the ANALYSIS segment.
* e.g. analysis('GATE1 count') -> '1234'
* @param keys varargs, returns first "hit"
* @returns {} if no-arg, else String, null if none were found.
*/
FCS.prototype.getAnalysis = function(...keys) {
return this.get(FCS.SEGMENT.HEADER, ...keys);
};
/**
* If no arguments are provided, returns *all* the TEXT segment.
*Otherwise, returns a single value from the TEXT segment.
* e.g. getText('$CYT') -> 'FACSort'
* @param keys varargs, returns first "hit"
* @returns {} if no-arg, else String, null if none were found.
*/
FCS.prototype.getText = function(...keys) {
return this.get(FCS.SEGMENT.TEXT, ...keys);
};
/**
* Returns an entire array of values from the text segment,
* The returned array has 1 based indexing
* e.g. get$PnX('N') => [,'FSC-H','SSC-H','FL1-H', etc...]
* @param x
* @returns {Array}
*/
FCS.prototype.get$PnX = function(x) {
let result = [];
result[0] = null;
for (let p = 1; p <= this.meta.$PAR; p++)
result[p] = this.text["$P" + p + x];
return result;
};
/**
* Returns numeric data if it was collected (i.e. meta.dataFormat was asNumber or asBoth)
* Whether this is the event[idx] or the parameter[idx] depends on meta.groupBy.
* @param idx 1-based.
* @returns {[]} of Numbers
*/
FCS.prototype.getNumericData = function(idx) {
return (this.dataAsNumbers) ?
this.dataAsNumbers[idx - 1] :
null;
};
/**
* Returns string data if it was collected (i.e. meta.dataFormat was asString or asBoth)
* Whether this is the event[idx] or the parameter[idx] depends on meta.groupBy.
* @param idx 1-based.
* @returns {[]} of Strings
*/
FCS.prototype.getStringData = function(idx) {
return (this.dataAsStrings) ?
this.dataAsStrings[idx - 1] :
null;
};
/**
* Return an shallow copy object of a smallish subset of us
* @param onlys[] dot delimited Strings, e.g. 'meta' to get all of meta, 'text.$P1N' to get parameter 1 name
* @returns {{}} will be empty if onlys is empty
*/
FCS.prototype.getOnly = function(onlys) {
// if only one, force to an array...
if (!Array.isArray(onlys)) onlys = [onlys];
let result = {};
for (let i = 0; i < onlys.length; i++) {
let s = onlys[i].split(".", 2); // we only go 1 or 2 deep
let s0 = s[0];
if (s.length === 1) {
// copy everything
result[s0] = this[s0];
} else {
if (!result[s0]) result[s0] = {};
result[s0][s[1]] = this[s0][s[1]];
}
}
return result;
};
/**
* Read asynchronously, using an FCSWriteableStream.
* @param readStream required
* @param moreOptions optional
* @param callback if present, callback(err, fcs) gets called at the end.
*/
FCS.prototype.readStreamAsync = function(readStream, moreOptions, callback) {
let self = this;
let fws = this.prepareWriteableStream(callback, readStream);
this.options(moreOptions);
readStream.pipe(fws);
};
/**
* Prepares a writeableStream for use with this FCS
* if a callback is provided, all you need do is readableStream.pipe(fws);
*
* @param callback if present, it will get called back with (err, fcs)
* @param readableStream if present, may get closed sooner...
* @returns {FCSWriteStream}
*/
FCS.prototype.prepareWriteableStream = function(callback, readableStream) {
let fws = new FCSWriteStream(this, readableStream);
if (callback) {
fws.on("finish", function(err) {
callback(err, fws.fcs); // access the underlying fcs via fws.getFCS()
});
fws.on("error", function(err) {
callback(err, fws.fcs);
});
}
return fws;
};
// here follow private methods
/**
* Decides various parameters and methods based upon our options
* @param databuf
* @returns {{asNumber: boolean, asString: boolean, decimalsToPrint: number, bigSkip: number}}
* @private
*/
FCS.prototype._prepareReadParameters = function(databuf) {
let isBE;
if (this.text.$BYTEORD.includes("2,1")) isBE = true;
else if (this.text.$BYTEORD.includes("1,2")) isBE = false;
else throw Error("cannot handle $BYTEORD= " + this.text.$BYTEORD);
let options = this.meta;
let readParameters = {
asNumber:
FCS.OPTION_VALUES.asNumber === options.dataFormat ||
FCS.OPTION_VALUES.asBoth === options.dataFormat,
asString:
FCS.OPTION_VALUES.asString === options.dataFormat ||
FCS.OPTION_VALUES.asBoth === options.dataFormat,
decimalsToPrint: Number(
options.decimalsToPrint || FCS.DEFAULT_VALUES.decimalsToPrint
),
bigSkip: 0,
};
readParameters.eventsToRead = Number(
options.eventsToRead || FCS.DEFAULT_VALUES.eventsToRead
);
if (
readParameters.eventsToRead <= 0 ||
readParameters.eventsToRead > this.meta.eventCount
)
readParameters.eventsToRead = this.meta.eventCount;
switch (this.text.$DATATYPE) {
case "D":
readParameters.fn = isBE ? databuf.readDoubleBE : databuf.readDoubleLE;
readParameters.bytes = 8;
break;
case "F":
readParameters.fn = isBE ? databuf.readFloatBE : databuf.readFloatLE;
readParameters.bytes = 4;
break;
case "I":
let bits = Number(this.text.$P1B);
if (bits > 16) {
readParameters.fn = isBE ? databuf.readUInt32BE : databuf.readUInt32LE;
readParameters.bytes = 4;
} else {
readParameters.fn = isBE ? databuf.readUInt16BE : databuf.readUInt16LE;
readParameters.bytes = 2;
}
break;
default:
throw Error("oops");
}
readParameters.bytesPerEvent = readParameters.bytes * this.meta.$PAR;
options.skip = options.skip || options.eventSkip; // fix bug#4
if (options.skip && readParameters.eventsToRead < this.meta.eventCount) {
let events2Skip;
if (Number.isFinite(options.skip)) events2Skip = options.skip;
else {
// FIXME, doesn't actually work
events2Skip =
Math.floor(this.meta.eventCount / readParameters.eventsToRead) - 2;
this.meta.computedSkip = options.skip + " -> " + events2Skip;
}
readParameters.bigSkip = events2Skip * readParameters.bytesPerEvent;
}
return readParameters;
};
/**
* Read data and group 1st by event (the natural order in the file)
*
* @param databuf required
* @param readParameters optional
* @returns {FCS} for convenience
*/
FCS.prototype._readDataGroupByEvent = function(databuf, readParameters) {
// determine if these are ints, floats, etc...
readParameters = readParameters || this._prepareReadParameters(databuf);
let offset = Number(this.header.beginData);
// local cache since heavily used
let bytesPerMeasurement = readParameters.bytes;
let databufReadFn = readParameters.fn;
let eventsToRead = readParameters.eventsToRead;
let numParams = Number(this.meta.$PAR);
let decimalsToPrint =
"I" === this.text.$DATATYPE ? -1 : readParameters.decimalsToPrint;
let e = Number;
let p = Number;
let v = Number;
let dataNumbers;
if (readParameters.asNumber) {
dataNumbers = new Array(eventsToRead);
for (e = 0; e < eventsToRead; e++) dataNumbers[e] = new Array(numParams);
}
let dataStrings = readParameters.asString ? new Array(eventsToRead) : null;
let eventString;
// loop over each event
for (e = 0; e < eventsToRead; e++) {
if (dataStrings) {
eventString = "[";
}
let dataE = dataNumbers ? dataNumbers[e] : null; // efficiency
// loop over each parameter
for (p = 0; p < numParams; p++) {
v = databufReadFn.call(databuf, offset);
offset += bytesPerMeasurement;
if (dataStrings) {
if (p > 0) eventString += ",";
if (decimalsToPrint >= 0) eventString += v.toFixed(decimalsToPrint);
else eventString += v;
}
if (dataE) dataE[p] = v;
}
if (dataStrings) {
eventString += "]";
dataStrings[e] = eventString;
}
offset += readParameters.bigSkip;
}
this.dataAsNumbers = dataNumbers;
this.dataAsStrings = dataStrings;
return this;
};
/**
* Read data and group 1st by parameter
*
* @param databuf required
* @param readParameters optional
* @returns {FCS} for convenience
*/
FCS.prototype._readDataGroupByParam = function(databuf, readParameters) {
readParameters = readParameters || this._prepareReadParameters(databuf);
let offset = Number(this.header.beginData);
// local cache since heavily used
let bytesPerMeasurement = readParameters.bytes;
let databufReadFn = readParameters.fn;
let eventsToRead = readParameters.eventsToRead;
let numParams = Number(this.meta.$PAR);
let decimalsToPrint =
"I" === this.text.$DATATYPE ? -1 : readParameters.decimalsToPrint;
let maxPerLine = Number(
this.meta.maxPerLine || FCS.DEFAULT_VALUES.maxPerLine
);
let e = Number;
let p = Number;
let v = Number;
let dataArray;
if (readParameters.asNumber) {
dataArray = new Array(eventsToRead);
for (e = 0; e < eventsToRead; e++) dataArray[e] = new Array(numParams);
}
let dataStrings;
if (readParameters.asString) {
dataStrings = [];
for (p = 0; p < numParams; p++) dataStrings[p] = "[";
}
for (e = 0; e < eventsToRead; e++) {
for (p = 0; p < numParams; p++) {
v = databufReadFn.call(databuf, offset);
offset += bytesPerMeasurement;
if (dataArray) dataArray[p][e] = v;
if (dataStrings) {
if (e > 0) {
dataStrings[p] += ",";
if (e % maxPerLine === 0) dataStrings[p] += "\n";
}
if (decimalsToPrint >= 0) dataStrings[p] += v.toFixed(decimalsToPrint);
else dataStrings[p] += v;
}
}
offset += readParameters.bigSkip;
}
if (dataStrings) {
for (p = 0; p < numParams; p++) {
dataStrings[p] =
dataStrings[p].substring(0, dataStrings[p].length - 1) + "]";
}
}
this.dataAsNumbers = dataArray;
this.dataAsStrings = dataStrings;
return this;
};
/**
* Read the header segment (the first 256 bytes)
*
* @param databuf required
* @param encoding usually absent, defaults to utf8
* @returns {} (see header variable for details)
* @private
*/
FCS.prototype._readHeader = function(databuf, encoding = "utf8") {
let fcsVersion = databuf.toString(encoding, 0, 6);
if ("FCS" !== fcsVersion.substring(0, 3)) {
throw Error("Bad FCS Version: " + fcsVersion);
}
let header = {
FCSVersion: fcsVersion,
beginText: Number(databuf.toString(encoding, 10, 18).trim()),
endText: Number(databuf.toString(encoding, 18, 26).trim()),
beginData: Number(databuf.toString(encoding, 26, 34).trim()),
endData: Number(databuf.toString(encoding, 34, 42).trim()),
beginAnalysis: Number(databuf.toString(encoding, 42, 50).trim()),
endAnalysis: Number(databuf.toString(encoding, 50, 58).trim()),
};
return header;
};
/**
* Reads the delimited key/value pairs of a TEXT or ANALYSIS segment
* @param string if falsy returns empty object
* @returns {{}}
*/
FCS.prototype._readTextOrAnalysis = function(string) {
let result = {};
if (!string) return result;
let delim = string.charAt(0);
if ("<" === delim) {
// Millipore puts in ANALYSIS as XML, don't try to split it up (TODO use xml2js)
result.asXML = string;
return result;
}
// test for escaped delimiters
let delim2 = delim + delim;
let needToHandleEscapees = string.indexOf(delim2) > 0;
let splits = string.split(delim);
// messy code...
if (needToHandleEscapees) {
let corrected = ["", ""];
let ic = 0;
let delimCount = 0;
for (let is = 1; is < splits.length; is++) {
let s = splits[is];
if (s) {
if (delimCount) {
while (delimCount > 0) {
corrected[ic] += delim;
delimCount -= 2; // a '////' will give 3 blanks but only two // are desired
}
if (delimCount === 0)
// odd number is poorly defined, let's make do...
corrected[++ic] = s;
else corrected[ic] += s;
delimCount = 0;
} else corrected[++ic] = s;
} else {
delimCount++;
}
}
splits = corrected;
}
// If string ended with the delimiter, there's an extra empty value. Remove it.
let slenminus1 = splits.length - 1;
if (!splits[slenminus1]) splits.length = slenminus1;
// Grab all the key/value pairs. Start at 1 cause split also added a blank field at the beginning
for (let i = 1; i < splits.length; i += 2) {
let key = splits[i].trim(); // Partec puts \n before analysis keywords
let value = splits[i + 1];
result[key] = value;
}
return result;
};
FCS.prototype._readData = function(databuf, readParameters) {
if (
"H" === this.text.$MODE ||
FCS.OPTION_VALUES.asNone === this.meta.dataFormat
)
return this;
readParameters = readParameters || this._prepareReadParameters(databuf);
if (FCS.OPTION_VALUES.byParam === this.meta.groupBy)
this._readDataGroupByParam(databuf, readParameters);
else this._readDataGroupByEvent(databuf, readParameters);
return this;
};
/**
* FCS 3.0 added support for huge files, where the actual DATA segment may be described in the TEXT segment
* Also sets meta.eventCount and meta.$PAR since they are used so often
*
* @param inText
* @private
*/
FCS.prototype._adjustHeaderBasedUponText = function(inText) {
inText = inText || this.text;
// update a few important meta.
this.meta.eventCount = Number(inText.$TOT);
this.meta.$PAR = Number(inText.$PAR);
// possibly adjust data and analysis headers for huge files
if (this.header.beginData === 0) {
this.header.beginData = Number(inText.$BEGINDATA);
this.header.endData = Number(inText.$ENDDATA);
}
if (this.header.beginAnalysis === 0) {
this.header.beginAnalysis = Number(inText.$BEGINANALYSIS || 0);
this.header.endAnalysis = Number(inText.$ENDANALYSIS || 0);
}
};
module.exports = FCS;