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/cs/cs-snomed.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
const { CodeSystemContentMode, CodeSystemFactoryProvider} = require('./cs-api');
|
|
1
|
+
const { CodeSystemContentMode, CodeSystemFactoryProvider, cannotDetermineSubsumption} = require('./cs-api');
|
|
2
2
|
const {
|
|
3
3
|
SnomedStrings, SnomedWords, SnomedStems, SnomedReferences,
|
|
4
4
|
SnomedDescriptions, SnomedDescriptionIndex, SnomedConceptList,
|
|
@@ -148,6 +148,58 @@ class SnomedServices {
|
|
|
148
148
|
refSetIndex: this.refSetIndex
|
|
149
149
|
}, this.isAIndex);
|
|
150
150
|
|
|
151
|
+
// The expression services check the MRCM attribute ranges, but those are ECL
|
|
152
|
+
// and the ECL engine lives here, so hand them an evaluator. Returning null
|
|
153
|
+
// means "could not decide", and leaves the value unchecked.
|
|
154
|
+
this.expressionServices.eclConstraintMatcher = (constraint, value) => this.mrcmRangeSatisfied(constraint, value);
|
|
155
|
+
this._mrcmRangeCache = new Map();
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Does this refinement value satisfy an MRCM range constraint?
|
|
160
|
+
*
|
|
161
|
+
* @param {string} constraint - the rangeConstraint ECL, e.g. "<< 182353008 |Side|"
|
|
162
|
+
* @param {SnomedExpression} value - the refinement's value expression
|
|
163
|
+
* @returns {boolean|null} null when the constraint cannot be evaluated
|
|
164
|
+
*/
|
|
165
|
+
mrcmRangeSatisfied(constraint, value) {
|
|
166
|
+
let entry = this._mrcmRangeCache.get(constraint);
|
|
167
|
+
if (entry === undefined) {
|
|
168
|
+
entry = null;
|
|
169
|
+
try {
|
|
170
|
+
const ast = new ECLParser(new ECLLexer(constraint).tokenize()).parse();
|
|
171
|
+
const ctxt = this._evalECLNode(ast, null);
|
|
172
|
+
entry = {
|
|
173
|
+
ast: ast,
|
|
174
|
+
wildcard: !!ctxt.eclWildcard,
|
|
175
|
+
members: new Set(this._eclToIndexArray(ctxt))
|
|
176
|
+
};
|
|
177
|
+
} catch (error) {
|
|
178
|
+
// Concrete value ranges (dec/int/str) and anything else the ECL engine
|
|
179
|
+
// does not support: remembered as unusable so it is parsed only once.
|
|
180
|
+
entry = null;
|
|
181
|
+
}
|
|
182
|
+
this._mrcmRangeCache.set(constraint, entry);
|
|
183
|
+
}
|
|
184
|
+
if (!entry) {
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
if (entry.wildcard) {
|
|
188
|
+
return true;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const concepts = (value.concepts || []).filter(c => c.reference != null && c.reference !== NO_REFERENCE);
|
|
192
|
+
const refined = value.hasRefinements() || value.hasRefinementGroups();
|
|
193
|
+
if (!refined) {
|
|
194
|
+
if (concepts.length === 0) {
|
|
195
|
+
return null; // a concrete value, or nothing we can resolve
|
|
196
|
+
}
|
|
197
|
+
// Every focus concept of a plain value must be in the constraint's set.
|
|
198
|
+
return concepts.every(c => entry.members.has(c.reference));
|
|
199
|
+
}
|
|
200
|
+
// A refined value: ask the ECL engine whether the expression satisfies the
|
|
201
|
+
// constraint (it knows that a refinement of X is still << X).
|
|
202
|
+
return this._eclExpressionSatisfies({ expression: value }, entry.ast, null);
|
|
151
203
|
}
|
|
152
204
|
|
|
153
205
|
close() {
|
|
@@ -664,7 +716,7 @@ class SnomedServices {
|
|
|
664
716
|
// we actually need the full concept list, otherwise filterSize returns 0
|
|
665
717
|
// and the iteration yields nothing. Materialise active concepts now.
|
|
666
718
|
if (forIteration && result.eclWildcard && (!result.descendants || result.descendants.length === 0)) {
|
|
667
|
-
result.descendants = this.
|
|
719
|
+
result.descendants = this._eclEnumerateAllConcepts(opContext);
|
|
668
720
|
delete result.eclWildcard;
|
|
669
721
|
// The materialised wildcard form differs from the lazy (eclWildcard) form,
|
|
670
722
|
// so mark it: the filter cache must not share it across iteration modes.
|
|
@@ -677,31 +729,29 @@ class SnomedServices {
|
|
|
677
729
|
};
|
|
678
730
|
|
|
679
731
|
/**
|
|
680
|
-
* Return every
|
|
681
|
-
*
|
|
732
|
+
* Return every concept's index. Used to materialise wildcard results when the
|
|
733
|
+
* filter needs to be iterated over (e.g. $expand).
|
|
734
|
+
*
|
|
735
|
+
* `*` means every concept the server knows, so this deliberately walks the
|
|
736
|
+
* concept table rather than descending from 138875005 |SNOMED CT Concept|.
|
|
737
|
+
* Two kinds of concept are in the table but not reachable from that root:
|
|
738
|
+
* inactive concepts (their is-a relationships are inactive too), and the
|
|
739
|
+
* foundation-metadata concepts that a subset carries without their is-a
|
|
740
|
+
* relationships - module concepts above all. Both belong in `*`, and both are
|
|
741
|
+
* counted by countAllCodes, so descending from the root made $expand of a
|
|
742
|
+
* `constraint = *` filter disagree with $expand of the whole code system.
|
|
743
|
+
*
|
|
744
|
+
* Inactive concepts are not filtered out here: activeOnly is applied by the
|
|
745
|
+
* expansion itself (see ValueSetExpander.processCodes), which is what lets one
|
|
746
|
+
* enumeration serve both activeOnly=true and activeOnly=false.
|
|
682
747
|
* @returns {number[]}
|
|
683
748
|
*/
|
|
684
|
-
|
|
685
|
-
// The wildcard `*` enumerates the SNOMED CT concept hierarchy: the root
|
|
686
|
-
// 138875005 |SNOMED CT Concept| and its descendants. This excludes the
|
|
687
|
-
// foundation-metadata concepts that appear in a subset's concept table
|
|
688
|
-
// (referenced by the RF2 files as module / type / acceptability ... ids)
|
|
689
|
-
// without their is-a relationships, so they are not reachable from the root
|
|
690
|
-
// and are not returned by `*` (matching Snowstorm). On a full edition every
|
|
691
|
-
// real concept is under the root, so nothing is lost.
|
|
692
|
-
const rootRes = this.concepts.findConcept(138875005n);
|
|
693
|
-
if (rootRes && rootRes.found) {
|
|
694
|
-
return this.filterIsA(138875005n, true).descendants;
|
|
695
|
-
}
|
|
696
|
-
// Fallback (no recognisable root): every active concept.
|
|
749
|
+
_eclEnumerateAllConcepts = function (opContext) {
|
|
697
750
|
const all = [];
|
|
698
751
|
const n = this.concepts.count();
|
|
699
752
|
for (let i = 0; i < n; i++) {
|
|
700
|
-
if (opContext) opContext.deadCheck('ecl:
|
|
701
|
-
|
|
702
|
-
if ((concept.flags & 0x0F) === 0) {
|
|
703
|
-
all.push(concept.index);
|
|
704
|
-
}
|
|
753
|
+
if (opContext) opContext.deadCheck('ecl:enumerateAllConcepts');
|
|
754
|
+
all.push(this.concepts.getConceptByCount(i).index);
|
|
705
755
|
}
|
|
706
756
|
return all;
|
|
707
757
|
};
|
|
@@ -918,7 +968,7 @@ class SnomedServices {
|
|
|
918
968
|
};
|
|
919
969
|
|
|
920
970
|
/**
|
|
921
|
-
* Wildcard —
|
|
971
|
+
* Wildcard — every concept, active or not. The eclWildcard flag tells filterCheck /
|
|
922
972
|
* filterLocate to accept every active concept without enumeration.
|
|
923
973
|
* @returns {SnomedFilterContext}
|
|
924
974
|
*/
|
|
@@ -1312,15 +1362,15 @@ class SnomedServices {
|
|
|
1312
1362
|
|
|
1313
1363
|
/**
|
|
1314
1364
|
* Like _eclToIndexArray, but if the context is a bare wildcard (no
|
|
1315
|
-
* descendants populated) it materialises the full
|
|
1316
|
-
* via
|
|
1365
|
+
* descendants populated) it materialises the full concept list
|
|
1366
|
+
* via _eclEnumerateAllConcepts. Used by dotted/refined evaluation,
|
|
1317
1367
|
* which need an explicit concept set to iterate over.
|
|
1318
1368
|
* @param {SnomedFilterContext} ctx
|
|
1319
1369
|
* @returns {number[]}
|
|
1320
1370
|
*/
|
|
1321
1371
|
_eclResolveSet = function (ctx, opContext) {
|
|
1322
1372
|
if (ctx.eclWildcard && (!ctx.descendants || ctx.descendants.length === 0)) {
|
|
1323
|
-
return this.
|
|
1373
|
+
return this._eclEnumerateAllConcepts(opContext);
|
|
1324
1374
|
}
|
|
1325
1375
|
return this._eclToIndexArray(ctx);
|
|
1326
1376
|
};
|
|
@@ -1369,9 +1419,9 @@ class SnomedServices {
|
|
|
1369
1419
|
const rightSet = new Set(this._eclToIndexArray(right));
|
|
1370
1420
|
|
|
1371
1421
|
if (left.eclWildcard) {
|
|
1372
|
-
// Wildcard minus the right set: use the shared
|
|
1373
|
-
//
|
|
1374
|
-
result.descendants = this.
|
|
1422
|
+
// Wildcard minus the right set: use the shared enumeration so `*` behaves
|
|
1423
|
+
// consistently everywhere.
|
|
1424
|
+
result.descendants = this._eclEnumerateAllConcepts(opContext)
|
|
1375
1425
|
.filter(idx => !rightSet.has(idx));
|
|
1376
1426
|
return result;
|
|
1377
1427
|
}
|
|
@@ -1734,19 +1784,6 @@ class SnomedProvider extends BaseCSServices {
|
|
|
1734
1784
|
}
|
|
1735
1785
|
}
|
|
1736
1786
|
|
|
1737
|
-
async incompleteValidationMessage(context) {
|
|
1738
|
-
|
|
1739
|
-
const ctxt = await this.#ensureContext(context);
|
|
1740
|
-
|
|
1741
|
-
if (!ctxt) return null;
|
|
1742
|
-
|
|
1743
|
-
if (ctxt.isComplex()) {
|
|
1744
|
-
return "The expression is grammatically correct and the concepts are valid, but the expression has not been checked against the SNOMED CT concept model (MRCM)";
|
|
1745
|
-
} else {
|
|
1746
|
-
return null;
|
|
1747
|
-
}
|
|
1748
|
-
}
|
|
1749
|
-
|
|
1750
1787
|
async locateIsA(code, parent, disallowParent = false) {
|
|
1751
1788
|
|
|
1752
1789
|
|
|
@@ -1881,25 +1918,37 @@ class SnomedProvider extends BaseCSServices {
|
|
|
1881
1918
|
if (ctxt instanceof SnomedExpressionContext) {
|
|
1882
1919
|
// ignore concepts for now, but list refinements and refinement groups
|
|
1883
1920
|
for (const refinement of ctxt.expression.refinements) {
|
|
1884
|
-
|
|
1885
|
-
const codeB = refinement.value.describe();
|
|
1886
|
-
const description = await this.display(codeB);
|
|
1887
|
-
let p = this._addCodeProperty(params, 'property', codeA, codeB, null, description);
|
|
1888
|
-
p.part.push({name: 'code-display', valueString: await this.display(codeA)});
|
|
1921
|
+
await this._addRefinementProperty(params, refinement);
|
|
1889
1922
|
}
|
|
1890
1923
|
for (const refinementGroup of ctxt.expression.refinementGroups) {
|
|
1891
1924
|
for (const refinement of refinementGroup.refinements) {
|
|
1892
|
-
|
|
1893
|
-
const codeB = refinement.value.describe();
|
|
1894
|
-
const description = await this.display(codeB);
|
|
1895
|
-
let p = this._addCodeProperty(params, 'property', codeA, codeB, null, description);
|
|
1896
|
-
p.part.push({name: 'code-display', valueString: await this.display(codeA)});
|
|
1925
|
+
await this._addRefinementProperty(params, refinement);
|
|
1897
1926
|
}
|
|
1898
1927
|
}
|
|
1899
1928
|
}
|
|
1900
1929
|
}
|
|
1901
1930
|
}
|
|
1902
1931
|
|
|
1932
|
+
/**
|
|
1933
|
+
* Report one refinement of a post-coordinated expression as a lookup property.
|
|
1934
|
+
*
|
|
1935
|
+
* The value is handled as an expression, not as a string: SnomedExpression's
|
|
1936
|
+
* describe() is a human-readable rendering that joins concepts and refinements
|
|
1937
|
+
* with commas ("85562004,272741003=24028007"), so round-tripping it through
|
|
1938
|
+
* display() parsed it back as SCG and failed on any refined value.
|
|
1939
|
+
*
|
|
1940
|
+
* @param {Object} params - the Parameters being built
|
|
1941
|
+
* @param {SnomedRefinement} refinement
|
|
1942
|
+
*/
|
|
1943
|
+
async _addRefinementProperty(params, refinement) {
|
|
1944
|
+
const codeA = refinement.name.code;
|
|
1945
|
+
const value = SnomedExpressionContext.fromExpression('', refinement.value);
|
|
1946
|
+
const codeB = await this.code(value);
|
|
1947
|
+
const description = await this.display(value);
|
|
1948
|
+
const p = this._addCodeProperty(params, 'property', codeA, codeB, null, description);
|
|
1949
|
+
p.part.push({name: 'code-display', valueString: await this.display(codeA)});
|
|
1950
|
+
}
|
|
1951
|
+
|
|
1903
1952
|
// Filter support
|
|
1904
1953
|
async doesFilter(prop, op, value) {
|
|
1905
1954
|
if (prop === 'concept') {
|
|
@@ -2131,7 +2180,7 @@ class SnomedProvider extends BaseCSServices {
|
|
|
2131
2180
|
// A wildcard (`*`) set is lazy - it carries the eclWildcard flag rather than a
|
|
2132
2181
|
// materialised descendant list - so count the active concepts it denotes.
|
|
2133
2182
|
if (set.eclWildcard && (!set.descendants || set.descendants.length === 0)) {
|
|
2134
|
-
return this.sct.
|
|
2183
|
+
return this.sct._eclEnumerateAllConcepts(this.opContext).length;
|
|
2135
2184
|
}
|
|
2136
2185
|
if (set.matches && set.matches.length > 0) {
|
|
2137
2186
|
return set.matches.length;
|
|
@@ -2183,7 +2232,7 @@ class SnomedProvider extends BaseCSServices {
|
|
|
2183
2232
|
const reference = ctxt.getReference();
|
|
2184
2233
|
|
|
2185
2234
|
if (set.eclWildcard) {
|
|
2186
|
-
return
|
|
2235
|
+
return ctxt; // `*` is every concept; inactive ones included
|
|
2187
2236
|
}
|
|
2188
2237
|
|
|
2189
2238
|
// A post-coordinated expression validated against an ECL value set: test the
|
|
@@ -2266,7 +2315,7 @@ class SnomedProvider extends BaseCSServices {
|
|
|
2266
2315
|
return this.#descendantsHas(set, reference);
|
|
2267
2316
|
}
|
|
2268
2317
|
if (set.eclWildcard) {
|
|
2269
|
-
return
|
|
2318
|
+
return true; // `*` is every concept; inactive ones included
|
|
2270
2319
|
}
|
|
2271
2320
|
return false;
|
|
2272
2321
|
}
|
|
@@ -2344,9 +2393,21 @@ class SnomedProvider extends BaseCSServices {
|
|
|
2344
2393
|
}
|
|
2345
2394
|
|
|
2346
2395
|
// Subsumption testing
|
|
2396
|
+
/**
|
|
2397
|
+
* Subsumption between two codes, either of which may be a postcoordinated expression.
|
|
2398
|
+
*
|
|
2399
|
+
* PRECONDITION: both codes have already been validated. doSubsumes (the only caller)
|
|
2400
|
+
* runs locate() on each first, which parses and runs checkExpression - so an
|
|
2401
|
+
* expression that breaks the concept model is reported as an invalid code before it
|
|
2402
|
+
* gets here, rather than having a subsumption answer computed for it. The check is
|
|
2403
|
+
* deliberately not repeated here: it evaluates ECL for the MRCM range constraints, and
|
|
2404
|
+
* doing that twice per request is not free.
|
|
2405
|
+
*
|
|
2406
|
+
* @param {string} codeA
|
|
2407
|
+
* @param {string} codeB
|
|
2408
|
+
* @returns {Promise<string>} equivalent | subsumes | subsumed-by | not-subsumed
|
|
2409
|
+
*/
|
|
2347
2410
|
async subsumesTest(codeA, codeB) {
|
|
2348
|
-
|
|
2349
|
-
|
|
2350
2411
|
try {
|
|
2351
2412
|
const exprA = new SnomedExpressionParser(this.sct.concepts).parse(codeA);
|
|
2352
2413
|
const exprB = new SnomedExpressionParser(this.sct.concepts).parse(codeB);
|
|
@@ -2375,10 +2436,32 @@ class SnomedProvider extends BaseCSServices {
|
|
|
2375
2436
|
} else if (b2) {
|
|
2376
2437
|
return 'subsumed-by';
|
|
2377
2438
|
} else {
|
|
2378
|
-
|
|
2439
|
+
// No structural relationship found - which is NOT the same as there being
|
|
2440
|
+
// none. Structural comparison of normal forms is sound when it succeeds (a
|
|
2441
|
+
// proof is a proof) but not when it fails: SNOMED's OWL axiom reference set
|
|
2442
|
+
// carries general concept inclusions and property chains whose entailments
|
|
2443
|
+
// are baked into the distributed inferred relationships for precoordinated
|
|
2444
|
+
// concepts, but a postcoordinated expression is one the classifier never saw,
|
|
2445
|
+
// so those entailments are not available for it. Answering 'not-subsumed'
|
|
2446
|
+
// here would assert something we have not established, in the direction that
|
|
2447
|
+
// wrongly excludes a code from a cohort.
|
|
2448
|
+
//
|
|
2449
|
+
// $subsumes has four outcome codes and none of them means "unknown", so the
|
|
2450
|
+
// only honest answer is an error. Both codes are valid and the request is
|
|
2451
|
+
// well formed, hence 422 rather than 400.
|
|
2452
|
+
throw cannotDetermineSubsumption('Unable to determine the subsumption relationship between '
|
|
2453
|
+
+ `'${codeA}' and '${codeB}': at least one is a postcoordinated expression and no `
|
|
2454
|
+
+ 'relationship follows from comparing their normal forms. Establishing that there '
|
|
2455
|
+
+ 'is no relationship would require classification, which this server does not do, '
|
|
2456
|
+
+ 'so no outcome is returned rather than one that may be wrong');
|
|
2379
2457
|
}
|
|
2380
2458
|
}
|
|
2381
2459
|
} catch (error) {
|
|
2460
|
+
// Errors that already say how they should be reported pass straight through -
|
|
2461
|
+
// wrapping them would lose the status and issue code.
|
|
2462
|
+
if (error.statusCode) {
|
|
2463
|
+
throw error;
|
|
2464
|
+
}
|
|
2382
2465
|
throw new Error(`Error in subsumption test: ${error.message}`);
|
|
2383
2466
|
}
|
|
2384
2467
|
}
|
package/tx/cs/cs-ucum.js
CHANGED
|
@@ -7,6 +7,7 @@ const { FilterExecutionContext, CodeSystemFactoryProvider} = require('./cs-api')
|
|
|
7
7
|
const ValueSet = require("../library/valueset");
|
|
8
8
|
const assert = require('assert');
|
|
9
9
|
const {UcumService} = require("../library/ucum-service");
|
|
10
|
+
const {Pair, Decimal} = require("../library/ucum-types");
|
|
10
11
|
const {validateArrayParameter, validateParameter, validateOptionalParameter} = require("../../library/utilities");
|
|
11
12
|
const {DesignationUse} = require("../library/designations");
|
|
12
13
|
const {BaseCSServices} = require("./cs-base");
|
|
@@ -397,11 +398,40 @@ class UcumCodeSystemProvider extends BaseCSServices {
|
|
|
397
398
|
return codeA === codeB;
|
|
398
399
|
}
|
|
399
400
|
|
|
401
|
+
/**
|
|
402
|
+
* UCUM has no hierarchy, so no code is ever a subtype of another. Two different codes can
|
|
403
|
+
* still mean the same unit though - 1/min and min-1 are the same thing written two ways -
|
|
404
|
+
* so when the codes differ, compare what they canonicalise to.
|
|
405
|
+
*
|
|
406
|
+
* This is the canonical *form*, not the canonical units: getCanonicalUnits() drops the
|
|
407
|
+
* magnitude, so m and cm both answer 'm' and would compare equal when they are different
|
|
408
|
+
* units. getCanonicalForm() carries the conversion factor with the unit, which is what
|
|
409
|
+
* distinguishes the same unit written differently from a merely comparable one.
|
|
410
|
+
*
|
|
411
|
+
* @param {string|UcumContext} codeA
|
|
412
|
+
* @param {string|UcumContext} codeB
|
|
413
|
+
* @returns {string} equivalent or not-subsumed
|
|
414
|
+
*/
|
|
400
415
|
async subsumesTest(codeA, codeB) {
|
|
416
|
+
const a = await this.#ensureContext(codeA);
|
|
417
|
+
const b = await this.#ensureContext(codeB);
|
|
401
418
|
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
419
|
+
if (a.code === b.code) {
|
|
420
|
+
return 'equivalent';
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
try {
|
|
424
|
+
const ca = this.ucumService.getCanonicalForm(new Pair(Decimal.one(), a.code));
|
|
425
|
+
const cb = this.ucumService.getCanonicalForm(new Pair(Decimal.one(), b.code));
|
|
426
|
+
if (ca.getCode() === cb.getCode() && ca.getValue().comparesTo(cb.getValue()) === 0) {
|
|
427
|
+
return 'equivalent';
|
|
428
|
+
}
|
|
429
|
+
} catch (e) {
|
|
430
|
+
// a unit that won't canonicalise is simply not equivalent to anything. locate() has
|
|
431
|
+
// already accepted both codes, so this is a unit the analyser can't reduce, not junk
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
return 'not-subsumed';
|
|
405
435
|
}
|
|
406
436
|
|
|
407
437
|
async extendLookup(ctxt, props, params) {
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* icd11-expressions.js -- postcoordinated ICD-11 expressions: parse, bind to axes, render.
|
|
3
|
+
*
|
|
4
|
+
* ICD-11 postcoordination has two levels of punctuation and one spelling per linearization:
|
|
5
|
+
*
|
|
6
|
+
* 1A00&XN8P1 MMS: a stem, then a value on one of its axes
|
|
7
|
+
* NC53.Y&XK8G&XJ778/PA71&XE5UF&XE4TZ a cluster: two stems, each with its own values
|
|
8
|
+
* 1D01.0Y/1G41/1G40 a cluster whose later members are values of the first
|
|
9
|
+
* d5409.qp3 ICF spells the same thing with a dot
|
|
10
|
+
* http://.../257068234 & http://.../194483911 any of it may be written as entity uris
|
|
11
|
+
*
|
|
12
|
+
* `&` says "this is a postcoordination value of the stem to my left": if it is not a value
|
|
13
|
+
* on any axis of that stem, the expression is wrong and says so. `/` says only "this is
|
|
14
|
+
* another element of the cluster": a member that happens to be a value on an unfilled axis
|
|
15
|
+
* of the current stem is taken as one (this is what the ICD Browser does, and it is what
|
|
16
|
+
* makes 1D01.0Y/1G41/1G40 mean what a coder means by it), and a member that is not simply
|
|
17
|
+
* starts a new stem.
|
|
18
|
+
*
|
|
19
|
+
* The syntax and the semantics are kept apart on purpose. The display renders the syntax --
|
|
20
|
+
* cluster members joined by " / ", values in brackets -- because that is what the coder
|
|
21
|
+
* wrote. The properties report the semantics: which value went on which axis. The two
|
|
22
|
+
* disagree for 1D01.0Y/1G41/1G40, and both are right.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
'use strict';
|
|
26
|
+
|
|
27
|
+
const { Issue } = require('../library/operation-outcome');
|
|
28
|
+
|
|
29
|
+
const SCHEMA_NS = 'http://id.who.int/icd/schema';
|
|
30
|
+
|
|
31
|
+
/** One element of the written expression, with the separator that introduced it. */
|
|
32
|
+
class ExpressionToken {
|
|
33
|
+
constructor(text, sep) {
|
|
34
|
+
this.text = text;
|
|
35
|
+
this.sep = sep; // null for the first token, otherwise '&', '/' or '.'
|
|
36
|
+
this.concept = null; // the ICD11Concept it names, once resolved
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* A parsed expression. `tokens` and `members` are the syntax; `stems` is the semantics.
|
|
42
|
+
* The context object the provider hands around for a postcoordinated code.
|
|
43
|
+
*/
|
|
44
|
+
class ICD11Expression {
|
|
45
|
+
constructor(raw, tokens, members, stems) {
|
|
46
|
+
this.raw = raw;
|
|
47
|
+
this.tokens = tokens;
|
|
48
|
+
this.members = members; // [{ head: Token, tail: [Token] }] -- as written
|
|
49
|
+
this.stems = stems; // [{ stem: Token, bindings: [{ axis, token }] }] -- as meant
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The short-code spelling: the form $lookup echoes back, whichever form came in. */
|
|
53
|
+
get canonicalCode() {
|
|
54
|
+
return this.tokens.map((t, i) => (i ? t.sep : '') + t.concept.canonicalCode).join('');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The entity-uri spelling. ICF's dot is the same operator as MMS's `&`, so it renders as
|
|
59
|
+
* `&` here -- the uri form has only the one spelling.
|
|
60
|
+
*/
|
|
61
|
+
get uriForm() {
|
|
62
|
+
return this.tokens
|
|
63
|
+
.map((t, i) => (i ? ` ${t.sep === '.' ? '&' : t.sep} ` : '') + t.concept.uri)
|
|
64
|
+
.join('');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** True when nothing was postcoordinated after all -- a bare code in expression clothing. */
|
|
68
|
+
get isTrivial() {
|
|
69
|
+
return this.tokens.length === 1;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Split the written form into tokens. A uri is one token including its slashes -- the `/`
|
|
75
|
+
* inside `http://id.who.int/icd/release/11/mms/257068234` is not a cluster separator -- so
|
|
76
|
+
* a uri runs to the next `&` or space. That leaves `/` between two uris ambiguous, which is
|
|
77
|
+
* a property of WHO's syntax rather than of this parser; the uri form in practice uses `&`.
|
|
78
|
+
*
|
|
79
|
+
* Returns null if the text is not shaped like an expression at all.
|
|
80
|
+
*/
|
|
81
|
+
function tokenize(text) {
|
|
82
|
+
const tokens = [];
|
|
83
|
+
const n = text.length;
|
|
84
|
+
let i = 0, sep = null;
|
|
85
|
+
while (i < n) {
|
|
86
|
+
while (i < n && text[i] === ' ') i++;
|
|
87
|
+
if (i >= n) return null; // trailing separator
|
|
88
|
+
const start = i;
|
|
89
|
+
const isUri = /^https?:\/\//.test(text.slice(i));
|
|
90
|
+
while (i < n && text[i] !== '&' && text[i] !== ' ' && (isUri || text[i] !== '/')) i++;
|
|
91
|
+
tokens.push(new ExpressionToken(text.slice(start, i), sep));
|
|
92
|
+
while (i < n && text[i] === ' ') i++;
|
|
93
|
+
if (i >= n) break;
|
|
94
|
+
if (text[i] !== '&' && text[i] !== '/') return null;
|
|
95
|
+
sep = text[i];
|
|
96
|
+
i++;
|
|
97
|
+
}
|
|
98
|
+
return tokens.length ? tokens : null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* ICF writes `d5409.qp3` where MMS writes `d5409&qp3`. A dot is only a separator when the
|
|
103
|
+
* whole token is not itself a code -- `1D01.0Y` is one code, and `BD11.2` is another -- so
|
|
104
|
+
* the longest prefix that resolves wins, and what is left becomes dot-separated values.
|
|
105
|
+
*
|
|
106
|
+
* Returns the tokens this one expands to, or null if nothing resolves.
|
|
107
|
+
*/
|
|
108
|
+
async function resolveToken(ctx, token) {
|
|
109
|
+
const whole = await ctx.resolve(token.text);
|
|
110
|
+
if (whole) {
|
|
111
|
+
token.concept = whole;
|
|
112
|
+
return [token];
|
|
113
|
+
}
|
|
114
|
+
if (!token.text.includes('.')) return null;
|
|
115
|
+
const parts = token.text.split('.');
|
|
116
|
+
for (let take = parts.length - 1; take >= 1; take--) {
|
|
117
|
+
const head = parts.slice(0, take).join('.');
|
|
118
|
+
const concept = await ctx.resolve(head);
|
|
119
|
+
if (!concept) continue;
|
|
120
|
+
const out = [new ExpressionToken(head, token.sep)];
|
|
121
|
+
out[0].concept = concept;
|
|
122
|
+
for (const rest of parts.slice(take)) {
|
|
123
|
+
const value = await ctx.resolve(rest);
|
|
124
|
+
if (!value) return null; // e.g. the pre-2026 ICF form d5409.3
|
|
125
|
+
const t = new ExpressionToken(rest, '.');
|
|
126
|
+
t.concept = value;
|
|
127
|
+
out.push(t);
|
|
128
|
+
}
|
|
129
|
+
return out;
|
|
130
|
+
}
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* The postcoordination axes a stem declares. Required axes come first, then WHO's own
|
|
136
|
+
* order. That ordering is what decides where an ambiguous value lands: 1D01.0Y declares
|
|
137
|
+
* hasManifestation (optional, second) and hasCausingCondition (required, fourth), 1G40 and
|
|
138
|
+
* 1G41 are both legal on either, and the ICD Browser reads 1D01.0Y/1G41/1G40 as causing
|
|
139
|
+
* condition then manifestation. An axis that must be filled is the one a coder is most
|
|
140
|
+
* likely to have been filling, so it gets first refusal.
|
|
141
|
+
*/
|
|
142
|
+
async function axesOf(ctx, concept) {
|
|
143
|
+
return ctx.all(
|
|
144
|
+
`SELECT id, axis, axis_name, required, allow_multiple, value_set_uri
|
|
145
|
+
FROM pc_scale WHERE concept = ? ORDER BY required DESC, seq`, [concept.id]);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Is `value` one of the permitted values of this axis -- a scale root or a descendant? */
|
|
149
|
+
async function permitted(ctx, axis, value) {
|
|
150
|
+
const row = await ctx.get(
|
|
151
|
+
`SELECT 1 AS ok FROM pc_scale_entity e
|
|
152
|
+
JOIN concept_closure cl ON cl.ancestor = e.target
|
|
153
|
+
WHERE e.scale = ? AND cl.descendant = ? LIMIT 1`, [axis.id, value.id]);
|
|
154
|
+
return !!row;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Put a value on an axis of the stem. An axis not yet used is always preferred over one
|
|
159
|
+
* that is, which is what keeps the two values of 1D01.0Y/1G41/1G40 apart: both are legal on
|
|
160
|
+
* hasCausingCondition and on hasManifestation, and coalescing them onto the first is the
|
|
161
|
+
* thing the ICD-API gets wrong (see doco.txt item 13). The same rule sends the second
|
|
162
|
+
* XM4SL9 of PB20&XM4SL9&XM4SL9&... to a second axis rather than dropping it.
|
|
163
|
+
*
|
|
164
|
+
* Returns the axis, or null when the value is on none of them.
|
|
165
|
+
*/
|
|
166
|
+
async function bind(ctx, stem, token) {
|
|
167
|
+
let fallback = null;
|
|
168
|
+
for (const axis of stem.axes) {
|
|
169
|
+
if (!await permitted(ctx, axis, token.concept)) continue;
|
|
170
|
+
if (!stem.used.has(axis.id)) {
|
|
171
|
+
stem.used.add(axis.id);
|
|
172
|
+
stem.bindings.push({ axis, token });
|
|
173
|
+
return axis;
|
|
174
|
+
}
|
|
175
|
+
if (fallback === null) fallback = axis;
|
|
176
|
+
}
|
|
177
|
+
if (fallback === null) return null;
|
|
178
|
+
// every axis this value fits is already carrying one. allowMultipleValues decides whether
|
|
179
|
+
// that is legal; nothing in the suite pins it yet, so the value is kept rather than lost.
|
|
180
|
+
stem.bindings.push({ axis: fallback, token });
|
|
181
|
+
return fallback;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Parse and bind. Returns an ICD11Expression, or null when the text does not resolve (which
|
|
186
|
+
* the caller reports as an ordinary unknown code). Throws an Issue when the text does
|
|
187
|
+
* resolve but says something that is not true -- a value on no axis of its stem -- because
|
|
188
|
+
* "not found" would hide the reason.
|
|
189
|
+
*/
|
|
190
|
+
async function parseExpression(ctx, text) {
|
|
191
|
+
const raw = tokenize(text);
|
|
192
|
+
if (!raw) return null;
|
|
193
|
+
|
|
194
|
+
const tokens = [];
|
|
195
|
+
for (const t of raw) {
|
|
196
|
+
const resolved = await resolveToken(ctx, t);
|
|
197
|
+
if (!resolved) return null;
|
|
198
|
+
tokens.push(...resolved);
|
|
199
|
+
}
|
|
200
|
+
if (tokens.length === 1) return null; // a plain code, already handled
|
|
201
|
+
|
|
202
|
+
// syntax: cluster members, split at '/'
|
|
203
|
+
const members = [];
|
|
204
|
+
for (const t of tokens) {
|
|
205
|
+
if (t.sep === '/' || members.length === 0) members.push({ head: t, tail: [] });
|
|
206
|
+
else members[members.length - 1].tail.push(t);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// semantics: which member is a stem, and which value went on which axis
|
|
210
|
+
const stems = [];
|
|
211
|
+
let cur = null;
|
|
212
|
+
for (let k = 0; k < members.length; k++) {
|
|
213
|
+
const m = members[k];
|
|
214
|
+
if (k > 0 && m.tail.length === 0 && cur && await bind(ctx, cur, m.head)) {
|
|
215
|
+
continue; // a cluster member that is a value
|
|
216
|
+
}
|
|
217
|
+
cur = { stem: m.head, axes: await axesOf(ctx, m.head.concept), bindings: [], used: new Set() };
|
|
218
|
+
stems.push(cur);
|
|
219
|
+
for (const t of m.tail) {
|
|
220
|
+
if (!await bind(ctx, cur, t)) {
|
|
221
|
+
throw notOnAnyAxis(ctx, cur, t);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return new ICD11Expression(text, tokens, members, stems);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* `&` asserted that this value belongs on an axis of the stem, and it does not. Say which
|
|
230
|
+
* value, and which axes the stem actually has: "could not be found in the CodeSystem" is
|
|
231
|
+
* the answer that sends people looking in the wrong place (doco.txt item 15).
|
|
232
|
+
*/
|
|
233
|
+
function notOnAnyAxis(ctx, stem, token) {
|
|
234
|
+
const axes = stem.axes.map(a => a.axis_name).join(', ');
|
|
235
|
+
const msg = axes
|
|
236
|
+
? `'${token.text}' is not a permitted value on any postcoordination axis of `
|
|
237
|
+
+ `'${stem.stem.text}'. That stem declares: ${axes}`
|
|
238
|
+
: `'${token.text}' cannot be postcoordinated onto '${stem.stem.text}': that concept `
|
|
239
|
+
+ `declares no postcoordination axes`;
|
|
240
|
+
return new Issue('error', 'code-invalid', null, null, msg, 'invalid-code', 400).handleAsOO(400);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
module.exports = { ICD11Expression, ExpressionToken, parseExpression, tokenize, SCHEMA_NS };
|