fhirsmith 0.18.0 → 0.18.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/CHANGELOG.md +72 -10
  2. package/ips/ips.js +251 -26
  3. package/ips/jurisdictions.js +65 -0
  4. package/ips/pipeline.js +67 -6
  5. package/ips/profile-editor.js +283 -4
  6. package/ips/readme.md +48 -5
  7. package/ips/render.js +317 -46
  8. package/ips/scripts.js +46 -0
  9. package/ips/store.js +55 -0
  10. package/library/daily-report.js +25 -15
  11. package/package.json +3 -3
  12. package/registry/api.js +179 -1
  13. package/registry/crawler.js +2 -0
  14. package/registry/documentation/api.md +68 -2
  15. package/registry/documentation/internals.md +4 -1
  16. package/registry/documentation/readme.md +3 -2
  17. package/registry/model.js +8 -0
  18. package/registry/openapi.yaml +84 -0
  19. package/registry/registry.js +53 -2
  20. package/translations/Messages.properties +1 -0
  21. package/tx/README.md +4 -0
  22. package/tx/closure/closure-store.js +14 -0
  23. package/tx/cm/cm-package.js +21 -15
  24. package/tx/cs/cs-api.js +3 -1
  25. package/tx/cs/cs-cs.js +59 -12
  26. package/tx/cs/cs-icf.js +47 -34
  27. package/tx/cs/cs-loinc.js +257 -3
  28. package/tx/cs/cs-snomed.js +544 -55
  29. package/tx/importers/csv-records.js +35 -0
  30. package/tx/importers/import-loinc.module.js +177 -41
  31. package/tx/importers/import-sct.module.js +131 -1
  32. package/tx/importers/subset-loinc.module.js +25 -6
  33. package/tx/library/codesystem.js +4 -1
  34. package/tx/library/designations.js +23 -30
  35. package/tx/library/tho-precedence.js +40 -0
  36. package/tx/openapi-operations.js +2 -1
  37. package/tx/provider.js +46 -29
  38. package/tx/sct/documentation.md +45 -21
  39. package/tx/sct/ecl.js +34 -11
  40. package/tx/sct/structures.js +15 -2
  41. package/tx/tx.fhir.org.yml +2 -2
  42. package/tx/vs/vs-package.js +17 -13
  43. package/tx/vs/vs-vsac.js +1 -1
  44. package/tx/workers/closure.js +20 -0
  45. package/tx/workers/expand.js +2 -2
  46. package/tx/workers/validate.js +10 -0
  47. package/utilities/make-sct-loinc-subset.js +250 -0
@@ -0,0 +1,35 @@
1
+ const fs = require('fs');
2
+ const readline = require('readline');
3
+
4
+ /**
5
+ * Yields the records of a CSV file one at a time, as text.
6
+ *
7
+ * A quoted field can contain a line break (LOINC 2.83's ComponentHierarchyBySystem.csv has one
8
+ * in a CODE_TEXT), so a record that ends inside quotes is joined to the next line until the
9
+ * quotes balance. An escaped quote ("") counts twice, so it doesn't upset the count.
10
+ */
11
+ async function* csvRecords(filePath) {
12
+ const rl = readline.createInterface({
13
+ input: fs.createReadStream(filePath),
14
+ crlfDelay: Infinity
15
+ });
16
+
17
+ let pending = null;
18
+ let quotes = 0;
19
+ for await (const line of rl) {
20
+ pending = pending === null ? line : pending + '\n' + line;
21
+ for (let i = 0; i < line.length; i++) {
22
+ if (line.charCodeAt(i) === 34) quotes++;
23
+ }
24
+ if (quotes % 2 === 0) {
25
+ yield pending;
26
+ pending = null;
27
+ quotes = 0;
28
+ }
29
+ }
30
+ if (pending !== null) {
31
+ yield pending;
32
+ }
33
+ }
34
+
35
+ module.exports = { csvRecords };
@@ -3,6 +3,7 @@ const fs = require('fs');
3
3
  const sqlite3 = require('sqlite3').verbose();
4
4
  const path = require('path');
5
5
  const readline = require('readline');
6
+ const { csvRecords } = require('./csv-records');
6
7
  const chalk = require('chalk');
7
8
 
