fhirsmith 0.11.2 → 0.12.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 +72 -2
- package/config-template.json +18 -1
- package/package.json +1 -1
- package/publisher/publisher.js +153 -5
- package/publisher/validation.js +198 -0
- package/server.js +12 -12
- package/stats-db.js +127 -0
- package/stats.js +172 -6
- package/translations/Messages.properties +4 -1
- package/tx/cs/cs-api.md +1 -1
- package/tx/cs/cs-snomed.js +103 -54
- package/tx/library/conceptmap.js +39 -0
- package/tx/library/parameter-check.js +139 -0
- package/tx/operation-context.js +220 -10
- package/tx/params.js +226 -175
- package/tx/sct/expressions.js +485 -0
- package/tx/tx.fhir.org.yml +1 -1
- package/tx/tx.js +57 -50
- package/tx/workers/batch-validate.js +5 -2
- package/tx/workers/cache-control.js +82 -6
- package/tx/workers/expand.js +80 -23
- package/tx/workers/lookup.js +14 -1
- package/tx/workers/translate.js +461 -30
- package/tx/workers/validate.js +7 -1
- package/tx/workers/worker.js +22 -10
- package/tx/xml/xml-base.js +21 -1
- package/tx/xversion/xv-conceptmap.js +81 -0
- package/tx/xversion/xv-parameters.js +8 -0
- 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
|
@@ -9,6 +9,17 @@
|
|
|
9
9
|
const MAX_TERM_LENGTH = 1024;
|
|
10
10
|
const NO_REFERENCE = 0xFFFFFFFF;
|
|
11
11
|
|
|
12
|
+
// MRCM (machine readable concept model) reference sets and metadata concepts.
|
|
13
|
+
// The range constraints in 723562003 are ECL, so they are only checked when an
|
|
14
|
+
// evaluator has been injected (SnomedServices does this - see eclConstraintMatcher).
|
|
15
|
+
const MRCM_ATTRIBUTE_DOMAIN_REFSET = 723561005n;
|
|
16
|
+
const MRCM_ATTRIBUTE_RANGE_REFSET = 723562003n;
|
|
17
|
+
// contentTypeId values that apply when validating a postcoordinated expression.
|
|
18
|
+
// 723593002 (precoordinated only) and 723595009 (new precoordinated only) are
|
|
19
|
+
// rules about authoring precoordinated concepts and do not constrain expressions.
|
|
20
|
+
const MRCM_CONTENT_TYPE_ALL = 723596005n;
|
|
21
|
+
const MRCM_CONTENT_TYPE_POSTCOORDINATED = 723594008n;
|
|
22
|
+
|
|
12
23
|
// Expression status enumeration
|
|
13
24
|
const SnomedExpressionStatus = {
|
|
14
25
|
Unknown: 0,
|
|
@@ -1454,6 +1465,8 @@ class SnomedExpressionServices {
|
|
|
1454
1465
|
if (expression.hasRefinements()) {
|
|
1455
1466
|
for (const refinement of expression.refinements) {
|
|
1456
1467
|
this.checkRefinement(refinement);
|
|
1468
|
+
this.checkRefinementDomain(expression, refinement);
|
|
1469
|
+
this.checkRefinementRange(refinement);
|
|
1457
1470
|
}
|
|
1458
1471
|
}
|
|
1459
1472
|
|
|
@@ -1461,11 +1474,483 @@ class SnomedExpressionServices {
|
|
|
1461
1474
|
for (const group of expression.refinementGroups) {
|
|
1462
1475
|
for (const refinement of group.refinements) {
|
|
1463
1476
|
this.checkRefinement(refinement);
|
|
1477
|
+
this.checkRefinementDomain(expression, refinement);
|
|
1478
|
+
this.checkRefinementRange(refinement);
|
|
1479
|
+
}
|
|
1480
|
+
}
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
this.checkExpressionCardinality(expression);
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
/**
|
|
1487
|
+
* Check one refinement against the MRCM attribute domain reference set: the
|
|
1488
|
+
* focus concept the attribute is applied to must be in the attribute's domain.
|
|
1489
|
+
*
|
|
1490
|
+
* Every applicable rule must hold, mandatory or optional. Internationally
|
|
1491
|
+
* there is exactly one optional rule - Laterality in the lateralizable body
|
|
1492
|
+
* structure reference set - and it is the rule that stops laterality being
|
|
1493
|
+
* applied to a structure that cannot be lateralized, so it is enforced.
|
|
1494
|
+
*
|
|
1495
|
+
* Attributes with no rule in this edition's MRCM are not checked, and neither
|
|
1496
|
+
* are cardinality, grouping, or the range constraint (which needs ECL).
|
|
1497
|
+
*
|
|
1498
|
+
* @param {SnomedExpression} expression - the expression the refinement is on
|
|
1499
|
+
* @param {SnomedRefinement} refinement
|
|
1500
|
+
*/
|
|
1501
|
+
checkRefinementDomain(expression, refinement) {
|
|
1502
|
+
const attribute = refinement.name ? refinement.name.reference : NO_REFERENCE;
|
|
1503
|
+
if (attribute === undefined || attribute === null || attribute === NO_REFERENCE) {
|
|
1504
|
+
return;
|
|
1505
|
+
}
|
|
1506
|
+
const rules = this.mrcmAttributeDomains().get(attribute);
|
|
1507
|
+
if (!rules || rules.length === 0) {
|
|
1508
|
+
// This edition's concept model says nothing about this attribute (3 active
|
|
1509
|
+
// attributes internationally have no domain rule and appear in no
|
|
1510
|
+
// postcoordination domain template), so there is nothing to check it against.
|
|
1511
|
+
return;
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
// The focus may be a conjunction of concepts; the expression is a subtype of
|
|
1515
|
+
// each of them, so the attribute is in domain if any focus concept is.
|
|
1516
|
+
const focus = [];
|
|
1517
|
+
for (const concept of expression.concepts) {
|
|
1518
|
+
if (concept.reference !== undefined && concept.reference !== null && concept.reference !== NO_REFERENCE) {
|
|
1519
|
+
focus.push(concept.reference);
|
|
1520
|
+
}
|
|
1521
|
+
}
|
|
1522
|
+
if (focus.length === 0) {
|
|
1523
|
+
return;
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
// Report every rule that failed, not just the first: a laterality on a
|
|
1527
|
+
// disorder breaks both the anatomical structure domain and the lateralizable
|
|
1528
|
+
// refset rule, and naming only one of them misleads.
|
|
1529
|
+
const failed = [];
|
|
1530
|
+
for (const rule of rules) {
|
|
1531
|
+
const ok = focus.some(f => rule.isRefset
|
|
1532
|
+
? this.conceptInRefSet(rule.domain, f)
|
|
1533
|
+
: this.subsumes(rule.domain, f));
|
|
1534
|
+
if (!ok) {
|
|
1535
|
+
failed.push((rule.isRefset ? 'for concepts in ' : 'in the domain ') + this.describeConceptForMessage(rule.domain));
|
|
1536
|
+
}
|
|
1537
|
+
}
|
|
1538
|
+
if (failed.length > 0) {
|
|
1539
|
+
const attrDesc = this.describeConceptForMessage(attribute);
|
|
1540
|
+
const focusDesc = focus.map(f => this.describeConceptForMessage(f)).join(' + ');
|
|
1541
|
+
throw new Error(`The SNOMED CT concept model does not allow the attribute ${attrDesc} on ${focusDesc}`
|
|
1542
|
+
+ ` (the attribute is only valid ${failed.join(', and ')})`);
|
|
1543
|
+
}
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1546
|
+
/**
|
|
1547
|
+
* The MRCM attribute domain reference set, indexed by attribute concept.
|
|
1548
|
+
* Built once per edition and cached; an edition with no MRCM yields an empty
|
|
1549
|
+
* map, which turns the domain check off rather than failing.
|
|
1550
|
+
*
|
|
1551
|
+
* @returns {Map<number, Array<{domain: number, isRefset: boolean}>>}
|
|
1552
|
+
*/
|
|
1553
|
+
mrcmAttributeDomains() {
|
|
1554
|
+
if (this._mrcmAttributeDomains) {
|
|
1555
|
+
return this._mrcmAttributeDomains;
|
|
1556
|
+
}
|
|
1557
|
+
const rules = new Map();
|
|
1558
|
+
this._mrcmAttributeDomains = rules;
|
|
1559
|
+
|
|
1560
|
+
const refSet = this.findRefSetByConceptId(MRCM_ATTRIBUTE_DOMAIN_REFSET);
|
|
1561
|
+
if (!refSet) {
|
|
1562
|
+
return rules; // no MRCM in this edition
|
|
1563
|
+
}
|
|
1564
|
+
const contentAll = this.conceptIndexOf(MRCM_CONTENT_TYPE_ALL);
|
|
1565
|
+
const contentPost = this.conceptIndexOf(MRCM_CONTENT_TYPE_POSTCOORDINATED);
|
|
1566
|
+
|
|
1567
|
+
// Every concept that defines a reference set, so a domain that is itself a
|
|
1568
|
+
// reference set (the lateralizable body structure refset) is tested by
|
|
1569
|
+
// membership rather than subsumption.
|
|
1570
|
+
const refSetConcepts = new Set();
|
|
1571
|
+
for (let i = 0; i < this.refSetIndex.count(); i++) {
|
|
1572
|
+
refSetConcepts.add(this.refSetIndex.getReferenceSet(i).definition);
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1575
|
+
const members = this.refSetMembers.getMembers(refSet.membersByRef) || [];
|
|
1576
|
+
for (const member of members) {
|
|
1577
|
+
if (member.kind !== 0) {
|
|
1578
|
+
continue; // concept members only
|
|
1579
|
+
}
|
|
1580
|
+
// Additional columns are stored as (value, type) pairs, in file order:
|
|
1581
|
+
// domainId, grouped, attributeCardinality, attributeInGroupCardinality,
|
|
1582
|
+
// ruleStrengthId, contentTypeId.
|
|
1583
|
+
const values = this.refs.getReferences(member.values);
|
|
1584
|
+
if (!values || values.length < 12) {
|
|
1585
|
+
continue;
|
|
1586
|
+
}
|
|
1587
|
+
const domain = values[0];
|
|
1588
|
+
const contentType = values[10];
|
|
1589
|
+
if (contentType !== contentAll && contentType !== contentPost) {
|
|
1590
|
+
continue; // a rule about authoring precoordinated concepts
|
|
1591
|
+
}
|
|
1592
|
+
// The importer stores 0 for a referenced component it could not resolve,
|
|
1593
|
+
// which would otherwise read as a domain of whichever concept sorts first.
|
|
1594
|
+
if (domain === 0) {
|
|
1595
|
+
continue;
|
|
1596
|
+
}
|
|
1597
|
+
let list = rules.get(member.ref);
|
|
1598
|
+
if (!list) {
|
|
1599
|
+
list = [];
|
|
1600
|
+
rules.set(member.ref, list);
|
|
1601
|
+
}
|
|
1602
|
+
list.push({
|
|
1603
|
+
domain: domain,
|
|
1604
|
+
isRefset: refSetConcepts.has(domain),
|
|
1605
|
+
// Only the maximums are enforced. A minimum ("1..1") says a concept in
|
|
1606
|
+
// this domain must have the attribute in its own definition; it does not
|
|
1607
|
+
// say a refinement of that concept has to restate it.
|
|
1608
|
+
max: this.parseCardinalityMax(values[4]), // attributeCardinality
|
|
1609
|
+
maxInGroup: this.parseCardinalityMax(values[6]) // attributeInGroupCardinality
|
|
1610
|
+
});
|
|
1611
|
+
}
|
|
1612
|
+
return rules;
|
|
1613
|
+
}
|
|
1614
|
+
|
|
1615
|
+
/**
|
|
1616
|
+
* Check one refinement's value against the MRCM attribute range: the range is
|
|
1617
|
+
* an ECL constraint, so this only runs when an evaluator has been injected
|
|
1618
|
+
* (`eclConstraintMatcher`, set by SnomedServices, which owns the ECL engine).
|
|
1619
|
+
* A matcher that returns null - an unparseable or unsupported constraint, such
|
|
1620
|
+
* as the concrete-value ranges "dec(>#0..)" - leaves the value unchecked
|
|
1621
|
+
* rather than rejecting it.
|
|
1622
|
+
*
|
|
1623
|
+
* @param {SnomedRefinement} refinement
|
|
1624
|
+
*/
|
|
1625
|
+
checkRefinementRange(refinement) {
|
|
1626
|
+
const attribute = refinement.name ? refinement.name.reference : NO_REFERENCE;
|
|
1627
|
+
if (attribute === undefined || attribute === null || attribute === NO_REFERENCE) {
|
|
1628
|
+
return;
|
|
1629
|
+
}
|
|
1630
|
+
const constraint = this.mrcmAttributeRanges().get(attribute);
|
|
1631
|
+
if (!constraint || !refinement.value) {
|
|
1632
|
+
return;
|
|
1633
|
+
}
|
|
1634
|
+
|
|
1635
|
+
// A concrete value range (dec/int/str) is checked here; everything else is
|
|
1636
|
+
// ECL and needs the evaluator the provider injects.
|
|
1637
|
+
const concreteRange = this.parseConcreteRange(constraint);
|
|
1638
|
+
const value = this.concreteValueOf(refinement.value);
|
|
1639
|
+
let ok;
|
|
1640
|
+
if (concreteRange) {
|
|
1641
|
+
ok = this.concreteValueSatisfies(concreteRange, value);
|
|
1642
|
+
} else if (value !== null) {
|
|
1643
|
+
ok = false; // a concrete value where the model wants a concept
|
|
1644
|
+
} else if (!this.eclConstraintMatcher) {
|
|
1645
|
+
return;
|
|
1646
|
+
} else {
|
|
1647
|
+
try {
|
|
1648
|
+
ok = this.eclConstraintMatcher(constraint, refinement.value);
|
|
1649
|
+
} catch (error) {
|
|
1650
|
+
ok = null; // the evaluator could not decide; do not invent a violation
|
|
1651
|
+
}
|
|
1652
|
+
}
|
|
1653
|
+
// null = could not decide: a constraint naming concepts this edition does not carry.
|
|
1654
|
+
if (ok === false) {
|
|
1655
|
+
const concrete = this.concreteValueOf(refinement.value);
|
|
1656
|
+
const value = concrete !== null ? concrete.text : (refinement.value.concepts || [])
|
|
1657
|
+
.filter(c => c.reference !== undefined && c.reference !== null && c.reference !== NO_REFERENCE)
|
|
1658
|
+
.map(c => this.describeConceptForMessage(c.reference)).join(' + ');
|
|
1659
|
+
throw new Error(`The SNOMED CT concept model does not allow ${value || 'this value'}`
|
|
1660
|
+
+ ` as the value of the attribute ${this.describeConceptForMessage(attribute)}`
|
|
1661
|
+
+ ` (the value must satisfy "${constraint}")`);
|
|
1662
|
+
}
|
|
1663
|
+
}
|
|
1664
|
+
|
|
1665
|
+
/**
|
|
1666
|
+
* Parse an MRCM concrete value range such as "dec(>#0..)", "int(#1..#10)" or
|
|
1667
|
+
* "str(...)". Returns null for anything that is not one - those are ECL.
|
|
1668
|
+
*
|
|
1669
|
+
* @param {string} constraint
|
|
1670
|
+
* @returns {{type: string, lowOp: string, low: number, highOp: string, high: number}|null}
|
|
1671
|
+
*/
|
|
1672
|
+
parseConcreteRange(constraint) {
|
|
1673
|
+
const match = /^\s*(dec|int|str)\s*\((.*)\)\s*$/.exec(constraint || '');
|
|
1674
|
+
if (!match) {
|
|
1675
|
+
return null;
|
|
1676
|
+
}
|
|
1677
|
+
const range = { type: match[1], lowOp: null, low: null, highOp: null, high: null };
|
|
1678
|
+
if (range.type === 'str') {
|
|
1679
|
+
return range; // no ordering on strings; only the type is checked
|
|
1680
|
+
}
|
|
1681
|
+
const parts = /^\s*([<>]=?)?\s*#?(-?\d+(?:\.\d+)?)?\s*\.\.\s*([<>]=?)?\s*#?(-?\d+(?:\.\d+)?)?\s*$/.exec(match[2]);
|
|
1682
|
+
if (!parts) {
|
|
1683
|
+
return range; // bounds we do not understand: the type check still applies
|
|
1684
|
+
}
|
|
1685
|
+
if (parts[2] !== undefined) { range.lowOp = parts[1] || '>='; range.low = parseFloat(parts[2]); }
|
|
1686
|
+
if (parts[4] !== undefined) { range.highOp = parts[3] || '<='; range.high = parseFloat(parts[4]); }
|
|
1687
|
+
return range;
|
|
1688
|
+
}
|
|
1689
|
+
|
|
1690
|
+
/**
|
|
1691
|
+
* The concrete value a refinement value carries, if it is one: SCG writes these
|
|
1692
|
+
* as #500 or "text" rather than as a concept.
|
|
1693
|
+
*
|
|
1694
|
+
* @param {SnomedExpression} expression - a refinement's value
|
|
1695
|
+
* @returns {{number: number|null, string: string|null, text: string}|null}
|
|
1696
|
+
*/
|
|
1697
|
+
concreteValueOf(expression) {
|
|
1698
|
+
if (!expression || (expression.refinements || []).length > 0 || (expression.refinementGroups || []).length > 0) {
|
|
1699
|
+
return null;
|
|
1700
|
+
}
|
|
1701
|
+
const concepts = expression.concepts || [];
|
|
1702
|
+
if (concepts.length !== 1) {
|
|
1703
|
+
return null;
|
|
1704
|
+
}
|
|
1705
|
+
const concept = concepts[0];
|
|
1706
|
+
if (concept.decimal !== undefined && concept.decimal !== null && concept.decimal !== '') {
|
|
1707
|
+
return { number: parseFloat(concept.decimal), string: null, text: '#' + concept.decimal };
|
|
1708
|
+
}
|
|
1709
|
+
if (concept.literal !== undefined && concept.literal !== null && concept.literal !== '') {
|
|
1710
|
+
return { number: null, string: concept.literal, text: JSON.stringify(concept.literal) };
|
|
1711
|
+
}
|
|
1712
|
+
return null;
|
|
1713
|
+
}
|
|
1714
|
+
|
|
1715
|
+
/**
|
|
1716
|
+
* @param {Object} range - from parseConcreteRange
|
|
1717
|
+
* @param {Object|null} value - from concreteValueOf
|
|
1718
|
+
* @returns {boolean}
|
|
1719
|
+
*/
|
|
1720
|
+
concreteValueSatisfies(range, value) {
|
|
1721
|
+
if (value === null) {
|
|
1722
|
+
return false; // a concept where the model wants a concrete value
|
|
1723
|
+
}
|
|
1724
|
+
if (range.type === 'str') {
|
|
1725
|
+
return value.string !== null;
|
|
1726
|
+
}
|
|
1727
|
+
if (value.number === null || !isFinite(value.number)) {
|
|
1728
|
+
return false;
|
|
1729
|
+
}
|
|
1730
|
+
if (range.type === 'int' && !Number.isInteger(value.number)) {
|
|
1731
|
+
return false;
|
|
1732
|
+
}
|
|
1733
|
+
if (range.low !== null) {
|
|
1734
|
+
if (range.lowOp === '>' && !(value.number > range.low)) return false;
|
|
1735
|
+
if (range.lowOp === '>=' && !(value.number >= range.low)) return false;
|
|
1736
|
+
}
|
|
1737
|
+
if (range.high !== null) {
|
|
1738
|
+
if (range.highOp === '<' && !(value.number < range.high)) return false;
|
|
1739
|
+
if (range.highOp === '<=' && !(value.number <= range.high)) return false;
|
|
1740
|
+
}
|
|
1741
|
+
return true;
|
|
1742
|
+
}
|
|
1743
|
+
|
|
1744
|
+
/**
|
|
1745
|
+
* The MRCM attribute range reference set, indexed by attribute concept, as the
|
|
1746
|
+
* rangeConstraint ECL string.
|
|
1747
|
+
*
|
|
1748
|
+
* Where an attribute has a range specifically for postcoordinated content
|
|
1749
|
+
* (contentTypeId 723594008) that one replaces the general "all content" range
|
|
1750
|
+
* rather than adding to it - 11 attributes internationally do this. After that
|
|
1751
|
+
* rule every attribute has exactly one applicable constraint, so there is no
|
|
1752
|
+
* question of ANDing or ORing them.
|
|
1753
|
+
*
|
|
1754
|
+
* @returns {Map<number, string>}
|
|
1755
|
+
*/
|
|
1756
|
+
mrcmAttributeRanges() {
|
|
1757
|
+
if (this._mrcmAttributeRanges) {
|
|
1758
|
+
return this._mrcmAttributeRanges;
|
|
1759
|
+
}
|
|
1760
|
+
const ranges = new Map();
|
|
1761
|
+
this._mrcmAttributeRanges = ranges;
|
|
1762
|
+
|
|
1763
|
+
const refSet = this.findRefSetByConceptId(MRCM_ATTRIBUTE_RANGE_REFSET);
|
|
1764
|
+
if (!refSet) {
|
|
1765
|
+
return ranges;
|
|
1766
|
+
}
|
|
1767
|
+
const contentAll = this.conceptIndexOf(MRCM_CONTENT_TYPE_ALL);
|
|
1768
|
+
const contentPost = this.conceptIndexOf(MRCM_CONTENT_TYPE_POSTCOORDINATED);
|
|
1769
|
+
const fromPostRow = new Set();
|
|
1770
|
+
|
|
1771
|
+
const members = this.refSetMembers.getMembers(refSet.membersByRef) || [];
|
|
1772
|
+
for (const member of members) {
|
|
1773
|
+
if (member.kind !== 0) {
|
|
1774
|
+
continue;
|
|
1775
|
+
}
|
|
1776
|
+
// (value, type) pairs: rangeConstraint, attributeRule, ruleStrengthId, contentTypeId
|
|
1777
|
+
const values = this.refs.getReferences(member.values);
|
|
1778
|
+
if (!values || values.length < 8) {
|
|
1779
|
+
continue;
|
|
1780
|
+
}
|
|
1781
|
+
const contentType = values[6];
|
|
1782
|
+
if (contentType !== contentAll && contentType !== contentPost) {
|
|
1783
|
+
continue;
|
|
1784
|
+
}
|
|
1785
|
+
if (ranges.has(member.ref) && fromPostRow.has(member.ref) && contentType !== contentPost) {
|
|
1786
|
+
continue; // keep the postcoordination-specific range
|
|
1787
|
+
}
|
|
1788
|
+
let constraint;
|
|
1789
|
+
try {
|
|
1790
|
+
constraint = this.strings.getEntry(values[0]);
|
|
1791
|
+
} catch (error) {
|
|
1792
|
+
continue;
|
|
1793
|
+
}
|
|
1794
|
+
if (!constraint) {
|
|
1795
|
+
continue;
|
|
1796
|
+
}
|
|
1797
|
+
ranges.set(member.ref, constraint);
|
|
1798
|
+
if (contentType === contentPost) {
|
|
1799
|
+
fromPostRow.add(member.ref);
|
|
1800
|
+
}
|
|
1801
|
+
}
|
|
1802
|
+
return ranges;
|
|
1803
|
+
}
|
|
1804
|
+
|
|
1805
|
+
/**
|
|
1806
|
+
* The upper bound from an MRCM cardinality string such as "0..1", "0..*" or
|
|
1807
|
+
* "0..0". Returns Infinity for "*", and Infinity for anything unparseable, so
|
|
1808
|
+
* a value we do not understand cannot invent a violation.
|
|
1809
|
+
*
|
|
1810
|
+
* @param {number} stringRef - offset of the cardinality string
|
|
1811
|
+
* @returns {number}
|
|
1812
|
+
*/
|
|
1813
|
+
parseCardinalityMax(stringRef) {
|
|
1814
|
+
let text;
|
|
1815
|
+
try {
|
|
1816
|
+
text = this.strings.getEntry(stringRef);
|
|
1817
|
+
} catch (error) {
|
|
1818
|
+
return Infinity;
|
|
1819
|
+
}
|
|
1820
|
+
const match = /^\s*\d+\s*\.\.\s*(\d+|\*)\s*$/.exec(text || '');
|
|
1821
|
+
if (!match) {
|
|
1822
|
+
return Infinity;
|
|
1823
|
+
}
|
|
1824
|
+
return match[1] === '*' ? Infinity : parseInt(match[1], 10);
|
|
1825
|
+
}
|
|
1826
|
+
|
|
1827
|
+
/**
|
|
1828
|
+
* Check the MRCM cardinality of the attributes used on one expression:
|
|
1829
|
+
* attributeCardinality caps how often an attribute may appear on the concept
|
|
1830
|
+
* at all, and attributeInGroupCardinality caps how often it may appear within
|
|
1831
|
+
* a single relationship group - a maximum of 0 there is how the MRCM says an
|
|
1832
|
+
* attribute is ungrouped (all 37 ungrouped attributes carry "0..0").
|
|
1833
|
+
*
|
|
1834
|
+
* Rules are ANDed, so where an attribute has several rules the tightest
|
|
1835
|
+
* maximum applies. Nested value expressions are checked separately, when
|
|
1836
|
+
* checkRefinement recurses into them.
|
|
1837
|
+
*
|
|
1838
|
+
* @param {SnomedExpression} expression
|
|
1839
|
+
*/
|
|
1840
|
+
checkExpressionCardinality(expression) {
|
|
1841
|
+
const domains = this.mrcmAttributeDomains();
|
|
1842
|
+
if (domains.size === 0) {
|
|
1843
|
+
return;
|
|
1844
|
+
}
|
|
1845
|
+
|
|
1846
|
+
const groups = expression.hasRefinementGroups() ? expression.refinementGroups : [];
|
|
1847
|
+
const ungrouped = expression.hasRefinements() ? expression.refinements : [];
|
|
1848
|
+
|
|
1849
|
+
const total = new Map(); // attribute -> occurrences anywhere on this expression
|
|
1850
|
+
const count = (refinement, into) => {
|
|
1851
|
+
const attribute = refinement.name ? refinement.name.reference : NO_REFERENCE;
|
|
1852
|
+
if (attribute === undefined || attribute === null || attribute === NO_REFERENCE) {
|
|
1853
|
+
return;
|
|
1854
|
+
}
|
|
1855
|
+
into.set(attribute, (into.get(attribute) || 0) + 1);
|
|
1856
|
+
if (into !== total) {
|
|
1857
|
+
total.set(attribute, (total.get(attribute) || 0) + 1);
|
|
1858
|
+
}
|
|
1859
|
+
};
|
|
1860
|
+
|
|
1861
|
+
for (const refinement of ungrouped) {
|
|
1862
|
+
count(refinement, total);
|
|
1863
|
+
}
|
|
1864
|
+
const perGroup = [];
|
|
1865
|
+
for (const group of groups) {
|
|
1866
|
+
const counts = new Map();
|
|
1867
|
+
for (const refinement of group.refinements) {
|
|
1868
|
+
count(refinement, counts);
|
|
1869
|
+
}
|
|
1870
|
+
perGroup.push(counts);
|
|
1871
|
+
}
|
|
1872
|
+
|
|
1873
|
+
const limit = (attribute, field) => {
|
|
1874
|
+
const rules = domains.get(attribute);
|
|
1875
|
+
if (!rules || rules.length === 0) {
|
|
1876
|
+
return Infinity;
|
|
1877
|
+
}
|
|
1878
|
+
return rules.reduce((least, rule) => Math.min(least, rule[field]), Infinity);
|
|
1879
|
+
};
|
|
1880
|
+
|
|
1881
|
+
for (const [attribute, used] of total) {
|
|
1882
|
+
const max = limit(attribute, 'max');
|
|
1883
|
+
if (used > max) {
|
|
1884
|
+
throw new Error(`The SNOMED CT concept model allows the attribute ${this.describeConceptForMessage(attribute)}`
|
|
1885
|
+
+ ` at most ${max} time(s) on one concept, but it is used ${used} times`);
|
|
1886
|
+
}
|
|
1887
|
+
}
|
|
1888
|
+
for (const counts of perGroup) {
|
|
1889
|
+
for (const [attribute, used] of counts) {
|
|
1890
|
+
const max = limit(attribute, 'maxInGroup');
|
|
1891
|
+
if (used > max) {
|
|
1892
|
+
if (max === 0) {
|
|
1893
|
+
throw new Error(`The SNOMED CT concept model does not allow the attribute ${this.describeConceptForMessage(attribute)}`
|
|
1894
|
+
+ ` to appear in a relationship group`);
|
|
1895
|
+
}
|
|
1896
|
+
throw new Error(`The SNOMED CT concept model allows the attribute ${this.describeConceptForMessage(attribute)}`
|
|
1897
|
+
+ ` at most ${max} time(s) in one relationship group, but it is used ${used} times`);
|
|
1464
1898
|
}
|
|
1465
1899
|
}
|
|
1466
1900
|
}
|
|
1467
1901
|
}
|
|
1468
1902
|
|
|
1903
|
+
/**
|
|
1904
|
+
* Is this concept a member of this reference set? Uses the per-concept list of
|
|
1905
|
+
* reference sets, so it costs a handful of comparisons rather than a scan of
|
|
1906
|
+
* the reference set's members.
|
|
1907
|
+
*/
|
|
1908
|
+
conceptInRefSet(refSetConcept, conceptIndex) {
|
|
1909
|
+
try {
|
|
1910
|
+
const concept = this.concepts.getConcept(conceptIndex);
|
|
1911
|
+
if (!concept.refsets) {
|
|
1912
|
+
return false;
|
|
1913
|
+
}
|
|
1914
|
+
const list = this.refs.getReferences(concept.refsets);
|
|
1915
|
+
return !!list && list.includes(refSetConcept);
|
|
1916
|
+
} catch (error) {
|
|
1917
|
+
return false;
|
|
1918
|
+
}
|
|
1919
|
+
}
|
|
1920
|
+
|
|
1921
|
+
findRefSetByConceptId(conceptId) {
|
|
1922
|
+
const index = this.conceptIndexOf(conceptId);
|
|
1923
|
+
if (index === null) {
|
|
1924
|
+
return null;
|
|
1925
|
+
}
|
|
1926
|
+
for (let i = 0; i < this.refSetIndex.count(); i++) {
|
|
1927
|
+
const refSet = this.refSetIndex.getReferenceSet(i);
|
|
1928
|
+
if (refSet.definition === index) {
|
|
1929
|
+
return refSet;
|
|
1930
|
+
}
|
|
1931
|
+
}
|
|
1932
|
+
return null;
|
|
1933
|
+
}
|
|
1934
|
+
|
|
1935
|
+
conceptIndexOf(conceptId) {
|
|
1936
|
+
const result = this.concepts.findConcept(conceptId);
|
|
1937
|
+
return result.found ? result.index : null;
|
|
1938
|
+
}
|
|
1939
|
+
|
|
1940
|
+
/** "11204002 |Structure of porta hepatis|", falling back to the bare id */
|
|
1941
|
+
describeConceptForMessage(reference) {
|
|
1942
|
+
const id = this.getConceptId(reference);
|
|
1943
|
+
try {
|
|
1944
|
+
const displays = this.listDisplayNames(reference, 0);
|
|
1945
|
+
if (displays && displays.length > 0 && displays[0].term) {
|
|
1946
|
+
return `${id} |${displays[0].term}|`;
|
|
1947
|
+
}
|
|
1948
|
+
} catch (error) {
|
|
1949
|
+
// fall through to the bare id
|
|
1950
|
+
}
|
|
1951
|
+
return id;
|
|
1952
|
+
}
|
|
1953
|
+
|
|
1469
1954
|
/**
|
|
1470
1955
|
* Validate concept reference
|
|
1471
1956
|
*/
|
package/tx/tx.fhir.org.yml
CHANGED