fhirsmith 0.18.0 → 0.18.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/CHANGELOG.md +72 -10
  2. package/ips/ips.js +251 -26
  3. package/ips/jurisdictions.js +65 -0
  4. package/ips/pipeline.js +67 -6
  5. package/ips/profile-editor.js +283 -4
  6. package/ips/readme.md +48 -5
  7. package/ips/render.js +317 -46
  8. package/ips/scripts.js +46 -0
  9. package/ips/store.js +55 -0
  10. package/library/daily-report.js +25 -15
  11. package/package.json +3 -3
  12. package/registry/api.js +179 -1
  13. package/registry/crawler.js +2 -0
  14. package/registry/documentation/api.md +68 -2
  15. package/registry/documentation/internals.md +4 -1
  16. package/registry/documentation/readme.md +3 -2
  17. package/registry/model.js +8 -0
  18. package/registry/openapi.yaml +84 -0
  19. package/registry/registry.js +53 -2
  20. package/translations/Messages.properties +1 -0
  21. package/tx/README.md +4 -0
  22. package/tx/closure/closure-store.js +14 -0
  23. package/tx/cm/cm-package.js +21 -15
  24. package/tx/cs/cs-api.js +3 -1
  25. package/tx/cs/cs-cs.js +59 -12
  26. package/tx/cs/cs-icf.js +47 -34
  27. package/tx/cs/cs-loinc.js +257 -3
  28. package/tx/cs/cs-snomed.js +544 -55
  29. package/tx/importers/csv-records.js +35 -0
  30. package/tx/importers/import-loinc.module.js +177 -41
  31. package/tx/importers/import-sct.module.js +131 -1
  32. package/tx/importers/subset-loinc.module.js +25 -6
  33. package/tx/library/codesystem.js +4 -1
  34. package/tx/library/designations.js +23 -30
  35. package/tx/library/tho-precedence.js +40 -0
  36. package/tx/openapi-operations.js +2 -1
  37. package/tx/provider.js +46 -29
  38. package/tx/sct/documentation.md +45 -21
  39. package/tx/sct/ecl.js +34 -11
  40. package/tx/sct/structures.js +15 -2
  41. package/tx/tx.fhir.org.yml +2 -2
  42. package/tx/vs/vs-package.js +17 -13
  43. package/tx/vs/vs-vsac.js +1 -1
  44. package/tx/workers/closure.js +20 -0
  45. package/tx/workers/expand.js +2 -2
  46. package/tx/workers/validate.js +10 -0
  47. package/utilities/make-sct-loinc-subset.js +250 -0
@@ -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, attribute AND/OR/",", groups,
863
- * attribute and group cardinality, attribute names that are expressions),
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, comments
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 (reverse attributes, != and concrete (numeric/string/
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
- throw new Error(`ECL alternate identifiers (${focus.scheme}#${focus.code}) are not supported - no alternate identifier reference sets are loaded`);
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. Say so rather than
1285
- // answering with an empty set, which would read as "no members".
1286
- if (memberList.length > 0 && !memberList.some(m => m.kind === 0)) {
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) are not supported - concrete value relationships are not loaded');
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
- types: this._eclAttributeTypes(attr.name, opContext),
1847
- valueSet: new Set(this._eclResolveSet(this._evalECLNode(attr.comparison.value, opContext), opContext))
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
- if (valueSet.has(rel.target)) matched.add(rel.group + ':' + rel.target);
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 relIdxs) {
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
- const time = this.sct.concepts.getConcept(ctxt.getReference()).effectiveTime;
2711
- // Pascal TDateTime epoch (1899-12-30) computed in UTC so the formatted
2712
- // date is timezone-independent. new Date(1899,11,30) is LOCAL midnight,
2713
- // which in zones ahead of UTC rolls the ISO date back by one day.
2714
- const pascalEpochUTC = Date.UTC(1899, 11, 30);
2715
- const date = new Date(pascalEpochUTC + time * 86400000);
2716
- const dateStr = date.toISOString().slice(0, 10);
2717
- this._addDateTimeProperty(params, 'property', 'effectiveTime', dateStr);
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
- const parents = this.sct.getConceptParents(ctxt.getReference());
2721
- for (let parentRef of parents) {
2722
- const code = this.sct.getConceptId(parentRef);
2723
- const description = this.sct.getDisplayName(parentRef);
2724
- this._addCodeProperty(params, 'property', 'parent', code, null, description);
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
- const children = this.sct.getConceptChildren(ctxt.getReference());
2728
- for (let childRef of children) {
2729
- const code = this.sct.getConceptId(childRef);
2730
- const description = this.sct.getDisplayName(childRef);
2731
- this._addCodeProperty(params, 'property', 'child', code, null, description);
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
- const moduleId = this.sct.concepts.getModuleId(ctxt.getReference());
2735
- if (moduleId) {
2736
- const code = this.sct.getConceptId(moduleId);
2737
- this._addCodeProperty(params, 'property', 'module', code, null, this.sct.getDisplayName(moduleId));
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.inactive !== undefined) {
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
  }