8
9
  class LoincModule extends BaseTerminologyModule {
@@ -291,7 +292,10 @@ class LoincModule extends BaseTerminologyModule {
291
292
  // Translations are a different case: a main-only import is a legitimate thing to want,
292
293
  // and their absence costs designations, not correctness
293
294
  const optionalFiles = [
294
- 'AccessoryFiles/LinguisticVariants/LinguisticVariants.csv'
295
+ 'AccessoryFiles/LinguisticVariants/LinguisticVariants.csv',
296
+ // the external code mappings: their absence costs maps to other code systems, nothing else
297
+ 'AccessoryFiles/PartFile/PartRelatedCodeMapping.csv',
298
+ 'AccessoryFiles/LoincIeeeMedicalDeviceCodeMappingTable/LoincIeeeMedicalDeviceCodeMappingTable.csv'
295
299
  ];
296
300
 
297
301
  const warnings = [];
@@ -428,6 +432,11 @@ class LoincModule extends BaseTerminologyModule {
428
432
  'CREATE INDEX IF NOT EXISTS idx_descriptions_type ON Descriptions(DescriptionTypeKey)',
429
433
  'CREATE INDEX IF NOT EXISTS idx_descriptions_code_type ON Descriptions(CodeKey, DescriptionTypeKey)',
430
434
 
435
+ // External mapping indexes
436
+ 'CREATE INDEX IF NOT EXISTS idx_externalcodes_code ON ExternalCodes(System, Code)',
437
+ 'CREATE INDEX IF NOT EXISTS idx_externalmappings_code ON ExternalMappings(CodeKey)',
438
+ 'CREATE INDEX IF NOT EXISTS idx_externalmappings_target ON ExternalMappings(ExternalCodeKey)',
439
+
431
440
  // Closure table indexes
432
441
  'CREATE INDEX IF NOT EXISTS idx_closure_ancestor ON Closure(AncestorKey)',
433
442
  'CREATE INDEX IF NOT EXISTS idx_closure_descendent ON Closure(DescendentKey)'
@@ -506,7 +515,9 @@ class LoincDataMigratorWithProgress {
506
515
  'AccessoryFiles/AnswerFile/AnswerList.csv',
507
516
  'AccessoryFiles/PartFile/LoincPartLink_Primary.csv',
508
517
  'AccessoryFiles/AnswerFile/LoincAnswerListLink.csv',
509
- 'AccessoryFiles/ComponentHierarchyBySystem/ComponentHierarchyBySystem.csv'
518
+ 'AccessoryFiles/ComponentHierarchyBySystem/ComponentHierarchyBySystem.csv',
519
+ 'AccessoryFiles/PartFile/PartRelatedCodeMapping.csv',
520
+ 'AccessoryFiles/LoincIeeeMedicalDeviceCodeMappingTable/LoincIeeeMedicalDeviceCodeMappingTable.csv'
510
521
  ];
511
522
 
512
523
  // Count main files
@@ -599,6 +610,10 @@ class LoincDataMigrator {
599
610
  this.props = new Map();
600
611
  this.propValues = new Map();
601
612
  this.partNames = new Map();
613
+ this.extCodeKey = 0;
614
+ this.extCodes = new Map(); // system|version|code -> ExternalCodeKey
615
+ this.extMapKey = 0;
616
+ this.extMaps = new Set(); // codeKey|ExternalCodeKey
602
617
 
603
618
  // Create tables and initial data
604
619
  await this.createTables(db, version, options.verbose);
@@ -631,6 +646,10 @@ class LoincDataMigrator {
631
646
  this.updateProgress(0, 'List Links');
632
647
  await this.processListLinks(db, sourceDir, 8, options);
633
648
 
649
+ this.updateProgress(0, 'External Mappings');
650
+ await this.processPartMappings(db, sourceDir, 9, options);
651
+ await this.processIeeeMappings(db, sourceDir, 9, options);
652
+
634
653
  this.updateProgress(0, 'Hierarchy');
635
654
  await this.processHierarchy(db, sourceDir, 9, options);
636
655
 
@@ -719,6 +738,21 @@ class LoincDataMigrator {
719
738
  DescriptionTypeKey INTEGER NOT NULL,
720
739
  Value TEXT NOT NULL
721
740
  )`,
741
+ // Codes in other code systems that LOINC codes map to (one row per system|version|code)
742
+ `CREATE TABLE ExternalCodes (
743
+ ExternalCodeKey INTEGER NOT NULL PRIMARY KEY,
744
+ System TEXT NOT NULL,
745
+ Version TEXT NULL,
746
+ Code TEXT NOT NULL,
747
+ Display TEXT NULL
748
+ )`,
749
+ // LOINC code (Codes.CodeKey) -> ExternalCodes, with a ConceptMap equivalence (R4 codes)
750
+ `CREATE TABLE ExternalMappings (
751
+ ExternalMappingKey INTEGER NOT NULL PRIMARY KEY,
752
+ CodeKey INTEGER NOT NULL,
753
+ ExternalCodeKey INTEGER NOT NULL,
754
+ Equivalence TEXT NOT NULL
755
+ )`,
722
756
  `CREATE TABLE Closure (
723
757
  AncestorKey INTEGER NOT NULL,
724
758
  DescendentKey INTEGER NOT NULL,
@@ -843,10 +877,7 @@ class LoincDataMigrator {
843
877
  return;
844
878
  }
845
879
 
846
- const rl = readline.createInterface({
847
- input: fs.createReadStream(filePath),
848
- crlfDelay: Infinity
849
- });
880
+ const rl = csvRecords(filePath);
850
881
 
851
882
  let lineCount = 0;
852
883
  for await (const line of rl) {
@@ -872,10 +903,7 @@ class LoincDataMigrator {
872
903
  if (options.verbose) console.log('Processing Parts...');
873
904
 
874
905
  const filePath = path.join(sourceDir, 'AccessoryFiles/PartFile/Part.csv');
875
- const rl = readline.createInterface({
876
- input: fs.createReadStream(filePath),
877
- crlfDelay: Infinity
878
- });
906
+ const rl = csvRecords(filePath);
879
907
 
880
908
  let lineCount = 0;
881
909
  let processedCount = 0;
@@ -927,10 +955,7 @@ class LoincDataMigrator {
927
955
  if (options.verbose) console.log('Processing Main Codes...');
928
956
 
929
957
  const filePath = path.join(sourceDir, 'LoincTable/Loinc.csv');
930
- const rl = readline.createInterface({
931
- input: fs.createReadStream(filePath),
932
- crlfDelay: Infinity
933
- });
958
+ const rl = csvRecords(filePath);
934
959
 
935
960
  let lineCount = 0;
936
961
  let processedCount = 0;
@@ -1007,10 +1032,7 @@ class LoincDataMigrator {
1007
1032
 
1008
1033
  if (options.verbose) console.log('Processing Consumer Names...');
1009
1034
 
1010
- const rl = readline.createInterface({
1011
- input: fs.createReadStream(filePath),
1012
- crlfDelay: Infinity
1013
- });
1035
+ const rl = csvRecords(filePath);
1014
1036
 
1015
1037
  let lineCount = 0;
1016
1038
  let processedCount = 0;
@@ -1045,10 +1067,7 @@ class LoincDataMigrator {
1045
1067
 
1046
1068
  if (options.verbose) console.log('Processing Answer Lists...');
1047
1069
 
1048
- const rl = readline.createInterface({
1049
- input: fs.createReadStream(filePath),
1050
- crlfDelay: Infinity
1051
- });
1070
+ const rl = csvRecords(filePath);
1052
1071
 
1053
1072
  let lineCount = 0;
1054
1073
  let processedCount = 0;
@@ -1058,7 +1077,7 @@ class LoincDataMigrator {
1058
1077
  lineCount++;
1059
1078
  if (lineCount === 1) continue;
1060
1079
 
1061
- const items = csvSplit(line, 11);
1080
+ const items = csvSplit(line, 15);
1062
1081
  if (items.length < 11) continue;
1063
1082
 
1064
1083
  await this.processListItem(db, items, currentList);
@@ -1111,6 +1130,111 @@ class LoincDataMigrator {
1111
1130
 
1112
1131
  this.addRelationship(db, listCodeKey, answerCodeKey, this.rels.get('Answer'));
1113
1132
  this.addRelationship(db, answerCodeKey, listCodeKey, this.rels.get('AnswerList'));
1133
+
1134
+ // ExtCodeId (11), ExtCodeDisplayName (12), ExtCodeSystem (13), ExtCodeSystemVersion (14).
1135
+ // The file carries no equivalence for these; an answer and its external code are the same thing
1136
+ if (items[11] && items[13]) {
1137
+ this.addExternalMapping(db, answerCodeKey, items[13], items[14], items[11], items[12], 'equivalent');
1138
+ }
1139
+ }
1140
+
1141
+ async processPartMappings(db, sourceDir, step, options) {
1142
+ const filePath = path.join(sourceDir, 'AccessoryFiles/PartFile/PartRelatedCodeMapping.csv');
1143
+ if (!fs.existsSync(filePath)) {
1144
+ if (options.verbose) console.warn(`Part mapping file not found: ${filePath}`);
1145
+ return;
1146
+ }
1147
+
1148
+ if (options.verbose) console.log('Processing Part Mappings...');
1149
+
1150
+ const rl = csvRecords(filePath);
1151
+
1152
+ // PartNumber, PartName, PartTypeName, ExtCodeId, ExtCodeDisplayName, ExtCodeSystem,
1153
+ // Equivalence, ContentOrigin, ExtCodeSystemVersion, ExtCodeSystemCopyrightNotice
1154
+ let lineCount = 0;
1155
+ let processedCount = 0;
1156
+ for await (const line of rl) {
1157
+ lineCount++;
1158
+ if (lineCount === 1) continue;
1159
+
1160
+ const items = csvSplit(line, 10);
1161
+ const partCode = items[0];
1162
+ const system = items[5];
1163
+ // RadLex mappings are not simple code-to-code maps; not handled yet
1164
+ if (!items[3] || !system || system === 'http://www.radlex.org' || !this.codes.has(partCode)) continue;
1165
+
1166
+ this.addExternalMapping(db, this.codes.get(partCode).key, system, items[8], items[3], items[4], items[6] || 'equivalent');
1167
+ processedCount++;
1168
+
1169
+ if (processedCount % 100 === 0) {
1170
+ this.updateProgress(100);
1171
+ }
1172
+ }
1173
+
1174
+ const remaining = processedCount % 100;
1175
+ if (remaining > 0) {
1176
+ this.updateProgress(remaining);
1177
+ }
1178
+ }
1179
+
1180
+ async processIeeeMappings(db, sourceDir, step, options) {
1181
+ const filePath = path.join(sourceDir, 'AccessoryFiles/LoincIeeeMedicalDeviceCodeMappingTable/LoincIeeeMedicalDeviceCodeMappingTable.csv');
1182
+ if (!fs.existsSync(filePath)) {
1183
+ if (options.verbose) console.warn(`IEEE mapping file not found: ${filePath}`);
1184
+ return;
1185
+ }
1186
+
1187
+ if (options.verbose) console.log('Processing IEEE Mappings...');
1188
+
1189
+ const rl = csvRecords(filePath);
1190
+
1191
+ // LOINC_NUM, LOINC_LONG_COMMON_NAME, IEEE_CF_CODE10, IEEE_REFID, EQUIVALENCE
1192
+ // The code is the CF_CODE10 (as used in FHIR, e.g. 150456); the REFID is its display.
1193
+ let lineCount = 0;
1194
+ let processedCount = 0;
1195
+ for await (const line of rl) {
1196
+ lineCount++;
1197
+ if (lineCount === 1) continue;
1198
+
1199
+ const items = csvSplit(line, 5);
1200
+ const loincCode = items[0];
1201
+ if (!items[2] || !this.codes.has(loincCode)) continue;
1202
+
1203
+ this.addExternalMapping(db, this.codes.get(loincCode).key, 'urn:iso:std:iso:11073:10101', null,
1204
+ items[2], items[3], ieeeEquivalence(items[4]));
1205
+ processedCount++;
1206
+
1207
+ if (processedCount % 100 === 0) {
1208
+ this.updateProgress(100);
1209
+ }
1210
+ }
1211
+
1212
+ const remaining = processedCount % 100;
1213
+ if (remaining > 0) {
1214
+ this.updateProgress(remaining);
1215
+ }
1216
+ }
1217
+
1218
+ addExternalMapping(db, codeKey, system, version, code, display, equivalence) {
1219
+ system = normaliseExternalSystem(system);
1220
+ version = version || null;
1221
+ const targetId = system + '|' + (version || '') + '|' + code;
1222
+ let extKey = this.extCodes.get(targetId);
1223
+ if (!extKey) {
1224
+ this.extCodeKey++;
1225
+ extKey = this.extCodeKey;
1226
+ this.extCodes.set(targetId, extKey);
1227
+ db.run('INSERT INTO ExternalCodes (ExternalCodeKey, System, Version, Code, Display) VALUES (?, ?, ?, ?, ?)',
1228
+ [extKey, system, version, code, display || null]);
1229
+ }
1230
+
1231
+ // the same answer appears once per answer list it's in
1232
+ const mapId = codeKey + '|' + extKey;
1233
+ if (this.extMaps.has(mapId)) return;
1234
+ this.extMaps.add(mapId);
1235
+ this.extMapKey++;
1236
+ db.run('INSERT INTO ExternalMappings (ExternalMappingKey, CodeKey, ExternalCodeKey, Equivalence) VALUES (?, ?, ?, ?)',
1237
+ [this.extMapKey, codeKey, extKey, equivalence]);
1114
1238
  }
1115
1239
 
1116
1240
  async processPartLinks(db, sourceDir, step, options) {
@@ -1122,10 +1246,7 @@ class LoincDataMigrator {
1122
1246
 
1123
1247
  if (options.verbose) console.log('Processing Part Links...');
1124
1248
 
1125
- const rl = readline.createInterface({
1126
- input: fs.createReadStream(filePath),
1127
- crlfDelay: Infinity
1128
- });
1249
+ const rl = csvRecords(filePath);
1129
1250
 
1130
1251
  let lineCount = 0;
1131
1252
  let processedCount = 0;
@@ -1171,10 +1292,7 @@ class LoincDataMigrator {
1171
1292
 
1172
1293
  if (options.verbose) console.log('Processing List Links...');
1173
1294
 
1174
- const rl = readline.createInterface({
1175
- input: fs.createReadStream(filePath),
1176
- crlfDelay: Infinity
1177
- });
1295
+ const rl = csvRecords(filePath);
1178
1296
 
1179
1297
  let lineCount = 0;
1180
1298
  let processedCount = 0;
@@ -1227,10 +1345,7 @@ class LoincDataMigrator {
1227
1345
 
1228
1346
  if (options.verbose) console.log('Processing Hierarchy...');
1229
1347
 
1230
- const rl = readline.createInterface({
1231
- input: fs.createReadStream(filePath),
1232
- crlfDelay: Infinity
1233
- });
1348
+ const rl = csvRecords(filePath);
1234
1349
 
1235
1350
  let lineCount = 0;
1236
1351
  let processedCount = 0;
@@ -1258,7 +1373,7 @@ class LoincDataMigrator {
1258
1373
  async processHierarchyItem(db, items) {
1259
1374
  const pathCode = items[3];
1260
1375
  const parentPath = items[2];
1261
- const description = items[4];
1376
+ const description = items[4].trim();
1262
1377
 
1263
1378
  if (!this.codes.has(pathCode)) {
1264
1379
  this.codeKey++;
@@ -1339,10 +1454,7 @@ class LoincDataMigrator {
1339
1454
  return;
1340
1455
  }
1341
1456
 
1342
- const rl = readline.createInterface({
1343
- input: fs.createReadStream(filePath),
1344
- crlfDelay: Infinity
1345
- });
1457
+ const rl = csvRecords(filePath);
1346
1458
 
1347
1459
  let lineCount = 0;
1348
1460
  let processedCount = 0;
@@ -1488,6 +1600,29 @@ function adjustPropName(s) {
1488
1600
  throw new Error(`Unknown Property Name: ${s}`);
1489
1601
  }
1490
1602
 
1603
+ // LOINC names some code systems with something other than their FHIR URI. The others not yet
1604
+ // known ('CMS LCDS', 'Unicode') are left as LOINC has them
1605
+ const EXTERNAL_SYSTEM_URIS = {
1606
+ 'CMS/HCPCS': 'http://www.cms.gov/Medicare/Coding/HCPCSReleaseCodeSets',
1607
+ 'https://www.cdc.gov/phin': 'urn:oid:2.16.840.1.113883.6.238' // CDC Race & Ethnicity
1608
+ };
1609
+
1610
+ function normaliseExternalSystem(system) {
1611
+ return EXTERNAL_SYSTEM_URIS[system] || system;
1612
+ }
1613
+
1614
+ // The IEEE table states equivalence from the LOINC side ('broader' = the LOINC code is broader,
1615
+ // e.g. Glucose in Serum or Plasma -> MDC_CONC_GLU_VENOUS_PLASMA). ConceptMap equivalence is stated
1616
+ // from the target side, so broader/narrower swap to narrower/wider.
1617
+ function ieeeEquivalence(s) {
1618
+ switch (s) {
1619
+ case 'broader': return 'narrower';
1620
+ case 'narrower': return 'wider';
1621
+ case '': return 'equivalent';
1622
+ default: return s;
1623
+ }
1624
+ }
1625
+
1491
1626
  function descClassType(s) {
1492
1627
  const types = {
1493
1628
  '1': 'Laboratory class',
@@ -1539,5 +1674,6 @@ function removeQuotes(str) {
1539
1674
 
1540
1675
  module.exports = {
1541
1676
  LoincModule,
1542
- LoincDataMigrator
1677
+ LoincDataMigrator,
1678
+ normaliseExternalSystem
1543
1679
  };
@@ -1136,6 +1136,12 @@ class SnomedImporter {
1136
1136
  // Step 10: Read relationships
1137
1137
  await this.readRelationships();
1138
1138
 
1139
+ // Step 10a: Read alternate identifiers (e.g. the LOINC codes of the LOINC Extension)
1140
+ await this.readIdentifiers();
1141
+
1142
+ // Step 10b: Read concrete value relationships (sct2_RelationshipConcreteValues)
1143
+ await this.readConcreteValues();
1144
+
1139
1145
  // Step 11: Link concepts
1140
1146
  this.linkConcepts();
1141
1147
 
@@ -1165,6 +1171,8 @@ class SnomedImporter {
1165
1171
  concepts: [],
1166
1172
  descriptions: [],
1167
1173
  relationships: [],
1174
+ identifiers: [],
1175
+ concreteValues: [],
1168
1176
  refsetDirectories: []
1169
1177
  };
1170
1178
 
@@ -1218,6 +1226,10 @@ class SnomedImporter {
1218
1226
  } else if (firstLine.startsWith('id\teffectiveTime\tactive\tmoduleId\tsourceId\tdestinationId\trelationshipGroup\ttypeId\tcharacteristicTypeId\tmodifierId') &&
1219
1227
  !filePath.includes('StatedRelationship')) {
1220
1228
  files.relationships.push(filePath);
1229
+ } else if (files.identifiers && firstLine.startsWith('alternateIdentifier\teffectiveTime\tactive\tmoduleId\tidentifierSchemeId\treferencedComponentId')) {
1230
+ files.identifiers.push(filePath);
1231
+ } else if (files.concreteValues && firstLine.startsWith('id\teffectiveTime\tactive\tmoduleId\tsourceId\tvalue\trelationshipGroup\ttypeId\tcharacteristicTypeId\tmodifierId')) {
1232
+ files.concreteValues.push(filePath);
1221
1233
  }
1222
1234
  } catch (error) {
1223
1235
  console.log(`DEBUG: Error reading file ${filePath}: ${error.message}`);
@@ -1726,6 +1738,108 @@ class SnomedImporter {
1726
1738
  return /^\d+$/.test(str);
1727
1739
  }
1728
1740
 
1741
+ /**
1742
+ * Read the alternate identifier files (sct2_Identifier). Each active row gives a
1743
+ * concept a code in another scheme - the LOINC Extension, for instance, gives each
1744
+ * of its concepts its LOINC code (scheme 30051010000102 |LOINC code identifier|).
1745
+ * Kept as [scheme, module, code, conceptId] for the concepts in this edition; where
1746
+ * a row appears more than once (Full files, or base + extension) the latest wins.
1747
+ */
1748
+ async readIdentifiers() {
1749
+ const rows = new Map(); // scheme|code|concept -> {effectiveTime, active, row}
1750
+ for (const file of this.files.identifiers || []) {
1751
+ const rl = readline.createInterface({
1752
+ input: fs.createReadStream(file),
1753
+ crlfDelay: Infinity
1754
+ });
1755
+ let lineCount = 0;
1756
+ for await (const line of rl) {
1757
+ lineCount++;
1758
+ if (lineCount === 1 || !line) continue;
1759
+ const parts = line.split('\t');
1760
+ if (parts.length < 6) continue;
1761
+ const [code, effectiveTime, active, moduleId, schemeId, conceptId] = parts.map(p => p.trim());
1762
+ const key = schemeId + '|' + code + '|' + conceptId;
1763
+ const prior = rows.get(key);
1764
+ if (!prior || prior.effectiveTime <= effectiveTime) {
1765
+ rows.set(key, { effectiveTime, active: active === '1', row: [schemeId, moduleId, code, conceptId] });
1766
+ }
1767
+ }
1768
+ }
1769
+ this.identifiers = [];
1770
+ let missing = 0;
1771
+ for (const r of rows.values()) {
1772
+ if (!r.active) continue;
1773
+ let known = false;
1774
+ try {
1775
+ known = this.conceptMap.has(BigInt(r.row[3]));
1776
+ } catch {
1777
+ known = false;
1778
+ }
1779
+ if (known) {
1780
+ this.identifiers.push(r.row);
1781
+ } else {
1782
+ missing++;
1783
+ }
1784
+ }
1785
+ this.identifiers.sort((a, b) => (a.join('|') < b.join('|') ? -1 : 1));
1786
+ if (this.identifiers.length > 0 || missing > 0) {
1787
+ console.log(`Alternate identifiers: ${this.identifiers.length}${missing ? ` (${missing} for concepts not in this edition, ignored)` : ''}`);
1788
+ }
1789
+ }
1790
+
1791
+ /**
1792
+ * Read the concrete value relationship files (sct2_RelationshipConcreteValues_*):
1793
+ * the numeric and string attribute values of concepts (strength, count of base of
1794
+ * active ingredient, ...). Kept as rows [sourceId, typeId, group, value] - the value
1795
+ * as written in RF2 ("#500", "#5.5", "\"text\"", "true") - for the ECL evaluator;
1796
+ * they are not relationships to concepts, so they do not join the relationship
1797
+ * table. Only active, inferred rows; where a row appears more than once the latest
1798
+ * effectiveTime wins.
1799
+ */
1800
+ async readConcreteValues() {
1801
+ const rows = new Map(); // id -> {effectiveTime, active, row}
1802
+ for (const file of this.files.concreteValues || []) {
1803
+ const rl = readline.createInterface({
1804
+ input: fs.createReadStream(file),
1805
+ crlfDelay: Infinity
1806
+ });
1807
+ let lineCount = 0;
1808
+ for await (const line of rl) {
1809
+ lineCount++;
1810
+ if (lineCount === 1 || !line) continue;
1811
+ const parts = line.split('\t');
1812
+ if (parts.length < 10) continue;
1813
+ const [id, effectiveTime, active, , sourceId, value, group, typeId, characteristicTypeId] = parts.map(p => p.trim());
1814
+ if (characteristicTypeId !== '900000000000011006') continue; // inferred only
1815
+ const prior = rows.get(id);
1816
+ if (!prior || prior.effectiveTime <= effectiveTime) {
1817
+ rows.set(id, { effectiveTime, active: active === '1', row: [sourceId, typeId, parseInt(group, 10) || 0, value] });
1818
+ }
1819
+ }
1820
+ }
1821
+ this.concreteValues = [];
1822
+ let missing = 0;
1823
+ for (const r of rows.values()) {
1824
+ if (!r.active) continue;
1825
+ let known = false;
1826
+ try {
1827
+ known = this.conceptMap.has(BigInt(r.row[0])) && this.conceptMap.has(BigInt(r.row[1]));
1828
+ } catch {
1829
+ known = false;
1830
+ }
1831
+ if (known) {
1832
+ this.concreteValues.push(r.row);
1833
+ } else {
1834
+ missing++;
1835
+ }
1836
+ }
1837
+ this.concreteValues.sort((a, b) => (a.join('|') < b.join('|') ? -1 : 1));
1838
+ if (this.concreteValues.length > 0 || missing > 0) {
1839
+ console.log(`Concrete values: ${this.concreteValues.length}${missing ? ` (${missing} for concepts not in this edition, ignored)` : ''}`);
1840
+ }
1841
+ }
1842
+
1729
1843
  async readRelationships() {
1730
1844
  // Estimate total lines
1731
1845
  let totalLines = 0;
@@ -2766,8 +2880,15 @@ class SnomedImporter {
2766
2880
 
2767
2881
  progressBar?.update(50);
2768
2882
 
2883
+ const identifiers = this.identifiers || [];
2884
+ const concreteValues = this.concreteValues || [];
2769
2885
  await writer.writeCache({
2770
- version: '17', // Current version
2886
+ // 17 = the base format; 18 = 17 plus the alternate identifiers; 19 = 18 plus
2887
+ // the concrete value relationships. Each is only used when it has something
2888
+ // to carry, so that caches without them stay readable by older servers.
2889
+ version: concreteValues.length > 0 ? '19' : identifiers.length > 0 ? '18' : '17',
2890
+ identifiers: identifiers,
2891
+ concreteValues: concreteValues,
2771
2892
  versionUri: this.isTesting ? this.config.uri.replace("/sct/", "/xsct/") : this.config.uri,
2772
2893
  versionDate: this.extractDateFromUri(this.config.uri),
2773
2894
 
@@ -2854,6 +2975,15 @@ class SnomedCacheWriter {
2854
2975
  }
2855
2976
  buffers.push(this.writeInteger(data.defaultLanguage));
2856
2977
 
2978
+ if (data.version === '18' || data.version === '19') {
2979
+ // alternate identifiers, as JSON: [[scheme, module, code, conceptId], ...]
2980
+ buffers.push(this.writeBytes(Buffer.from(JSON.stringify(data.identifiers || []), 'utf8')));
2981
+ }
2982
+ if (data.version === '19') {
2983
+ // concrete value relationships, as JSON: [[sourceId, typeId, group, value], ...]
2984
+ buffers.push(this.writeBytes(Buffer.from(JSON.stringify(data.concreteValues || []), 'utf8')));
2985
+ }
2986
+
2857
2987
  // Write to file
2858
2988
  const finalBuffer = Buffer.concat(buffers);
2859
2989
  await fs.promises.writeFile(this.filePath, finalBuffer);
@@ -2,6 +2,7 @@ const { BaseTerminologyModule } = require('./tx-import-base');
2
2
  const fs = require('fs');
3
3
  const path = require('path');
4
4
  const readline = require('readline');
5
+ const { csvRecords } = require('./csv-records');
5
6
  const chalk = require('chalk');
6
7
 
7
8
  class LoincSubsetModule extends BaseTerminologyModule {
@@ -716,6 +717,16 @@ class LoincSubsetProcessor {
716
717
  dest: 'AccessoryFiles/ComponentHierarchyBySystem/ComponentHierarchyBySystem.csv',
717
718
  handler: 'processHierarchy'
718
719
  },
720
+ {
721
+ source: 'AccessoryFiles/PartFile/PartRelatedCodeMapping.csv',
722
+ dest: 'AccessoryFiles/PartFile/PartRelatedCodeMapping.csv',
723
+ handler: 'processFirstColumnCodes'
724
+ },
725
+ {
726
+ source: 'AccessoryFiles/LoincIeeeMedicalDeviceCodeMappingTable/LoincIeeeMedicalDeviceCodeMappingTable.csv',
727
+ dest: 'AccessoryFiles/LoincIeeeMedicalDeviceCodeMappingTable/LoincIeeeMedicalDeviceCodeMappingTable.csv',
728
+ handler: 'processFirstColumnCodes'
729
+ },
719
730
  {
720
731
  source: 'AccessoryFiles/LinguisticVariants/LinguisticVariants.csv',
721
732
  dest: 'AccessoryFiles/LinguisticVariants/LinguisticVariants.csv',
@@ -787,7 +798,8 @@ class LoincSubsetProcessor {
787
798
  path.join(destDir, 'AccessoryFiles/ConsumerName'),
788
799
  path.join(destDir, 'AccessoryFiles/AnswerFile'),
789
800
  path.join(destDir, 'AccessoryFiles/ComponentHierarchyBySystem'),
790
- path.join(destDir, 'AccessoryFiles/LinguisticVariants')
801
+ path.join(destDir, 'AccessoryFiles/LinguisticVariants'),
802
+ path.join(destDir, 'AccessoryFiles/LoincIeeeMedicalDeviceCodeMappingTable')
791
803
  ];
792
804
 
793
805
  for (const dir of dirs) {
@@ -879,6 +891,16 @@ class LoincSubsetProcessor {
879
891
  });
880
892
  }
881
893
 
894
+ // keep the rows whose first column (the LOINC code or part) is in the subset
895
+ async processFirstColumnCodes(sourcePath, destPath) {
896
+ await this.processFileWithFilter(sourcePath, destPath, (line, lineNum) => {
897
+ if (lineNum === 1) return true; // Keep header
898
+
899
+ const items = this.csvSplit(line, 1);
900
+ return this.targetCodes.has(this.removeQuotes(items[0]));
901
+ });
902
+ }
903
+
882
904
  async processAnswerLists(sourcePath, destPath) {
883
905
  await this.processFileWithFilter(sourcePath, destPath, (line, lineNum) => {
884
906
  if (lineNum === 1) return true; // Keep header
@@ -1002,13 +1024,10 @@ class LoincSubsetProcessor {
1002
1024
  return;
1003
1025
  }
1004
1026
 
1005
- const readStream = fs.createReadStream(sourcePath);
1006
1027
  const writeStream = fs.createWriteStream(destPath);
1007
1028
 
1008
- const rl = readline.createInterface({
1009
- input: readStream,
1010
- crlfDelay: Infinity
1011
- });
1029
+ // records, not lines: a quoted field can hold a line break
1030
+ const rl = csvRecords(sourcePath);
1012
1031
 
1013
1032
  let lineNum = 0;
1014
1033
  let includedLines = 0;
@@ -649,7 +649,10 @@ class CodeSystem extends CanonicalResource {
649
649
  if (this.jsonObj.url === 'https://terminology.dhp.uz/fhir/CodeSystem/loinc-supplement-uz') {
650
650
  return true;
651
651
  }
652
- return (this.jsonObj.extension || []).find(x => x.url == 'http://hl7.org/fhir/StructureDefinition/codesystem-supplement-type' && getValuePrimitive(x) == 'lang-pack');
652
+ // a global language pack is used whether or not it is referenced. The code was 'lang-pack'
653
+ // until Oct 2026; it is still accepted, so that supplements marked the old way keep working
654
+ return (this.jsonObj.extension || []).find(x => x.url == 'http://hl7.org/fhir/StructureDefinition/codesystem-supplement-type'
655
+ && ['global-lang-pack', 'lang-pack'].includes(getValuePrimitive(x)));
653
656
  }
654
657
 
655
658
  isPropUri(code, uri) {