fhirsmith 0.17.0 → 0.18.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 +83 -0
- package/README.md +80 -13
- package/config-template.json +106 -1
- package/ips/ai-client.js +263 -0
- package/ips/composer.js +805 -0
- package/ips/editor-schema.js +289 -0
- package/ips/editor.js +290 -0
- package/ips/fhirpath-ips.js +397 -0
- package/ips/ips-codeable-concepts.md +1742 -0
- package/ips/ips-template.html +125 -0
- package/ips/ips-valuesets.js +159 -0
- package/ips/ips.js +1736 -0
- package/ips/membership.js +179 -0
- package/ips/pipeline.js +1012 -0
- package/ips/presenter.js +974 -0
- package/ips/profile-editor.js +578 -0
- package/ips/profiles.js +506 -0
- package/ips/readme.md +460 -0
- package/ips/render.js +1482 -0
- package/ips/search-params.js +83 -0
- package/ips/search.js +667 -0
- package/ips/share.js +178 -0
- package/ips/store.js +650 -0
- package/ips/terminology.js +395 -0
- package/ips/tx-client.js +202 -0
- package/ips/valuesets/CodeSystem-absent-unknown-uv-ips.json +206 -0
- package/ips/valuesets/ValueSet-absent-or-unknown-immunizations-uv-ips.json +59 -0
- package/ips/valuesets/ValueSet-ips-allergy-code.json +59 -0
- package/ips/valuesets/ValueSet-ips-allergy-manifestation.json +356 -0
- package/ips/valuesets/ValueSet-ips-allergy-substance.json +37 -0
- package/ips/valuesets/ValueSet-ips-body-site.json +26 -0
- package/ips/valuesets/ValueSet-ips-clinical-finding-or-none.json +35 -0
- package/ips/valuesets/ValueSet-ips-clinical-finding.json +26 -0
- package/ips/valuesets/ValueSet-ips-condition-code.json +72 -0
- package/ips/valuesets/ValueSet-ips-condition-severity.json +30 -0
- package/ips/valuesets/ValueSet-ips-condition-stage.json +26 -0
- package/ips/valuesets/ValueSet-ips-exposure-route.json +26 -0
- package/ips/valuesets/ValueSet-ips-imaging-procedure.json +25 -0
- package/ips/valuesets/ValueSet-ips-immunization-status-reason.json +43 -0
- package/ips/valuesets/ValueSet-ips-international-codes.json +25 -0
- package/ips/valuesets/ValueSet-ips-medication-ingredient.json +26 -0
- package/ips/valuesets/ValueSet-ips-medication-status-reason.json +37 -0
- package/ips/valuesets/ValueSet-ips-medication.json +50 -0
- package/ips/valuesets/ValueSet-ips-observation-code.json +18 -0
- package/ips/valuesets/ValueSet-ips-observation-interpretation.json +18 -0
- package/ips/valuesets/ValueSet-ips-procedure-followup.json +51 -0
- package/ips/valuesets/ValueSet-ips-procedure-outcome.json +30 -0
- package/ips/valuesets/ValueSet-ips-procedure-reason.json +37 -0
- package/ips/valuesets/ValueSet-ips-procedure.json +116 -0
- package/ips/valuesets/ValueSet-ips-route.json +46 -0
- package/ips/valuesets/ValueSet-ips-target-disease.json +171 -0
- package/ips/valuesets/ValueSet-ips-vaccine.json +31 -0
- package/ips/valuesets/elements.json +44 -0
- package/library/daily-report.js +422 -0
- package/library/fhir-search-values.js +138 -0
- package/library/html-server.js +32 -23
- package/library/mailer.js +77 -0
- package/library/narrative-check.js +21 -2
- package/library/npm-audit.js +25 -0
- package/library/package-manager.js +4 -2
- package/library/sqlite-session-store.js +29 -1
- package/package.json +7 -2
- package/packages/packages.js +34 -3
- package/publisher/publisher.js +29 -0
- package/registry/auth-store.js +366 -0
- package/registry/auth.js +872 -0
- package/registry/crawler.js +41 -1
- package/registry/documentation/api.md +162 -0
- package/registry/documentation/configuration.md +93 -0
- package/registry/documentation/documents.md +74 -0
- package/registry/documentation/internals.md +151 -0
- package/registry/documentation/readme.md +100 -0
- package/registry/documentation/security.md +199 -0
- package/registry/documents.js +309 -0
- package/registry/openapi.yaml +30 -0
- package/registry/registry-template.html +1 -1
- package/registry/registry.js +230 -44
- package/registry/uptime.js +154 -0
- package/server.js +105 -44
- package/shl/shl.js +64 -9
- package/stats-db.js +93 -0
- package/stats.js +158 -1
- package/testing/search.js +2 -108
- package/testing/store.js +5 -0
- package/testing/testing.js +20 -0
- package/token/token-template.html +5 -4
- package/token/token.js +17 -10
- package/translations/Messages.properties +9 -0
- package/tx/README.md +52 -0
- package/tx/cs/cs-api.js +90 -14
- package/tx/cs/cs-cs.js +56 -1
- package/tx/cs/cs-icd11.js +366 -112
- package/tx/cs/cs-icf.js +418 -0
- package/tx/cs/cs-loinc.js +47 -25
- package/tx/cs/cs-omop.js +10 -3
- package/tx/cs/cs-snomed.js +918 -55
- package/tx/cs/cs-ucum.js +6 -0
- package/tx/cs/icd11-expressions.js +191 -78
- package/tx/importers/export-icd11-codesystem.js +58 -12
- package/tx/importers/icd11-schema.md +9 -5
- package/tx/library.js +98 -4
- package/tx/openapi-operations.js +4 -0
- package/tx/params.js +8 -1
- package/tx/provider.js +16 -4
- package/tx/sct/documentation.md +251 -0
- package/tx/sct/ecl.js +758 -46
- package/tx/sct/expressions.js +113 -52
- package/tx/syndication/syndication-feed.js +218 -0
- package/tx/syndication/syndication-store.js +415 -0
- package/tx/tx-html.js +15 -1
- package/tx/tx.fhir.org.yml +2 -3
- package/tx/tx.js +144 -2
- package/tx/vs/vs-vsac.js +25 -6
- package/tx/workers/expand.js +33 -6
- package/tx/workers/lookup.js +61 -6
- package/tx/workers/subsumes.js +1 -1
- package/tx/workers/translate.js +166 -15
- package/tx/workers/validate.js +128 -6
- package/utilities/make-sct-test-extension.js +264 -0
- package/xig/xig.js +32 -0
- package/registry/readme.md +0 -124
package/tx/library.js
CHANGED
|
@@ -93,6 +93,12 @@ class Library {
|
|
|
93
93
|
totalDownloaded = 0;
|
|
94
94
|
vsacCfg = undefined;
|
|
95
95
|
|
|
96
|
+
/**
|
|
97
|
+
* {SyndicationStore|null} where the content this library loads is recorded for the
|
|
98
|
+
* syndication feed, when the server has one (see tx/syndication)
|
|
99
|
+
*/
|
|
100
|
+
syndication = null;
|
|
101
|
+
|
|
96
102
|
registerProvider(source, factory, isDefault = false) {
|
|
97
103
|
this.#logSystem(factory.system(), factory.version(), source);
|
|
98
104
|
if (isDefault || !this.codeSystemFactories.has(factory.system())) {
|
|
@@ -607,6 +613,7 @@ class Library {
|
|
|
607
613
|
const loinc = new LoincServicesFactory(this.i18n, loincFN);
|
|
608
614
|
await loinc.load();
|
|
609
615
|
this.registerProvider(loincFN, loinc, isDefault);
|
|
616
|
+
this.#syndicateBinary('loinc', loincFN, loinc);
|
|
610
617
|
}
|
|
611
618
|
|
|
612
619
|
async loadRxnorm(details, isDefault, mode) {
|
|
@@ -617,6 +624,7 @@ class Library {
|
|
|
617
624
|
const rxn = new RxNormServicesFactory(this.i18n, rxNormFN);
|
|
618
625
|
await rxn.load();
|
|
619
626
|
this.registerProvider(rxNormFN, rxn, isDefault);
|
|
627
|
+
this.#syndicateBinary('rxnorm', rxNormFN, rxn);
|
|
620
628
|
}
|
|
621
629
|
|
|
622
630
|
async loadNdc(details, isDefault, mode) {
|
|
@@ -627,6 +635,7 @@ class Library {
|
|
|
627
635
|
const ndc = new NdcServicesFactory(this.i18n, ndcFN);
|
|
628
636
|
await ndc.load();
|
|
629
637
|
this.registerProvider(ndcFN, ndc, isDefault);
|
|
638
|
+
this.#syndicateBinary('ndc', ndcFN, ndc);
|
|
630
639
|
}
|
|
631
640
|
|
|
632
641
|
async loadUnii(details, isDefault, mode) {
|
|
@@ -637,6 +646,7 @@ class Library {
|
|
|
637
646
|
const unii = new UniiServicesFactory(this.i18n, uniFN);
|
|
638
647
|
await unii.load();
|
|
639
648
|
this.registerProvider(uniFN, unii, isDefault);
|
|
649
|
+
this.#syndicateBinary('unii', uniFN, unii);
|
|
640
650
|
}
|
|
641
651
|
|
|
642
652
|
async loadSnomed(details, isDefault, mode) {
|
|
@@ -647,6 +657,7 @@ class Library {
|
|
|
647
657
|
const sct = new SnomedServicesFactory(this.i18n, sctFN);
|
|
648
658
|
await sct.load();
|
|
649
659
|
this.registerProvider(sctFN, sct, isDefault);
|
|
660
|
+
this.#syndicateBinary('snomed', sctFN, sct);
|
|
650
661
|
}
|
|
651
662
|
|
|
652
663
|
async loadCpt(details, isDefault, mode) {
|
|
@@ -657,6 +668,8 @@ class Library {
|
|
|
657
668
|
const cpt = new CPTServicesFactory(this.i18n, cptFN);
|
|
658
669
|
await cpt.load();
|
|
659
670
|
this.registerProvider(cptFN, cpt, isDefault);
|
|
671
|
+
// the store syndicates the public fragment only, never the full (licensed) database
|
|
672
|
+
this.#syndicateBinary('cpt', cptFN, cpt);
|
|
660
673
|
}
|
|
661
674
|
|
|
662
675
|
async loadOmop(details, isDefault, mode) {
|
|
@@ -667,6 +680,7 @@ class Library {
|
|
|
667
680
|
const omop = new OMOPServicesFactory(this.i18n, omopFN);
|
|
668
681
|
await omop.load();
|
|
669
682
|
this.registerProvider(omopFN, omop, isDefault);
|
|
683
|
+
this.#syndicateBinary('omop', omopFN, omop);
|
|
670
684
|
}
|
|
671
685
|
|
|
672
686
|
/**
|
|
@@ -679,13 +693,69 @@ class Library {
|
|
|
679
693
|
if (mode === "fetch" || mode === "npm") {
|
|
680
694
|
return;
|
|
681
695
|
}
|
|
696
|
+
// one file, so one syndication entry - identified by the MMS, if the file has it
|
|
697
|
+
let main = null;
|
|
682
698
|
for (const sys of await ICD11ServicesFactory.listSystems(icdFN)) {
|
|
683
699
|
const factory = new ICD11ServicesFactory(this.i18n, icdFN, sys.code);
|
|
684
700
|
await factory.load();
|
|
685
701
|
this.registerProvider(icdFN, factory, isDefault);
|
|
702
|
+
if (!main || sys.code === 'mms') {
|
|
703
|
+
main = factory;
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
if (main) {
|
|
707
|
+
this.#syndicateBinary('icd11', icdFN, main);
|
|
686
708
|
}
|
|
687
709
|
}
|
|
688
710
|
|
|
711
|
+
/**
|
|
712
|
+
* Records a binary source file (SNOMED cache, LOINC database etc) in the syndication store,
|
|
713
|
+
* if there is one. The store works in the background, and decides itself whether the file
|
|
714
|
+
* is one it syndicates; nothing it does can stop the library loading.
|
|
715
|
+
* @private
|
|
716
|
+
*/
|
|
717
|
+
#syndicateBinary(sourceType, filePath, factory) {
|
|
718
|
+
if (!this.syndication) {
|
|
719
|
+
return;
|
|
720
|
+
}
|
|
721
|
+
try {
|
|
722
|
+
let name = null;
|
|
723
|
+
try {
|
|
724
|
+
name = factory.name();
|
|
725
|
+
} catch (e) {
|
|
726
|
+
// not every factory has a name
|
|
727
|
+
}
|
|
728
|
+
void this.syndication.inductBinary({
|
|
729
|
+
sourceType, filePath, baseUrl: this.baseUrl, system: factory.system(), version: factory.version(), name
|
|
730
|
+
});
|
|
731
|
+
} catch (error) {
|
|
732
|
+
this.log.warn(`Syndication: unable to record ${filePath}: ${error.message}`);
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
/**
|
|
737
|
+
* Records a package in the syndication store, if there is one.
|
|
738
|
+
* @param {PackageContentLoader} contentLoader - the loaded package
|
|
739
|
+
* @param {Object} source - {requestedVersion} for an npm: source, {url} for a url: source,
|
|
740
|
+
* {fhirVersion} (major.minor) for an endpoint's core package
|
|
741
|
+
*/
|
|
742
|
+
syndicatePackage(contentLoader, source = {}) {
|
|
743
|
+
if (!this.syndication || !contentLoader || !contentLoader.package) {
|
|
744
|
+
return;
|
|
745
|
+
}
|
|
746
|
+
const pkg = contentLoader.package;
|
|
747
|
+
void this.syndication.inductPackage({
|
|
748
|
+
id: contentLoader.id(),
|
|
749
|
+
version: contentLoader.version(),
|
|
750
|
+
canonical: pkg.canonical,
|
|
751
|
+
title: pkg.title,
|
|
752
|
+
description: pkg.description,
|
|
753
|
+
url: source.url,
|
|
754
|
+
fhirVersion: source.fhirVersion,
|
|
755
|
+
requestedVersion: source.requestedVersion
|
|
756
|
+
});
|
|
757
|
+
}
|
|
758
|
+
|
|
689
759
|
/**
|
|
690
760
|
* Returns true if the given url/version should be excluded from npm/url package loading.
|
|
691
761
|
* Matches against the ignored list using either plain url or url#version.
|
|
@@ -707,12 +777,12 @@ class Library {
|
|
|
707
777
|
version = parts[1];
|
|
708
778
|
}
|
|
709
779
|
const packagePath = await packageManager.fetch(packageId, version);
|
|
710
|
-
await this.#loadPackageFromPath(packagePath, mode, csOnly);
|
|
780
|
+
await this.#loadPackageFromPath(packagePath, mode, csOnly, { requestedVersion: version });
|
|
711
781
|
}
|
|
712
782
|
|
|
713
783
|
async loadUrl(packageManager, url, isDefault, mode, csOnly) {
|
|
714
784
|
const packagePath = await packageManager.fetchUrl(url);
|
|
715
|
-
await this.#loadPackageFromPath(packagePath, mode, csOnly);
|
|
785
|
+
await this.#loadPackageFromPath(packagePath, mode, csOnly, { url });
|
|
716
786
|
}
|
|
717
787
|
|
|
718
788
|
/**
|
|
@@ -723,13 +793,14 @@ class Library {
|
|
|
723
793
|
* array-backed codeSystems list, breaking url sources).
|
|
724
794
|
* @private
|
|
725
795
|
*/
|
|
726
|
-
async #loadPackageFromPath(packagePath, mode, csOnly) {
|
|
796
|
+
async #loadPackageFromPath(packagePath, mode, csOnly, source) {
|
|
727
797
|
if (mode === "fetch" || mode === "cs") {
|
|
728
798
|
return;
|
|
729
799
|
}
|
|
730
800
|
const fullPackagePath = path.join(this.cacheFolder, packagePath);
|
|
731
801
|
const contentLoader = new PackageContentLoader(fullPackagePath);
|
|
732
802
|
await contentLoader.initialize();
|
|
803
|
+
this.syndicatePackage(contentLoader, source);
|
|
733
804
|
|
|
734
805
|
this.packageSources.push(contentLoader.id()+"#"+contentLoader.version());
|
|
735
806
|
|
|
@@ -852,6 +923,27 @@ class Library {
|
|
|
852
923
|
*/
|
|
853
924
|
async #loadLocalSource(contentLoader) {
|
|
854
925
|
await contentLoader.initialize();
|
|
926
|
+
if (this.syndication) {
|
|
927
|
+
// each resource is an entry in its own right (those without a version are skipped)
|
|
928
|
+
for (const entry of contentLoader.index.files) {
|
|
929
|
+
if (this.#isIgnored(entry.url, entry.version)) {
|
|
930
|
+
continue;
|
|
931
|
+
}
|
|
932
|
+
let title = null;
|
|
933
|
+
let description = null;
|
|
934
|
+
try {
|
|
935
|
+
const json = JSON.parse(await fs.readFile(path.join(contentLoader.sourceFolder, entry.filename), 'utf8'));
|
|
936
|
+
title = json.title || json.name || null;
|
|
937
|
+
description = typeof json.description === 'string' ? json.description : null;
|
|
938
|
+
} catch (e) {
|
|
939
|
+
// initialize() already read it; if it can't now, the store will say so
|
|
940
|
+
}
|
|
941
|
+
void this.syndication.inductResource({
|
|
942
|
+
filePath: path.join(contentLoader.sourceFolder, entry.filename),
|
|
943
|
+
resourceType: entry.resourceType, url: entry.url, version: entry.version, title, description
|
|
944
|
+
});
|
|
945
|
+
}
|
|
946
|
+
}
|
|
855
947
|
|
|
856
948
|
this.packageSources.push(contentLoader.id() + "#" + contentLoader.version());
|
|
857
949
|
|
|
@@ -1050,7 +1142,9 @@ class Library {
|
|
|
1050
1142
|
|
|
1051
1143
|
// Load FHIR packages - these will be added to valueSetProviders first
|
|
1052
1144
|
for (const packageId of fhirPackages) {
|
|
1053
|
-
await provider.loadNpm(this.packageManager, this.cacheFolder, packageId, false, "npm", false);
|
|
1145
|
+
const contentLoader = await provider.loadNpm(this.packageManager, this.cacheFolder, packageId, false, "npm", false);
|
|
1146
|
+
// the core packages are the only version specific content in the syndication store
|
|
1147
|
+
this.syndicatePackage(contentLoader, { fhirVersion: VersionUtilities.getMajMin(String(fhirVersion)) });
|
|
1054
1148
|
}
|
|
1055
1149
|
|
|
1056
1150
|
|
package/tx/openapi-operations.js
CHANGED
|
@@ -209,6 +209,7 @@ const OPERATIONS = [
|
|
|
209
209
|
p('version', 'string', '1', 'The version of the code system'),
|
|
210
210
|
p('coding', 'Coding', '1', 'The code, as a Coding'),
|
|
211
211
|
p('property', 'code', '*', 'A property to return (by default, all of them)'),
|
|
212
|
+
p('post-coordination-metadata', 'boolean', '1', 'For a post-coordinated code: describe every post-coordination axis in full - the axis rules, and the axes that carry no value (ICD-11). By default only the axes that carry a value are listed'),
|
|
212
213
|
],
|
|
213
214
|
out: [
|
|
214
215
|
p('name', 'string', '1', 'The name of the code system', { min: 1 }),
|
|
@@ -283,11 +284,14 @@ const OPERATIONS = [
|
|
|
283
284
|
parts: [
|
|
284
285
|
p('relationship', 'code', '1', 'How the target relates to the source'),
|
|
285
286
|
p('concept', 'Coding', '1', 'The target'),
|
|
287
|
+
p('normalized-concept', 'Coding', '1', 'The target in its code system\'s normal form, when the map states it in another form'),
|
|
288
|
+
p('note', 'string', '1', 'The target\'s code system is not known, so the code has not been validated'),
|
|
286
289
|
p('product', '(parts)', '*', 'Another element of the product (element, concept)'),
|
|
287
290
|
p('originMap', 'canonical', '1', 'The concept map'),
|
|
288
291
|
p('sourceConcept', 'Coding', '1', 'What was translated (for a translation in reverse)')
|
|
289
292
|
]
|
|
290
293
|
}),
|
|
294
|
+
p('error', 'string', '*', 'A target code the maps gave that is not valid in its code system'),
|
|
291
295
|
p('used-conceptmap', 'canonical', '*', 'A concept map that was used')
|
|
292
296
|
]
|
|
293
297
|
},
|
package/tx/params.js
CHANGED
|
@@ -380,6 +380,12 @@ class TxParameters {
|
|
|
380
380
|
if (claim('includeDefinition')) this.includeDefinition = asBool();
|
|
381
381
|
break;
|
|
382
382
|
}
|
|
383
|
+
// $lookup: describe each postcoordination axis in full (ICD-11) - the axis rules, and the
|
|
384
|
+
// axes that carry no value. Off unless asked for
|
|
385
|
+
case 'post-coordination-metadata': {
|
|
386
|
+
if (claim('post-coordination-metadata')) this.postCoordinationMetadata = asBool();
|
|
387
|
+
break;
|
|
388
|
+
}
|
|
383
389
|
case 'activeOnly': {
|
|
384
390
|
if (claim('activeOnly')) this.activeOnly = asBool();
|
|
385
391
|
break;
|
|
@@ -624,7 +630,7 @@ class TxParameters {
|
|
|
624
630
|
// are included, and diagnostics.
|
|
625
631
|
// filter is free text, so JSON.stringify it to avoid delimiter collisions.
|
|
626
632
|
s = s + 'f:' + JSON.stringify(this.filter || '') + '|' +
|
|
627
|
-
b(this.abstractOk) + b(this.diagnostics);
|
|
633
|
+
b(this.abstractOk) + b(this.diagnostics) + b(this.postCoordinationMetadata);
|
|
628
634
|
for (let t of this.FVersionRules) {
|
|
629
635
|
s = s + t.asString() + '|';
|
|
630
636
|
}
|
|
@@ -676,6 +682,7 @@ class TxParameters {
|
|
|
676
682
|
this.hasVersionsMatch = other.hasVersionsMatch;
|
|
677
683
|
this.hasDisplayWarning = other.hasDisplayWarning;
|
|
678
684
|
this.sort = other.sort;
|
|
685
|
+
this.postCoordinationMetadata = other.postCoordinationMetadata;
|
|
679
686
|
|
|
680
687
|
if (other.FSeen) {
|
|
681
688
|
this.FSeen = new Set(other.FSeen);
|
package/tx/provider.js
CHANGED
|
@@ -151,7 +151,7 @@ class Provider {
|
|
|
151
151
|
validateParameter(opContext, "opContext", OperationContext);
|
|
152
152
|
validateParameter(codeSystem, "codeSystem", CodeSystem);
|
|
153
153
|
validateArrayParameter(supplements, "supplements", CodeSystem);
|
|
154
|
-
return
|
|
154
|
+
return FhirCodeSystemProvider.create(opContext, codeSystem, supplements);
|
|
155
155
|
}
|
|
156
156
|
|
|
157
157
|
|
|
@@ -160,6 +160,8 @@ class Provider {
|
|
|
160
160
|
* space (CodeSystem/core-v3-MaritalStatus): the core packages for different FHIR versions
|
|
161
161
|
* share ids, and so does other content (hl7.terminology), so no endpoint can serve them on
|
|
162
162
|
* their own ids (see tx/library/resource-ids.js)
|
|
163
|
+
*
|
|
164
|
+
* @returns {Promise<PackageContentLoader|undefined>} the loaded package (mode "npm")
|
|
163
165
|
*/
|
|
164
166
|
async loadNpm(packageManager, cacheFolder, details, isDefault, mode) {
|
|
165
167
|
// Parse packageId and version from details (e.g., "hl7.terminology.r4#6.0.2")
|
|
@@ -216,6 +218,7 @@ class Provider {
|
|
|
216
218
|
await cm.initialize();
|
|
217
219
|
cm.assignIds(CORE_PREFIX);
|
|
218
220
|
this.conceptMapProviders.push(cm);
|
|
221
|
+
return contentLoader;
|
|
219
222
|
}
|
|
220
223
|
|
|
221
224
|
getCodeSystemById(opContext, id) {
|
|
@@ -331,16 +334,25 @@ class Provider {
|
|
|
331
334
|
|
|
332
335
|
|
|
333
336
|
|
|
334
|
-
async findConceptMapForTranslation(opContext, conceptMaps, sourceSystem, sourceScope, targetScope, targetSystem, sourceCode = null) {
|
|
337
|
+
async findConceptMapForTranslation(opContext, conceptMaps, sourceSystem, sourceVersion, sourceScope, targetScope, targetSystem, sourceCode = null) {
|
|
335
338
|
for (let cmp of this.conceptMapProviders) {
|
|
336
339
|
await cmp.findConceptMapForTranslation(opContext, conceptMaps, sourceSystem, sourceScope, targetScope, targetSystem, sourceCode);
|
|
337
340
|
}
|
|
338
341
|
if (sourceSystem && targetSystem) {
|
|
342
|
+
// Each code system is asked once - except the source system itself, where every
|
|
343
|
+
// version (every SNOMED edition, say) is asked, since whether a map exists and what
|
|
344
|
+
// it says can depend on which version the source coding is in. The factory decides
|
|
345
|
+
// from sourceVersion whether it is the one being asked about.
|
|
339
346
|
let uris = new Set();
|
|
347
|
+
let asked = new Set(); // a factory is registered under several keys (system, system|version...)
|
|
340
348
|
for (let csp of this.codeSystemFactories.values()) {
|
|
341
|
-
if (
|
|
349
|
+
if (asked.has(csp)) {
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
352
|
+
if (csp.system() === sourceSystem || !uris.has(csp.system())) {
|
|
342
353
|
uris.add(csp.system());
|
|
343
|
-
|
|
354
|
+
asked.add(csp);
|
|
355
|
+
await csp.findImplicitConceptMaps(conceptMaps, sourceSystem, sourceVersion, targetSystem);
|
|
344
356
|
}
|
|
345
357
|
}
|
|
346
358
|
}
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
# SNOMED CT support in FHIRsmith: known limitations
|
|
2
|
+
|
|
3
|
+
This file records what the SNOMED CT code system provider (`tx/cs/cs-snomed.js`,
|
|
4
|
+
`tx/sct/*`) does not do, so that nobody has to rediscover it from the code. It is
|
|
5
|
+
organised by area: ECL, post-coordinated expressions, and everything else. Where
|
|
6
|
+
a limitation is a deliberate decision rather than unfinished work, it says so.
|
|
7
|
+
|
|
8
|
+
The import pipeline (`tx/importers/import-sct.module.js`) reads the RF2 snapshot
|
|
9
|
+
concept, description (including text definitions, which share the description
|
|
10
|
+
file layout), inferred relationship, and reference set files. It does not read
|
|
11
|
+
stated relationships, the OWL axiom reference sets, or the concrete value
|
|
12
|
+
relationship file; several of the limitations below follow from that.
|
|
13
|
+
|
|
14
|
+
## ECL
|
|
15
|
+
|
|
16
|
+
The parser (`tx/sct/ecl.js`) accepts the ECL 2.2 grammar with the exceptions
|
|
17
|
+
listed under "Not parsed". The evaluator (`SnomedServices.filterECL` and the
|
|
18
|
+
`_eval*` methods in `cs-snomed.js`) evaluates the subset listed under
|
|
19
|
+
"Evaluated"; anything the parser accepts but the evaluator does not implement
|
|
20
|
+
is reported as `UNSUPPORTED_ECL`, and a syntax error or an unknown concept as
|
|
21
|
+
`INVALID_ECL`. Both are OperationOutcomes with tx-issue-type `vs-invalid`.
|
|
22
|
+
|
|
23
|
+
Note that `ECLValidator.evaluate*` in `ecl.js` is an older, weaker evaluator
|
|
24
|
+
that the server does not use. `filterECL` is the one that matters.
|
|
25
|
+
|
|
26
|
+
### Evaluated
|
|
27
|
+
|
|
28
|
+
- All eight hierarchy operators (`<`, `<<`, `<!`, `<<!`, `>`, `>>`, `>!`, `>>!`)
|
|
29
|
+
on a concept reference, on `*`, on `^ refset` and on a bracketed expression
|
|
30
|
+
(applied to every member of the set), and top/bottom of set (`!!> (...)`,
|
|
31
|
+
`!!< (...)`).
|
|
32
|
+
- `*` (every concept, active and inactive - `activeOnly` is the expansion's job).
|
|
33
|
+
- `^` member-of on a concept or any bracketed expression, with
|
|
34
|
+
`{{ M ... }}` member filters on `effectiveTime`, `active`, `moduleId` and the
|
|
35
|
+
reference set's own fields (concept, integer and string typed from the RF2
|
|
36
|
+
file name). Since inactive member rows are dropped at import, `active = false`
|
|
37
|
+
never matches.
|
|
38
|
+
- `AND` / `OR` / `MINUS` (and `,`), with the ECL 6.4 rule that conjunction and
|
|
39
|
+
disjunction cannot be mixed without brackets.
|
|
40
|
+
- Refinements: `=` against an expression value; attribute names that are a
|
|
41
|
+
concept, `*`, or any sub expression (`<< 762705008 = ...`); attribute sets
|
|
42
|
+
joined by `AND`, `OR` or `,`, with brackets; attribute groups, including
|
|
43
|
+
`AND`/`OR` inside and between groups; attribute and group cardinality,
|
|
44
|
+
including `[0..0]`. Cardinality counts occurrences per role group, per the
|
|
45
|
+
SNOMED Computable Languages Group ruling (see `_countAttributeMatches`).
|
|
46
|
+
- Dotted expressions (`< X . 363698007`, `. << 363698007`, `. (A OR B)`).
|
|
47
|
+
- `{{ C ... }}` concept filters on `active`, `definitionStatus`,
|
|
48
|
+
`definitionStatusId`, `moduleId` and `effectiveTime`.
|
|
49
|
+
- `{{ D ... }}` description filters on `term` (match semantics: each word of
|
|
50
|
+
the search term starts a word of the description, case-insensitively; and
|
|
51
|
+
`wild:` whole-term patterns), `language`, `typeId` / `type` (syn, fsn, def),
|
|
52
|
+
`dialectId` / `dialect` with acceptability sets (`(prefer)`, `(accept)` or
|
|
53
|
+
acceptability concept ids; the `dialect` aliases known are en-us, en-gb,
|
|
54
|
+
en-au, en-ca, en-nz, en-ie, fr-ca, fr-be, nl-be, nl-nl, es-es, sv-se, da-dk),
|
|
55
|
+
`moduleId`, `effectiveTime`, `active` and `id`. The filters in one block
|
|
56
|
+
must be satisfied by a single description; separate blocks may be satisfied
|
|
57
|
+
by different descriptions. Only active descriptions are considered unless
|
|
58
|
+
the block has an `active` filter.
|
|
59
|
+
- Comments (`/* ... */`), terms (`|...|`, not checked against descriptions).
|
|
60
|
+
|
|
61
|
+
### Parsed but reported as unsupported
|
|
62
|
+
|
|
63
|
+
- Reverse attributes (`R 363698007 = ...`).
|
|
64
|
+
- `!=` against an expression value in a refinement.
|
|
65
|
+
- Concrete values in refinements - numeric (`= #5`, `>= #1.5`), string
|
|
66
|
+
(`= "x"`, `= match:"x"`), boolean (`= true`). The importer does not read
|
|
67
|
+
`sct2_RelationshipConcreteValues_*`, so no concrete values are in the cache
|
|
68
|
+
to compare against. Supporting them means extending the importer and the
|
|
69
|
+
relationship record, and rebuilding every cache.
|
|
70
|
+
- Alternate identifiers (`"ICD10#A01.1"`, `SCTID#123`). No alternate
|
|
71
|
+
identifier reference sets are loaded.
|
|
72
|
+
- Reference set field selection on member-of (`^ [targetComponentId] refset`,
|
|
73
|
+
`^ [referencedComponentId, targetComponentId] refset`). Deliberate: the
|
|
74
|
+
selected fields, and the referenced components of many reference sets,
|
|
75
|
+
are descriptions or other non-concept components, which an expansion has
|
|
76
|
+
no way to represent.
|
|
77
|
+
- History supplements (`{{ + HISTORY-MIN / MOD / MAX }}`). The association
|
|
78
|
+
reference sets they draw on are loaded, so this is implementable.
|
|
79
|
+
|
|
80
|
+
### Not parsed
|
|
81
|
+
|
|
82
|
+
- `wild:` search terms are rejected in member filters (accepted in refinement
|
|
83
|
+
string values, which are then unsupported anyway).
|
|
84
|
+
- A concept id must be 6-18 digits; anything else is a syntax error (this is
|
|
85
|
+
what makes `<< abc` invalid rather than unsupported).
|
|
86
|
+
|
|
87
|
+
### Other ECL notes
|
|
88
|
+
|
|
89
|
+
- Description filter term matching walks the descriptions of every concept in
|
|
90
|
+
the result set rather than using the text index, so `* {{ D term = "x" }}`
|
|
91
|
+
on a full edition scans every description. It yields to the event loop via
|
|
92
|
+
the operation's dead-check but is not fast.
|
|
93
|
+
- A `dialect` alias the server does not know is reported as unsupported, not
|
|
94
|
+
invalid; the alias table is `ECL_DIALECT_ALIASES` in `cs-snomed.js`.
|
|
95
|
+
|
|
96
|
+
- Term checking: a wrong term after a concept id (`10200004 |Lever structure|`)
|
|
97
|
+
is accepted silently. The tx-ecosystem test `expand-ecl-term-mismatch` expects
|
|
98
|
+
this.
|
|
99
|
+
- `^` on a concept that is not a reference set yields an empty result rather
|
|
100
|
+
than an error (Snowstorm's behaviour; agreed in the tx-ecosystem suite).
|
|
101
|
+
`^` on a reference set whose members are descriptions (a language reference
|
|
102
|
+
set) is reported as unsupported - there are no concepts to return.
|
|
103
|
+
- The ECL result for a value set filter is cached per (value set, code system
|
|
104
|
+
version), so the first expansion of a large constraint is slow and later ones
|
|
105
|
+
are not.
|
|
106
|
+
|
|
107
|
+
## Post-coordinated expressions
|
|
108
|
+
|
|
109
|
+
Expressions are parsed by `SnomedExpressionParser` (`tx/sct/expressions.js`)
|
|
110
|
+
in SNOMED Compositional Grammar, including `===` / `<<<` definition status
|
|
111
|
+
markers, nested (bracketed) attribute values, concrete values and terms.
|
|
112
|
+
They are normalised, checked against the MRCM, and compared structurally.
|
|
113
|
+
|
|
114
|
+
### Validation
|
|
115
|
+
|
|
116
|
+
`checkExpression` rejects an expression whose refinements break the concept
|
|
117
|
+
model (`$validate-code` then answers `result = false`): the attribute must be
|
|
118
|
+
allowed on the focus concept's domain (MRCM attribute domain reference set
|
|
119
|
+
`723561005`), the value must satisfy the attribute's range (`723562003`,
|
|
120
|
+
whether that is ECL or a concrete range such as `dec(>#0..)`), the attribute
|
|
121
|
+
and in-group cardinality maximums apply, and an attribute the MRCM marks as
|
|
122
|
+
grouped must be in a role group. Known limits:
|
|
123
|
+
|
|
124
|
+
- Minimum cardinality is not enforced (it constrains a concept's own
|
|
125
|
+
definition, not a refinement of it). The converse of the grouping rule
|
|
126
|
+
(grouped = 0 means must not be grouped) is enforced only through the
|
|
127
|
+
in-group cardinality `0..0`.
|
|
128
|
+
- Attributes with no MRCM rule in the loaded edition are not checked at all.
|
|
129
|
+
Internationally that is `704347000 |Observes|`, `131195008 |Subject of
|
|
130
|
+
information|` and `363705008 |Has definitional manifestation|`, and
|
|
131
|
+
`762951001 |Has ingredient|`, whose only domain row is scoped to new
|
|
132
|
+
precoordinated content (an MRCM inconsistency, not something to work around).
|
|
133
|
+
- An edition with no MRCM reference sets (e.g. the committed jest test cache)
|
|
134
|
+
gets no concept model checking at all; the checks go inert.
|
|
135
|
+
- The ungrouped "Close To User Form" is rejected rather than transformed to the
|
|
136
|
+
classifiable form. None of the seven Level 1 transformations in SNOMED's
|
|
137
|
+
postcoordination guide (refining an existing attribute, adding a self-grouped
|
|
138
|
+
attribute, adding severity, lateralising findings and procedures, adding
|
|
139
|
+
context to findings and procedures) is implemented.
|
|
140
|
+
- Negative concrete values (`#-5`) fail to parse ("Expected character } but
|
|
141
|
+
found -"). SCG permits them.
|
|
142
|
+
- Laterality on a non-lateralisable body structure is caught only when the
|
|
143
|
+
edition carries reference set `723264001`; the tx-ecosystem test edition
|
|
144
|
+
does not, so the IG suite cannot assert it.
|
|
145
|
+
|
|
146
|
+
### Subsumption
|
|
147
|
+
|
|
148
|
+
`$subsumes` on expressions works by comparing normal forms: each side is
|
|
149
|
+
expanded through its fully defined concepts (primitives keep their atom and
|
|
150
|
+
contribute their defining attributes), then matched structurally, trying every
|
|
151
|
+
role group pairing.
|
|
152
|
+
|
|
153
|
+
- Positive answers (`subsumes`, `subsumed-by`, `equivalent`) are sound.
|
|
154
|
+
A negative answer is not provable this way: SNOMED's OWL axiom reference set
|
|
155
|
+
carries general concept inclusions and property chains whose entailments are
|
|
156
|
+
baked into the distributed inferred relationships for precoordinated concepts,
|
|
157
|
+
but an expression is something the classifier never saw. So when no
|
|
158
|
+
relationship is found and either side is an expression, the server returns an
|
|
159
|
+
OperationOutcome (HTTP 422, issue code `not-supported`, tx-issue-type
|
|
160
|
+
`cannot-determine`) rather than `not-subsumed`. Between two plain concepts all
|
|
161
|
+
four outcomes are sound (the transitive closure is the classifier's own
|
|
162
|
+
output).
|
|
163
|
+
- There is no reasoner. Removing the decline above would need an OWL
|
|
164
|
+
classifier over the axiom reference set (which is not imported).
|
|
165
|
+
- Normalisation depth is capped at 50 and throws rather than truncating,
|
|
166
|
+
because a truncated normal form is unsound in both directions.
|
|
167
|
+
- `subsumesTest` does not re-validate its inputs; `$subsumes` runs `locate()`
|
|
168
|
+
(and so `checkExpression`) on both codes first.
|
|
169
|
+
|
|
170
|
+
### Lookup and display
|
|
171
|
+
|
|
172
|
+
- `$lookup` on an expression returns the rendered expression and its
|
|
173
|
+
components; the normal form, parents and children properties are returned
|
|
174
|
+
for single-concept expressions only.
|
|
175
|
+
- The display of an expression is the server's own rendering (no spaces,
|
|
176
|
+
`:{...}`), and every concept in it is given its preferred term.
|
|
177
|
+
|
|
178
|
+
## Everything else
|
|
179
|
+
|
|
180
|
+
### Data not imported
|
|
181
|
+
|
|
182
|
+
- Stated relationships and the OWL axiom reference sets. Everything is driven
|
|
183
|
+
by the distributed inferred relationships; there is no classification.
|
|
184
|
+
- Concrete value relationships (see ECL above).
|
|
185
|
+
- Alternate identifier reference sets.
|
|
186
|
+
- Inactive reference set member rows (only active rows are stored).
|
|
187
|
+
- Module ids and definition status on concepts are recorded by the current
|
|
188
|
+
importer; caches built before mid-2026 have neither (every concept looks
|
|
189
|
+
fully defined, module is 0). The jest cache `tx/data/snomed-testing.cache`
|
|
190
|
+
is one of those.
|
|
191
|
+
|
|
192
|
+
### Language and display
|
|
193
|
+
|
|
194
|
+
- Description languages are mapped to a small fixed set at import
|
|
195
|
+
(`mapLanguageCode`); an unknown language is recorded as English. Norwegian
|
|
196
|
+
or Finnish descriptions, say, would be labelled `en`.
|
|
197
|
+
- `SnomedExpressionServices.codeForLanguage` has a stale language map that
|
|
198
|
+
disagrees with the importer; only the term is read from it today.
|
|
199
|
+
- With no `displayLanguage`, every edition defaults to US then GB English
|
|
200
|
+
preferred synonyms. A concept whose only English description is its FSN
|
|
201
|
+
gets its national-language preferred synonym instead.
|
|
202
|
+
- `sharedData.defaultLanguage` is hard-coded to 1 at import and
|
|
203
|
+
`getDisplayName` ignores the language argument it is passed.
|
|
204
|
+
|
|
205
|
+
### Filters and implicit resources
|
|
206
|
+
|
|
207
|
+
- ValueSet filters supported on `compose.include.filter`: `concept` (`is-a`,
|
|
208
|
+
`descendent-of`, `in`), `inactive =`, `moduleId =`, `constraint =` (ECL),
|
|
209
|
+
`expressions =`, and `<attributeId> = <conceptId>`. Other operators on these
|
|
210
|
+
properties, and other properties, are rejected.
|
|
211
|
+
- Implicit value sets: `?fhir_vs`, `?fhir_vs=refset`, `?fhir_vs=refset/<id>`
|
|
212
|
+
and `?fhir_vs=isa/<id>`, with optional edition and version segments.
|
|
213
|
+
`?fhir_vs=ecl/<expression>` is not recognised; use a `constraint` filter.
|
|
214
|
+
- Implicit concept maps (`?fhir_cm=<id>`): only the four historical association
|
|
215
|
+
reference sets (POSSIBLY EQUIVALENT TO, REPLACED BY, SAME AS, ALTERNATIVE).
|
|
216
|
+
Simple and extended map reference sets (ICD-10, ICD-O, ...) are loaded but
|
|
217
|
+
are not exposed as implicit concept maps, and reverse translation is not
|
|
218
|
+
supported.
|
|
219
|
+
- Edition to edition translation (`<edition>/version/<date>?fhir_cm=edition/<module>`):
|
|
220
|
+
a `$translate` from an edition to one it depends on (per the module dependency
|
|
221
|
+
reference set) needs no map - the answer comes from the definitions. A concept in
|
|
222
|
+
the target edition's modules is its own translation; an inactive concept translates
|
|
223
|
+
through its historical associations; a fully defined extension concept translates
|
|
224
|
+
to its normal form (or to the target concept with that normal form, if there is
|
|
225
|
+
one), provided the normal form uses only target edition content; anything else
|
|
226
|
+
translates to its proximal target-edition ancestors as `source-is-narrower-than-target`,
|
|
227
|
+
unless the only such ancestors are top-level hierarchies, which is reported as a
|
|
228
|
+
reason for no translation. The map is found from `targetSystem` =
|
|
229
|
+
`http://snomed.info/sct|<target edition[/version/date]>`, or nominated by url.
|
|
230
|
+
- `$closure` works through `subsumesTest`, so adding a post-coordinated
|
|
231
|
+
expression to a closure table hits the `cannot-determine` rule above whenever
|
|
232
|
+
it is unrelated to an existing entry.
|
|
233
|
+
|
|
234
|
+
### Text search
|
|
235
|
+
|
|
236
|
+
- The `filter` parameter on `$expand` uses an in-memory word index built the
|
|
237
|
+
first time an edition is searched (`tx/sct/text-index.js`): every token of
|
|
238
|
+
every active description, matched by prefix and by Porter stem. The stemmer
|
|
239
|
+
is English, so in other languages only prefix matching helps. Inactive
|
|
240
|
+
descriptions are not indexed.
|
|
241
|
+
|
|
242
|
+
### Known data quality traps
|
|
243
|
+
|
|
244
|
+
- The three copies of the tx-ecosystem SNOMED test edition
|
|
245
|
+
(`data/terminology-cache/sct_test_*.cache`, `tx/data/snomed-testing.cache`,
|
|
246
|
+
`data/snomed-testing.cache`) drift. The committed jest cache is older than
|
|
247
|
+
the RF2 subset in `tx/data/snomed` and lacks module ids, definition status
|
|
248
|
+
and some concepts; rebuilding it changes test expectations.
|
|
249
|
+
- `tx.fhir.org` (the Java server) and FHIRsmith disagree on a few expected
|
|
250
|
+
results in the tx-ecosystem suite (display selection, MRCM); the suite
|
|
251
|
+
records FHIRsmith's reading where Grahame has decided it.
|