Skip to content

Commit 066d7ab

Browse files
authored
Switch to new hematology analyzer format (#969)
#### Rationale Clinpath acquired a new hematology analyzer which has a different text format with more tests. This updates the parser used by clinpath to fit the new text format and tests. #### Related Pull Requests #### Changes - Update hematology upload to include new tests - Add new tests to hematology display grid
1 parent ffc956c commit 066d7ab

4 files changed

Lines changed: 178 additions & 166 deletions

File tree

WNPRC_EHR/resources/queries/study/hematologyAbsCount.sql

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ ON (
2525
)
2626

2727
WHERE
28-
b.testId IN ('NE', 'LY', 'MN', 'EO', 'BS', 'BANDS', 'METAMYELO', 'MYELO', 'TP', 'RETICULO', 'PRO MYELO', 'ATYP', 'OTHER')
28+
b.testId IN ('NE', 'LY', 'MN', 'EO', 'BS', 'BANDS', 'METAMYELO', 'MYELO', 'TP', 'RETICULO', 'PRO MYELO', 'ATYP', 'OTHER', 'NRBC')
2929
and b.qcstate.publicdata = true
3030
and b2.qcstate.publicdata = true
3131
and b2.id is not null
Lines changed: 34 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,37 @@
1-
21
SELECT
3-
b.id,
4-
b.date,
5-
b.testId,
6-
group_concat(b.result) as results
2+
b.id,
3+
COALESCE(cpr.date, max(b.date)) as date,
4+
b.testId,
5+
group_concat(b.result) as results
76

87
FROM (SELECT
9-
b.id,
10-
b.date,
11-
b.testId,
12-
coalesce(b.taskid, b.parentid, b.runId) as runId,
13-
b.resultoorindicator,
14-
CASE
15-
WHEN b.result IS NULL THEN b.qualresult
16-
ELSE CAST(TRUNCATE(ROUND(CAST(b.result AS NUMERIC), 2), 2) AS VARCHAR)
17-
END as result
18-
19-
FROM study."Hematology Results" b
20-
21-
WHERE testId IN ('WBC', 'RBC', 'HGB', 'HCT', 'MCV', 'MCH','MCHC', 'RDW', 'PLT', 'MPV','PCV','NE','LY','MN','EO','BS','BANDS','METAMYELO','MYELO','TP','RETICULO', 'PRO MYELO', 'ATYP', 'OTHER')
22-
and b.qcstate.publicdata = true
23-
24-
UNION ALL
25-
26-
SELECT
27-
b.id,
28-
b.date,
29-
b.testId,
30-
coalesce(b.taskid, b.parentid) as runId,
31-
b.percentoorindicator,
32-
CAST(TRUNCATE(ROUND(CAST(b.result AS NUMERIC), 2), 2) AS VARCHAR),
33-
FROM study.hematologyAbsCount b
34-
35-
) b
36-
37-
GROUP BY b.id, b.date, b.runId, b.testId
38-
PIVOT results BY testId IN ('WBC', 'RBC', 'HGB', 'HCT', 'MCV', 'MCH','MCHC', 'RDW', 'PLT', 'MPV','PCV','NE','NE-ABS','LY','LY-ABS','MN','MN-ABS','EO','EO-ABS','BS','BS-ABS','BANDS','BANDS-ABS','METAMYELO','MYELO','TP','RETICULO', 'PRO MYELO', 'ATYP', 'OTHER')
39-
8+
b.id,
9+
b.date,
10+
b.testId,
11+
coalesce(b.taskid, b.parentid, b.runId) as runId,
12+
b.resultoorindicator,
13+
CASE
14+
WHEN b.result IS NULL THEN b.qualresult
15+
ELSE CAST(CAST(ROUND(CAST(b.result AS NUMERIC), 4) AS DOUBLE) AS VARCHAR)
16+
END as result
17+
FROM study."Hematology Results" b
18+
19+
WHERE testId IN ('WBC', 'RBC', 'HGB', 'HCT', 'MCV', 'MCH','MCHC', 'RDW','RDW-SD', 'RDW-CV', 'PLT', 'MPV','PCV','NE','LY','MN','EO','BS','BANDS','METAMYELO','MYELO','TP','RETICULO', 'RETIC-AB', 'IRF', 'NRBC', 'NRBC-ABS', 'RETIC HGB', 'IPF', 'PDW', 'P-LCR', 'PCT', 'LFR', 'MFR', 'HFR', 'PRO MYELO', 'ATYP', 'OTHER')
20+
and b.qcstate.publicdata = true
21+
22+
UNION ALL
23+
24+
SELECT
25+
b.id,
26+
b.date,
27+
b.testId,
28+
coalesce(b.taskid, b.parentid) as runId,
29+
b.percentoorindicator,
30+
CAST(CAST(ROUND(CAST(b.result AS NUMERIC), 2) AS DOUBLE) AS VARCHAR) AS result
31+
FROM study.hematologyAbsCount b
32+
33+
) b
34+
LEFT JOIN study.clinpathRuns cpr ON b.runId = cpr.objectId
35+
36+
GROUP BY b.id, b.runId, b.testId, cpr.date
37+
PIVOT results BY testId IN ('WBC', 'RBC', 'HGB', 'HCT', 'MCV', 'MCH','MCHC', 'RDW','RDW-SD', 'RDW-CV', 'PLT', 'MPV','PCV','NE','NE-ABS','LY','LY-ABS','MN','MN-ABS','EO','EO-ABS','BS','BS-ABS','BANDS','BANDS-ABS','METAMYELO','MYELO','PRO MYELO', 'ATYP', 'OTHER', 'TP','RETICULO', 'RETIC-AB', 'IRF', 'NRBC', 'NRBC-ABS', 'RETIC HGB', 'IPF', 'PDW', 'P-LCR', 'PCT', 'LFR', 'MFR', 'HFR')

