fhirsmith 0.11.2 → 0.13.0
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 +377 -2
- package/README.md +1 -1
- package/config-template.json +31 -1
- package/library/languages.js +191 -34
- package/library/path-safety.js +51 -0
- package/library/request-token.js +56 -0
- package/package.json +2 -2
- package/packages/packages.js +17 -118
- package/publisher/publisher.js +208 -30
- package/publisher/validation.js +210 -0
- package/server.js +24 -15
- package/stats-db.js +127 -0
- package/stats.js +172 -6
- package/translations/Messages.properties +5 -1
- package/tx/README.md +21 -0
- package/tx/cs/cs-api.js +30 -1
- package/tx/cs/cs-api.md +1 -1
- package/tx/cs/cs-cs.js +1 -1
- package/tx/cs/cs-icd11.js +805 -0
- package/tx/cs/cs-lang.js +331 -85
- package/tx/cs/cs-loinc.js +45 -4
- package/tx/cs/cs-mimetypes.js +492 -21
- package/tx/cs/cs-snomed.js +141 -58
- package/tx/cs/cs-ucum.js +33 -3
- package/tx/cs/icd11-expressions.js +243 -0
- package/tx/importers/export-icd11-codesystem.js +823 -0
- package/tx/importers/icd11-schema.md +285 -0
- package/tx/importers/import-icd11.js +861 -0
- package/tx/importers/import-loinc.module.js +14 -7
- package/tx/importers/import-sct.module.js +10 -1
- package/tx/library/codesystem.js +21 -27
- package/tx/library/conceptmap.js +39 -0
- package/tx/library/nesting-limit.js +79 -0
- package/tx/library/operation-outcome.js +97 -3
- package/tx/library/parameter-check.js +139 -0
- package/tx/library/valueset.js +16 -20
- package/tx/library.js +23 -1
- package/tx/ocl/cache/cache-utils.cjs +5 -3
- package/tx/ocl/cs-ocl.cjs +73 -17
- package/tx/ocl/jobs/background-queue.cjs +6 -3
- package/tx/ocl/mappers/concept-mapper.cjs +9 -1
- package/tx/ocl/shared/patches.cjs +48 -1
- package/tx/ocl/vs-ocl.cjs +15 -13
- package/tx/operation-context.js +220 -10
- package/tx/params.js +226 -175
- package/tx/sct/expressions.js +677 -15
- package/tx/tx.fhir.org.yml +3 -2
- package/tx/tx.js +72 -92
- package/tx/workers/batch-validate.js +11 -18
- package/tx/workers/batch.js +16 -28
- package/tx/workers/cache-control.js +87 -17
- package/tx/workers/closure.js +3 -8
- package/tx/workers/compare.js +6 -34
- package/tx/workers/expand.js +108 -60
- package/tx/workers/lookup.js +35 -40
- package/tx/workers/read.js +6 -40
- package/tx/workers/search.js +2 -8
- package/tx/workers/subsumes.js +58 -41
- package/tx/workers/translate.js +472 -47
- package/tx/workers/validate.js +83 -78
- package/tx/workers/worker.js +92 -11
- package/tx/xml/xml-base.js +264 -77
- package/tx/xversion/xv-conceptmap.js +81 -0
- package/tx/xversion/xv-parameters.js +8 -0
- package/tx/xversion/xv-valueset.js +39 -1
- package/utilities/merge-audit.py +178 -0
- package/utilities/why-merge-audit.md +39 -0
- package/tx/data/snomed-subset.txt +0 -84
package/tx/sct/expressions.js
CHANGED
|
@@ -6,9 +6,27 @@
|
|
|
6
6
|
* Licensed under BSD-3-Clause
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
const { SnomedConceptList } = require('./structures');
|
|
10
|
+
|
|
9
11
|
const MAX_TERM_LENGTH = 1024;
|
|
12
|
+
// How deep concept-definition expansion may go while building a long normal form.
|
|
13
|
+
// Attribute values are themselves expanded, so a chain of definitions can descend a
|
|
14
|
+
// long way; in real data it is a handful of levels.
|
|
15
|
+
const MAX_NORMALISATION_DEPTH = 50;
|
|
16
|
+
|
|
10
17
|
const NO_REFERENCE = 0xFFFFFFFF;
|
|
11
18
|
|
|
19
|
+
// MRCM (machine readable concept model) reference sets and metadata concepts.
|
|
20
|
+
// The range constraints in 723562003 are ECL, so they are only checked when an
|
|
21
|
+
// evaluator has been injected (SnomedServices does this - see eclConstraintMatcher).
|
|
22
|
+
const MRCM_ATTRIBUTE_DOMAIN_REFSET = 723561005n;
|
|
23
|
+
const MRCM_ATTRIBUTE_RANGE_REFSET = 723562003n;
|
|
24
|
+
// contentTypeId values that apply when validating a postcoordinated expression.
|
|
25
|
+
// 723593002 (precoordinated only) and 723595009 (new precoordinated only) are
|
|
26
|
+
// rules about authoring precoordinated concepts and do not constrain expressions.
|
|
27
|
+
const MRCM_CONTENT_TYPE_ALL = 723596005n;
|
|
28
|
+
const MRCM_CONTENT_TYPE_POSTCOORDINATED = 723594008n;
|
|
29
|
+
|
|
12
30
|
// Expression status enumeration
|
|
13
31
|
const SnomedExpressionStatus = {
|
|
14
32
|
Unknown: 0,
|
|
@@ -582,6 +600,15 @@ class SnomedExpressionParser {
|
|
|
582
600
|
|
|
583
601
|
this.ws();
|
|
584
602
|
result.code = this.conceptId();
|
|
603
|
+
// Resolve the attribute name the same way concept() resolves a focus concept. Without this
|
|
604
|
+
// the name keeps NO_REFERENCE, and SnomedConcept.matches() then compares by reference on one
|
|
605
|
+
// side and by code on the other, which makes refinement matching (and so subsumption between
|
|
606
|
+
// two refined expressions) asymmetric.
|
|
607
|
+
if (this.conceptList) {
|
|
608
|
+
const found = this.conceptList.findConcept(result.code);
|
|
609
|
+
this.rule(found.found, 'Concept "' + result.code + '" not valid');
|
|
610
|
+
result.reference = found.index;
|
|
611
|
+
}
|
|
585
612
|
this.ws();
|
|
586
613
|
|
|
587
614
|
if (this.gchar('|')) {
|
|
@@ -1024,7 +1051,12 @@ class SnomedExpressionServices {
|
|
|
1024
1051
|
}
|
|
1025
1052
|
|
|
1026
1053
|
const result = new SnomedExpressionParser().parse(source);
|
|
1027
|
-
|
|
1054
|
+
// Resolve concept ids to references, but do NOT run checkExpression: the MRCM rules it
|
|
1055
|
+
// applies are the postcoordination ones, and this is a concept's own stored definition -
|
|
1056
|
+
// precoordinated content, authored under MRCM content types that do not apply here. Running
|
|
1057
|
+
// them rejects the normal form of any concept using an attribute that is precoordinated-only
|
|
1058
|
+
// (e.g. 246075003 |Causative agent| on a disorder), which broke subsumption for expressions.
|
|
1059
|
+
this.resolveExpressionReferences(result);
|
|
1028
1060
|
return result;
|
|
1029
1061
|
}
|
|
1030
1062
|
}
|
|
@@ -1191,13 +1223,46 @@ class SnomedExpressionServices {
|
|
|
1191
1223
|
/**
|
|
1192
1224
|
* Normalize expression to normal form
|
|
1193
1225
|
*/
|
|
1194
|
-
|
|
1226
|
+
/**
|
|
1227
|
+
* Expand an expression to its long normal form, ready for structural comparison.
|
|
1228
|
+
*
|
|
1229
|
+
* A fully defined concept is replaced by its definition, because that definition is
|
|
1230
|
+
* both necessary and sufficient. A primitive concept is NOT replaced - its definition
|
|
1231
|
+
* is only necessary, and substituting it would make the expression look equivalent to
|
|
1232
|
+
* things it merely subsumes. But the primitive's defining attributes are still
|
|
1233
|
+
* entailed by it, so they are added alongside the concept rather than discarded:
|
|
1234
|
+
* `4846001 |Anicteric viral hepatitis|` is primitive and carries an inferred
|
|
1235
|
+
* `363698007 |Finding site| = 10200004 |Liver structure|`, so every instance of it
|
|
1236
|
+
* really is a disease with that finding site, and `64572001:{363698007=10200004}`
|
|
1237
|
+
* really does subsume it. Dropping those attributes - which is what this used to do -
|
|
1238
|
+
* lost that answer and reported not-subsumed, which is simply wrong.
|
|
1239
|
+
*
|
|
1240
|
+
* Keeping the concept itself in the focus is what stops the addition being unsound in
|
|
1241
|
+
* the other direction: the primitive stays as an atom that nothing else can match, so
|
|
1242
|
+
* an expression carrying the same attributes is never reported as equivalent to it.
|
|
1243
|
+
*
|
|
1244
|
+
* @param {SnomedExpression} exp
|
|
1245
|
+
* @param {number} [depth] - recursion guard, see MAX_NORMALISATION_DEPTH
|
|
1246
|
+
* @returns {SnomedExpression}
|
|
1247
|
+
*/
|
|
1248
|
+
normaliseExpression(exp, depth = 0) {
|
|
1249
|
+
if (depth > MAX_NORMALISATION_DEPTH) {
|
|
1250
|
+
// Expanding attribute values can descend a long way, and truncating silently is
|
|
1251
|
+
// not safe in both directions: dropping conditions from the subsuming side makes
|
|
1252
|
+
// it demand less and so subsume more, which would be a false positive. Fail
|
|
1253
|
+
// instead, and let the caller report that it could not answer.
|
|
1254
|
+
throw new Error(`Could not normalise the expression: it nests more than ${MAX_NORMALISATION_DEPTH}`
|
|
1255
|
+
+ ' levels deep once concept definitions are expanded');
|
|
1256
|
+
}
|
|
1195
1257
|
const work = new SnomedExpression();
|
|
1196
1258
|
|
|
1197
1259
|
// Process concepts
|
|
1198
1260
|
for (const concept of exp.concepts) {
|
|
1199
|
-
if (concept.reference === NO_REFERENCE
|
|
1261
|
+
if (concept.reference === NO_REFERENCE) {
|
|
1200
1262
|
work.concepts.push(concept);
|
|
1263
|
+
} else if (this.isPrimitive(concept.reference)) {
|
|
1264
|
+
work.concepts.push(concept);
|
|
1265
|
+
this.addDefiningAttributes(concept.reference, work, depth);
|
|
1201
1266
|
} else {
|
|
1202
1267
|
const ex = this.createNormalForm(concept.reference);
|
|
1203
1268
|
work.merge(ex);
|
|
@@ -1209,7 +1274,7 @@ class SnomedExpressionServices {
|
|
|
1209
1274
|
const refDst = new SnomedRefinement();
|
|
1210
1275
|
work.refinements.push(refDst);
|
|
1211
1276
|
refDst.name = refSrc.name;
|
|
1212
|
-
refDst.value = this.normaliseExpression(refSrc.value);
|
|
1277
|
+
refDst.value = this.normaliseExpression(refSrc.value, depth + 1);
|
|
1213
1278
|
}
|
|
1214
1279
|
|
|
1215
1280
|
// Process refinement groups
|
|
@@ -1221,7 +1286,7 @@ class SnomedExpressionServices {
|
|
|
1221
1286
|
const refDst = new SnomedRefinement();
|
|
1222
1287
|
grpDst.refinements.push(refDst);
|
|
1223
1288
|
refDst.name = refSrc.name;
|
|
1224
|
-
refDst.value = this.normaliseExpression(refSrc.value);
|
|
1289
|
+
refDst.value = this.normaliseExpression(refSrc.value, depth + 1);
|
|
1225
1290
|
}
|
|
1226
1291
|
}
|
|
1227
1292
|
|
|
@@ -1230,6 +1295,56 @@ class SnomedExpressionServices {
|
|
|
1230
1295
|
return work2.canonical();
|
|
1231
1296
|
}
|
|
1232
1297
|
|
|
1298
|
+
/**
|
|
1299
|
+
* Add a concept's own defining (non is-a) relationships to an expression, as grouped
|
|
1300
|
+
* and ungrouped refinements with their values normalised.
|
|
1301
|
+
*
|
|
1302
|
+
* Used for primitives, whose attributes are entailed but whose concept must stay in
|
|
1303
|
+
* the focus - see normaliseExpression. The stored normal form cannot be used for this:
|
|
1304
|
+
* it is built by createDefinedExpression, which stops at a primitive too, so the
|
|
1305
|
+
* cached normal form of a primitive is just the concept itself. Reading the
|
|
1306
|
+
* relationships here means existing caches keep working rather than needing a rebuild.
|
|
1307
|
+
*
|
|
1308
|
+
* @param {number} reference - the concept whose attributes to add
|
|
1309
|
+
* @param {SnomedExpression} work - expression to add them to
|
|
1310
|
+
* @param {number} depth - current normalisation depth
|
|
1311
|
+
*/
|
|
1312
|
+
addDefiningAttributes(reference, work, depth) {
|
|
1313
|
+
const groups = new Map();
|
|
1314
|
+
|
|
1315
|
+
for (const relIndex of this.getDefiningRelationships(reference)) {
|
|
1316
|
+
const rel = this.relationships.getRelationship(relIndex);
|
|
1317
|
+
|
|
1318
|
+
const ref = new SnomedRefinement();
|
|
1319
|
+
ref.name = new SnomedConcept(rel.relType);
|
|
1320
|
+
ref.name.code = this.getConceptId(rel.relType);
|
|
1321
|
+
|
|
1322
|
+
const value = new SnomedExpression();
|
|
1323
|
+
const target = new SnomedConcept(rel.target);
|
|
1324
|
+
target.code = this.getConceptId(rel.target);
|
|
1325
|
+
value.concepts.push(target);
|
|
1326
|
+
ref.value = this.normaliseExpression(value, depth + 1);
|
|
1327
|
+
|
|
1328
|
+
if (rel.group === 0) {
|
|
1329
|
+
if (!work.hasRefinement(ref)) {
|
|
1330
|
+
work.refinements.push(ref);
|
|
1331
|
+
}
|
|
1332
|
+
} else {
|
|
1333
|
+
const key = rel.group.toString();
|
|
1334
|
+
if (!groups.has(key)) {
|
|
1335
|
+
groups.set(key, new SnomedRefinementGroup());
|
|
1336
|
+
}
|
|
1337
|
+
groups.get(key).refinements.push(ref);
|
|
1338
|
+
}
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
for (const grp of groups.values()) {
|
|
1342
|
+
if (!work.hasRefinementGroup(grp)) {
|
|
1343
|
+
work.refinementGroups.push(grp);
|
|
1344
|
+
}
|
|
1345
|
+
}
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1233
1348
|
/**
|
|
1234
1349
|
* Check if expression a subsumes expression b
|
|
1235
1350
|
*/
|
|
@@ -1255,10 +1370,20 @@ class SnomedExpressionServices {
|
|
|
1255
1370
|
}
|
|
1256
1371
|
}
|
|
1257
1372
|
|
|
1258
|
-
// Check refinement groups
|
|
1373
|
+
// Check refinement groups. Every group in e1 has to be satisfied by SOME group in e2 - try
|
|
1374
|
+
// them all rather than only the first one whose attribute names line up. Two normalisations
|
|
1375
|
+
// of the same expression can put equivalent groups in a different order, and when several
|
|
1376
|
+
// groups share the same attribute names (e.g. two morphology/site groups on one disorder)
|
|
1377
|
+
// picking the first match and giving up made subsumption depend on that order.
|
|
1259
1378
|
for (const r of e1.refinementGroups) {
|
|
1260
|
-
|
|
1261
|
-
|
|
1379
|
+
let ok = false;
|
|
1380
|
+
for (const rt of e2.refinementGroups) {
|
|
1381
|
+
if (this.subsumesGroup(r, rt)) {
|
|
1382
|
+
ok = true;
|
|
1383
|
+
break;
|
|
1384
|
+
}
|
|
1385
|
+
}
|
|
1386
|
+
if (!ok) {
|
|
1262
1387
|
return false;
|
|
1263
1388
|
}
|
|
1264
1389
|
}
|
|
@@ -1443,6 +1568,28 @@ class SnomedExpressionServices {
|
|
|
1443
1568
|
return null;
|
|
1444
1569
|
}
|
|
1445
1570
|
|
|
1571
|
+
/**
|
|
1572
|
+
* Resolve every concept id in an expression to its internal reference, without applying any
|
|
1573
|
+
* of the concept model rules. Used for expressions the server generated itself (normal forms),
|
|
1574
|
+
* where the ids are known good and the MRCM postcoordination rules do not apply
|
|
1575
|
+
* @param {SnomedExpression} expression
|
|
1576
|
+
*/
|
|
1577
|
+
resolveExpressionReferences(expression) {
|
|
1578
|
+
for (const concept of expression.concepts) {
|
|
1579
|
+
this.checkConcept(concept);
|
|
1580
|
+
}
|
|
1581
|
+
for (const refinement of expression.refinements) {
|
|
1582
|
+
this.checkConcept(refinement.name);
|
|
1583
|
+
this.resolveExpressionReferences(refinement.value);
|
|
1584
|
+
}
|
|
1585
|
+
for (const group of expression.refinementGroups) {
|
|
1586
|
+
for (const refinement of group.refinements) {
|
|
1587
|
+
this.checkConcept(refinement.name);
|
|
1588
|
+
this.resolveExpressionReferences(refinement.value);
|
|
1589
|
+
}
|
|
1590
|
+
}
|
|
1591
|
+
}
|
|
1592
|
+
|
|
1446
1593
|
/**
|
|
1447
1594
|
* Validate expression structure and concept references
|
|
1448
1595
|
*/
|
|
@@ -1454,6 +1601,9 @@ class SnomedExpressionServices {
|
|
|
1454
1601
|
if (expression.hasRefinements()) {
|
|
1455
1602
|
for (const refinement of expression.refinements) {
|
|
1456
1603
|
this.checkRefinement(refinement);
|
|
1604
|
+
this.checkRefinementDomain(expression, refinement);
|
|
1605
|
+
this.checkRefinementRange(refinement);
|
|
1606
|
+
this.checkRefinementGrouped(refinement);
|
|
1457
1607
|
}
|
|
1458
1608
|
}
|
|
1459
1609
|
|
|
@@ -1461,11 +1611,526 @@ class SnomedExpressionServices {
|
|
|
1461
1611
|
for (const group of expression.refinementGroups) {
|
|
1462
1612
|
for (const refinement of group.refinements) {
|
|
1463
1613
|
this.checkRefinement(refinement);
|
|
1614
|
+
this.checkRefinementDomain(expression, refinement);
|
|
1615
|
+
this.checkRefinementRange(refinement);
|
|
1616
|
+
}
|
|
1617
|
+
}
|
|
1618
|
+
}
|
|
1619
|
+
|
|
1620
|
+
this.checkExpressionCardinality(expression);
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
/**
|
|
1624
|
+
* Check one refinement against the MRCM attribute domain reference set: the
|
|
1625
|
+
* focus concept the attribute is applied to must be in the attribute's domain.
|
|
1626
|
+
*
|
|
1627
|
+
* Every applicable rule must hold, mandatory or optional. Internationally
|
|
1628
|
+
* there is exactly one optional rule - Laterality in the lateralizable body
|
|
1629
|
+
* structure reference set - and it is the rule that stops laterality being
|
|
1630
|
+
* applied to a structure that cannot be lateralized, so it is enforced.
|
|
1631
|
+
*
|
|
1632
|
+
* Attributes with no rule in this edition's MRCM are not checked, and neither
|
|
1633
|
+
* is cardinality (checkExpressionCardinality), grouping (checkRefinementGrouped)
|
|
1634
|
+
* or the range constraint (checkRefinementRange, which needs ECL).
|
|
1635
|
+
*
|
|
1636
|
+
* @param {SnomedExpression} expression - the expression the refinement is on
|
|
1637
|
+
* @param {SnomedRefinement} refinement
|
|
1638
|
+
*/
|
|
1639
|
+
checkRefinementDomain(expression, refinement) {
|
|
1640
|
+
const attribute = refinement.name ? refinement.name.reference : NO_REFERENCE;
|
|
1641
|
+
if (attribute === undefined || attribute === null || attribute === NO_REFERENCE) {
|
|
1642
|
+
return;
|
|
1643
|
+
}
|
|
1644
|
+
const rules = this.mrcmAttributeDomains().get(attribute);
|
|
1645
|
+
if (!rules || rules.length === 0) {
|
|
1646
|
+
// This edition's concept model says nothing about this attribute (3 active
|
|
1647
|
+
// attributes internationally have no domain rule and appear in no
|
|
1648
|
+
// postcoordination domain template), so there is nothing to check it against.
|
|
1649
|
+
return;
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1652
|
+
// The focus may be a conjunction of concepts; the expression is a subtype of
|
|
1653
|
+
// each of them, so the attribute is in domain if any focus concept is.
|
|
1654
|
+
const focus = [];
|
|
1655
|
+
for (const concept of expression.concepts) {
|
|
1656
|
+
if (concept.reference !== undefined && concept.reference !== null && concept.reference !== NO_REFERENCE) {
|
|
1657
|
+
focus.push(concept.reference);
|
|
1658
|
+
}
|
|
1659
|
+
}
|
|
1660
|
+
if (focus.length === 0) {
|
|
1661
|
+
return;
|
|
1662
|
+
}
|
|
1663
|
+
|
|
1664
|
+
// Report every rule that failed, not just the first: a laterality on a
|
|
1665
|
+
// disorder breaks both the anatomical structure domain and the lateralizable
|
|
1666
|
+
// refset rule, and naming only one of them misleads.
|
|
1667
|
+
const failed = [];
|
|
1668
|
+
for (const rule of rules) {
|
|
1669
|
+
const ok = focus.some(f => rule.isRefset
|
|
1670
|
+
? this.conceptInRefSet(rule.domain, f)
|
|
1671
|
+
: this.subsumes(rule.domain, f));
|
|
1672
|
+
if (!ok) {
|
|
1673
|
+
failed.push((rule.isRefset ? 'for concepts in ' : 'in the domain ') + this.describeConceptForMessage(rule.domain));
|
|
1674
|
+
}
|
|
1675
|
+
}
|
|
1676
|
+
if (failed.length > 0) {
|
|
1677
|
+
const attrDesc = this.describeConceptForMessage(attribute);
|
|
1678
|
+
const focusDesc = focus.map(f => this.describeConceptForMessage(f)).join(' + ');
|
|
1679
|
+
throw new Error(`The SNOMED CT concept model does not allow the attribute ${attrDesc} on ${focusDesc}`
|
|
1680
|
+
+ ` (the attribute is only valid ${failed.join(', and ')})`);
|
|
1681
|
+
}
|
|
1682
|
+
}
|
|
1683
|
+
|
|
1684
|
+
/**
|
|
1685
|
+
* Check that an attribute the MRCM marks as grouped is actually in a relationship
|
|
1686
|
+
* group. This is only ever called for refinements that are NOT in a group.
|
|
1687
|
+
*
|
|
1688
|
+
* The MRCM attribute domain reference set carries a `grouped` flag per rule, and it
|
|
1689
|
+
* is a real constraint rather than a stylistic preference: `363698007 |Finding site|`
|
|
1690
|
+
* is grouped, so `40468003:363698007=10200004` is not a valid expression even though
|
|
1691
|
+
* every part of it is - it has to be written `40468003:{363698007=10200004}`. The
|
|
1692
|
+
* distinction matters because a group is what ties an attribute to the others that
|
|
1693
|
+
* apply with it, and an expression that omits the braces has not said which.
|
|
1694
|
+
*
|
|
1695
|
+
* SNOMED's postcoordination guide calls the ungrouped form Close To User Form and
|
|
1696
|
+
* defines a transformation ("Adding a Self-grouped Attribute") that converts it to
|
|
1697
|
+
* the Classifiable Form. That transformation is not implemented here, so the
|
|
1698
|
+
* ungrouped form is rejected rather than silently repaired: telling the caller their
|
|
1699
|
+
* expression is not classifiable is honest, quietly guessing what they meant is not.
|
|
1700
|
+
*
|
|
1701
|
+
* As elsewhere in this check, an attribute with no rule in this edition's MRCM is
|
|
1702
|
+
* left alone, and where several rules apply the strictest wins.
|
|
1703
|
+
*
|
|
1704
|
+
* @param {SnomedRefinement} refinement - a refinement outside any group
|
|
1705
|
+
*/
|
|
1706
|
+
checkRefinementGrouped(refinement) {
|
|
1707
|
+
const attribute = refinement.name ? refinement.name.reference : NO_REFERENCE;
|
|
1708
|
+
if (attribute === undefined || attribute === null || attribute === NO_REFERENCE) {
|
|
1709
|
+
return;
|
|
1710
|
+
}
|
|
1711
|
+
const rules = this.mrcmAttributeDomains().get(attribute);
|
|
1712
|
+
if (!rules || rules.length === 0) {
|
|
1713
|
+
return;
|
|
1714
|
+
}
|
|
1715
|
+
if (!rules.some(rule => rule.grouped)) {
|
|
1716
|
+
return;
|
|
1717
|
+
}
|
|
1718
|
+
const attrDesc = this.describeConceptForMessage(attribute);
|
|
1719
|
+
throw new Error(`The SNOMED CT concept model requires the attribute ${attrDesc} to be in a`
|
|
1720
|
+
+ ` relationship group, so it cannot be used ungrouped (write it as {${attrDesc} = ...})`);
|
|
1721
|
+
}
|
|
1722
|
+
|
|
1723
|
+
/**
|
|
1724
|
+
* The MRCM attribute domain reference set, indexed by attribute concept.
|
|
1725
|
+
* Built once per edition and cached; an edition with no MRCM yields an empty
|
|
1726
|
+
* map, which turns the domain check off rather than failing.
|
|
1727
|
+
*
|
|
1728
|
+
* @returns {Map<number, Array<{domain: number, isRefset: boolean, grouped: boolean, max: number, maxInGroup: number}>>}
|
|
1729
|
+
*/
|
|
1730
|
+
mrcmAttributeDomains() {
|
|
1731
|
+
if (this._mrcmAttributeDomains) {
|
|
1732
|
+
return this._mrcmAttributeDomains;
|
|
1733
|
+
}
|
|
1734
|
+
const rules = new Map();
|
|
1735
|
+
this._mrcmAttributeDomains = rules;
|
|
1736
|
+
|
|
1737
|
+
const refSet = this.findRefSetByConceptId(MRCM_ATTRIBUTE_DOMAIN_REFSET);
|
|
1738
|
+
if (!refSet) {
|
|
1739
|
+
return rules; // no MRCM in this edition
|
|
1740
|
+
}
|
|
1741
|
+
const contentAll = this.conceptIndexOf(MRCM_CONTENT_TYPE_ALL);
|
|
1742
|
+
const contentPost = this.conceptIndexOf(MRCM_CONTENT_TYPE_POSTCOORDINATED);
|
|
1743
|
+
|
|
1744
|
+
// Every concept that defines a reference set, so a domain that is itself a
|
|
1745
|
+
// reference set (the lateralizable body structure refset) is tested by
|
|
1746
|
+
// membership rather than subsumption.
|
|
1747
|
+
const refSetConcepts = new Set();
|
|
1748
|
+
for (let i = 0; i < this.refSetIndex.count(); i++) {
|
|
1749
|
+
refSetConcepts.add(this.refSetIndex.getReferenceSet(i).definition);
|
|
1750
|
+
}
|
|
1751
|
+
|
|
1752
|
+
const members = this.refSetMembers.getMembers(refSet.membersByRef) || [];
|
|
1753
|
+
for (const member of members) {
|
|
1754
|
+
if (member.kind !== 0) {
|
|
1755
|
+
continue; // concept members only
|
|
1756
|
+
}
|
|
1757
|
+
// Additional columns are stored as (value, type) pairs, in file order:
|
|
1758
|
+
// domainId, grouped, attributeCardinality, attributeInGroupCardinality,
|
|
1759
|
+
// ruleStrengthId, contentTypeId.
|
|
1760
|
+
const values = this.refs.getReferences(member.values);
|
|
1761
|
+
if (!values || values.length < 12) {
|
|
1762
|
+
continue;
|
|
1763
|
+
}
|
|
1764
|
+
const domain = values[0];
|
|
1765
|
+
const contentType = values[10];
|
|
1766
|
+
if (contentType !== contentAll && contentType !== contentPost) {
|
|
1767
|
+
continue; // a rule about authoring precoordinated concepts
|
|
1768
|
+
}
|
|
1769
|
+
// The importer stores 0 for a referenced component it could not resolve,
|
|
1770
|
+
// which would otherwise read as a domain of whichever concept sorts first.
|
|
1771
|
+
if (domain === 0) {
|
|
1772
|
+
continue;
|
|
1773
|
+
}
|
|
1774
|
+
let list = rules.get(member.ref);
|
|
1775
|
+
if (!list) {
|
|
1776
|
+
list = [];
|
|
1777
|
+
rules.set(member.ref, list);
|
|
1778
|
+
}
|
|
1779
|
+
list.push({
|
|
1780
|
+
domain: domain,
|
|
1781
|
+
isRefset: refSetConcepts.has(domain),
|
|
1782
|
+
// The grouped column is a plain 0/1 (type 4), not a reference into the string
|
|
1783
|
+
// table like the cardinalities beside it - reading it as one yields garbage.
|
|
1784
|
+
grouped: values[2] === 1,
|
|
1785
|
+
// Only the maximums are enforced. A minimum ("1..1") says a concept in
|
|
1786
|
+
// this domain must have the attribute in its own definition; it does not
|
|
1787
|
+
// say a refinement of that concept has to restate it.
|
|
1788
|
+
max: this.parseCardinalityMax(values[4]), // attributeCardinality
|
|
1789
|
+
maxInGroup: this.parseCardinalityMax(values[6]) // attributeInGroupCardinality
|
|
1790
|
+
});
|
|
1791
|
+
}
|
|
1792
|
+
return rules;
|
|
1793
|
+
}
|
|
1794
|
+
|
|
1795
|
+
/**
|
|
1796
|
+
* Check one refinement's value against the MRCM attribute range: the range is
|
|
1797
|
+
* an ECL constraint, so this only runs when an evaluator has been injected
|
|
1798
|
+
* (`eclConstraintMatcher`, set by SnomedServices, which owns the ECL engine).
|
|
1799
|
+
* A matcher that returns null - an unparseable or unsupported constraint, such
|
|
1800
|
+
* as the concrete-value ranges "dec(>#0..)" - leaves the value unchecked
|
|
1801
|
+
* rather than rejecting it.
|
|
1802
|
+
*
|
|
1803
|
+
* @param {SnomedRefinement} refinement
|
|
1804
|
+
*/
|
|
1805
|
+
checkRefinementRange(refinement) {
|
|
1806
|
+
const attribute = refinement.name ? refinement.name.reference : NO_REFERENCE;
|
|
1807
|
+
if (attribute === undefined || attribute === null || attribute === NO_REFERENCE) {
|
|
1808
|
+
return;
|
|
1809
|
+
}
|
|
1810
|
+
const constraint = this.mrcmAttributeRanges().get(attribute);
|
|
1811
|
+
if (!constraint || !refinement.value) {
|
|
1812
|
+
return;
|
|
1813
|
+
}
|
|
1814
|
+
|
|
1815
|
+
// A concrete value range (dec/int/str) is checked here; everything else is
|
|
1816
|
+
// ECL and needs the evaluator the provider injects.
|
|
1817
|
+
const concreteRange = this.parseConcreteRange(constraint);
|
|
1818
|
+
const value = this.concreteValueOf(refinement.value);
|
|
1819
|
+
let ok;
|
|
1820
|
+
if (concreteRange) {
|
|
1821
|
+
ok = this.concreteValueSatisfies(concreteRange, value);
|
|
1822
|
+
} else if (value !== null) {
|
|
1823
|
+
ok = false; // a concrete value where the model wants a concept
|
|
1824
|
+
} else if (!this.eclConstraintMatcher) {
|
|
1825
|
+
return;
|
|
1826
|
+
} else {
|
|
1827
|
+
try {
|
|
1828
|
+
ok = this.eclConstraintMatcher(constraint, refinement.value);
|
|
1829
|
+
} catch (error) {
|
|
1830
|
+
ok = null; // the evaluator could not decide; do not invent a violation
|
|
1831
|
+
}
|
|
1832
|
+
}
|
|
1833
|
+
// null = could not decide: a constraint naming concepts this edition does not carry.
|
|
1834
|
+
if (ok === false) {
|
|
1835
|
+
const concrete = this.concreteValueOf(refinement.value);
|
|
1836
|
+
const value = concrete !== null ? concrete.text : (refinement.value.concepts || [])
|
|
1837
|
+
.filter(c => c.reference !== undefined && c.reference !== null && c.reference !== NO_REFERENCE)
|
|
1838
|
+
.map(c => this.describeConceptForMessage(c.reference)).join(' + ');
|
|
1839
|
+
throw new Error(`The SNOMED CT concept model does not allow ${value || 'this value'}`
|
|
1840
|
+
+ ` as the value of the attribute ${this.describeConceptForMessage(attribute)}`
|
|
1841
|
+
+ ` (the value must satisfy "${constraint}")`);
|
|
1842
|
+
}
|
|
1843
|
+
}
|
|
1844
|
+
|
|
1845
|
+
/**
|
|
1846
|
+
* Parse an MRCM concrete value range such as "dec(>#0..)", "int(#1..#10)" or
|
|
1847
|
+
* "str(...)". Returns null for anything that is not one - those are ECL.
|
|
1848
|
+
*
|
|
1849
|
+
* @param {string} constraint
|
|
1850
|
+
* @returns {{type: string, lowOp: string, low: number, highOp: string, high: number}|null}
|
|
1851
|
+
*/
|
|
1852
|
+
parseConcreteRange(constraint) {
|
|
1853
|
+
const match = /^\s*(dec|int|str)\s*\((.*)\)\s*$/.exec(constraint || '');
|
|
1854
|
+
if (!match) {
|
|
1855
|
+
return null;
|
|
1856
|
+
}
|
|
1857
|
+
const range = { type: match[1], lowOp: null, low: null, highOp: null, high: null };
|
|
1858
|
+
if (range.type === 'str') {
|
|
1859
|
+
return range; // no ordering on strings; only the type is checked
|
|
1860
|
+
}
|
|
1861
|
+
const parts = /^\s*([<>]=?)?\s*#?(-?\d+(?:\.\d+)?)?\s*\.\.\s*([<>]=?)?\s*#?(-?\d+(?:\.\d+)?)?\s*$/.exec(match[2]);
|
|
1862
|
+
if (!parts) {
|
|
1863
|
+
return range; // bounds we do not understand: the type check still applies
|
|
1864
|
+
}
|
|
1865
|
+
if (parts[2] !== undefined) { range.lowOp = parts[1] || '>='; range.low = parseFloat(parts[2]); }
|
|
1866
|
+
if (parts[4] !== undefined) { range.highOp = parts[3] || '<='; range.high = parseFloat(parts[4]); }
|
|
1867
|
+
return range;
|
|
1868
|
+
}
|
|
1869
|
+
|
|
1870
|
+
/**
|
|
1871
|
+
* The concrete value a refinement value carries, if it is one: SCG writes these
|
|
1872
|
+
* as #500 or "text" rather than as a concept.
|
|
1873
|
+
*
|
|
1874
|
+
* @param {SnomedExpression} expression - a refinement's value
|
|
1875
|
+
* @returns {{number: number|null, string: string|null, text: string}|null}
|
|
1876
|
+
*/
|
|
1877
|
+
concreteValueOf(expression) {
|
|
1878
|
+
if (!expression || (expression.refinements || []).length > 0 || (expression.refinementGroups || []).length > 0) {
|
|
1879
|
+
return null;
|
|
1880
|
+
}
|
|
1881
|
+
const concepts = expression.concepts || [];
|
|
1882
|
+
if (concepts.length !== 1) {
|
|
1883
|
+
return null;
|
|
1884
|
+
}
|
|
1885
|
+
const concept = concepts[0];
|
|
1886
|
+
if (concept.decimal !== undefined && concept.decimal !== null && concept.decimal !== '') {
|
|
1887
|
+
return { number: parseFloat(concept.decimal), string: null, text: '#' + concept.decimal };
|
|
1888
|
+
}
|
|
1889
|
+
if (concept.literal !== undefined && concept.literal !== null && concept.literal !== '') {
|
|
1890
|
+
return { number: null, string: concept.literal, text: JSON.stringify(concept.literal) };
|
|
1891
|
+
}
|
|
1892
|
+
return null;
|
|
1893
|
+
}
|
|
1894
|
+
|
|
1895
|
+
/**
|
|
1896
|
+
* @param {Object} range - from parseConcreteRange
|
|
1897
|
+
* @param {Object|null} value - from concreteValueOf
|
|
1898
|
+
* @returns {boolean}
|
|
1899
|
+
*/
|
|
1900
|
+
concreteValueSatisfies(range, value) {
|
|
1901
|
+
if (value === null) {
|
|
1902
|
+
return false; // a concept where the model wants a concrete value
|
|
1903
|
+
}
|
|
1904
|
+
if (range.type === 'str') {
|
|
1905
|
+
return value.string !== null;
|
|
1906
|
+
}
|
|
1907
|
+
if (value.number === null || !isFinite(value.number)) {
|
|
1908
|
+
return false;
|
|
1909
|
+
}
|
|
1910
|
+
if (range.type === 'int' && !Number.isInteger(value.number)) {
|
|
1911
|
+
return false;
|
|
1912
|
+
}
|
|
1913
|
+
if (range.low !== null) {
|
|
1914
|
+
if (range.lowOp === '>' && !(value.number > range.low)) return false;
|
|
1915
|
+
if (range.lowOp === '>=' && !(value.number >= range.low)) return false;
|
|
1916
|
+
}
|
|
1917
|
+
if (range.high !== null) {
|
|
1918
|
+
if (range.highOp === '<' && !(value.number < range.high)) return false;
|
|
1919
|
+
if (range.highOp === '<=' && !(value.number <= range.high)) return false;
|
|
1920
|
+
}
|
|
1921
|
+
return true;
|
|
1922
|
+
}
|
|
1923
|
+
|
|
1924
|
+
/**
|
|
1925
|
+
* The MRCM attribute range reference set, indexed by attribute concept, as the
|
|
1926
|
+
* rangeConstraint ECL string.
|
|
1927
|
+
*
|
|
1928
|
+
* Where an attribute has a range specifically for postcoordinated content
|
|
1929
|
+
* (contentTypeId 723594008) that one replaces the general "all content" range
|
|
1930
|
+
* rather than adding to it - 11 attributes internationally do this. After that
|
|
1931
|
+
* rule every attribute has exactly one applicable constraint, so there is no
|
|
1932
|
+
* question of ANDing or ORing them.
|
|
1933
|
+
*
|
|
1934
|
+
* @returns {Map<number, string>}
|
|
1935
|
+
*/
|
|
1936
|
+
mrcmAttributeRanges() {
|
|
1937
|
+
if (this._mrcmAttributeRanges) {
|
|
1938
|
+
return this._mrcmAttributeRanges;
|
|
1939
|
+
}
|
|
1940
|
+
const ranges = new Map();
|
|
1941
|
+
this._mrcmAttributeRanges = ranges;
|
|
1942
|
+
|
|
1943
|
+
const refSet = this.findRefSetByConceptId(MRCM_ATTRIBUTE_RANGE_REFSET);
|
|
1944
|
+
if (!refSet) {
|
|
1945
|
+
return ranges;
|
|
1946
|
+
}
|
|
1947
|
+
const contentAll = this.conceptIndexOf(MRCM_CONTENT_TYPE_ALL);
|
|
1948
|
+
const contentPost = this.conceptIndexOf(MRCM_CONTENT_TYPE_POSTCOORDINATED);
|
|
1949
|
+
const fromPostRow = new Set();
|
|
1950
|
+
|
|
1951
|
+
const members = this.refSetMembers.getMembers(refSet.membersByRef) || [];
|
|
1952
|
+
for (const member of members) {
|
|
1953
|
+
if (member.kind !== 0) {
|
|
1954
|
+
continue;
|
|
1955
|
+
}
|
|
1956
|
+
// (value, type) pairs: rangeConstraint, attributeRule, ruleStrengthId, contentTypeId
|
|
1957
|
+
const values = this.refs.getReferences(member.values);
|
|
1958
|
+
if (!values || values.length < 8) {
|
|
1959
|
+
continue;
|
|
1960
|
+
}
|
|
1961
|
+
const contentType = values[6];
|
|
1962
|
+
if (contentType !== contentAll && contentType !== contentPost) {
|
|
1963
|
+
continue;
|
|
1964
|
+
}
|
|
1965
|
+
if (ranges.has(member.ref) && fromPostRow.has(member.ref) && contentType !== contentPost) {
|
|
1966
|
+
continue; // keep the postcoordination-specific range
|
|
1967
|
+
}
|
|
1968
|
+
let constraint;
|
|
1969
|
+
try {
|
|
1970
|
+
constraint = this.strings.getEntry(values[0]);
|
|
1971
|
+
} catch (error) {
|
|
1972
|
+
continue;
|
|
1973
|
+
}
|
|
1974
|
+
if (!constraint) {
|
|
1975
|
+
continue;
|
|
1976
|
+
}
|
|
1977
|
+
ranges.set(member.ref, constraint);
|
|
1978
|
+
if (contentType === contentPost) {
|
|
1979
|
+
fromPostRow.add(member.ref);
|
|
1980
|
+
}
|
|
1981
|
+
}
|
|
1982
|
+
return ranges;
|
|
1983
|
+
}
|
|
1984
|
+
|
|
1985
|
+
/**
|
|
1986
|
+
* The upper bound from an MRCM cardinality string such as "0..1", "0..*" or
|
|
1987
|
+
* "0..0". Returns Infinity for "*", and Infinity for anything unparseable, so
|
|
1988
|
+
* a value we do not understand cannot invent a violation.
|
|
1989
|
+
*
|
|
1990
|
+
* @param {number} stringRef - offset of the cardinality string
|
|
1991
|
+
* @returns {number}
|
|
1992
|
+
*/
|
|
1993
|
+
parseCardinalityMax(stringRef) {
|
|
1994
|
+
let text;
|
|
1995
|
+
try {
|
|
1996
|
+
text = this.strings.getEntry(stringRef);
|
|
1997
|
+
} catch (error) {
|
|
1998
|
+
return Infinity;
|
|
1999
|
+
}
|
|
2000
|
+
const match = /^\s*\d+\s*\.\.\s*(\d+|\*)\s*$/.exec(text || '');
|
|
2001
|
+
if (!match) {
|
|
2002
|
+
return Infinity;
|
|
2003
|
+
}
|
|
2004
|
+
return match[1] === '*' ? Infinity : parseInt(match[1], 10);
|
|
2005
|
+
}
|
|
2006
|
+
|
|
2007
|
+
/**
|
|
2008
|
+
* Check the MRCM cardinality of the attributes used on one expression:
|
|
2009
|
+
* attributeCardinality caps how often an attribute may appear on the concept
|
|
2010
|
+
* at all, and attributeInGroupCardinality caps how often it may appear within
|
|
2011
|
+
* a single relationship group - a maximum of 0 there is how the MRCM says an
|
|
2012
|
+
* attribute is ungrouped (all 37 ungrouped attributes carry "0..0").
|
|
2013
|
+
*
|
|
2014
|
+
* Rules are ANDed, so where an attribute has several rules the tightest
|
|
2015
|
+
* maximum applies. Nested value expressions are checked separately, when
|
|
2016
|
+
* checkRefinement recurses into them.
|
|
2017
|
+
*
|
|
2018
|
+
* @param {SnomedExpression} expression
|
|
2019
|
+
*/
|
|
2020
|
+
checkExpressionCardinality(expression) {
|
|
2021
|
+
const domains = this.mrcmAttributeDomains();
|
|
2022
|
+
if (domains.size === 0) {
|
|
2023
|
+
return;
|
|
2024
|
+
}
|
|
2025
|
+
|
|
2026
|
+
const groups = expression.hasRefinementGroups() ? expression.refinementGroups : [];
|
|
2027
|
+
const ungrouped = expression.hasRefinements() ? expression.refinements : [];
|
|
2028
|
+
|
|
2029
|
+
const total = new Map(); // attribute -> occurrences anywhere on this expression
|
|
2030
|
+
const count = (refinement, into) => {
|
|
2031
|
+
const attribute = refinement.name ? refinement.name.reference : NO_REFERENCE;
|
|
2032
|
+
if (attribute === undefined || attribute === null || attribute === NO_REFERENCE) {
|
|
2033
|
+
return;
|
|
2034
|
+
}
|
|
2035
|
+
into.set(attribute, (into.get(attribute) || 0) + 1);
|
|
2036
|
+
if (into !== total) {
|
|
2037
|
+
total.set(attribute, (total.get(attribute) || 0) + 1);
|
|
2038
|
+
}
|
|
2039
|
+
};
|
|
2040
|
+
|
|
2041
|
+
for (const refinement of ungrouped) {
|
|
2042
|
+
count(refinement, total);
|
|
2043
|
+
}
|
|
2044
|
+
const perGroup = [];
|
|
2045
|
+
for (const group of groups) {
|
|
2046
|
+
const counts = new Map();
|
|
2047
|
+
for (const refinement of group.refinements) {
|
|
2048
|
+
count(refinement, counts);
|
|
2049
|
+
}
|
|
2050
|
+
perGroup.push(counts);
|
|
2051
|
+
}
|
|
2052
|
+
|
|
2053
|
+
const limit = (attribute, field) => {
|
|
2054
|
+
const rules = domains.get(attribute);
|
|
2055
|
+
if (!rules || rules.length === 0) {
|
|
2056
|
+
return Infinity;
|
|
2057
|
+
}
|
|
2058
|
+
return rules.reduce((least, rule) => Math.min(least, rule[field]), Infinity);
|
|
2059
|
+
};
|
|
2060
|
+
|
|
2061
|
+
for (const [attribute, used] of total) {
|
|
2062
|
+
const max = limit(attribute, 'max');
|
|
2063
|
+
if (used > max) {
|
|
2064
|
+
throw new Error(`The SNOMED CT concept model allows the attribute ${this.describeConceptForMessage(attribute)}`
|
|
2065
|
+
+ ` at most ${max} time(s) on one concept, but it is used ${used} times`);
|
|
2066
|
+
}
|
|
2067
|
+
}
|
|
2068
|
+
for (const counts of perGroup) {
|
|
2069
|
+
for (const [attribute, used] of counts) {
|
|
2070
|
+
const max = limit(attribute, 'maxInGroup');
|
|
2071
|
+
if (used > max) {
|
|
2072
|
+
if (max === 0) {
|
|
2073
|
+
throw new Error(`The SNOMED CT concept model does not allow the attribute ${this.describeConceptForMessage(attribute)}`
|
|
2074
|
+
+ ` to appear in a relationship group`);
|
|
2075
|
+
}
|
|
2076
|
+
throw new Error(`The SNOMED CT concept model allows the attribute ${this.describeConceptForMessage(attribute)}`
|
|
2077
|
+
+ ` at most ${max} time(s) in one relationship group, but it is used ${used} times`);
|
|
1464
2078
|
}
|
|
1465
2079
|
}
|
|
1466
2080
|
}
|
|
1467
2081
|
}
|
|
1468
2082
|
|
|
2083
|
+
/**
|
|
2084
|
+
* Is this concept a member of this reference set? Uses the per-concept list of
|
|
2085
|
+
* reference sets, so it costs a handful of comparisons rather than a scan of
|
|
2086
|
+
* the reference set's members.
|
|
2087
|
+
*/
|
|
2088
|
+
conceptInRefSet(refSetConcept, conceptIndex) {
|
|
2089
|
+
try {
|
|
2090
|
+
const concept = this.concepts.getConcept(conceptIndex);
|
|
2091
|
+
if (!concept.refsets) {
|
|
2092
|
+
return false;
|
|
2093
|
+
}
|
|
2094
|
+
const list = this.refs.getReferences(concept.refsets);
|
|
2095
|
+
return !!list && list.includes(refSetConcept);
|
|
2096
|
+
} catch (error) {
|
|
2097
|
+
return false;
|
|
2098
|
+
}
|
|
2099
|
+
}
|
|
2100
|
+
|
|
2101
|
+
findRefSetByConceptId(conceptId) {
|
|
2102
|
+
const index = this.conceptIndexOf(conceptId);
|
|
2103
|
+
if (index === null) {
|
|
2104
|
+
return null;
|
|
2105
|
+
}
|
|
2106
|
+
for (let i = 0; i < this.refSetIndex.count(); i++) {
|
|
2107
|
+
const refSet = this.refSetIndex.getReferenceSet(i);
|
|
2108
|
+
if (refSet.definition === index) {
|
|
2109
|
+
return refSet;
|
|
2110
|
+
}
|
|
2111
|
+
}
|
|
2112
|
+
return null;
|
|
2113
|
+
}
|
|
2114
|
+
|
|
2115
|
+
conceptIndexOf(conceptId) {
|
|
2116
|
+
const result = this.concepts.findConcept(conceptId);
|
|
2117
|
+
return result.found ? result.index : null;
|
|
2118
|
+
}
|
|
2119
|
+
|
|
2120
|
+
/** "11204002 |Structure of porta hepatis|", falling back to the bare id */
|
|
2121
|
+
describeConceptForMessage(reference) {
|
|
2122
|
+
const id = this.getConceptId(reference);
|
|
2123
|
+
try {
|
|
2124
|
+
const displays = this.listDisplayNames(reference, 0);
|
|
2125
|
+
if (displays && displays.length > 0 && displays[0].term) {
|
|
2126
|
+
return `${id} |${displays[0].term}|`;
|
|
2127
|
+
}
|
|
2128
|
+
} catch (error) {
|
|
2129
|
+
// fall through to the bare id
|
|
2130
|
+
}
|
|
2131
|
+
return id;
|
|
2132
|
+
}
|
|
2133
|
+
|
|
1469
2134
|
/**
|
|
1470
2135
|
* Validate concept reference
|
|
1471
2136
|
*/
|
|
@@ -1675,9 +2340,7 @@ class SnomedExpressionServices {
|
|
|
1675
2340
|
// In SNOMED CT, primitive concepts are not fully defined by their relationships
|
|
1676
2341
|
try {
|
|
1677
2342
|
const concept = this.concepts.getConcept(reference);
|
|
1678
|
-
|
|
1679
|
-
// This may vary based on the specific implementation
|
|
1680
|
-
return (concept.flags & 1) !== 0;
|
|
2343
|
+
return (concept.flags & SnomedConceptList.MASK_CONCEPT_PRIMITIVE) !== 0;
|
|
1681
2344
|
} catch (error) {
|
|
1682
2345
|
// If we can't read the concept, assume it's primitive for safety
|
|
1683
2346
|
this.log.warn(`Warning: Could not check primitive status for concept ${reference}: ${error.message}`);
|
|
@@ -2002,10 +2665,9 @@ class SnomedExpressionServicesExtended extends SnomedExpressionServices {
|
|
|
2002
2665
|
isPrimitive(reference) {
|
|
2003
2666
|
try {
|
|
2004
2667
|
const concept = this.concepts.getConcept(reference);
|
|
2005
|
-
//
|
|
2006
|
-
//
|
|
2007
|
-
|
|
2008
|
-
return (concept.flags & 1) !== 0;
|
|
2668
|
+
// definitionStatusId 900000000000074008 (primitive) is recorded at import time as
|
|
2669
|
+
// MASK_CONCEPT_PRIMITIVE in the concept flags byte
|
|
2670
|
+
return (concept.flags & SnomedConceptList.MASK_CONCEPT_PRIMITIVE) !== 0;
|
|
2009
2671
|
} catch (error) {
|
|
2010
2672
|
// If we can't read the concept, assume it's primitive for safety
|
|
2011
2673
|
if (this.building) {
|