fhirsmith 0.17.0 → 0.18.1
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.
- package/CHANGELOG.md +113 -0
- package/README.md +80 -13
- package/config-template.json +106 -1
- package/ips/ai-client.js +263 -0
- package/ips/composer.js +805 -0
- package/ips/editor-schema.js +289 -0
- package/ips/editor.js +290 -0
- package/ips/fhirpath-ips.js +397 -0
- package/ips/ips-codeable-concepts.md +1742 -0
- package/ips/ips-template.html +125 -0
- package/ips/ips-valuesets.js +159 -0
- package/ips/ips.js +1736 -0
- package/ips/membership.js +179 -0
- package/ips/pipeline.js +1012 -0
- package/ips/presenter.js +974 -0
- package/ips/profile-editor.js +578 -0
- package/ips/profiles.js +506 -0
- package/ips/readme.md +460 -0
- package/ips/render.js +1482 -0
- package/ips/search-params.js +83 -0
- package/ips/search.js +667 -0
- package/ips/share.js +178 -0
- package/ips/store.js +650 -0
- package/ips/terminology.js +395 -0
- package/ips/tx-client.js +202 -0
- package/ips/valuesets/CodeSystem-absent-unknown-uv-ips.json +206 -0
- package/ips/valuesets/ValueSet-absent-or-unknown-immunizations-uv-ips.json +59 -0
- package/ips/valuesets/ValueSet-ips-allergy-code.json +59 -0
- package/ips/valuesets/ValueSet-ips-allergy-manifestation.json +356 -0
- package/ips/valuesets/ValueSet-ips-allergy-substance.json +37 -0
- package/ips/valuesets/ValueSet-ips-body-site.json +26 -0
- package/ips/valuesets/ValueSet-ips-clinical-finding-or-none.json +35 -0
- package/ips/valuesets/ValueSet-ips-clinical-finding.json +26 -0
- package/ips/valuesets/ValueSet-ips-condition-code.json +72 -0
- package/ips/valuesets/ValueSet-ips-condition-severity.json +30 -0
- package/ips/valuesets/ValueSet-ips-condition-stage.json +26 -0
- package/ips/valuesets/ValueSet-ips-exposure-route.json +26 -0
- package/ips/valuesets/ValueSet-ips-imaging-procedure.json +25 -0
- package/ips/valuesets/ValueSet-ips-immunization-status-reason.json +43 -0
- package/ips/valuesets/ValueSet-ips-international-codes.json +25 -0
- package/ips/valuesets/ValueSet-ips-medication-ingredient.json +26 -0
- package/ips/valuesets/ValueSet-ips-medication-status-reason.json +37 -0
- package/ips/valuesets/ValueSet-ips-medication.json +50 -0
- package/ips/valuesets/ValueSet-ips-observation-code.json +18 -0
- package/ips/valuesets/ValueSet-ips-observation-interpretation.json +18 -0
- package/ips/valuesets/ValueSet-ips-procedure-followup.json +51 -0
- package/ips/valuesets/ValueSet-ips-procedure-outcome.json +30 -0
- package/ips/valuesets/ValueSet-ips-procedure-reason.json +37 -0
- package/ips/valuesets/ValueSet-ips-procedure.json +116 -0
- package/ips/valuesets/ValueSet-ips-route.json +46 -0
- package/ips/valuesets/ValueSet-ips-target-disease.json +171 -0
- package/ips/valuesets/ValueSet-ips-vaccine.json +31 -0
- package/ips/valuesets/elements.json +44 -0
- package/library/daily-report.js +432 -0
- package/library/fhir-search-values.js +138 -0
- package/library/html-server.js +32 -23
- package/library/mailer.js +77 -0
- package/library/narrative-check.js +21 -2
- package/library/npm-audit.js +25 -0
- package/library/package-manager.js +4 -2
- package/library/sqlite-session-store.js +29 -1
- package/package.json +8 -3
- package/packages/packages.js +34 -3
- package/publisher/publisher.js +29 -0
- package/registry/auth-store.js +366 -0
- package/registry/auth.js +872 -0
- package/registry/crawler.js +41 -1
- package/registry/documentation/api.md +162 -0
- package/registry/documentation/configuration.md +93 -0
- package/registry/documentation/documents.md +74 -0
- package/registry/documentation/internals.md +151 -0
- package/registry/documentation/readme.md +100 -0
- package/registry/documentation/security.md +199 -0
- package/registry/documents.js +309 -0
- package/registry/openapi.yaml +30 -0
- package/registry/registry-template.html +1 -1
- package/registry/registry.js +230 -44
- package/registry/uptime.js +154 -0
- package/server.js +105 -44
- package/shl/shl.js +64 -9
- package/stats-db.js +93 -0
- package/stats.js +158 -1
- package/testing/search.js +2 -108
- package/testing/store.js +5 -0
- package/testing/testing.js +20 -0
- package/token/token-template.html +5 -4
- package/token/token.js +17 -10
- package/translations/Messages.properties +10 -0
- package/tx/README.md +52 -0
- package/tx/closure/closure-store.js +14 -0
- package/tx/cs/cs-api.js +92 -14
- package/tx/cs/cs-cs.js +64 -3
- package/tx/cs/cs-icd11.js +366 -112
- package/tx/cs/cs-icf.js +431 -0
- package/tx/cs/cs-loinc.js +296 -27
- package/tx/cs/cs-omop.js +10 -3
- package/tx/cs/cs-snomed.js +1210 -80
- package/tx/cs/cs-ucum.js +6 -0
- package/tx/cs/icd11-expressions.js +191 -78
- package/tx/importers/csv-records.js +35 -0
- package/tx/importers/export-icd11-codesystem.js +58 -12
- package/tx/importers/icd11-schema.md +9 -5
- package/tx/importers/import-loinc.module.js +177 -41
- package/tx/importers/import-sct.module.js +66 -1
- package/tx/importers/subset-loinc.module.js +25 -6
- package/tx/library/codesystem.js +4 -1
- package/tx/library/designations.js +23 -30
- package/tx/library.js +98 -4
- package/tx/openapi-operations.js +6 -1
- package/tx/params.js +8 -1
- package/tx/provider.js +24 -6
- package/tx/sct/documentation.md +251 -0
- package/tx/sct/ecl.js +758 -46
- package/tx/sct/expressions.js +113 -52
- package/tx/sct/structures.js +8 -2
- package/tx/syndication/syndication-feed.js +218 -0
- package/tx/syndication/syndication-store.js +415 -0
- package/tx/tx-html.js +15 -1
- package/tx/tx.fhir.org.yml +3 -4
- package/tx/tx.js +144 -2
- package/tx/vs/vs-vsac.js +25 -6
- package/tx/workers/closure.js +20 -0
- package/tx/workers/expand.js +35 -8
- package/tx/workers/lookup.js +61 -6
- package/tx/workers/subsumes.js +1 -1
- package/tx/workers/translate.js +166 -15
- package/tx/workers/validate.js +138 -6
- package/utilities/make-sct-loinc-subset.js +250 -0
- package/utilities/make-sct-test-extension.js +264 -0
- package/xig/xig.js +32 -0
- package/registry/readme.md +0 -124
package/tx/workers/validate.js
CHANGED
|
@@ -582,6 +582,7 @@ class ValueSetChecker {
|
|
|
582
582
|
messages.push(this.addNormalisationIssue(op, path, code, normalised, cs));
|
|
583
583
|
normalForm.value = normalised;
|
|
584
584
|
}
|
|
585
|
+
await this.noteClassificationCode(cs, ctxt.context, code, normalForm);
|
|
585
586
|
let msg = await cs.incompleteValidationMessage(ctxt.context, this.params.HTTPLanguages);
|
|
586
587
|
if (msg) {
|
|
587
588
|
op.addIssue(new Issue('information', 'informational', addToPath(path, 'code'), null, msg, 'process-note'));
|
|
@@ -1079,6 +1080,8 @@ class ValueSetChecker {
|
|
|
1079
1080
|
} else if (c.display && list.designations.length > 0) {
|
|
1080
1081
|
await this.checkDisplays(list, defLang, c, msg, op, path);
|
|
1081
1082
|
}
|
|
1083
|
+
await this.checkCodingExtensions(csd, c, path, op, msg);
|
|
1084
|
+
await this.checkCodeIssues(csd, c, path, op, msg);
|
|
1082
1085
|
// Report the FIRST matching coding, not the last: when a CodeableConcept
|
|
1083
1086
|
// has several valid codings, the code/system/version/display echoed back
|
|
1084
1087
|
// are those of the earliest coding that validated. Only capture once
|
|
@@ -1349,10 +1352,18 @@ class ValueSetChecker {
|
|
|
1349
1352
|
result.addParamCode('normalized-code', normalForm.value);
|
|
1350
1353
|
}
|
|
1351
1354
|
|
|
1355
|
+
let outCode = null;
|
|
1352
1356
|
if (pcode) {
|
|
1353
|
-
|
|
1357
|
+
outCode = pcode;
|
|
1354
1358
|
} else if (tcode && mode !== 'codeableConcept') {
|
|
1355
|
-
|
|
1359
|
+
outCode = tcode;
|
|
1360
|
+
}
|
|
1361
|
+
if (outCode) {
|
|
1362
|
+
result.addParamCode('code', outCode);
|
|
1363
|
+
const cc = normalForm.classificationCodes?.get(outCode);
|
|
1364
|
+
if (cc) {
|
|
1365
|
+
result.addParamCode('classificationCode', cc);
|
|
1366
|
+
}
|
|
1356
1367
|
}
|
|
1357
1368
|
if (pver) {
|
|
1358
1369
|
result.addParamStr('version', pver);
|
|
@@ -1416,6 +1427,46 @@ class ValueSetChecker {
|
|
|
1416
1427
|
return result;
|
|
1417
1428
|
}
|
|
1418
1429
|
|
|
1430
|
+
/**
|
|
1431
|
+
* Extensions on the coding that its code system defines and so can check (ICD-11's
|
|
1432
|
+
* classificationCode). Anything wrong is an error: the extension is there to be relied on.
|
|
1433
|
+
*/
|
|
1434
|
+
async checkCodingExtensions(cs, c, path, op, msg) {
|
|
1435
|
+
if (!cs || typeof cs.checkCodingExtensions !== 'function' || !c.extension || c.extension.length === 0) {
|
|
1436
|
+
return;
|
|
1437
|
+
}
|
|
1438
|
+
const loc = await cs.locate(c.code);
|
|
1439
|
+
if (!loc.context) {
|
|
1440
|
+
return;
|
|
1441
|
+
}
|
|
1442
|
+
for (const p of await cs.checkCodingExtensions(c, loc.context)) {
|
|
1443
|
+
const m = this.worker.i18n.translate(p.msgId, this.params.HTTPLanguages, p.params);
|
|
1444
|
+
msg(m);
|
|
1445
|
+
op.addIssue(new Issue('error', 'invalid', addToPath(path, 'extension[' + p.index + ']'), p.msgId, m, 'invalid-data'));
|
|
1446
|
+
}
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
/**
|
|
1450
|
+
* What the code system has to say about a code that is valid (an ICD-11 expression with two
|
|
1451
|
+
* values on an axis that takes one, say).
|
|
1452
|
+
*/
|
|
1453
|
+
async checkCodeIssues(cs, c, path, op, msg) {
|
|
1454
|
+
if (!cs || typeof cs.codeIssues !== 'function' || !c.code) {
|
|
1455
|
+
return;
|
|
1456
|
+
}
|
|
1457
|
+
const loc = await cs.locate(c.code);
|
|
1458
|
+
if (!loc.context) {
|
|
1459
|
+
return;
|
|
1460
|
+
}
|
|
1461
|
+
for (const p of await cs.codeIssues(loc.context)) {
|
|
1462
|
+
const m = this.worker.i18n.translate(p.msgId, this.params.HTTPLanguages, p.params);
|
|
1463
|
+
if (p.severity === 'error') {
|
|
1464
|
+
msg(m);
|
|
1465
|
+
}
|
|
1466
|
+
op.addIssue(new Issue(p.severity, 'business-rule', addToPath(path, 'code'), p.msgId, m, 'code-rule'));
|
|
1467
|
+
}
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1419
1470
|
async checkDisplays(list, defLang, c, msg, op, path) {
|
|
1420
1471
|
let hd = list.hasDisplay(this.params.workingLanguages(), null, c.display, false, DisplayCheckingStyle.CASE_INSENSITIVE)
|
|
1421
1472
|
if (!hd.found) {
|
|
@@ -1522,6 +1573,60 @@ class ValueSetChecker {
|
|
|
1522
1573
|
*
|
|
1523
1574
|
* @returns {string} the message, for callers that also collect it into `messages`
|
|
1524
1575
|
*/
|
|
1576
|
+
/**
|
|
1577
|
+
* The code came in its normal form; if the code system also has a classification code for
|
|
1578
|
+
* it (ICD-11's short code), remember it against that code, so the output can report the
|
|
1579
|
+
* one that belongs to whatever ends up in the `code` parameter.
|
|
1580
|
+
*/
|
|
1581
|
+
/**
|
|
1582
|
+
* For value set membership found through an enumerated concept or a filter: the same
|
|
1583
|
+
* normal-form reporting the whole-code-system path does, but only for code systems that
|
|
1584
|
+
* have a classification code (ICD-11), so that other code systems' value set results are
|
|
1585
|
+
* unchanged.
|
|
1586
|
+
*/
|
|
1587
|
+
async noteValueSetNormalForm(op, path, cs, context, code, normalForm) {
|
|
1588
|
+
if (!(await cs.classificationCode(context))) {
|
|
1589
|
+
return;
|
|
1590
|
+
}
|
|
1591
|
+
const normalised = await cs.code(context);
|
|
1592
|
+
if (normalised && normalised !== code) {
|
|
1593
|
+
this.addNormalisationIssue(op, path, code, normalised, cs);
|
|
1594
|
+
normalForm.value = normalised;
|
|
1595
|
+
}
|
|
1596
|
+
await this.noteClassificationCode(cs, context, code, normalForm);
|
|
1597
|
+
}
|
|
1598
|
+
|
|
1599
|
+
/**
|
|
1600
|
+
* True when an enumerated concept in a value set denotes the same concept as the code
|
|
1601
|
+
* asked about, though spelled differently - ICD-11's short code and entity id. Only
|
|
1602
|
+
* code systems with a classification code have two spellings, so for the rest this is
|
|
1603
|
+
* one call and false.
|
|
1604
|
+
*/
|
|
1605
|
+
async sameConceptDifferentForm(cs, code, enumerated) {
|
|
1606
|
+
if (code === enumerated) {
|
|
1607
|
+
return false;
|
|
1608
|
+
}
|
|
1609
|
+
const a = await cs.locate(code);
|
|
1610
|
+
if (!a.context || !(await cs.classificationCode(a.context))) {
|
|
1611
|
+
return false;
|
|
1612
|
+
}
|
|
1613
|
+
const b = await cs.locate(enumerated);
|
|
1614
|
+
return !!b.context && (await cs.code(a.context)) === (await cs.code(b.context));
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1617
|
+
async noteClassificationCode(cs, context, code, normalForm) {
|
|
1618
|
+
// the code system decides whether this spelling of the code earns a classificationCode
|
|
1619
|
+
// (ICD-11: when it was written with entity ids, in any order - not when it was written
|
|
1620
|
+
// with the classification codes themselves)
|
|
1621
|
+
const cc = await cs.reportedClassificationCode(context, code);
|
|
1622
|
+
if (cc && cc !== code) {
|
|
1623
|
+
if (!normalForm.classificationCodes) {
|
|
1624
|
+
normalForm.classificationCodes = new Map();
|
|
1625
|
+
}
|
|
1626
|
+
normalForm.classificationCodes.set(code, cc);
|
|
1627
|
+
}
|
|
1628
|
+
}
|
|
1629
|
+
|
|
1525
1630
|
addNormalisationIssue(op, path, code, normalised, cs) {
|
|
1526
1631
|
const caseOnly = normalised.toLowerCase() === code.toLowerCase();
|
|
1527
1632
|
const id = caseOnly ? 'CODE_CASE_DIFFERENCE' : 'CODE_NOT_IN_NORMAL_FORM';
|
|
@@ -1558,6 +1663,7 @@ class ValueSetChecker {
|
|
|
1558
1663
|
this.addNormalisationIssue(op, path, code, normalised, cs);
|
|
1559
1664
|
normalForm.value = normalised;
|
|
1560
1665
|
}
|
|
1666
|
+
await this.noteClassificationCode(cs, loc.context, code, normalForm);
|
|
1561
1667
|
let msg = await cs.incompleteValidationMessage(loc.context, this.params.HTTPLanguages);
|
|
1562
1668
|
if (msg) {
|
|
1563
1669
|
op.addIssue(new Issue('information', 'informational', addToPath(path, 'code'), null, msg, 'process-note'));
|
|
@@ -1623,10 +1729,20 @@ class ValueSetChecker {
|
|
|
1623
1729
|
}
|
|
1624
1730
|
}
|
|
1625
1731
|
|
|
1732
|
+
// an enumerated concept may be written in the other form (ICD-11 short code vs uri)
|
|
1733
|
+
let altMatch = null;
|
|
1734
|
+
if (cset.concept && !cset.concept.some(cc => cc.code === code)) {
|
|
1735
|
+
for (const cc of cset.concept) {
|
|
1736
|
+
if (await this.sameConceptDifferentForm(cs, code, cc.code)) {
|
|
1737
|
+
altMatch = cc;
|
|
1738
|
+
break;
|
|
1739
|
+
}
|
|
1740
|
+
}
|
|
1741
|
+
}
|
|
1626
1742
|
for (let cc of cset.concept || []) {
|
|
1627
1743
|
this.worker.deadCheck('checkConceptSet#1');
|
|
1628
1744
|
let c = cc.code;
|
|
1629
|
-
if (code === c) {
|
|
1745
|
+
if (code === c || cc === altMatch) {
|
|
1630
1746
|
let loc = await cs.locate(code);
|
|
1631
1747
|
if (loc.context !== null) {
|
|
1632
1748
|
loc = loc.context;
|
|
@@ -1662,6 +1778,9 @@ class ValueSetChecker {
|
|
|
1662
1778
|
inactive.code = code;
|
|
1663
1779
|
vstatus.value = await cs.getStatus(loc);
|
|
1664
1780
|
}
|
|
1781
|
+
if (role !== 'not in') {
|
|
1782
|
+
await this.noteValueSetNormalForm(op, path, cs, loc, code, normalForm);
|
|
1783
|
+
}
|
|
1665
1784
|
result = true;
|
|
1666
1785
|
return result;
|
|
1667
1786
|
}
|
|
@@ -1691,7 +1810,7 @@ class ValueSetChecker {
|
|
|
1691
1810
|
let loc = await cs.filterLocate(prep, filters[0], code);
|
|
1692
1811
|
if (loc != null && !(typeof loc === 'string') && filters.length > 1) {
|
|
1693
1812
|
for (let i = 1; i < filters.length; i++) {
|
|
1694
|
-
if (
|
|
1813
|
+
if ((await cs.filterCheck(prep, filters[i], loc)) != true) { // a failing check may return a message, which is truthy
|
|
1695
1814
|
loc = null;
|
|
1696
1815
|
break;
|
|
1697
1816
|
}
|
|
@@ -1733,6 +1852,9 @@ class ValueSetChecker {
|
|
|
1733
1852
|
}
|
|
1734
1853
|
}
|
|
1735
1854
|
}
|
|
1855
|
+
if (role !== 'not in') {
|
|
1856
|
+
await this.noteValueSetNormalForm(op, path, cs, loc, code, normalForm);
|
|
1857
|
+
}
|
|
1736
1858
|
result = true;
|
|
1737
1859
|
return result;
|
|
1738
1860
|
}
|
|
@@ -2035,7 +2157,7 @@ class ValidateWorker extends TerminologyWorker {
|
|
|
2035
2157
|
return res.status(422).json(this.operationOutcome('error', 'not-found',
|
|
2036
2158
|
`CodeSystem/${id} not found`));
|
|
2037
2159
|
}
|
|
2038
|
-
const csp =
|
|
2160
|
+
const csp = FhirCodeSystemProvider.create(this.opContext, new CodeSystem(codeSystem), []);
|
|
2039
2161
|
|
|
2040
2162
|
// Extract coded value
|
|
2041
2163
|
let mode = { mode : null }
|
|
@@ -2168,7 +2290,7 @@ class ValidateWorker extends TerminologyWorker {
|
|
|
2168
2290
|
// Check for codeSystem resource parameter
|
|
2169
2291
|
const csResource = this.getResourceParam(params, 'codeSystem');
|
|
2170
2292
|
if (csResource) {
|
|
2171
|
-
return
|
|
2293
|
+
return FhirCodeSystemProvider.create(this.opContext, new CodeSystem(csResource), []); // todo: supplements
|
|
2172
2294
|
}
|
|
2173
2295
|
let path = coded == null ? null : this.systemPath(mode);
|
|
2174
2296
|
let fromCoded = false;
|
|
@@ -2345,6 +2467,16 @@ class ValidateWorker extends TerminologyWorker {
|
|
|
2345
2467
|
|
|
2346
2468
|
let vs = this.makeVsForCS(codeSystem);
|
|
2347
2469
|
|
|
2470
|
+
// The checker isn't prepared (there is no real value set to analyse), and preparing is
|
|
2471
|
+
// where the supplements asked for with useSupplement are found. So note the ones the code
|
|
2472
|
+
// system has here, or every useSupplement on CodeSystem/$validate-code is reported as
|
|
2473
|
+
// missing, even when the supplement was passed in tx-resource
|
|
2474
|
+
for (const s of this.requiredSupplements) {
|
|
2475
|
+
if (codeSystem.hasSupplement(s)) {
|
|
2476
|
+
this.usedSupplements.add(s);
|
|
2477
|
+
}
|
|
2478
|
+
}
|
|
2479
|
+
|
|
2348
2480
|
// Create and prepare checker
|
|
2349
2481
|
const checker = new ValueSetChecker(this, vs, params);
|
|
2350
2482
|
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Generates the LOINC Extension (LOINC ontology, module 11010000107,
|
|
4
|
+
* namespace 1010000) content for the SNOMED CT test edition that the
|
|
5
|
+
* tx-ecosystem tests use.
|
|
6
|
+
*
|
|
7
|
+
* The agreed scope for LOINC Extension support is:
|
|
8
|
+
* - $lookup on a SNOMED concept returns its LOINC code
|
|
9
|
+
* - $translate using the implicit map ?fhir_cm=11010000107 (the LOINC module)
|
|
10
|
+
* - $expand with a filter for concepts that have a LOINC code
|
|
11
|
+
*
|
|
12
|
+
* None of that needs the LOINC ontology's concept model, so this script takes
|
|
13
|
+
* a handful of real LOINC Extension concepts from the published release and
|
|
14
|
+
* copies their concept, description, language refset and identifier rows,
|
|
15
|
+
* but NOT their defining relationships or OWL axioms (those reference
|
|
16
|
+
* hundreds of concepts that are not in the test subset). Instead each concept
|
|
17
|
+
* gets a single generated IS A to an observable entity that is in the
|
|
18
|
+
* subset, and is made primitive (a fully defined concept defined only by an
|
|
19
|
+
* IS A would be equivalent to its parent).
|
|
20
|
+
*
|
|
21
|
+
* The two metadata concepts the identifiers hang off - the module concept and
|
|
22
|
+
* the LOINC identifier scheme - are copied too, re-parented to metadata
|
|
23
|
+
* concepts that are in the subset (their real parents are not).
|
|
24
|
+
*
|
|
25
|
+
* All rows are given the edition's effective time (20250909). Everything else
|
|
26
|
+
* - ids, terms, case significance, acceptability, LOINC codes - is as
|
|
27
|
+
* published. Rows are deterministic, so the script can be re-run safely.
|
|
28
|
+
*
|
|
29
|
+
* Usage:
|
|
30
|
+
* node utilities/make-sct-loinc-subset.js <LOINC Extension release folder> [tx/data/snomed]
|
|
31
|
+
* e.g.
|
|
32
|
+
* node utilities/make-sct-loinc-subset.js \
|
|
33
|
+
* ~/archive/terminologies/SnomedCT_LOINCExtension_PRODUCTION_LO1010000_20261021T120000Z \
|
|
34
|
+
* ../../igs/tx-ecosystem-ig/tx-source/snomed
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
const fs = require('fs');
|
|
38
|
+
const path = require('path');
|
|
39
|
+
const crypto = require('crypto');
|
|
40
|
+
|
|
41
|
+
const source = process.argv[2];
|
|
42
|
+
if (!source) {
|
|
43
|
+
console.error('Usage: node utilities/make-sct-loinc-subset.js <LOINC Extension release folder> [target snomed folder]');
|
|
44
|
+
process.exit(1);
|
|
45
|
+
}
|
|
46
|
+
const root = process.argv[3] || path.join(__dirname, '..', 'tx', 'data', 'snomed');
|
|
47
|
+
const TAG = 'LO1010000';
|
|
48
|
+
const ET = '20250909';
|
|
49
|
+
const MODULE = '11010000107'; // LOINC Extension module
|
|
50
|
+
const EDITION_MODULE = '31000003106'; // the test edition module
|
|
51
|
+
const NAMESPACE = '1010000';
|
|
52
|
+
const EOL = '\r\n';
|
|
53
|
+
|
|
54
|
+
// --- RF2 constants ------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
const PRIMITIVE = '900000000000074008';
|
|
57
|
+
const IS_A = '116680003';
|
|
58
|
+
const INFERRED = '900000000000011006';
|
|
59
|
+
const EXISTENTIAL = '900000000000451002';
|
|
60
|
+
const OWL_AXIOM_REFSET = '733073007';
|
|
61
|
+
const MODULE_DEPENDENCY_REFSET = '900000000000534007';
|
|
62
|
+
const INT_CORE = '900000000000207008';
|
|
63
|
+
const INT_MODEL = '900000000000012004';
|
|
64
|
+
const INT_ET = '20250801'; // the International release the subset was extracted from
|
|
65
|
+
|
|
66
|
+
// --- The subset ---------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
// LOINC code -> parent in the test subset (null = inactive, no relationships).
|
|
69
|
+
// The LOINC concept id is taken from the release's identifier file.
|
|
70
|
+
const loincCodes = [
|
|
71
|
+
// all but the last are in the tx-ecosystem LOINC test subset, so they can be translated both ways
|
|
72
|
+
['2823-3', '11953005'], // Potassium [Moles/volume] in Serum or Plasma -> Renal function
|
|
73
|
+
['22760-3', '11953005'], // Potassium [Mass/volume] in Serum or Plasma -> Renal function
|
|
74
|
+
['6298-4', '11953005'], // Potassium [Moles/volume] in Blood -> Renal function
|
|
75
|
+
['2828-2', '11953005'], // Potassium [Moles/volume] in Urine -> Renal function
|
|
76
|
+
['14402-2', '11953005'], // Creatinine [Mass/volume] in Gastric fluid -> Renal function
|
|
77
|
+
['14394-1', '11953005'], // Urea nitrogen [Mass/volume] in Pleural fluid -> Renal function
|
|
78
|
+
['104597-0', '363787002'], // Glucose [Mass/volume] in Venous blood by Glucometer -> Observable entity
|
|
79
|
+
['95084-0', '363787002'], // C peptide [Mass/volume] in Serum or Plasma --2 hours post meal -> Observable entity
|
|
80
|
+
['95095-6', '363787002'], // Glucagon [Mass/volume] in Serum or Plasma --2 hours post meal -> Observable entity
|
|
81
|
+
['31157-1', '363787002'], // Carboxyhemoglobin [Mass/volume] in Blood -> Observable entity
|
|
82
|
+
['95594-8', '363787002'], // Calcifediol [Mass/volume] in Capillary blood -> Observable entity
|
|
83
|
+
['2067-7', null] // inactive in the release, but still has an (active) LOINC identifier; not in the LOINC test subset
|
|
84
|
+
];
|
|
85
|
+
|
|
86
|
+
// metadata concepts -> substitute parent in the test subset
|
|
87
|
+
const metadata = [
|
|
88
|
+
[MODULE, '900000000000443000'], // LOINC Extension module -> Module (real parent 1201891009 not in subset)
|
|
89
|
+
['30051010000102', '900000000000454005'] // LOINC code identifier -> Foundation metadata concept (real parent 900000000000453004 not in subset)
|
|
90
|
+
];
|
|
91
|
+
|
|
92
|
+
// --- Read the release ---------------------------------------------------
|
|
93
|
+
|
|
94
|
+
function findFile(dir, prefix) {
|
|
95
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
96
|
+
const p = path.join(dir, entry.name);
|
|
97
|
+
if (entry.isDirectory()) {
|
|
98
|
+
const r = findFile(p, prefix);
|
|
99
|
+
if (r) return r;
|
|
100
|
+
} else if (entry.name.startsWith(prefix)) {
|
|
101
|
+
return p;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function readRf2(prefix) {
|
|
108
|
+
const file = findFile(path.join(source, 'Snapshot'), prefix);
|
|
109
|
+
if (!file) throw new Error(`No ${prefix}* file in ${source}/Snapshot`);
|
|
110
|
+
const lines = fs.readFileSync(file, 'utf8').split(/\r?\n/).filter(l => l.length > 0);
|
|
111
|
+
return { header: lines[0], rows: lines.slice(1).map(l => l.split('\t')) };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const identifiers = readRf2('sct2_Identifier_Snapshot');
|
|
115
|
+
const conceptFile = readRf2('sct2_Concept_Snapshot');
|
|
116
|
+
const descriptionFile = readRf2('sct2_Description_Snapshot');
|
|
117
|
+
const languageFile = readRf2('der2_cRefset_LanguageSnapshot');
|
|
118
|
+
const relationshipFile = readRf2('sct2_Relationship_Snapshot');
|
|
119
|
+
|
|
120
|
+
const conceptById = new Map(conceptFile.rows.map(r => [r[0], r]));
|
|
121
|
+
const identifierByCode = new Map(identifiers.rows.map(r => [r[0], r]));
|
|
122
|
+
|
|
123
|
+
// --- Ids for generated rows ---------------------------------------------
|
|
124
|
+
|
|
125
|
+
const d = [
|
|
126
|
+
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9], [1, 2, 3, 4, 0, 6, 7, 8, 9, 5], [2, 3, 4, 0, 1, 7, 8, 9, 5, 6],
|
|
127
|
+
[3, 4, 0, 1, 2, 8, 9, 5, 6, 7], [4, 0, 1, 2, 3, 9, 5, 6, 7, 8], [5, 9, 8, 7, 6, 0, 4, 3, 2, 1],
|
|
128
|
+
[6, 5, 9, 8, 7, 1, 0, 4, 3, 2], [7, 6, 5, 9, 8, 2, 1, 0, 4, 3], [8, 7, 6, 5, 9, 3, 2, 1, 0, 4],
|
|
129
|
+
[9, 8, 7, 6, 5, 4, 3, 2, 1, 0]];
|
|
130
|
+
const p = [
|
|
131
|
+
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9], [1, 5, 7, 6, 2, 8, 3, 0, 9, 4], [5, 8, 0, 3, 7, 9, 6, 1, 4, 2],
|
|
132
|
+
[8, 9, 1, 6, 0, 4, 3, 5, 2, 7], [9, 4, 5, 3, 1, 2, 6, 8, 7, 0], [4, 2, 8, 6, 5, 7, 3, 9, 0, 1],
|
|
133
|
+
[2, 7, 9, 3, 8, 0, 6, 4, 1, 5], [7, 0, 4, 6, 8, 1, 5, 3, 9, 2]];
|
|
134
|
+
const inv = [0, 4, 3, 2, 1, 5, 6, 7, 8, 9];
|
|
135
|
+
|
|
136
|
+
function verhoeff(numStr) {
|
|
137
|
+
let c = 0;
|
|
138
|
+
const digits = numStr.split('').reverse().map(Number);
|
|
139
|
+
for (let i = 0; i < digits.length; i++) {
|
|
140
|
+
c = d[c][p[(i + 1) % 8][digits[i]]];
|
|
141
|
+
}
|
|
142
|
+
return inv[c];
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Relationship ids are allocated from a high item range and checked against
|
|
146
|
+
// the release so they can never collide with a published relationship id.
|
|
147
|
+
const usedRelationshipIds = new Set(relationshipFile.rows.map(r => r[0]));
|
|
148
|
+
let nextItem = 99000001;
|
|
149
|
+
function relationshipId() {
|
|
150
|
+
for (;;) {
|
|
151
|
+
const body = `${nextItem++}${NAMESPACE}12`;
|
|
152
|
+
const id = body + verhoeff(body);
|
|
153
|
+
if (!usedRelationshipIds.has(id)) return id;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function memberId(...parts) {
|
|
158
|
+
const h = crypto.createHash('sha1').update(['sct-loinc-subset', ...parts].join('|')).digest('hex');
|
|
159
|
+
return `${h.slice(0, 8)}-${h.slice(8, 12)}-5${h.slice(13, 16)}-a${h.slice(17, 20)}-${h.slice(20, 32)}`;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// --- Build rows ---------------------------------------------------------
|
|
163
|
+
|
|
164
|
+
const wanted = []; // [conceptId, parent|null, loincCode|null]
|
|
165
|
+
for (const [code, parent] of loincCodes) {
|
|
166
|
+
const ident = identifierByCode.get(code);
|
|
167
|
+
if (!ident) throw new Error(`LOINC code ${code} has no identifier in the release`);
|
|
168
|
+
if (ident[3] !== MODULE) throw new Error(`Identifier for ${code} is in module ${ident[3]}, not ${MODULE}`);
|
|
169
|
+
wanted.push([ident[5], parent, code]);
|
|
170
|
+
}
|
|
171
|
+
for (const [id, parent] of metadata) wanted.push([id, parent, null]);
|
|
172
|
+
|
|
173
|
+
const conceptRows = [];
|
|
174
|
+
const relationshipRows = [];
|
|
175
|
+
const owlRows = [];
|
|
176
|
+
const identifierRows = [];
|
|
177
|
+
const summary = [];
|
|
178
|
+
const wantedIds = new Set(wanted.map(w => w[0]));
|
|
179
|
+
|
|
180
|
+
for (const [id, parent, code] of wanted) {
|
|
181
|
+
const c = conceptById.get(id);
|
|
182
|
+
if (!c) throw new Error(`Concept ${id} not found in the release`);
|
|
183
|
+
if (c[3] !== MODULE) throw new Error(`Concept ${id} is in module ${c[3]}, not ${MODULE}`);
|
|
184
|
+
const active = c[2];
|
|
185
|
+
if (active === '1' && !parent) throw new Error(`Active concept ${id} needs a parent`);
|
|
186
|
+
if (active === '0' && parent) throw new Error(`Inactive concept ${id} must not be given a parent`);
|
|
187
|
+
conceptRows.push([id, ET, active, MODULE, PRIMITIVE]);
|
|
188
|
+
if (parent) {
|
|
189
|
+
relationshipRows.push([relationshipId(), ET, '1', MODULE, id, parent, '0', IS_A, INFERRED, EXISTENTIAL]);
|
|
190
|
+
owlRows.push([memberId('owl', id), ET, '1', MODULE, OWL_AXIOM_REFSET, id, `SubClassOf(:${id} :${parent})`]);
|
|
191
|
+
}
|
|
192
|
+
if (code) {
|
|
193
|
+
const ident = identifierByCode.get(code);
|
|
194
|
+
identifierRows.push([ident[0], ET, ident[2], ident[3], ident[4], ident[5]]);
|
|
195
|
+
}
|
|
196
|
+
summary.push({ id, code, active, parent });
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const descriptionRows = descriptionFile.rows
|
|
200
|
+
.filter(r => wantedIds.has(r[4]))
|
|
201
|
+
.map(r => [r[0], ET, ...r.slice(2)]);
|
|
202
|
+
const descriptionIds = new Set(descriptionRows.map(r => r[0]));
|
|
203
|
+
for (const s of summary) {
|
|
204
|
+
const fsn = descriptionRows.find(r => r[4] === s.id && r[2] === '1' && r[6] === '900000000000003001');
|
|
205
|
+
if (!fsn) throw new Error(`No active FSN for ${s.id}`);
|
|
206
|
+
s.fsn = fsn[7];
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const languageRows = languageFile.rows
|
|
210
|
+
.filter(r => descriptionIds.has(r[5]))
|
|
211
|
+
.map(r => [r[0], ET, ...r.slice(2)]);
|
|
212
|
+
|
|
213
|
+
const moduleDependencyRows = [
|
|
214
|
+
[memberId('dep', MODULE, INT_CORE), ET, '1', MODULE, MODULE_DEPENDENCY_REFSET, INT_CORE, ET, INT_ET],
|
|
215
|
+
[memberId('dep', MODULE, INT_MODEL), ET, '1', MODULE, MODULE_DEPENDENCY_REFSET, INT_MODEL, ET, INT_ET],
|
|
216
|
+
[memberId('dep', EDITION_MODULE, MODULE), ET, '1', EDITION_MODULE, MODULE_DEPENDENCY_REFSET, MODULE, ET, ET]
|
|
217
|
+
];
|
|
218
|
+
|
|
219
|
+
// --- Write files --------------------------------------------------------
|
|
220
|
+
|
|
221
|
+
function sortRows(rows) {
|
|
222
|
+
return rows.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function write(rel, header, rows) {
|
|
226
|
+
const file = path.join(root, rel);
|
|
227
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
228
|
+
fs.writeFileSync(file, [header, ...sortRows(rows).map(r => r.join('\t'))].join(EOL) + EOL, 'utf8');
|
|
229
|
+
console.log(`${rows.length.toString().padStart(3)} rows ${rel}`);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
write(`Terminology/sct2_Concept_Snapshot_${TAG}_${ET}.txt`,
|
|
233
|
+
'id\teffectiveTime\tactive\tmoduleId\tdefinitionStatusId', conceptRows);
|
|
234
|
+
write(`Terminology/sct2_Description_Snapshot-en_${TAG}_${ET}.txt`,
|
|
235
|
+
'id\teffectiveTime\tactive\tmoduleId\tconceptId\tlanguageCode\ttypeId\tterm\tcaseSignificanceId', descriptionRows);
|
|
236
|
+
write(`Terminology/sct2_Relationship_Snapshot_${TAG}_${ET}.txt`,
|
|
237
|
+
'id\teffectiveTime\tactive\tmoduleId\tsourceId\tdestinationId\trelationshipGroup\ttypeId\tcharacteristicTypeId\tmodifierId', relationshipRows);
|
|
238
|
+
write(`Terminology/sct2_sRefset_OWLExpressionSnapshot_${TAG}_${ET}.txt`,
|
|
239
|
+
'id\teffectiveTime\tactive\tmoduleId\trefsetId\treferencedComponentId\towlExpression', owlRows);
|
|
240
|
+
write(`Terminology/sct2_Identifier_Snapshot_${TAG}_${ET}.txt`,
|
|
241
|
+
'alternateIdentifier\teffectiveTime\tactive\tmoduleId\tidentifierSchemeId\treferencedComponentId', identifierRows);
|
|
242
|
+
write(`Refset/Language/der2_cRefset_LanguageSnapshot-en_${TAG}_${ET}.txt`,
|
|
243
|
+
'id\teffectiveTime\tactive\tmoduleId\trefsetId\treferencedComponentId\tacceptabilityId', languageRows);
|
|
244
|
+
write(`Refset/Metadata/der2_ssRefset_ModuleDependencySnapshot_${TAG}_${ET}.txt`,
|
|
245
|
+
'id\teffectiveTime\tactive\tmoduleId\trefsetId\treferencedComponentId\tsourceEffectiveTime\ttargetEffectiveTime', moduleDependencyRows);
|
|
246
|
+
|
|
247
|
+
console.log('\nConcepts:');
|
|
248
|
+
for (const s of summary) {
|
|
249
|
+
console.log(` ${s.id.padEnd(16)} ${(s.code || '').padEnd(8)} ${s.active === '0' ? '(inactive) ' : ''}${s.fsn}`);
|
|
250
|
+
}
|