WNPRC_EHR/resources/web/ehr/ext3/ExtContainers.js

Lines changed: 138 additions & 129 deletions
Original file line numberDiff line numberDiff line change
@@ -939,161 +939,171 @@ EHR.ext.HematologyExcelWin = Ext.extend(Ext.Panel, {
939939
this.processData(data);
940940
},
941941
processData: function(data){
942-
var skippedRows = [];
943942
var runsStore = Ext.StoreMgr.get("study||Clinpath Runs||||");
944943
var unitStore = Ext.StoreMgr.get("ehr_lookups||hematology_tests||testid||testid");
945944

946-
var result;
947-
var tests;
948-
var row1;
949-
var row2;
950-
var toAdd = [];
951-
952-
if(!data.length || !data[0].length){
945+
if (!Array.isArray(data) || !data.length || !data[0]?.length) {
953946
alert('Something went wrong processing the file');
954947
console.log(data)
955948
return;
956949
}
957950

958-
data = data[0][0].split(/D1U/i);
951+
const cleanData = data[0][0].replace(/\s+/g, ' ').trim(); // remove extra whitespace
959952

960-
Ext.each(data, function(row, idx){
961-
if(!row.match(/D2U/i))
962-
return;
963-
964-
row = row.split(/D2U/i);
965-
966-
row1 = row[0];
967-
row2 = row[1];
968-
row1 = row1.replace(/\s+/g, '');
969-
row2 = row2.split(/\s+/);
970-
row2 = row2.slice(2, row2.length-1);
971-
row2 = row2.join('');
972-
973-
result = {};
974-
tests = {};
953+
const formattedObjData = {};
954+
const regex = /(\.D[A-Z0-9]+U)\s+(.*?)(?=\.\D[A-Z0-9]+U|$)/gs;
955+
let match;
956+
while ((match = regex.exec(cleanData)) !== null) {
957+
const key = match[1];
958+
const value = match[2].trim();
959+
const animalId = value.substring(25, 31).toLowerCase();
960+
if(!formattedObjData[animalId]){
961+
formattedObjData[animalId] = {};
962+
}
963+
formattedObjData[animalId][key] = value;
964+
}
965+
const skippedRows = [];
966+
const toAdd = [];
975967

976-
//result.animalId = row1[2].substr(0,6);
977-
result.animalId = row1.substring(27,33);
978-
result.animalId = result.animalId.toLowerCase();
968+
// CONFIGURABLE: Define where to start extracting substrings from D2U and D6U
969+
const headerConfig = {
970+
".D2U": 31, // "XN-10^854650000000017000 cj2795" (0-30) (31 length)
971+
".D6U": 137, // remove header plus 106 "reserved"
972+
// Example: D2U starts with "XN-10^... cj2795", skip header, then parse fixed fields
973+
};
979974

980-
var requestNumber = runsStore.find('Id',result.animalId)
981-
var record = runsStore.getAt(requestNumber);
975+
Object.entries(formattedObjData).forEach(([animalId, rawTests]) => {
976+
if (!rawTests[".D2U"] || !rawTests[".D6U"]) {
977+
alert("Failed to parse formatted data (missing .D1U/.D2U...) for ", animalId);
978+
return;
979+
}
980+
const d2uRaw = rawTests[".D2U"];
981+
const d6uRaw = rawTests[".D6U"];
982+
// Remove headers
983+
984+
const d2u = d2uRaw.slice(headerConfig[".D2U"]);
985+
const d6u = d6uRaw.slice(headerConfig[".D6U"]);
986+
987+
/* These tests were based on the standard definitions revision 7 and 12. The ones initially provided (revision 12) seems to be incorrect given
988+
* our current output. To best understand this data refer to revision 7.
989+
*/
990+
991+
/*
992+
"Reserved" Definition: “0”s or " "(space) are set to this parameter to fill the specified number of characters.
993+
Reserved should be "0" characters but this doesn't seem to be the actual case, it looks like it is a mix
994+
of "0" and " " (space). Because of this it can cause issues with CoolTerm because CoolTerm trims whitespace,
995+
which is needed to correctly guess some values.
996+
*/
997+
998+
const tests = {
999+
WBC: d2u.substring(0, 6),
1000+
RBC: d2u.substring(6, 11),
1001+
HGB: d2u.substring(11, 16),
1002+
HCT: d2u.substring(16, 21),
1003+
MCV: d2u.substring(21, 26),
1004+
MCH: d2u.substring(26, 31),
1005+
MCHC: d2u.substring(31, 36),
1006+
PLT: d2u.substring(36, 41),
1007+
LY: d2u.substring(41, 46),
1008+
MN: d2u.substring(46, 51),
1009+
NE: d2u.substring(51, 56),
1010+
EO: d2u.substring(56, 61),
1011+
BS: d2u.substring(61, 66),
1012+
"RDW-CV": d2u.substring(95, 101),
1013+
"RDW-SD": d2u.substring(101, 106),
1014+
MPV: d2u.substring(106, 111),
1015+
RETICULO: d2u.substring(121, 126),
1016+
IRF: d2u.substring(131, 136),
1017+
NRBC: d2u.substring(157, 162),
1018+
'RETIC-AB': d2u.substring(179, 185),
1019+
'RETIC HGB': d2u.substring(185, 190),
1020+
IPF: d2u.substring(190, 195),
1021+
// Start D6U Tests
1022+
PDW: d6u.substring(0, 5),
1023+
"P-LCR": d6u.substring(10, 15),
1024+
LFR: d6u.substring(30, 35),
1025+
MFR: d6u.substring(35, 40),
1026+
HFR: d6u.substring(40, 45),
1027+
PCT: d6u.substring(45, 50),
1028+
};
9821029

1030+
// Lookup run record
1031+
const requestNumber = runsStore.find('Id', animalId);
1032+
const record = runsStore.getAt(requestNumber);
1033+
let collectionDate;
9831034
//Getting the collection time from the request itself, if it matches animalId
984-
if(requestNumber!= -1 && result.animalId == record.get('Id')){
1035+
if(requestNumber !== -1 && animalId === record.get('Id')){
9851036

986-
var collectionDate = record.get('date');
1037+
collectionDate = record.get('date');
9871038
}
9881039

989-
//result.sequenceNo = row1[1].substr(20,4);
990-
//result.date = new Date(row1[2].substr(6,4), row1[2].substr(10,2)-1, row1[2].substr(12,2));
991-
//result.date = new Date(row1.substr(33,4), row1.substr(37,2)-1, row1.substr(39,2));
992-
result.date= new Date(collectionDate);
993-
994-
if(!result.animalId || runsStore.find('Id', result.animalId)==-1){
1040+
if(!animalId || runsStore.find('Id', animalId)===-1){
9951041
//alert('ID: '+result.animalId+' not found in Clinpath Runs section. Records will not be added');
996-
skippedRows.push('Not found in Clinpath Runs: '+result.animalId);
1042+
skippedRows.push('Not found in Clinpath Runs: ' + animalId);
1043+
console.log("Skip Row", animalId);
9971044
return;
9981045
}
9991046

1000-
tests['WBC'] = row2.substring(6, 12);
1001-
tests['RBC'] = row2.substring(12, 17);
1002-
tests['HGB'] = row2.substring(17, 22);
1003-
tests['HCT'] = row2.substring(22, 27);
1004-
tests['MCV'] = row2.substring(27, 32);
1005-
tests['MCH'] = row2.substring(32, 37);
1006-
tests['MCHC'] = row2.substring(37, 42);
1007-
tests['PLT'] = row2.substring(42, 47);
1008-
//tests['LYMPH%'] = row2.substring(47, 52);
1009-
tests['LY'] = row2.substring(47, 52);
1010-
1011-
//tests['MONO%'] = row2.substring(52, 57);
1012-
tests['MN'] = row2.substring(52, 57);
1013-
1014-
//tests['SEG%'] = row2.substring(57, 62);
1015-
tests['NE'] = row2.substring(57, 62);
1016-
1017-
//tests['EOSIN%'] = row2.substring(62, 67);
1018-
tests['EO'] = row2.substring(62, 67);
1019-
1020-
//tests['BASO%'] = row2.substring(67, 72);
1021-
tests['BS'] = row2.substring(67, 72);
1022-
1023-
//tests['LYMPH#'] = row2.substring(72, 78);
1024-
//tests['MONO#'] = row2.substring(78, 84);
1025-
//tests['SEG#'] = row2.substring(84, 90);
1026-
//tests['EOSIN#'] = row2.substring(90, 96);
1027-
//tests['BASO#'] = row2.substring(96, 102);
1028-
//tests['RDW'] = row2.substring(102, 107);
1029-
tests['RDW-CV'] = row2.substring(102, 107);
1030-
tests['RDW-SD'] = row2.substring(107, 112);
1031-
//tests['PDW'] = row2.substring(112, 117);
1032-
tests['MPV'] = row2.substring(117, 122);
1033-
//tests['P-LCR'] = row2.substring(122, 127);
1034-
1035-
1036-
var value;
1037-
for(var test in tests){
1038-
var origVal = tests[test];
1039-
value = tests[test];
1047+
const result = {
1048+
animalId,
1049+
date: new Date(collectionDate),
1050+
};
10401051

1041-
if (value.match(/^00(\d){4}$/)) {
1042-
tests[test] = value.substring(2,5) / 100;
1043-
}
1044-
//note: at the moment WBC is the only test with 6 chars, so this test is possibly redundant
1045-
else if (value.match(/^0(\d){4,}$/) && test=='WBC') {
1046-
tests[test] = value.substring(1,5) / 100;
1047-
}
1048-
else if (value.match(/^0\d{4}$/)){
1049-
if (test=='RBC') {
1050-
tests[test] = value.substring(1,4) / 100;
1051-
}
1052-
else if (test=='PLT') {
1053-
tests[test] = value.substring(1,4) / 1; //convert to number
1054-
}
1055-
else {
1056-
tests[test] = value.substring(1,4) / 10;
1052+
1053+
/*
1054+
Rules: Define the test and how many digits from the right the decimal should appear in the string.
1055+
It does not matter how many initial digits are in the string (WBC has 6 while RBC has 5).
1056+
The majority of tests are 1 decimal, so that is the default. If the test does not exist within rules
1057+
specifically it will use the default.
1058+
Ex.
1059+
Rule: 2
1060+
RBC: 000.00
1061+
WBRC: 0000.00
1062+
*/
1063+
const rules = {
1064+
WBC: 2,
1065+
'NRBC#': 2,
1066+
RBC: 2,
1067+
RETICULO: 2,
1068+
'RETIC-AB': 4,
1069+
PLT: 0,
1070+
PCT: 2,
1071+
_default: 1
1072+
};
1073+
1074+
for(var test in tests){
1075+
const decimal = (test in rules) ? rules[test] : rules['_default'];
1076+
const value = tests[test];
1077+
1078+
const data = value.slice(0, -1);
1079+
1080+
const number = parseInt(data);
1081+
1082+
// Convert to string to easily manipulate decimal placement
1083+
let numberStr = number.toString();
1084+
1085+
// If decimal is 0, no decimal point needed
1086+
if (decimal === 0) {
1087+
tests[test] = number;
1088+
} else {
1089+
// If we need to add a decimal point
1090+
if (numberStr.length <= decimal) {
1091+
// If number is shorter than decimal places, pad with zeros
1092+
numberStr = '0'.repeat(decimal - numberStr.length) + numberStr;
1093+
tests[test] = parseFloat('0.' + numberStr);
1094+
} else {
1095+
// Insert decimal point at the correct position from the right
1096+
const decimalIndex = numberStr.length - decimal;
1097+
const formatted = numberStr.slice(0, decimalIndex) + '.' + numberStr.slice(decimalIndex);
1098+
tests[test] = parseFloat(formatted);
10571099
}
10581100
}
1059-
else if (test=='PLT') {
1060-
tests[test] = value.substring(0,4);
1061-
}
1062-
1063-
//NOTE: the following is a possible replacement for the logic above
1064-
//it attempts to more clearly define how the parsing works
1065-
//so far as i can tell, specific tests return different sets of decimals
1066-
//and there is no clear way to determine decimal number without knowing the test name
1067-
// if(value.match(/^(\d){5,6}$/)){
1068-
// //we drop the last digit in all cases
1069-
// value = value.substr(0, value.length-1);
1070-
//
1071-
// var decimals = 1;
1072-
// //WBC is output as 10^1/uL, but reported at 10^3/ul
1073-
// //RBC is output as 10^4/ul, but reported at 10^6/ul
1074-
// if(test=='WBC' || test=='RBC')
1075-
// decimals = 2;
1076-
// if(test=='PLT')
1077-
// decimals = 0;
1078-
//
1079-
// value = value / Math.pow(10, decimals);
1080-
//
1081-
// value = Ext4.util.Format.round(value, decimals);
1082-
// }
1083-
// else {
1084-
// //alert('Value: '+value+' is not a number');
1085-
// return;
1086-
// }
1087-
//
1088-
// if(value != tests[test]){
1089-
// console.log('error: '+test+'/'+tests[test]+'/'+value+'/'+decimals);
1090-
// }
10911101

10921102
//find units
10931103
var idx = unitStore.find('testid', test);
10941104
var units = null;
10951105
var sortOrder = null;
1096-
if(idx!=-1){
1106+
if(idx !== -1){
10971107
units = unitStore.getAt(idx).get('units');
10981108
sortOrder = unitStore.getAt(idx).get('sort_order');
10991109
}
@@ -1112,8 +1122,7 @@ EHR.ext.HematologyExcelWin = Ext.extend(Ext.Panel, {
11121122
sortOrder: sortOrder
11131123
});
11141124
}
1115-
1116-
}, this);
1125+
});
11171126

11181127
if(toAdd.length){
11191128
toAdd.sort(function(a, b){

0 commit comments

Comments
 (0)