fhirsmith 0.18.1 → 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.
- package/CHANGELOG.md +41 -9
- package/ips/ips.js +251 -26
- package/ips/jurisdictions.js +65 -0
- package/ips/pipeline.js +67 -6
- package/ips/profile-editor.js +283 -4
- package/ips/readme.md +48 -5
- package/ips/render.js +317 -46
- package/ips/scripts.js +46 -0
- package/ips/store.js +55 -0
- package/package.json +3 -3
- package/registry/api.js +179 -1
- package/registry/crawler.js +2 -0
- package/registry/documentation/api.md +68 -2
- package/registry/documentation/internals.md +4 -1
- package/registry/documentation/readme.md +3 -2
- package/registry/model.js +8 -0
- package/registry/openapi.yaml +84 -0
- package/registry/registry.js +53 -2
- package/tx/README.md +4 -0
- package/tx/cm/cm-package.js +21 -15
- package/tx/cs/cs-cs.js +51 -10
- package/tx/cs/cs-loinc.js +8 -1
- package/tx/cs/cs-snomed.js +253 -31
- package/tx/importers/import-sct.module.js +69 -4
- package/tx/library/tho-precedence.js +40 -0
- package/tx/provider.js +36 -25
- package/tx/sct/documentation.md +45 -21
- package/tx/sct/ecl.js +34 -11
- package/tx/sct/structures.js +9 -2
- package/tx/tx.fhir.org.yml +1 -1
- package/tx/vs/vs-package.js +17 -13
- package/tx/vs/vs-vsac.js +1 -1
package/tx/cs/cs-cs.js
CHANGED
|
@@ -19,6 +19,9 @@ const regexUtilities = require("../../library/regex-utilities");
|
|
|
19
19
|
// filter results do not depend on forIteration, so one form is stored per version.
|
|
20
20
|
const FILTER_ANALYSIS = Symbol('filterAnalysis');
|
|
21
21
|
|
|
22
|
+
// The standard concept properties (http://hl7.org/fhir/concept-properties)
|
|
23
|
+
const CONCEPT_PROPERTIES = 'http://hl7.org/fhir/concept-properties#';
|
|
24
|
+
|
|
22
25
|
// The texts of each concept as a text filter compares them, by concept. Folding is
|
|
23
26
|
// the costly part of a search over a large code system, so it is done once per
|
|
24
27
|
// concept, and dropped with the code system.
|
|
@@ -315,6 +318,46 @@ class FhirCodeSystemProvider extends BaseCSServices {
|
|
|
315
318
|
return this.codeSystem.jsonObj.property || null;
|
|
316
319
|
}
|
|
317
320
|
|
|
321
|
+
/**
|
|
322
|
+
* The codes this code system uses for one of the standard concept properties
|
|
323
|
+
* (http://hl7.org/fhir/concept-properties#<name>). A property declared with the
|
|
324
|
+
* standard uri counts whatever local code it was given. A code that the code
|
|
325
|
+
* system declares with a different uri is a different property, and does not
|
|
326
|
+
* count. A code the code system doesn't declare (or declares with no uri) is
|
|
327
|
+
* recognised by the code alone - the standard code, and any legacy aliases
|
|
328
|
+
*
|
|
329
|
+
* @param {string} name - the standard property code (e.g. notSelectable)
|
|
330
|
+
* @param {string[]} aliases - legacy codes also recognised when not declared otherwise
|
|
331
|
+
* @returns {Set<string>} the local codes for the property
|
|
332
|
+
*/
|
|
333
|
+
standardPropertyCodes(name, aliases = []) {
|
|
334
|
+
const uri = CONCEPT_PROPERTIES + name;
|
|
335
|
+
const defs = this.propertyDefinitions() || [];
|
|
336
|
+
const codes = new Set();
|
|
337
|
+
for (const d of defs) {
|
|
338
|
+
if (d.uri === uri) {
|
|
339
|
+
codes.add(d.code);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
for (const c of [name, ...aliases]) {
|
|
343
|
+
if (!defs.some(d => d.code === c && d.uri && d.uri !== uri)) {
|
|
344
|
+
codes.add(c);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
return codes;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Whether the code system declares the code of a standard concept property for a
|
|
352
|
+
* property of its own (with a different uri), so that the code is not the standard one
|
|
353
|
+
* @param {string} name - the standard property code
|
|
354
|
+
* @returns {boolean}
|
|
355
|
+
*/
|
|
356
|
+
redefinesStandardProperty(name) {
|
|
357
|
+
const uri = CONCEPT_PROPERTIES + name;
|
|
358
|
+
return (this.propertyDefinitions() || []).some(d => d.code === name && d.uri && d.uri !== uri);
|
|
359
|
+
}
|
|
360
|
+
|
|
318
361
|
/**
|
|
319
362
|
* @param {Languages} languages - Language specification
|
|
320
363
|
* @returns {boolean} Whether any displays are available for the languages
|
|
@@ -524,14 +567,10 @@ class FhirCodeSystemProvider extends BaseCSServices {
|
|
|
524
567
|
return false;
|
|
525
568
|
}
|
|
526
569
|
|
|
527
|
-
// Check for
|
|
570
|
+
// Check for the notSelectable property, by whatever code this code system gives it
|
|
528
571
|
if (ctxt.concept.property && Array.isArray(ctxt.concept.property)) {
|
|
529
|
-
const
|
|
530
|
-
|
|
531
|
-
p.code === 'not-selectable' ||
|
|
532
|
-
p.code === 'notSelectable' ||
|
|
533
|
-
p.uri === 'http://hl7.org/fhir/concept-properties#notSelectable'
|
|
534
|
-
);
|
|
572
|
+
const codes = this.standardPropertyCodes('notSelectable', ['abstract', 'not-selectable']);
|
|
573
|
+
const abstractProp = ctxt.concept.property.find(p => codes.has(p.code));
|
|
535
574
|
if (abstractProp && abstractProp.valueBoolean) {
|
|
536
575
|
return true;
|
|
537
576
|
}
|
|
@@ -1399,9 +1438,10 @@ class FhirCodeSystemProvider extends BaseCSServices {
|
|
|
1399
1438
|
}
|
|
1400
1439
|
}
|
|
1401
1440
|
|
|
1402
|
-
// Handle known special properties
|
|
1441
|
+
// Handle known special properties - unless the code system uses the code for a
|
|
1442
|
+
// property of its own, in which case the property filter above applies
|
|
1403
1443
|
const knownProperties = ['notSelectable', 'status', 'inactive', 'deprecated'];
|
|
1404
|
-
if (knownProperties.includes(prop)) {
|
|
1444
|
+
if (knownProperties.includes(prop) && !this.redefinesStandardProperty(prop)) {
|
|
1405
1445
|
results = await this._handleKnownPropertyFilter(filterContext, prop, op, value);
|
|
1406
1446
|
}
|
|
1407
1447
|
|
|
@@ -1705,13 +1745,14 @@ class FhirCodeSystemProvider extends BaseCSServices {
|
|
|
1705
1745
|
async _handleKnownPropertyFilter(filterContext, prop, op, value) {
|
|
1706
1746
|
const results = new FhirCodeSystemProviderFilterContext();
|
|
1707
1747
|
const allConcepts = this.codeSystem.getAllConcepts();
|
|
1748
|
+
const nsCodes = this.standardPropertyCodes('notSelectable', ['abstract', 'not-selectable']);
|
|
1708
1749
|
|
|
1709
1750
|
for (const concept of allConcepts) {
|
|
1710
1751
|
this.opContext.deadCheck('cs:knownPropertyFilter');
|
|
1711
1752
|
let matches = false;
|
|
1712
1753
|
|
|
1713
1754
|
if (prop === 'notSelectable') {
|
|
1714
|
-
const abstractProp = (concept.property || []).find(p =>
|
|
1755
|
+
const abstractProp = (concept.property || []).find(p => nsCodes.has(p.code));
|
|
1715
1756
|
let vv = abstractProp ? String(getValuePrimitive(abstractProp)) : null;
|
|
1716
1757
|
if (op === '=') {
|
|
1717
1758
|
matches = (vv === value);
|
package/tx/cs/cs-loinc.js
CHANGED
|
@@ -492,7 +492,14 @@ class LoincServices extends BaseCSServices {
|
|
|
492
492
|
} else {
|
|
493
493
|
for (const row of rows) {
|
|
494
494
|
if (this._hasProp(props, row.Relationship, true)) {
|
|
495
|
-
|
|
495
|
+
if (row.Relationship === 'CLASS') {
|
|
496
|
+
// CLASS is a string property - the class name (e.g. PULM). The Part code for the
|
|
497
|
+
// class is also returned, as partCode (an informal extension to the property)
|
|
498
|
+
this._addStringProperty(params, 'property', row.Relationship, row.Value || row.Code)
|
|
499
|
+
.part.push({name: 'partCode', valueCode: row.Code});
|
|
500
|
+
} else {
|
|
501
|
+
this._addCodeProperty(params, 'property', row.Relationship, row.Code);
|
|
502
|
+
}
|
|
496
503
|
}
|
|
497
504
|
}
|
|
498
505
|
resolve();
|
package/tx/cs/cs-snomed.js
CHANGED
|
@@ -37,7 +37,7 @@ const SNOMED_FILTER_ANALYSIS = Symbol('snomedFilterAnalysis');
|
|
|
37
37
|
* returned as. An identifier in an unknown scheme is kept but not reported.
|
|
38
38
|
*/
|
|
39
39
|
const IDENTIFIER_SCHEMES = {
|
|
40
|
-
'30051010000102': { system: 'http://loinc.org', property: 'loinc' } // |LOINC code identifier| (LOINC Extension)
|
|
40
|
+
'30051010000102': { system: 'http://loinc.org', property: 'loinc', alias: 'LOINC' } // |LOINC code identifier| (LOINC Extension)
|
|
41
41
|
};
|
|
42
42
|
|
|
43
43
|
/** The $expand / ECL-free filter on alternate identifiers: mapped:<scheme> exists true|false */
|
|
@@ -206,6 +206,39 @@ class SnomedServices {
|
|
|
206
206
|
this._mrcmRangeCache = new Map();
|
|
207
207
|
|
|
208
208
|
this.#loadIdentifiers(sharedData.identifiers || []);
|
|
209
|
+
this.#loadConcreteValues(sharedData.concreteValues);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Index the concrete value relationships (cache version 19: rows of
|
|
214
|
+
* [sourceId, typeId, group, value], value as written in RF2 - "#500", "#5.5",
|
|
215
|
+
* a quoted string, or true/false) by source concept index, for the ECL
|
|
216
|
+
* evaluator. `concreteValues` stays null when the cache predates them, which
|
|
217
|
+
* is how a concrete-value refinement knows to report itself as unsupported
|
|
218
|
+
* rather than answering with nothing.
|
|
219
|
+
*/
|
|
220
|
+
#loadConcreteValues(rows) {
|
|
221
|
+
this.concreteValues = null;
|
|
222
|
+
if (!rows) return;
|
|
223
|
+
this.concreteValues = new Map();
|
|
224
|
+
for (const [sourceId, typeId, group, raw] of rows) {
|
|
225
|
+
const src = this.concepts.findConcept(sourceId);
|
|
226
|
+
const type = this.concepts.findConcept(typeId);
|
|
227
|
+
if (!src.found || !type.found) continue;
|
|
228
|
+
let value;
|
|
229
|
+
if (/^#-?\d+(\.\d+)?$/.test(raw)) {
|
|
230
|
+
value = { kind: 'number', value: Number(raw.substring(1)), decimal: raw.includes('.') };
|
|
231
|
+
} else if (/^".*"$/.test(raw)) {
|
|
232
|
+
value = { kind: 'string', value: raw.substring(1, raw.length - 1) };
|
|
233
|
+
} else if (/^(true|false)$/i.test(raw)) {
|
|
234
|
+
value = { kind: 'boolean', value: raw.toLowerCase() === 'true' };
|
|
235
|
+
} else {
|
|
236
|
+
value = { kind: 'string', value: raw };
|
|
237
|
+
}
|
|
238
|
+
const list = this.concreteValues.get(src.index) || [];
|
|
239
|
+
list.push({ relType: type.index, group: group || 0, concrete: value });
|
|
240
|
+
this.concreteValues.set(src.index, list);
|
|
241
|
+
}
|
|
209
242
|
}
|
|
210
243
|
|
|
211
244
|
/**
|
|
@@ -912,14 +945,16 @@ class SnomedServices {
|
|
|
912
945
|
* hierarchy operators on *, ^ and bracketed sets (applied per member)
|
|
913
946
|
* AND / OR / MINUS compound expressions
|
|
914
947
|
* !!> / !!< (top / bottom of set)
|
|
915
|
-
* refinements (= against an expression,
|
|
916
|
-
* attribute
|
|
948
|
+
* refinements (= and != against an expression, reversed (R) attributes,
|
|
949
|
+
* attribute AND/OR/",", groups, attribute and group cardinality,
|
|
950
|
+
* attribute names that are expressions),
|
|
917
951
|
* dotted expressions, {{ C ... }} concept filters, {{ D ... }} description
|
|
918
|
-
* filters,
|
|
952
|
+
* filters, {{ + HISTORY... }} history supplements, concrete (numeric /
|
|
953
|
+
* string / boolean) values in refinements (cache version 19+), comments
|
|
954
|
+
*
|
|
955
|
+
* alternate identifiers (LOINC#code, from the identifier scheme aliases)
|
|
919
956
|
*
|
|
920
|
-
* Everything else (
|
|
921
|
-
* boolean) values in refinements, alternate identifiers, reference set field
|
|
922
|
-
* selection on ^, {{ + ... }} history supplements) throws an informative
|
|
957
|
+
* Everything else (reference set field selection on ^) throws an informative
|
|
923
958
|
* error which is reported as UNSUPPORTED_ECL.
|
|
924
959
|
*/
|
|
925
960
|
|
|
@@ -1068,9 +1103,6 @@ class SnomedServices {
|
|
|
1068
1103
|
// ({{ M ... }}) are handled in _evalMemberOf; concept filters ({{ C ... }})
|
|
1069
1104
|
// and description filters ({{ D ... }}) are applied to the result below.
|
|
1070
1105
|
if (node.filters && node.filters.length > 0) {
|
|
1071
|
-
if (node.filters.some(fc => fc.kind === '+')) {
|
|
1072
|
-
throw new Error('ECL history supplements ({{ + ... }}) are not supported');
|
|
1073
|
-
}
|
|
1074
1106
|
const inner = { ...node, filters: undefined };
|
|
1075
1107
|
let ctx = this._evalSubExpression(inner, opContext);
|
|
1076
1108
|
const conceptFilters = node.filters.filter(fc => fc.kind === 'C');
|
|
@@ -1080,12 +1112,26 @@ class SnomedServices {
|
|
|
1080
1112
|
for (const fc of node.filters.filter(fc => fc.kind === 'D')) {
|
|
1081
1113
|
ctx = this._applyDescriptionFilter(ctx, fc.filters, opContext);
|
|
1082
1114
|
}
|
|
1115
|
+
for (const fc of node.filters.filter(fc => fc.kind === '+')) {
|
|
1116
|
+
ctx = this._applyHistorySupplement(ctx, fc, opContext);
|
|
1117
|
+
}
|
|
1083
1118
|
return ctx;
|
|
1084
1119
|
}
|
|
1085
1120
|
|
|
1086
|
-
// Alternate identifiers (ECL 2.1 "scheme#code")
|
|
1121
|
+
// Alternate identifiers (ECL 2.1 "scheme#code"): the concept(s) that carry
|
|
1122
|
+
// the code in that identifier scheme, then the operator applies to them as
|
|
1123
|
+
// to any set. The scheme is an alias from the ECL specification (LOINC) or
|
|
1124
|
+
// the identifier scheme concept's own id.
|
|
1087
1125
|
if (focus.type === ECLNodeType.ALT_IDENTIFIER) {
|
|
1088
|
-
|
|
1126
|
+
const set = this._eclAltIdentifier(focus);
|
|
1127
|
+
if (!operator) {
|
|
1128
|
+
const result = new SnomedFilterContext();
|
|
1129
|
+
result.descendants = set;
|
|
1130
|
+
return result;
|
|
1131
|
+
}
|
|
1132
|
+
const base = new SnomedFilterContext();
|
|
1133
|
+
base.descendants = set;
|
|
1134
|
+
return this._eclApplyOperatorToSet(base, operator, opContext);
|
|
1089
1135
|
}
|
|
1090
1136
|
|
|
1091
1137
|
// Top / bottom of set: the members of the focus set with no ancestor
|
|
@@ -1160,6 +1206,32 @@ class SnomedServices {
|
|
|
1160
1206
|
return result;
|
|
1161
1207
|
};
|
|
1162
1208
|
|
|
1209
|
+
/**
|
|
1210
|
+
* Resolve an ECL alternate identifier (scheme#code) to concept indexes. The
|
|
1211
|
+
* scheme is a known alias (IDENTIFIER_SCHEMES[].alias, case-insensitive) or
|
|
1212
|
+
* an identifier scheme concept id. An unknown scheme, or a code nobody has,
|
|
1213
|
+
* is an invalid expression - the same standing as an unknown concept.
|
|
1214
|
+
*/
|
|
1215
|
+
_eclAltIdentifier = function (node) {
|
|
1216
|
+
let schemeId = null;
|
|
1217
|
+
if (/^\d{6,18}$/.test(node.scheme)) {
|
|
1218
|
+
schemeId = node.scheme;
|
|
1219
|
+
} else {
|
|
1220
|
+
for (const [id, def] of Object.entries(IDENTIFIER_SCHEMES)) {
|
|
1221
|
+
if (def.alias && def.alias.toLowerCase() === node.scheme.toLowerCase()) schemeId = id;
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
const invalid = (msg) => { const e = new Error(msg); e.eclInvalid = true; return e; };
|
|
1225
|
+
if (!schemeId) {
|
|
1226
|
+
throw invalid(`The alternate identifier scheme "${node.scheme}" is not known`);
|
|
1227
|
+
}
|
|
1228
|
+
const concepts = this.getConceptsForIdentifier(schemeId, node.code);
|
|
1229
|
+
if (concepts.length === 0) {
|
|
1230
|
+
throw invalid(`No concept has the alternate identifier ${node.scheme}#${node.code}`);
|
|
1231
|
+
}
|
|
1232
|
+
return concepts.map(id => this.concepts.findConcept(id)).filter(r => r.found).map(r => r.index);
|
|
1233
|
+
};
|
|
1234
|
+
|
|
1163
1235
|
/**
|
|
1164
1236
|
* !!> / !!< : keep the concepts in `set` that have no ancestor (top) or no
|
|
1165
1237
|
* descendant (bottom) that is also in `set`. Walks the stated parents of each
|
|
@@ -1317,6 +1389,9 @@ class SnomedServices {
|
|
|
1317
1389
|
}
|
|
1318
1390
|
const refsetConcepts = this._eclResolveSet(this._evalECLNode(memberOfNode.refSet, opContext), opContext);
|
|
1319
1391
|
const memberFilters = this._eclPrepareMemberFilters(memberOfNode.memberFilters, opContext);
|
|
1392
|
+
const rs = memberOfNode.refSet;
|
|
1393
|
+
const explicit = rs.type === ECLNodeType.CONCEPT_REFERENCE ||
|
|
1394
|
+
(rs.type === ECLNodeType.SUB_EXPRESSION_CONSTRAINT && !rs.operator && rs.focus.type === ECLNodeType.CONCEPT_REFERENCE);
|
|
1320
1395
|
|
|
1321
1396
|
const members = new Set();
|
|
1322
1397
|
for (const refsetIdx of refsetConcepts) {
|
|
@@ -1334,9 +1409,12 @@ class SnomedServices {
|
|
|
1334
1409
|
}
|
|
1335
1410
|
const memberList = this.refSetMembers.getMembers(membersRef) || [];
|
|
1336
1411
|
// A reference set whose members are descriptions (a language reference
|
|
1337
|
-
// set, say) has no concept members to return.
|
|
1338
|
-
// answering with an empty set, which would
|
|
1339
|
-
|
|
1412
|
+
// set, say) has no concept members to return. When it was named
|
|
1413
|
+
// explicitly, say so rather than answering with an empty set, which would
|
|
1414
|
+
// read as "no members". When it merely falls inside a computed operand
|
|
1415
|
+
// (^ *, ^ (< 900000000000455006)) it contributes nothing and the rest of
|
|
1416
|
+
// the operand still counts.
|
|
1417
|
+
if (explicit && memberList.length > 0 && !memberList.some(m => m.kind === 0)) {
|
|
1340
1418
|
throw new Error(`The members of reference set ${this.concepts.getConceptId(refsetIdx)} are descriptions, not concepts, so ^ cannot be evaluated for it`);
|
|
1341
1419
|
}
|
|
1342
1420
|
for (const m of memberList) {
|
|
@@ -1356,6 +1434,82 @@ class SnomedServices {
|
|
|
1356
1434
|
return result;
|
|
1357
1435
|
};
|
|
1358
1436
|
|
|
1437
|
+
/**
|
|
1438
|
+
* Apply a history supplement ({{ + HISTORY... }}): add to the result the
|
|
1439
|
+
* inactive concepts that the selected historical association reference sets
|
|
1440
|
+
* associate with a concept in the result (the association rows whose
|
|
1441
|
+
* targetComponentId is in the result).
|
|
1442
|
+
*
|
|
1443
|
+
* Profiles (ECL 2.x): MIN = SAME AS; MOD = SAME AS, WAS A, REPLACED BY,
|
|
1444
|
+
* PARTIALLY EQUIVALENT TO; MAX (and a bare HISTORY) = every reference set
|
|
1445
|
+
* under 900000000000522004 |Historical association|; HISTORY (expr) = the
|
|
1446
|
+
* reference sets the expression yields. A reference set the edition does not
|
|
1447
|
+
* carry simply contributes nothing.
|
|
1448
|
+
*/
|
|
1449
|
+
_applyHistorySupplement = function (ctx, hs, opContext) {
|
|
1450
|
+
const HISTORICAL_ASSOCIATION = '900000000000522004';
|
|
1451
|
+
const PROFILES = {
|
|
1452
|
+
MIN: ['900000000000527005'],
|
|
1453
|
+
MOD: ['900000000000527005', '900000000000528000', '900000000000526001', '1186924009']
|
|
1454
|
+
};
|
|
1455
|
+
let refsetIdxs;
|
|
1456
|
+
if (hs.profile === 'EXPLICIT') {
|
|
1457
|
+
refsetIdxs = this._eclResolveSet(this._evalECLNode(hs.refsets, opContext), opContext);
|
|
1458
|
+
} else if (hs.profile === 'MAX') {
|
|
1459
|
+
const root = this.concepts.findConcept(HISTORICAL_ASSOCIATION);
|
|
1460
|
+
refsetIdxs = root.found ? this._eclToIndexArray(this.filterIsA(HISTORICAL_ASSOCIATION, false)) : [];
|
|
1461
|
+
} else {
|
|
1462
|
+
refsetIdxs = PROFILES[hs.profile].map(id => this.concepts.findConcept(id)).filter(r => r.found).map(r => r.index);
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
const base = this._eclResolveSet(ctx, opContext);
|
|
1466
|
+
const inResult = new Set(base);
|
|
1467
|
+
const added = new Set();
|
|
1468
|
+
for (const refsetIdx of refsetIdxs) {
|
|
1469
|
+
if (opContext) opContext.deadCheck('ecl:history');
|
|
1470
|
+
const byTarget = this._historyIndex(refsetIdx);
|
|
1471
|
+
if (!byTarget) continue;
|
|
1472
|
+
for (const [target, sources] of byTarget) {
|
|
1473
|
+
if (inResult.has(target)) {
|
|
1474
|
+
for (const src of sources) {
|
|
1475
|
+
if (!inResult.has(src)) added.add(src);
|
|
1476
|
+
}
|
|
1477
|
+
}
|
|
1478
|
+
}
|
|
1479
|
+
}
|
|
1480
|
+
const result = new SnomedFilterContext();
|
|
1481
|
+
result.descendants = base.concat([...added]);
|
|
1482
|
+
return result;
|
|
1483
|
+
};
|
|
1484
|
+
|
|
1485
|
+
/**
|
|
1486
|
+
* target concept index -> [referenced (inactive) concept indexes] for one
|
|
1487
|
+
* association reference set, built once per edition. null when the concept
|
|
1488
|
+
* is not a reference set with concept-valued members.
|
|
1489
|
+
*/
|
|
1490
|
+
_historyIndex = function (refsetIdx) {
|
|
1491
|
+
this._historyIndexes = this._historyIndexes || new Map();
|
|
1492
|
+
if (this._historyIndexes.has(refsetIdx)) {
|
|
1493
|
+
return this._historyIndexes.get(refsetIdx);
|
|
1494
|
+
}
|
|
1495
|
+
let index = null;
|
|
1496
|
+
const refSet = this._findRefSetRecord(refsetIdx);
|
|
1497
|
+
if (refSet && refSet.membersByRef && refSet.membersByRef !== 0xFFFFFFFF) {
|
|
1498
|
+
for (const m of this.refSetMembers.getMembers(refSet.membersByRef) || []) {
|
|
1499
|
+
if (m.kind !== 0 || !m.values) continue;
|
|
1500
|
+
const values = this.refs.getReferences(m.values);
|
|
1501
|
+
// targetComponentId is the first additional field, stored as (value, type) with type 1 = concept
|
|
1502
|
+
if (!values || values.length < 2 || values[1] !== 1) continue;
|
|
1503
|
+
index = index || new Map();
|
|
1504
|
+
const list = index.get(values[0]) || [];
|
|
1505
|
+
list.push(m.ref);
|
|
1506
|
+
index.set(values[0], list);
|
|
1507
|
+
}
|
|
1508
|
+
}
|
|
1509
|
+
this._historyIndexes.set(refsetIdx, index);
|
|
1510
|
+
return index;
|
|
1511
|
+
};
|
|
1512
|
+
|
|
1359
1513
|
/**
|
|
1360
1514
|
* Apply {{ C ... }} concept filters to a result: each concept is tested on
|
|
1361
1515
|
* its own active flag, module, effectiveTime and definition status. Several
|
|
@@ -1855,17 +2009,11 @@ class SnomedServices {
|
|
|
1855
2009
|
* @returns {boolean}
|
|
1856
2010
|
*/
|
|
1857
2011
|
_attributeMatches = function (rels, attr, groupFilter, opContext) {
|
|
1858
|
-
if (attr.reverse) {
|
|
1859
|
-
throw new Error('ECL reverse attributes (R) are not yet supported');
|
|
1860
|
-
}
|
|
1861
2012
|
if (!attr.comparison) {
|
|
1862
2013
|
throw new Error('ECL attribute without a comparison is not supported');
|
|
1863
2014
|
}
|
|
1864
|
-
if (attr.comparison.type !== ECLNodeType.EXPRESSION_COMPARISON) {
|
|
1865
|
-
throw new Error('ECL concrete values (numeric, string and boolean attribute values)
|
|
1866
|
-
}
|
|
1867
|
-
if (attr.comparison.operator !== ECLTokenType.EQUALS) {
|
|
1868
|
-
throw new Error('ECL != in refinements is not yet supported');
|
|
2015
|
+
if (attr.comparison.type !== ECLNodeType.EXPRESSION_COMPARISON && !this.concreteValues) {
|
|
2016
|
+
throw new Error('ECL concrete values (numeric, string and boolean attribute values) cannot be evaluated - this edition was imported without its concrete value relationships');
|
|
1869
2017
|
}
|
|
1870
2018
|
|
|
1871
2019
|
const count = this._countAttributeMatches(rels, attr, groupFilter, opContext);
|
|
@@ -1895,15 +2043,30 @@ class SnomedServices {
|
|
|
1895
2043
|
// fresh per request, so memoising on the node is safe within a request.
|
|
1896
2044
|
let resolved = attr._eclResolved;
|
|
1897
2045
|
if (!resolved) {
|
|
1898
|
-
resolved = {
|
|
1899
|
-
|
|
1900
|
-
valueSet
|
|
1901
|
-
}
|
|
2046
|
+
resolved = { types: this._eclAttributeTypes(attr.name, opContext) };
|
|
2047
|
+
if (attr.comparison.type === ECLNodeType.EXPRESSION_COMPARISON) {
|
|
2048
|
+
resolved.valueSet = new Set(this._eclResolveSet(this._evalECLNode(attr.comparison.value, opContext), opContext));
|
|
2049
|
+
} else {
|
|
2050
|
+
resolved.concrete = this._eclConcreteMatcher(attr.comparison);
|
|
2051
|
+
}
|
|
1902
2052
|
attr._eclResolved = resolved;
|
|
1903
2053
|
}
|
|
1904
2054
|
const types = resolved.types;
|
|
1905
2055
|
const valueSet = resolved.valueSet;
|
|
1906
2056
|
|
|
2057
|
+
if (resolved.concrete) {
|
|
2058
|
+
// A concrete-value comparison: only the concrete rows count, each
|
|
2059
|
+
// occurrence being a (group, value) pair
|
|
2060
|
+
const matched = new Set();
|
|
2061
|
+
for (const rel of rels) {
|
|
2062
|
+
if (!rel.concrete) continue;
|
|
2063
|
+
if (!types.any && !types.types.has(rel.relType)) continue;
|
|
2064
|
+
if (groupFilter !== null && rel.group !== groupFilter) continue;
|
|
2065
|
+
if (resolved.concrete(rel.concrete)) matched.add(rel.group + ':' + JSON.stringify(rel.concrete.value));
|
|
2066
|
+
}
|
|
2067
|
+
return matched.size;
|
|
2068
|
+
}
|
|
2069
|
+
|
|
1907
2070
|
// ECL cardinality counts OCCURRENCES of a matching attribute, per role group
|
|
1908
2071
|
// — not distinct values. ECL runs against the necessary normal form, where a
|
|
1909
2072
|
// role group has no redundant attributes, so the same (type, value) in two
|
|
@@ -1912,13 +2075,26 @@ class SnomedServices {
|
|
|
1912
2075
|
// form, not to de-duplicating across groups). We therefore count distinct
|
|
1913
2076
|
// (group, target) pairs: within a group filter this is the matching values
|
|
1914
2077
|
// in that group; ungrouped it is the total occurrences across all groups.
|
|
2078
|
+
//
|
|
2079
|
+
// "!=" counts the occurrences whose value is NOT in the set (so "A != X"
|
|
2080
|
+
// matches a concept with at least one A whose value is something other than
|
|
2081
|
+
// X, and "[0..0] A != X" one whose every A is X).
|
|
2082
|
+
//
|
|
2083
|
+
// A reversed attribute ("R A = X") is matched against the concept's INBOUND
|
|
2084
|
+
// relationships: the concept is the value of an A attribute on some concept
|
|
2085
|
+
// in X. _conceptRelRecords supplies those tagged `inbound`; the records built
|
|
2086
|
+
// for a post-coordinated expression have none, so R matches nothing there.
|
|
2087
|
+
const notEquals = attr.comparison.operator === ECLTokenType.NOT_EQUALS;
|
|
2088
|
+
const reverse = !!attr.reverse;
|
|
1915
2089
|
const matched = new Set();
|
|
1916
2090
|
for (const rel of rels) {
|
|
1917
2091
|
if (opContext) opContext.deadCheck('ecl:countAttributeMatches');
|
|
1918
|
-
if (!rel.active) continue;
|
|
2092
|
+
if (!rel.active || rel.concrete) continue;
|
|
2093
|
+
if (!!rel.inbound !== reverse) continue;
|
|
1919
2094
|
if (!types.any && !types.types.has(rel.relType)) continue;
|
|
1920
2095
|
if (groupFilter !== null && rel.group !== groupFilter) continue;
|
|
1921
|
-
|
|
2096
|
+
const value = reverse ? rel.source : rel.target;
|
|
2097
|
+
if (valueSet.has(value) !== notEquals) matched.add(rel.group + ':' + value);
|
|
1922
2098
|
}
|
|
1923
2099
|
return matched.size;
|
|
1924
2100
|
};
|
|
@@ -1983,12 +2159,58 @@ class SnomedServices {
|
|
|
1983
2159
|
// target, group} — the shape the refinement matchers consume. Sharing this
|
|
1984
2160
|
// shape lets the same matchers run against a post-coordinated expression's
|
|
1985
2161
|
// relationships (see _expressionRelRecords).
|
|
2162
|
+
/**
|
|
2163
|
+
* A predicate over a stored concrete value for one comparison node:
|
|
2164
|
+
* numeric: = != < <= > >= against a number (integers and decimals compare as
|
|
2165
|
+
* numbers, so #5 = #5.0)
|
|
2166
|
+
* string: = / != against typed search terms (match / wild semantics, as for
|
|
2167
|
+
* description terms), any of a set
|
|
2168
|
+
* boolean: = / != true/false
|
|
2169
|
+
* A value of another kind never matches (and never "not-matches" either: a
|
|
2170
|
+
* string attribute is not "!= #5").
|
|
2171
|
+
*/
|
|
2172
|
+
_eclConcreteMatcher = function (comparison) {
|
|
2173
|
+
switch (comparison.type) {
|
|
2174
|
+
case ECLNodeType.NUMERIC_COMPARISON: {
|
|
2175
|
+
const n = Number(comparison.value);
|
|
2176
|
+
const op = { [ECLTokenType.EQUALS]: '=', [ECLTokenType.NOT_EQUALS]: '!=', [ECLTokenType.LT]: '<', [ECLTokenType.LTE]: '<=', [ECLTokenType.GT]: '>', [ECLTokenType.GTE]: '>=' }[comparison.operator] || comparison.operator;
|
|
2177
|
+
return (v) => v.kind === 'number' && this._eclCompareValues(v.value, op, [n], (a, b) => a - b);
|
|
2178
|
+
}
|
|
2179
|
+
case ECLNodeType.STRING_COMPARISON: {
|
|
2180
|
+
const matchers = comparison.values.map(t => this._eclTermMatcher(typeof t === 'string' ? { kind: 'match', value: t } : t));
|
|
2181
|
+
const neg = comparison.operator === ECLTokenType.NOT_EQUALS;
|
|
2182
|
+
return (v) => v.kind === 'string' && (matchers.some(m => m(v.value)) !== neg);
|
|
2183
|
+
}
|
|
2184
|
+
case ECLNodeType.BOOLEAN_COMPARISON: {
|
|
2185
|
+
const neg = comparison.operator === ECLTokenType.NOT_EQUALS;
|
|
2186
|
+
return (v) => v.kind === 'boolean' && ((v.value === comparison.value) !== neg);
|
|
2187
|
+
}
|
|
2188
|
+
default:
|
|
2189
|
+
throw new Error(`Unsupported comparison ${comparison.type}`);
|
|
2190
|
+
}
|
|
2191
|
+
};
|
|
2192
|
+
|
|
1986
2193
|
_conceptRelRecords = function (conceptIdx) {
|
|
1987
|
-
const relIdxs = this.getConceptRelationships(conceptIdx);
|
|
1988
2194
|
const out = [];
|
|
1989
|
-
for (const relIdx of
|
|
2195
|
+
for (const relIdx of this.getConceptRelationships(conceptIdx)) {
|
|
1990
2196
|
out.push(this.relationships.getRelationship(relIdx));
|
|
1991
2197
|
}
|
|
2198
|
+
// concrete value rows, for numeric / string / boolean comparisons
|
|
2199
|
+
if (this.concreteValues) {
|
|
2200
|
+
for (const cv of this.concreteValues.get(conceptIdx) || []) {
|
|
2201
|
+
out.push({ active: true, relType: cv.relType, group: cv.group, target: -1, concrete: cv.concrete });
|
|
2202
|
+
}
|
|
2203
|
+
}
|
|
2204
|
+
// inbound relationships, for reversed (R) attributes
|
|
2205
|
+
const concept = this.concepts.getConcept(conceptIdx);
|
|
2206
|
+
if (concept.inbounds) {
|
|
2207
|
+
for (const relIdx of this.refs.getReferences(concept.inbounds) || []) {
|
|
2208
|
+
const rel = this.relationships.getRelationship(relIdx);
|
|
2209
|
+
if (rel.active && rel.relType !== this.isAIndex) {
|
|
2210
|
+
out.push({ ...rel, inbound: true });
|
|
2211
|
+
}
|
|
2212
|
+
}
|
|
2213
|
+
}
|
|
1992
2214
|
return out;
|
|
1993
2215
|
};
|
|
1994
2216
|
|
|
@@ -1139,6 +1139,9 @@ class SnomedImporter {
|
|
|
1139
1139
|
// Step 10a: Read alternate identifiers (e.g. the LOINC codes of the LOINC Extension)
|
|
1140
1140
|
await this.readIdentifiers();
|
|
1141
1141
|
|
|
1142
|
+
// Step 10b: Read concrete value relationships (sct2_RelationshipConcreteValues)
|
|
1143
|
+
await this.readConcreteValues();
|
|
1144
|
+
|
|
1142
1145
|
// Step 11: Link concepts
|
|
1143
1146
|
this.linkConcepts();
|
|
1144
1147
|
|
|
@@ -1169,6 +1172,7 @@ class SnomedImporter {
|
|
|
1169
1172
|
descriptions: [],
|
|
1170
1173
|
relationships: [],
|
|
1171
1174
|
identifiers: [],
|
|
1175
|
+
concreteValues: [],
|
|
1172
1176
|
refsetDirectories: []
|
|
1173
1177
|
};
|
|
1174
1178
|
|
|
@@ -1224,6 +1228,8 @@ class SnomedImporter {
|
|
|
1224
1228
|
files.relationships.push(filePath);
|
|
1225
1229
|
} else if (files.identifiers && firstLine.startsWith('alternateIdentifier\teffectiveTime\tactive\tmoduleId\tidentifierSchemeId\treferencedComponentId')) {
|
|
1226
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);
|
|
1227
1233
|
}
|
|
1228
1234
|
} catch (error) {
|
|
1229
1235
|
console.log(`DEBUG: Error reading file ${filePath}: ${error.message}`);
|
|
@@ -1782,6 +1788,58 @@ class SnomedImporter {
|
|
|
1782
1788
|
}
|
|
1783
1789
|
}
|
|
1784
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
|
+
|
|
1785
1843
|
async readRelationships() {
|
|
1786
1844
|
// Estimate total lines
|
|
1787
1845
|
let totalLines = 0;
|
|
@@ -2823,11 +2881,14 @@ class SnomedImporter {
|
|
|
2823
2881
|
progressBar?.update(50);
|
|
2824
2882
|
|
|
2825
2883
|
const identifiers = this.identifiers || [];
|
|
2884
|
+
const concreteValues = this.concreteValues || [];
|
|
2826
2885
|
await writer.writeCache({
|
|
2827
|
-
// 18 = 17 plus the alternate identifiers
|
|
2828
|
-
//
|
|
2829
|
-
|
|
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',
|
|
2830
2890
|
identifiers: identifiers,
|
|
2891
|
+
concreteValues: concreteValues,
|
|
2831
2892
|
versionUri: this.isTesting ? this.config.uri.replace("/sct/", "/xsct/") : this.config.uri,
|
|
2832
2893
|
versionDate: this.extractDateFromUri(this.config.uri),
|
|
2833
2894
|
|
|
@@ -2914,10 +2975,14 @@ class SnomedCacheWriter {
|
|
|
2914
2975
|
}
|
|
2915
2976
|
buffers.push(this.writeInteger(data.defaultLanguage));
|
|
2916
2977
|
|
|
2917
|
-
if (data.version === '18') {
|
|
2978
|
+
if (data.version === '18' || data.version === '19') {
|
|
2918
2979
|
// alternate identifiers, as JSON: [[scheme, module, code, conceptId], ...]
|
|
2919
2980
|
buffers.push(this.writeBytes(Buffer.from(JSON.stringify(data.identifiers || []), 'utf8')));
|
|
2920
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
|
+
}
|
|
2921
2986
|
|
|
2922
2987
|
// Write to file
|
|
2923
2988
|
const finalBuffer = Buffer.concat(buffers);
|