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.
- package/CHANGELOG.md +72 -10
- 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/library/daily-report.js +25 -15
- 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/translations/Messages.properties +1 -0
- package/tx/README.md +4 -0
- package/tx/closure/closure-store.js +14 -0
- package/tx/cm/cm-package.js +21 -15
- package/tx/cs/cs-api.js +3 -1
- package/tx/cs/cs-cs.js +59 -12
- package/tx/cs/cs-icf.js +47 -34
- package/tx/cs/cs-loinc.js +257 -3
- package/tx/cs/cs-snomed.js +544 -55
- package/tx/importers/csv-records.js +35 -0
- package/tx/importers/import-loinc.module.js +177 -41
- package/tx/importers/import-sct.module.js +131 -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/tho-precedence.js +40 -0
- package/tx/openapi-operations.js +2 -1
- package/tx/provider.js +46 -29
- package/tx/sct/documentation.md +45 -21
- package/tx/sct/ecl.js +34 -11
- package/tx/sct/structures.js +15 -2
- package/tx/tx.fhir.org.yml +2 -2
- package/tx/vs/vs-package.js +17 -13
- package/tx/vs/vs-vsac.js +1 -1
- package/tx/workers/closure.js +20 -0
- package/tx/workers/expand.js +2 -2
- package/tx/workers/validate.js +10 -0
- package/utilities/make-sct-loinc-subset.js +250 -0
package/tx/cs/cs-snomed.js
CHANGED
|
@@ -31,6 +31,18 @@ const Logger = require("../../library/logger");
|
|
|
31
31
|
// shared - it simply recomputes.
|
|
32
32
|
const SNOMED_FILTER_ANALYSIS = Symbol('snomedFilterAnalysis');
|
|
33
33
|
|
|
34
|
+
/**
|
|
35
|
+
* Alternate identifier schemes (sct2_Identifier) that FHIRsmith knows how to present:
|
|
36
|
+
* the code system the identifiers are codes in, and the $lookup property they are
|
|
37
|
+
* returned as. An identifier in an unknown scheme is kept but not reported.
|
|
38
|
+
*/
|
|
39
|
+
const IDENTIFIER_SCHEMES = {
|
|
40
|
+
'30051010000102': { system: 'http://loinc.org', property: 'loinc', alias: 'LOINC' } // |LOINC code identifier| (LOINC Extension)
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/** The $expand / ECL-free filter on alternate identifiers: mapped:<scheme> exists true|false */
|
|
44
|
+
const MAPPED_FILTER_PREFIX = 'mapped:';
|
|
45
|
+
|
|
34
46
|
// Description language index -> language code. The index is assigned at import
|
|
35
47
|
// time by SnomedImporter.mapLanguageCode(); this table is its inverse and must
|
|
36
48
|
// be kept in step with it.
|
|
@@ -192,6 +204,80 @@ class SnomedServices {
|
|
|
192
204
|
// means "could not decide", and leaves the value unchecked.
|
|
193
205
|
this.expressionServices.eclConstraintMatcher = (constraint, value) => this.mrcmRangeSatisfied(constraint, value);
|
|
194
206
|
this._mrcmRangeCache = new Map();
|
|
207
|
+
|
|
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
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Index the alternate identifiers (sct2_Identifier rows, active only, as
|
|
246
|
+
* [scheme, module, code, conceptId]) both ways: concept -> identifiers, and
|
|
247
|
+
* scheme|code -> concepts. Also which modules carry identifiers, since each such
|
|
248
|
+
* module defines an implicit map (?fhir_cm=<module>).
|
|
249
|
+
*/
|
|
250
|
+
#loadIdentifiers(rows) {
|
|
251
|
+
this.identifiersByConcept = new Map();
|
|
252
|
+
this.conceptsByIdentifier = new Map();
|
|
253
|
+
this.identifierModules = new Map(); // module -> Set of schemes
|
|
254
|
+
for (const [scheme, module, code, conceptId] of rows) {
|
|
255
|
+
const entry = { scheme, module, code };
|
|
256
|
+
if (!this.identifiersByConcept.has(conceptId)) {
|
|
257
|
+
this.identifiersByConcept.set(conceptId, []);
|
|
258
|
+
}
|
|
259
|
+
this.identifiersByConcept.get(conceptId).push(entry);
|
|
260
|
+
const key = scheme + '|' + code;
|
|
261
|
+
if (!this.conceptsByIdentifier.has(key)) {
|
|
262
|
+
this.conceptsByIdentifier.set(key, []);
|
|
263
|
+
}
|
|
264
|
+
this.conceptsByIdentifier.get(key).push(conceptId);
|
|
265
|
+
if (!this.identifierModules.has(module)) {
|
|
266
|
+
this.identifierModules.set(module, new Set());
|
|
267
|
+
}
|
|
268
|
+
this.identifierModules.get(module).add(scheme);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** The alternate identifiers of a concept (by id), optionally only those of one scheme */
|
|
273
|
+
getIdentifiers(conceptId, scheme = null) {
|
|
274
|
+
const list = this.identifiersByConcept.get(String(conceptId)) || [];
|
|
275
|
+
return scheme ? list.filter(i => i.scheme === scheme) : list;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** The concept ids that have the given code in the given scheme */
|
|
279
|
+
getConceptsForIdentifier(scheme, code) {
|
|
280
|
+
return this.conceptsByIdentifier.get(scheme + '|' + code) || [];
|
|
195
281
|
}
|
|
196
282
|
|
|
197
283
|
/**
|
|
@@ -859,14 +945,16 @@ class SnomedServices {
|
|
|
859
945
|
* hierarchy operators on *, ^ and bracketed sets (applied per member)
|
|
860
946
|
* AND / OR / MINUS compound expressions
|
|
861
947
|
* !!> / !!< (top / bottom of set)
|
|
862
|
-
* refinements (= against an expression,
|
|
863
|
-
* 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),
|
|
864
951
|
* dotted expressions, {{ C ... }} concept filters, {{ D ... }} description
|
|
865
|
-
* 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)
|
|
866
956
|
*
|
|
867
|
-
* Everything else (
|
|
868
|
-
* boolean) values in refinements, alternate identifiers, reference set field
|
|
869
|
-
* selection on ^, {{ + ... }} history supplements) throws an informative
|
|
957
|
+
* Everything else (reference set field selection on ^) throws an informative
|
|
870
958
|
* error which is reported as UNSUPPORTED_ECL.
|
|
871
959
|
*/
|
|
872
960
|
|
|
@@ -1015,9 +1103,6 @@ class SnomedServices {
|
|
|
1015
1103
|
// ({{ M ... }}) are handled in _evalMemberOf; concept filters ({{ C ... }})
|
|
1016
1104
|
// and description filters ({{ D ... }}) are applied to the result below.
|
|
1017
1105
|
if (node.filters && node.filters.length > 0) {
|
|
1018
|
-
if (node.filters.some(fc => fc.kind === '+')) {
|
|
1019
|
-
throw new Error('ECL history supplements ({{ + ... }}) are not supported');
|
|
1020
|
-
}
|
|
1021
1106
|
const inner = { ...node, filters: undefined };
|
|
1022
1107
|
let ctx = this._evalSubExpression(inner, opContext);
|
|
1023
1108
|
const conceptFilters = node.filters.filter(fc => fc.kind === 'C');
|
|
@@ -1027,12 +1112,26 @@ class SnomedServices {
|
|
|
1027
1112
|
for (const fc of node.filters.filter(fc => fc.kind === 'D')) {
|
|
1028
1113
|
ctx = this._applyDescriptionFilter(ctx, fc.filters, opContext);
|
|
1029
1114
|
}
|
|
1115
|
+
for (const fc of node.filters.filter(fc => fc.kind === '+')) {
|
|
1116
|
+
ctx = this._applyHistorySupplement(ctx, fc, opContext);
|
|
1117
|
+
}
|
|
1030
1118
|
return ctx;
|
|
1031
1119
|
}
|
|
1032
1120
|
|
|
1033
|
-
// 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.
|
|
1034
1125
|
if (focus.type === ECLNodeType.ALT_IDENTIFIER) {
|
|
1035
|
-
|
|
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);
|
|
1036
1135
|
}
|
|
1037
1136
|
|
|
1038
1137
|
// Top / bottom of set: the members of the focus set with no ancestor
|
|
@@ -1107,6 +1206,32 @@ class SnomedServices {
|
|
|
1107
1206
|
return result;
|
|
1108
1207
|
};
|
|
1109
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
|
+
|
|
1110
1235
|
/**
|
|
1111
1236
|
* !!> / !!< : keep the concepts in `set` that have no ancestor (top) or no
|
|
1112
1237
|
* descendant (bottom) that is also in `set`. Walks the stated parents of each
|
|
@@ -1264,6 +1389,9 @@ class SnomedServices {
|
|
|
1264
1389
|
}
|
|
1265
1390
|
const refsetConcepts = this._eclResolveSet(this._evalECLNode(memberOfNode.refSet, opContext), opContext);
|
|
1266
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);
|
|
1267
1395
|
|
|
1268
1396
|
const members = new Set();
|
|
1269
1397
|
for (const refsetIdx of refsetConcepts) {
|
|
@@ -1281,9 +1409,12 @@ class SnomedServices {
|
|
|
1281
1409
|
}
|
|
1282
1410
|
const memberList = this.refSetMembers.getMembers(membersRef) || [];
|
|
1283
1411
|
// A reference set whose members are descriptions (a language reference
|
|
1284
|
-
// set, say) has no concept members to return.
|
|
1285
|
-
// answering with an empty set, which would
|
|
1286
|
-
|
|
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)) {
|
|
1287
1418
|
throw new Error(`The members of reference set ${this.concepts.getConceptId(refsetIdx)} are descriptions, not concepts, so ^ cannot be evaluated for it`);
|
|
1288
1419
|
}
|
|
1289
1420
|
for (const m of memberList) {
|
|
@@ -1303,6 +1434,82 @@ class SnomedServices {
|
|
|
1303
1434
|
return result;
|
|
1304
1435
|
};
|
|
1305
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
|
+
|
|
1306
1513
|
/**
|
|
1307
1514
|
* Apply {{ C ... }} concept filters to a result: each concept is tested on
|
|
1308
1515
|
* its own active flag, module, effectiveTime and definition status. Several
|
|
@@ -1802,17 +2009,11 @@ class SnomedServices {
|
|
|
1802
2009
|
* @returns {boolean}
|
|
1803
2010
|
*/
|
|
1804
2011
|
_attributeMatches = function (rels, attr, groupFilter, opContext) {
|
|
1805
|
-
if (attr.reverse) {
|
|
1806
|
-
throw new Error('ECL reverse attributes (R) are not yet supported');
|
|
1807
|
-
}
|
|
1808
2012
|
if (!attr.comparison) {
|
|
1809
2013
|
throw new Error('ECL attribute without a comparison is not supported');
|
|
1810
2014
|
}
|
|
1811
|
-
if (attr.comparison.type !== ECLNodeType.EXPRESSION_COMPARISON) {
|
|
1812
|
-
throw new Error('ECL concrete values (numeric, string and boolean attribute values)
|
|
1813
|
-
}
|
|
1814
|
-
if (attr.comparison.operator !== ECLTokenType.EQUALS) {
|
|
1815
|
-
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');
|
|
1816
2017
|
}
|
|
1817
2018
|
|
|
1818
2019
|
const count = this._countAttributeMatches(rels, attr, groupFilter, opContext);
|
|
@@ -1842,15 +2043,30 @@ class SnomedServices {
|
|
|
1842
2043
|
// fresh per request, so memoising on the node is safe within a request.
|
|
1843
2044
|
let resolved = attr._eclResolved;
|
|
1844
2045
|
if (!resolved) {
|
|
1845
|
-
resolved = {
|
|
1846
|
-
|
|
1847
|
-
valueSet
|
|
1848
|
-
}
|
|
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
|
+
}
|
|
1849
2052
|
attr._eclResolved = resolved;
|
|
1850
2053
|
}
|
|
1851
2054
|
const types = resolved.types;
|
|
1852
2055
|
const valueSet = resolved.valueSet;
|
|
1853
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
|
+
|
|
1854
2070
|
// ECL cardinality counts OCCURRENCES of a matching attribute, per role group
|
|
1855
2071
|
// — not distinct values. ECL runs against the necessary normal form, where a
|
|
1856
2072
|
// role group has no redundant attributes, so the same (type, value) in two
|
|
@@ -1859,13 +2075,26 @@ class SnomedServices {
|
|
|
1859
2075
|
// form, not to de-duplicating across groups). We therefore count distinct
|
|
1860
2076
|
// (group, target) pairs: within a group filter this is the matching values
|
|
1861
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;
|
|
1862
2089
|
const matched = new Set();
|
|
1863
2090
|
for (const rel of rels) {
|
|
1864
2091
|
if (opContext) opContext.deadCheck('ecl:countAttributeMatches');
|
|
1865
|
-
if (!rel.active) continue;
|
|
2092
|
+
if (!rel.active || rel.concrete) continue;
|
|
2093
|
+
if (!!rel.inbound !== reverse) continue;
|
|
1866
2094
|
if (!types.any && !types.types.has(rel.relType)) continue;
|
|
1867
2095
|
if (groupFilter !== null && rel.group !== groupFilter) continue;
|
|
1868
|
-
|
|
2096
|
+
const value = reverse ? rel.source : rel.target;
|
|
2097
|
+
if (valueSet.has(value) !== notEquals) matched.add(rel.group + ':' + value);
|
|
1869
2098
|
}
|
|
1870
2099
|
return matched.size;
|
|
1871
2100
|
};
|
|
@@ -1930,12 +2159,58 @@ class SnomedServices {
|
|
|
1930
2159
|
// target, group} — the shape the refinement matchers consume. Sharing this
|
|
1931
2160
|
// shape lets the same matchers run against a post-coordinated expression's
|
|
1932
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
|
+
|
|
1933
2193
|
_conceptRelRecords = function (conceptIdx) {
|
|
1934
|
-
const relIdxs = this.getConceptRelationships(conceptIdx);
|
|
1935
2194
|
const out = [];
|
|
1936
|
-
for (const relIdx of
|
|
2195
|
+
for (const relIdx of this.getConceptRelationships(conceptIdx)) {
|
|
1937
2196
|
out.push(this.relationships.getRelationship(relIdx));
|
|
1938
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
|
+
}
|
|
1939
2214
|
return out;
|
|
1940
2215
|
};
|
|
1941
2216
|
|
|
@@ -2707,34 +2982,46 @@ class SnomedProvider extends BaseCSServices {
|
|
|
2707
2982
|
const ctxt = await this.#ensureContext(context);
|
|
2708
2983
|
if (ctxt) {
|
|
2709
2984
|
if (!(ctxt instanceof SnomedExpressionContext) || ctxt.expression?.concepts.length == 1) {
|
|
2710
|
-
|
|
2711
|
-
//
|
|
2712
|
-
//
|
|
2713
|
-
//
|
|
2714
|
-
|
|
2715
|
-
|
|
2716
|
-
|
|
2717
|
-
|
|
2718
|
-
|
|
2985
|
+
// Only what was asked for, if anything was (see _hasProp): effectiveTime, module,
|
|
2986
|
+
// parent, child and the defining relationships are returned by default but are not
|
|
2987
|
+
// important enough to return when the client named the properties it wants.
|
|
2988
|
+
// The mappings (alternate identifiers such as the LOINC code) are returned by
|
|
2989
|
+
// default too, and when asked for by name.
|
|
2990
|
+
if (this._hasProp(props, 'effectiveTime', true)) {
|
|
2991
|
+
const time = this.sct.concepts.getConcept(ctxt.getReference()).effectiveTime;
|
|
2992
|
+
// Pascal TDateTime epoch (1899-12-30) computed in UTC so the formatted
|
|
2993
|
+
// date is timezone-independent. new Date(1899,11,30) is LOCAL midnight,
|
|
2994
|
+
// which in zones ahead of UTC rolls the ISO date back by one day.
|
|
2995
|
+
const pascalEpochUTC = Date.UTC(1899, 11, 30);
|
|
2996
|
+
const date = new Date(pascalEpochUTC + time * 86400000);
|
|
2997
|
+
const dateStr = date.toISOString().slice(0, 10);
|
|
2998
|
+
this._addDateTimeProperty(params, 'property', 'effectiveTime', dateStr);
|
|
2999
|
+
}
|
|
2719
3000
|
|
|
2720
|
-
|
|
2721
|
-
|
|
2722
|
-
|
|
2723
|
-
|
|
2724
|
-
|
|
3001
|
+
if (this._hasProp(props, 'parent', true)) {
|
|
3002
|
+
const parents = this.sct.getConceptParents(ctxt.getReference());
|
|
3003
|
+
for (let parentRef of parents) {
|
|
3004
|
+
const code = this.sct.getConceptId(parentRef);
|
|
3005
|
+
const description = this.sct.getDisplayName(parentRef);
|
|
3006
|
+
this._addCodeProperty(params, 'property', 'parent', code, null, description);
|
|
3007
|
+
}
|
|
2725
3008
|
}
|
|
2726
3009
|
|
|
2727
|
-
|
|
2728
|
-
|
|
2729
|
-
|
|
2730
|
-
|
|
2731
|
-
|
|
3010
|
+
if (this._hasProp(props, 'child', true)) {
|
|
3011
|
+
const children = this.sct.getConceptChildren(ctxt.getReference());
|
|
3012
|
+
for (let childRef of children) {
|
|
3013
|
+
const code = this.sct.getConceptId(childRef);
|
|
3014
|
+
const description = this.sct.getDisplayName(childRef);
|
|
3015
|
+
this._addCodeProperty(params, 'property', 'child', code, null, description);
|
|
3016
|
+
}
|
|
2732
3017
|
}
|
|
2733
3018
|
|
|
2734
|
-
|
|
2735
|
-
|
|
2736
|
-
|
|
2737
|
-
|
|
3019
|
+
if (this._hasProp(props, 'module', true)) {
|
|
3020
|
+
const moduleId = this.sct.concepts.getModuleId(ctxt.getReference());
|
|
3021
|
+
if (moduleId) {
|
|
3022
|
+
const code = this.sct.getConceptId(moduleId);
|
|
3023
|
+
this._addCodeProperty(params, 'property', 'module', code, null, this.sct.getDisplayName(moduleId));
|
|
3024
|
+
}
|
|
2738
3025
|
}
|
|
2739
3026
|
|
|
2740
3027
|
const relationships = this.sct.getConceptRelationships(ctxt.getReference());
|
|
@@ -2742,7 +3029,7 @@ class SnomedProvider extends BaseCSServices {
|
|
|
2742
3029
|
for (let relationshipRef of relationships) {
|
|
2743
3030
|
const relationship = this.sct.relationships.getRelationship(relationshipRef);
|
|
2744
3031
|
const relType = this.sct.getConceptId(relationship.relType);
|
|
2745
|
-
if (relType != '116680003') {
|
|
3032
|
+
if (relType != '116680003' && this._hasProp(props, relType, true)) {
|
|
2746
3033
|
const relTypeD = this.sct.getDisplayName(relationship.relType);
|
|
2747
3034
|
const code = this.sct.getConceptId(relationship.target);
|
|
2748
3035
|
const description = this.sct.getDisplayName(relationship.target);
|
|
@@ -2753,6 +3040,8 @@ class SnomedProvider extends BaseCSServices {
|
|
|
2753
3040
|
}
|
|
2754
3041
|
}
|
|
2755
3042
|
}
|
|
3043
|
+
|
|
3044
|
+
this.#addMappingProperties(ctxt, props, params);
|
|
2756
3045
|
}
|
|
2757
3046
|
if (ctxt instanceof SnomedExpressionContext) {
|
|
2758
3047
|
// ignore concepts for now, but list refinements and refinement groups
|
|
@@ -2768,6 +3057,27 @@ class SnomedProvider extends BaseCSServices {
|
|
|
2768
3057
|
}
|
|
2769
3058
|
}
|
|
2770
3059
|
|
|
3060
|
+
/**
|
|
3061
|
+
* The concept's alternate identifiers in the schemes we know (IDENTIFIER_SCHEMES), as
|
|
3062
|
+
* Coding-valued properties named for the scheme - e.g. loinc = http://loinc.org#2160-0.
|
|
3063
|
+
* Returned by default, and when asked for by name.
|
|
3064
|
+
*/
|
|
3065
|
+
#addMappingProperties(ctxt, props, params) {
|
|
3066
|
+
const conceptId = this.sct.getConceptId(ctxt.getReference());
|
|
3067
|
+
for (const ident of this.sct.getIdentifiers(conceptId)) {
|
|
3068
|
+
const scheme = IDENTIFIER_SCHEMES[ident.scheme];
|
|
3069
|
+
if (scheme && this._hasProp(props, scheme.property, true)) {
|
|
3070
|
+
params.push({
|
|
3071
|
+
name: 'property',
|
|
3072
|
+
part: [
|
|
3073
|
+
{ name: 'code', valueCode: scheme.property },
|
|
3074
|
+
{ name: 'value', valueCoding: { system: scheme.system, code: ident.code } }
|
|
3075
|
+
]
|
|
3076
|
+
});
|
|
3077
|
+
}
|
|
3078
|
+
}
|
|
3079
|
+
}
|
|
3080
|
+
|
|
2771
3081
|
/**
|
|
2772
3082
|
* Report one refinement of a post-coordinated expression as a lookup property.
|
|
2773
3083
|
*
|
|
@@ -2823,6 +3133,11 @@ class SnomedProvider extends BaseCSServices {
|
|
|
2823
3133
|
return true;
|
|
2824
3134
|
}
|
|
2825
3135
|
|
|
3136
|
+
if (prop.startsWith(MAPPED_FILTER_PREFIX)) {
|
|
3137
|
+
return op === 'exists' && ['true', 'false'].includes(value) &&
|
|
3138
|
+
this.sct.stringToIdOrZero(prop.substring(MAPPED_FILTER_PREFIX.length)) !== 0n;
|
|
3139
|
+
}
|
|
3140
|
+
|
|
2826
3141
|
const cid = this.sct.stringToIdOrZero(prop);
|
|
2827
3142
|
if (cid != 0) {
|
|
2828
3143
|
const id = this.sct.stringToIdOrZero(value);
|
|
@@ -2979,6 +3294,27 @@ class SnomedProvider extends BaseCSServices {
|
|
|
2979
3294
|
return null;
|
|
2980
3295
|
}
|
|
2981
3296
|
|
|
3297
|
+
// mapped:<identifier scheme> exists true|false - concepts that do (or don't) have an
|
|
3298
|
+
// alternate identifier in that scheme (e.g. mapped:30051010000102: has a LOINC code).
|
|
3299
|
+
// A prefix, so as not to be confused with the attribute filters (<attribute> = <value>)
|
|
3300
|
+
if (prop.startsWith(MAPPED_FILTER_PREFIX)) {
|
|
3301
|
+
const scheme = prop.substring(MAPPED_FILTER_PREFIX.length);
|
|
3302
|
+
if (this.sct.stringToIdOrZero(scheme) === 0n) {
|
|
3303
|
+
throw new Error(`Invalid identifier scheme: ${scheme}`);
|
|
3304
|
+
}
|
|
3305
|
+
if (op !== 'exists') {
|
|
3306
|
+
throw new Error(`Unsupported filter operation: ${prop} ${op} ${value}`);
|
|
3307
|
+
}
|
|
3308
|
+
if (value !== 'true' && value !== 'false') {
|
|
3309
|
+
throw new Error(`Invalid filter value: ${value}`);
|
|
3310
|
+
}
|
|
3311
|
+
const filter = new SnomedFilterContext();
|
|
3312
|
+
filter.mappedScheme = scheme;
|
|
3313
|
+
filter.mappedExists = value === 'true';
|
|
3314
|
+
filterContext.filters.push(filter);
|
|
3315
|
+
return null;
|
|
3316
|
+
}
|
|
3317
|
+
|
|
2982
3318
|
const cid = this.sct.stringToIdOrZero(prop);
|
|
2983
3319
|
if (cid != 0) {
|
|
2984
3320
|
|
|
@@ -3082,7 +3418,9 @@ class SnomedProvider extends BaseCSServices {
|
|
|
3082
3418
|
}
|
|
3083
3419
|
let found = false;
|
|
3084
3420
|
|
|
3085
|
-
if (set.
|
|
3421
|
+
if (set.mappedScheme !== undefined) {
|
|
3422
|
+
found = this.#isMapped(reference, set.mappedScheme) === set.mappedExists;
|
|
3423
|
+
} else if (set.inactive !== undefined) {
|
|
3086
3424
|
let concept = this.sct.concepts.getConcept(reference);
|
|
3087
3425
|
let active = (concept.flags & 0x0F) === 0;
|
|
3088
3426
|
found = active !== set.inactive
|
|
@@ -3128,6 +3466,9 @@ class SnomedProvider extends BaseCSServices {
|
|
|
3128
3466
|
}
|
|
3129
3467
|
|
|
3130
3468
|
const reference = concept.getReference();
|
|
3469
|
+
if (set.mappedScheme !== undefined) {
|
|
3470
|
+
return this.#isMapped(reference, set.mappedScheme) === set.mappedExists;
|
|
3471
|
+
}
|
|
3131
3472
|
if (set.inactive !== undefined) {
|
|
3132
3473
|
return this.sct.isActive(reference) !== set.inactive;
|
|
3133
3474
|
}
|
|
@@ -3187,10 +3528,39 @@ class SnomedProvider extends BaseCSServices {
|
|
|
3187
3528
|
return set._matchSet.has(reference);
|
|
3188
3529
|
}
|
|
3189
3530
|
|
|
3531
|
+
/** Whether the concept (by reference) has an alternate identifier in the scheme */
|
|
3532
|
+
#isMapped(reference, scheme) {
|
|
3533
|
+
return this.sct.getIdentifiers(this.sct.getConceptId(reference), scheme).length > 0;
|
|
3534
|
+
}
|
|
3535
|
+
|
|
3190
3536
|
#ensurePopulated(set) {
|
|
3191
3537
|
if (set.populationDone) {
|
|
3192
3538
|
return;
|
|
3193
3539
|
}
|
|
3540
|
+
if (set.mappedScheme !== undefined) {
|
|
3541
|
+
if (set.mappedExists) {
|
|
3542
|
+
// straight from the identifier index, in concept order
|
|
3543
|
+
const refs = new Set();
|
|
3544
|
+
for (const [conceptId, idents] of this.sct.identifiersByConcept) {
|
|
3545
|
+
if (idents.some(i => i.scheme === set.mappedScheme)) {
|
|
3546
|
+
const r = this.sct.concepts.findConcept(BigInt(conceptId));
|
|
3547
|
+
if (r.found) {
|
|
3548
|
+
refs.add(r.index);
|
|
3549
|
+
}
|
|
3550
|
+
}
|
|
3551
|
+
}
|
|
3552
|
+
set.descendants = [...refs].sort((a, b) => a - b);
|
|
3553
|
+
} else {
|
|
3554
|
+
for (let i = 0; i < this.sct.concepts.count(); i++) {
|
|
3555
|
+
const concept = this.sct.concepts.getConceptByCount(i);
|
|
3556
|
+
if (!this.#isMapped(concept.index, set.mappedScheme)) {
|
|
3557
|
+
set.descendants.push(concept.index);
|
|
3558
|
+
}
|
|
3559
|
+
}
|
|
3560
|
+
}
|
|
3561
|
+
set.populationDone = true;
|
|
3562
|
+
return;
|
|
3563
|
+
}
|
|
3194
3564
|
if (set.inactive !== undefined && set.descendants.length === 0) {
|
|
3195
3565
|
for (let i = 0; i < this.sct.concepts.count(); i++) {
|
|
3196
3566
|
let concept = this.sct.concepts.getConceptByCount(i);
|
|
@@ -3342,6 +3712,9 @@ class SnomedProvider extends BaseCSServices {
|
|
|
3342
3712
|
}
|
|
3343
3713
|
|
|
3344
3714
|
async getTranslations(map, coding, target, reverse) {
|
|
3715
|
+
if (map && map.jsonObj.identifierMap) {
|
|
3716
|
+
return this.translateByIdentifier(map, coding, target);
|
|
3717
|
+
}
|
|
3345
3718
|
if (!map || reverse) {
|
|
3346
3719
|
return [];
|
|
3347
3720
|
}
|
|
@@ -3405,6 +3778,54 @@ class SnomedProvider extends BaseCSServices {
|
|
|
3405
3778
|
return result;
|
|
3406
3779
|
}
|
|
3407
3780
|
|
|
3781
|
+
/**
|
|
3782
|
+
* Translate using a module's alternate identifiers (the implicit map ?fhir_cm=<module>,
|
|
3783
|
+
* e.g. the LOINC Extension's LOINC codes). The identifiers say the concept and the code
|
|
3784
|
+
* are the same thing, so the map works the same in both directions: a SNOMED CT concept
|
|
3785
|
+
* translates to its codes, and a code in the identifier's system to its concepts. Either
|
|
3786
|
+
* way the relationship is equivalent.
|
|
3787
|
+
*/
|
|
3788
|
+
async translateByIdentifier(map, coding, target) {
|
|
3789
|
+
const { schemes } = map.jsonObj.identifierMap;
|
|
3790
|
+
const targetSystem = target ? target.split('|')[0] : null;
|
|
3791
|
+
const result = [];
|
|
3792
|
+
if (coding.system === this.system()) {
|
|
3793
|
+
const ctxt = await this.locate(coding.code);
|
|
3794
|
+
if (!ctxt.context) {
|
|
3795
|
+
return [];
|
|
3796
|
+
}
|
|
3797
|
+
const conceptId = this.sct.getConceptId(ctxt.context.getReference());
|
|
3798
|
+
for (const ident of this.sct.getIdentifiers(conceptId)) {
|
|
3799
|
+
const scheme = IDENTIFIER_SCHEMES[ident.scheme];
|
|
3800
|
+
if (scheme && schemes.includes(ident.scheme) && (!targetSystem || targetSystem === scheme.system)) {
|
|
3801
|
+
result.push({
|
|
3802
|
+
map: map.vurl,
|
|
3803
|
+
system: scheme.system,
|
|
3804
|
+
code: ident.code,
|
|
3805
|
+
relationship: 'equivalent'
|
|
3806
|
+
});
|
|
3807
|
+
}
|
|
3808
|
+
}
|
|
3809
|
+
} else if (!targetSystem || targetSystem === this.system()) {
|
|
3810
|
+
for (const schemeId of schemes) {
|
|
3811
|
+
const scheme = IDENTIFIER_SCHEMES[schemeId];
|
|
3812
|
+
if (scheme && scheme.system === coding.system) {
|
|
3813
|
+
for (const conceptId of this.sct.getConceptsForIdentifier(schemeId, coding.code)) {
|
|
3814
|
+
result.push({
|
|
3815
|
+
map: map.vurl,
|
|
3816
|
+
system: this.system(),
|
|
3817
|
+
version: this.version(),
|
|
3818
|
+
code: conceptId,
|
|
3819
|
+
display: await this.display(conceptId),
|
|
3820
|
+
relationship: 'equivalent'
|
|
3821
|
+
});
|
|
3822
|
+
}
|
|
3823
|
+
}
|
|
3824
|
+
}
|
|
3825
|
+
}
|
|
3826
|
+
return result;
|
|
3827
|
+
}
|
|
3828
|
+
|
|
3408
3829
|
/**
|
|
3409
3830
|
* Translate a concept of this edition to the edition it depends on (typically a national
|
|
3410
3831
|
* extension to the International Edition). There is no map: the answer comes from the
|
|
@@ -3789,6 +4210,9 @@ class SnomedServicesFactory extends CodeSystemFactoryProvider {
|
|
|
3789
4210
|
* edition URI plus ?fhir_cm=edition/<target edition module>
|
|
3790
4211
|
*/
|
|
3791
4212
|
async findImplicitConceptMaps(conceptMaps, source, sourceVersion, dest) {
|
|
4213
|
+
if (dest) {
|
|
4214
|
+
this.#findIdentifierMaps(conceptMaps, source, sourceVersion, dest);
|
|
4215
|
+
}
|
|
3792
4216
|
if (source !== this.system() || !sourceVersion || !dest || !this.isMyEdition(sourceVersion)) {
|
|
3793
4217
|
return;
|
|
3794
4218
|
}
|
|
@@ -3823,6 +4247,61 @@ class SnomedServicesFactory extends CodeSystemFactoryProvider {
|
|
|
3823
4247
|
});
|
|
3824
4248
|
}
|
|
3825
4249
|
|
|
4250
|
+
/**
|
|
4251
|
+
* Maps implied by alternate identifiers, found from the systems rather than nominated:
|
|
4252
|
+
* from this edition (source system|version naming it) to an identifier system (LOINC,
|
|
4253
|
+
* say), or from an identifier system to this edition (the target given as
|
|
4254
|
+
* system|edition URI, with or without the version)
|
|
4255
|
+
*/
|
|
4256
|
+
#findIdentifierMaps(conceptMaps, source, sourceVersion, dest) {
|
|
4257
|
+
const bar = dest.indexOf('|');
|
|
4258
|
+
const destSystem = bar === -1 ? dest : dest.substring(0, bar);
|
|
4259
|
+
const destVersion = bar === -1 ? null : dest.substring(bar + 1);
|
|
4260
|
+
let identSystem;
|
|
4261
|
+
if (source === this.system() && sourceVersion && this.isMyEdition(sourceVersion)) {
|
|
4262
|
+
identSystem = destSystem;
|
|
4263
|
+
} else if (destSystem === this.system() && destVersion && this.isMyEdition(destVersion)) {
|
|
4264
|
+
identSystem = source;
|
|
4265
|
+
} else {
|
|
4266
|
+
return;
|
|
4267
|
+
}
|
|
4268
|
+
for (const [moduleId, schemes] of this.snomedServices.identifierModules) {
|
|
4269
|
+
if ([...schemes].some(s => IDENTIFIER_SCHEMES[s] && IDENTIFIER_SCHEMES[s].system === identSystem)) {
|
|
4270
|
+
const cm = this.identifierMap(moduleId);
|
|
4271
|
+
if (cm && !conceptMaps.some(m => m.url === cm.url)) {
|
|
4272
|
+
conceptMaps.push(cm);
|
|
4273
|
+
}
|
|
4274
|
+
}
|
|
4275
|
+
}
|
|
4276
|
+
}
|
|
4277
|
+
|
|
4278
|
+
/**
|
|
4279
|
+
* The implicit map defined by a module's alternate identifiers, if the module has any
|
|
4280
|
+
* in a scheme we know. Its url follows the edition maps: <source edition>/version/<date>?fhir_cm=<module>
|
|
4281
|
+
*/
|
|
4282
|
+
identifierMap(moduleId) {
|
|
4283
|
+
const schemes = [...(this.snomedServices.identifierModules.get(moduleId) || [])].filter(s => IDENTIFIER_SCHEMES[s]);
|
|
4284
|
+
if (schemes.length === 0) {
|
|
4285
|
+
return null;
|
|
4286
|
+
}
|
|
4287
|
+
const systems = schemes.map(s => IDENTIFIER_SCHEMES[s].system);
|
|
4288
|
+
const moduleName = this.snomedServices.conceptExists(moduleId)
|
|
4289
|
+
? this.snomedServices.getDisplayName(this.snomedServices.concepts.findConcept(BigInt(moduleId)).index) : moduleId;
|
|
4290
|
+
return new ConceptMap({
|
|
4291
|
+
resourceType: 'ConceptMap',
|
|
4292
|
+
internalSource: this,
|
|
4293
|
+
identifierMap: { module: moduleId, schemes: schemes },
|
|
4294
|
+
url: `${this.version()}?fhir_cm=${moduleId}`,
|
|
4295
|
+
name: `SNOMED CT ${moduleName} Concept Map`,
|
|
4296
|
+
description: `The concept map implied by the alternate identifiers (${systems.join(', ')}) of the module ${moduleId} |${moduleName}| in ${this.version()}`,
|
|
4297
|
+
status: 'active',
|
|
4298
|
+
group: [
|
|
4299
|
+
...systems.map(s => ({ source: this.system(), target: s })),
|
|
4300
|
+
...systems.map(s => ({ source: s, target: this.system() }))
|
|
4301
|
+
]
|
|
4302
|
+
});
|
|
4303
|
+
}
|
|
4304
|
+
|
|
3826
4305
|
/** Whether a source version names this edition: exactly, or the edition without a version */
|
|
3827
4306
|
isMyEdition(version) {
|
|
3828
4307
|
const mine = this.version();
|
|
@@ -3846,6 +4325,16 @@ class SnomedServicesFactory extends CodeSystemFactoryProvider {
|
|
|
3846
4325
|
const dep = this.snomedServices.editionDependency(`http://snomed.info/sct/${url.substring(q + '?fhir_cm=edition/'.length)}`);
|
|
3847
4326
|
return dep ? this.editionTranslationMap(dep) : null;
|
|
3848
4327
|
}
|
|
4328
|
+
// <edition[/version/date]>?fhir_cm=<module>: the map implied by a module's alternate
|
|
4329
|
+
// identifiers (e.g. 11010000107, the LOINC Extension's LOINC codes)
|
|
4330
|
+
const qm = url.indexOf('?fhir_cm=');
|
|
4331
|
+
if (qm !== -1 && url.substring(0, qm) !== this.system()) {
|
|
4332
|
+
if (!this.isMyEdition(url.substring(0, qm))) {
|
|
4333
|
+
return null;
|
|
4334
|
+
}
|
|
4335
|
+
await this.#ensureLoaded();
|
|
4336
|
+
return this.identifierMap(url.substring(qm + '?fhir_cm='.length));
|
|
4337
|
+
}
|
|
3849
4338
|
if (!url.startsWith(this.system()+"?fhir_cm=")) {
|
|
3850
4339
|
return null;
|
|
3851
4340
|
}
|