fhirsmith 0.18.0 → 0.18.1

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.
@@ -31,6 +31,18 @@ const Logger = require("../../library/logger");
31
31
  // shared - it simply recomputes.
32
32
  const SNOMED_FILTER_ANALYSIS = Symbol('snomedFilterAnalysis');
33
33
 
34
+ /**
35
+ * Alternate identifier schemes (sct2_Identifier) that FHIRsmith knows how to present:
36
+ * the code system the identifiers are codes in, and the $lookup property they are
37
+ * returned as. An identifier in an unknown scheme is kept but not reported.
38
+ */
39
+ const IDENTIFIER_SCHEMES = {
40
+ '30051010000102': { system: 'http://loinc.org', property: 'loinc' } // |LOINC code identifier| (LOINC Extension)
41
+ };
42
+
43
+ /** The $expand / ECL-free filter on alternate identifiers: mapped:<scheme> exists true|false */
44
+ const MAPPED_FILTER_PREFIX = 'mapped:';
45
+
34
46
  // Description language index -> language code. The index is assigned at import
35
47
  // time by SnomedImporter.mapLanguageCode(); this table is its inverse and must
36
48
  // be kept in step with it.
@@ -192,6 +204,47 @@ class SnomedServices {
192
204
  // means "could not decide", and leaves the value unchecked.
193
205
  this.expressionServices.eclConstraintMatcher = (constraint, value) => this.mrcmRangeSatisfied(constraint, value);
194
206
  this._mrcmRangeCache = new Map();
207
+
208
+ this.#loadIdentifiers(sharedData.identifiers || []);
209
+ }
210
+
211
+ /**
212
+ * Index the alternate identifiers (sct2_Identifier rows, active only, as
213
+ * [scheme, module, code, conceptId]) both ways: concept -> identifiers, and
214
+ * scheme|code -> concepts. Also which modules carry identifiers, since each such
215
+ * module defines an implicit map (?fhir_cm=<module>).
216
+ */
217
+ #loadIdentifiers(rows) {
218
+ this.identifiersByConcept = new Map();
219
+ this.conceptsByIdentifier = new Map();
220
+ this.identifierModules = new Map(); // module -> Set of schemes
221
+ for (const [scheme, module, code, conceptId] of rows) {
222
+ const entry = { scheme, module, code };
223
+ if (!this.identifiersByConcept.has(conceptId)) {
224
+ this.identifiersByConcept.set(conceptId, []);
225
+ }
226
+ this.identifiersByConcept.get(conceptId).push(entry);
227
+ const key = scheme + '|' + code;
228
+ if (!this.conceptsByIdentifier.has(key)) {
229
+ this.conceptsByIdentifier.set(key, []);
230
+ }
231
+ this.conceptsByIdentifier.get(key).push(conceptId);
232
+ if (!this.identifierModules.has(module)) {
233
+ this.identifierModules.set(module, new Set());
234
+ }
235
+ this.identifierModules.get(module).add(scheme);
236
+ }
237
+ }
238
+
239
+ /** The alternate identifiers of a concept (by id), optionally only those of one scheme */
240
+ getIdentifiers(conceptId, scheme = null) {
241
+ const list = this.identifiersByConcept.get(String(conceptId)) || [];
242
+ return scheme ? list.filter(i => i.scheme === scheme) : list;
243
+ }
244
+
245
+ /** The concept ids that have the given code in the given scheme */
246
+ getConceptsForIdentifier(scheme, code) {
247
+ return this.conceptsByIdentifier.get(scheme + '|' + code) || [];
195
248
  }
196
249
 
197
250
  /**
@@ -2707,34 +2760,46 @@ class SnomedProvider extends BaseCSServices {
2707
2760
  const ctxt = await this.#ensureContext(context);
2708
2761
  if (ctxt) {
2709
2762
  if (!(ctxt instanceof SnomedExpressionContext) || ctxt.expression?.concepts.length == 1) {
2710
- const time = this.sct.concepts.getConcept(ctxt.getReference()).effectiveTime;
2711
- // Pascal TDateTime epoch (1899-12-30) computed in UTC so the formatted
2712
- // date is timezone-independent. new Date(1899,11,30) is LOCAL midnight,
2713
- // which in zones ahead of UTC rolls the ISO date back by one day.
2714
- const pascalEpochUTC = Date.UTC(1899, 11, 30);
2715
- const date = new Date(pascalEpochUTC + time * 86400000);
2716
- const dateStr = date.toISOString().slice(0, 10);
2717
- this._addDateTimeProperty(params, 'property', 'effectiveTime', dateStr);
2718
-
2763
+ // Only what was asked for, if anything was (see _hasProp): effectiveTime, module,
2764
+ // parent, child and the defining relationships are returned by default but are not
2765
+ // important enough to return when the client named the properties it wants.
2766
+ // The mappings (alternate identifiers such as the LOINC code) are returned by
2767
+ // default too, and when asked for by name.
2768
+ if (this._hasProp(props, 'effectiveTime', true)) {
2769
+ const time = this.sct.concepts.getConcept(ctxt.getReference()).effectiveTime;
2770
+ // Pascal TDateTime epoch (1899-12-30) computed in UTC so the formatted
2771
+ // date is timezone-independent. new Date(1899,11,30) is LOCAL midnight,
2772
+ // which in zones ahead of UTC rolls the ISO date back by one day.
2773
+ const pascalEpochUTC = Date.UTC(1899, 11, 30);
2774
+ const date = new Date(pascalEpochUTC + time * 86400000);
2775
+ const dateStr = date.toISOString().slice(0, 10);
2776
+ this._addDateTimeProperty(params, 'property', 'effectiveTime', dateStr);
2777
+ }
2719
2778
 
2720
- const parents = this.sct.getConceptParents(ctxt.getReference());
2721
- for (let parentRef of parents) {
2722
- const code = this.sct.getConceptId(parentRef);
2723
- const description = this.sct.getDisplayName(parentRef);
2724
- this._addCodeProperty(params, 'property', 'parent', code, null, description);
2779
+ if (this._hasProp(props, 'parent', true)) {
2780
+ const parents = this.sct.getConceptParents(ctxt.getReference());
2781
+ for (let parentRef of parents) {
2782
+ const code = this.sct.getConceptId(parentRef);
2783
+ const description = this.sct.getDisplayName(parentRef);
2784
+ this._addCodeProperty(params, 'property', 'parent', code, null, description);
2785
+ }
2725
2786
  }
2726
2787
 
2727
- const children = this.sct.getConceptChildren(ctxt.getReference());
2728
- for (let childRef of children) {
2729
- const code = this.sct.getConceptId(childRef);
2730
- const description = this.sct.getDisplayName(childRef);
2731
- this._addCodeProperty(params, 'property', 'child', code, null, description);
2788
+ if (this._hasProp(props, 'child', true)) {
2789
+ const children = this.sct.getConceptChildren(ctxt.getReference());
2790
+ for (let childRef of children) {
2791
+ const code = this.sct.getConceptId(childRef);
2792
+ const description = this.sct.getDisplayName(childRef);
2793
+ this._addCodeProperty(params, 'property', 'child', code, null, description);
2794
+ }
2732
2795
  }
2733
2796
 
2734
- const moduleId = this.sct.concepts.getModuleId(ctxt.getReference());
2735
- if (moduleId) {
2736
- const code = this.sct.getConceptId(moduleId);
2737
- this._addCodeProperty(params, 'property', 'module', code, null, this.sct.getDisplayName(moduleId));
2797
+ if (this._hasProp(props, 'module', true)) {
2798
+ const moduleId = this.sct.concepts.getModuleId(ctxt.getReference());
2799
+ if (moduleId) {
2800
+ const code = this.sct.getConceptId(moduleId);
2801
+ this._addCodeProperty(params, 'property', 'module', code, null, this.sct.getDisplayName(moduleId));
2802
+ }
2738
2803
  }
2739
2804
 
2740
2805
  const relationships = this.sct.getConceptRelationships(ctxt.getReference());
@@ -2742,7 +2807,7 @@ class SnomedProvider extends BaseCSServices {
2742
2807
  for (let relationshipRef of relationships) {
2743
2808
  const relationship = this.sct.relationships.getRelationship(relationshipRef);
2744
2809
  const relType = this.sct.getConceptId(relationship.relType);
2745
- if (relType != '116680003') {
2810
+ if (relType != '116680003' && this._hasProp(props, relType, true)) {
2746
2811
  const relTypeD = this.sct.getDisplayName(relationship.relType);
2747
2812
  const code = this.sct.getConceptId(relationship.target);
2748
2813
  const description = this.sct.getDisplayName(relationship.target);
@@ -2753,6 +2818,8 @@ class SnomedProvider extends BaseCSServices {
2753
2818
  }
2754
2819
  }
2755
2820
  }
2821
+
2822
+ this.#addMappingProperties(ctxt, props, params);
2756
2823
  }
2757
2824
  if (ctxt instanceof SnomedExpressionContext) {
2758
2825
  // ignore concepts for now, but list refinements and refinement groups
@@ -2768,6 +2835,27 @@ class SnomedProvider extends BaseCSServices {
2768
2835
  }
2769
2836
  }
2770
2837
 
2838
+ /**
2839
+ * The concept's alternate identifiers in the schemes we know (IDENTIFIER_SCHEMES), as
2840
+ * Coding-valued properties named for the scheme - e.g. loinc = http://loinc.org#2160-0.
2841
+ * Returned by default, and when asked for by name.
2842
+ */
2843
+ #addMappingProperties(ctxt, props, params) {
2844
+ const conceptId = this.sct.getConceptId(ctxt.getReference());
2845
+ for (const ident of this.sct.getIdentifiers(conceptId)) {
2846
+ const scheme = IDENTIFIER_SCHEMES[ident.scheme];
2847
+ if (scheme && this._hasProp(props, scheme.property, true)) {
2848
+ params.push({
2849
+ name: 'property',
2850
+ part: [
2851
+ { name: 'code', valueCode: scheme.property },
2852
+ { name: 'value', valueCoding: { system: scheme.system, code: ident.code } }
2853
+ ]
2854
+ });
2855
+ }
2856
+ }
2857
+ }
2858
+
2771
2859
  /**
2772
2860
  * Report one refinement of a post-coordinated expression as a lookup property.
2773
2861
  *
@@ -2823,6 +2911,11 @@ class SnomedProvider extends BaseCSServices {
2823
2911
  return true;
2824
2912
  }
2825
2913
 
2914
+ if (prop.startsWith(MAPPED_FILTER_PREFIX)) {
2915
+ return op === 'exists' && ['true', 'false'].includes(value) &&
2916
+ this.sct.stringToIdOrZero(prop.substring(MAPPED_FILTER_PREFIX.length)) !== 0n;
2917
+ }
2918
+
2826
2919
  const cid = this.sct.stringToIdOrZero(prop);
2827
2920
  if (cid != 0) {
2828
2921
  const id = this.sct.stringToIdOrZero(value);
@@ -2979,6 +3072,27 @@ class SnomedProvider extends BaseCSServices {
2979
3072
  return null;
2980
3073
  }
2981
3074
 
3075
+ // mapped:<identifier scheme> exists true|false - concepts that do (or don't) have an
3076
+ // alternate identifier in that scheme (e.g. mapped:30051010000102: has a LOINC code).
3077
+ // A prefix, so as not to be confused with the attribute filters (<attribute> = <value>)
3078
+ if (prop.startsWith(MAPPED_FILTER_PREFIX)) {
3079
+ const scheme = prop.substring(MAPPED_FILTER_PREFIX.length);
3080
+ if (this.sct.stringToIdOrZero(scheme) === 0n) {
3081
+ throw new Error(`Invalid identifier scheme: ${scheme}`);
3082
+ }
3083
+ if (op !== 'exists') {
3084
+ throw new Error(`Unsupported filter operation: ${prop} ${op} ${value}`);
3085
+ }
3086
+ if (value !== 'true' && value !== 'false') {
3087
+ throw new Error(`Invalid filter value: ${value}`);
3088
+ }
3089
+ const filter = new SnomedFilterContext();
3090
+ filter.mappedScheme = scheme;
3091
+ filter.mappedExists = value === 'true';
3092
+ filterContext.filters.push(filter);
3093
+ return null;
3094
+ }
3095
+
2982
3096
  const cid = this.sct.stringToIdOrZero(prop);
2983
3097
  if (cid != 0) {
2984
3098
 
@@ -3082,7 +3196,9 @@ class SnomedProvider extends BaseCSServices {
3082
3196
  }
3083
3197
  let found = false;
3084
3198
 
3085
- if (set.inactive !== undefined) {
3199
+ if (set.mappedScheme !== undefined) {
3200
+ found = this.#isMapped(reference, set.mappedScheme) === set.mappedExists;
3201
+ } else if (set.inactive !== undefined) {
3086
3202
  let concept = this.sct.concepts.getConcept(reference);
3087
3203
  let active = (concept.flags & 0x0F) === 0;
3088
3204
  found = active !== set.inactive
@@ -3128,6 +3244,9 @@ class SnomedProvider extends BaseCSServices {
3128
3244
  }
3129
3245
 
3130
3246
  const reference = concept.getReference();
3247
+ if (set.mappedScheme !== undefined) {
3248
+ return this.#isMapped(reference, set.mappedScheme) === set.mappedExists;
3249
+ }
3131
3250
  if (set.inactive !== undefined) {
3132
3251
  return this.sct.isActive(reference) !== set.inactive;
3133
3252
  }
@@ -3187,10 +3306,39 @@ class SnomedProvider extends BaseCSServices {
3187
3306
  return set._matchSet.has(reference);
3188
3307
  }
3189
3308
 
3309
+ /** Whether the concept (by reference) has an alternate identifier in the scheme */
3310
+ #isMapped(reference, scheme) {
3311
+ return this.sct.getIdentifiers(this.sct.getConceptId(reference), scheme).length > 0;
3312
+ }
3313
+
3190
3314
  #ensurePopulated(set) {
3191
3315
  if (set.populationDone) {
3192
3316
  return;
3193
3317
  }
3318
+ if (set.mappedScheme !== undefined) {
3319
+ if (set.mappedExists) {
3320
+ // straight from the identifier index, in concept order
3321
+ const refs = new Set();
3322
+ for (const [conceptId, idents] of this.sct.identifiersByConcept) {
3323
+ if (idents.some(i => i.scheme === set.mappedScheme)) {
3324
+ const r = this.sct.concepts.findConcept(BigInt(conceptId));
3325
+ if (r.found) {
3326
+ refs.add(r.index);
3327
+ }
3328
+ }
3329
+ }
3330
+ set.descendants = [...refs].sort((a, b) => a - b);
3331
+ } else {
3332
+ for (let i = 0; i < this.sct.concepts.count(); i++) {
3333
+ const concept = this.sct.concepts.getConceptByCount(i);
3334
+ if (!this.#isMapped(concept.index, set.mappedScheme)) {
3335
+ set.descendants.push(concept.index);
3336
+ }
3337
+ }
3338
+ }
3339
+ set.populationDone = true;
3340
+ return;
3341
+ }
3194
3342
  if (set.inactive !== undefined && set.descendants.length === 0) {
3195
3343
  for (let i = 0; i < this.sct.concepts.count(); i++) {
3196
3344
  let concept = this.sct.concepts.getConceptByCount(i);
@@ -3342,6 +3490,9 @@ class SnomedProvider extends BaseCSServices {
3342
3490
  }
3343
3491
 
3344
3492
  async getTranslations(map, coding, target, reverse) {
3493
+ if (map && map.jsonObj.identifierMap) {
3494
+ return this.translateByIdentifier(map, coding, target);
3495
+ }
3345
3496
  if (!map || reverse) {
3346
3497
  return [];
3347
3498
  }
@@ -3405,6 +3556,54 @@ class SnomedProvider extends BaseCSServices {
3405
3556
  return result;
3406
3557
  }
3407
3558
 
3559
+ /**
3560
+ * Translate using a module's alternate identifiers (the implicit map ?fhir_cm=<module>,
3561
+ * e.g. the LOINC Extension's LOINC codes). The identifiers say the concept and the code
3562
+ * are the same thing, so the map works the same in both directions: a SNOMED CT concept
3563
+ * translates to its codes, and a code in the identifier's system to its concepts. Either
3564
+ * way the relationship is equivalent.
3565
+ */
3566
+ async translateByIdentifier(map, coding, target) {
3567
+ const { schemes } = map.jsonObj.identifierMap;
3568
+ const targetSystem = target ? target.split('|')[0] : null;
3569
+ const result = [];
3570
+ if (coding.system === this.system()) {
3571
+ const ctxt = await this.locate(coding.code);
3572
+ if (!ctxt.context) {
3573
+ return [];
3574
+ }
3575
+ const conceptId = this.sct.getConceptId(ctxt.context.getReference());
3576
+ for (const ident of this.sct.getIdentifiers(conceptId)) {
3577
+ const scheme = IDENTIFIER_SCHEMES[ident.scheme];
3578
+ if (scheme && schemes.includes(ident.scheme) && (!targetSystem || targetSystem === scheme.system)) {
3579
+ result.push({
3580
+ map: map.vurl,
3581
+ system: scheme.system,
3582
+ code: ident.code,
3583
+ relationship: 'equivalent'
3584
+ });
3585
+ }
3586
+ }
3587
+ } else if (!targetSystem || targetSystem === this.system()) {
3588
+ for (const schemeId of schemes) {
3589
+ const scheme = IDENTIFIER_SCHEMES[schemeId];
3590
+ if (scheme && scheme.system === coding.system) {
3591
+ for (const conceptId of this.sct.getConceptsForIdentifier(schemeId, coding.code)) {
3592
+ result.push({
3593
+ map: map.vurl,
3594
+ system: this.system(),
3595
+ version: this.version(),
3596
+ code: conceptId,
3597
+ display: await this.display(conceptId),
3598
+ relationship: 'equivalent'
3599
+ });
3600
+ }
3601
+ }
3602
+ }
3603
+ }
3604
+ return result;
3605
+ }
3606
+
3408
3607
  /**
3409
3608
  * Translate a concept of this edition to the edition it depends on (typically a national
3410
3609
  * extension to the International Edition). There is no map: the answer comes from the
@@ -3789,6 +3988,9 @@ class SnomedServicesFactory extends CodeSystemFactoryProvider {
3789
3988
  * edition URI plus ?fhir_cm=edition/<target edition module>
3790
3989
  */
3791
3990
  async findImplicitConceptMaps(conceptMaps, source, sourceVersion, dest) {
3991
+ if (dest) {
3992
+ this.#findIdentifierMaps(conceptMaps, source, sourceVersion, dest);
3993
+ }
3792
3994
  if (source !== this.system() || !sourceVersion || !dest || !this.isMyEdition(sourceVersion)) {
3793
3995
  return;
3794
3996
  }
@@ -3823,6 +4025,61 @@ class SnomedServicesFactory extends CodeSystemFactoryProvider {
3823
4025
  });
3824
4026
  }
3825
4027
 
4028
+ /**
4029
+ * Maps implied by alternate identifiers, found from the systems rather than nominated:
4030
+ * from this edition (source system|version naming it) to an identifier system (LOINC,
4031
+ * say), or from an identifier system to this edition (the target given as
4032
+ * system|edition URI, with or without the version)
4033
+ */
4034
+ #findIdentifierMaps(conceptMaps, source, sourceVersion, dest) {
4035
+ const bar = dest.indexOf('|');
4036
+ const destSystem = bar === -1 ? dest : dest.substring(0, bar);
4037
+ const destVersion = bar === -1 ? null : dest.substring(bar + 1);
4038
+ let identSystem;
4039
+ if (source === this.system() && sourceVersion && this.isMyEdition(sourceVersion)) {
4040
+ identSystem = destSystem;
4041
+ } else if (destSystem === this.system() && destVersion && this.isMyEdition(destVersion)) {
4042
+ identSystem = source;
4043
+ } else {
4044
+ return;
4045
+ }
4046
+ for (const [moduleId, schemes] of this.snomedServices.identifierModules) {
4047
+ if ([...schemes].some(s => IDENTIFIER_SCHEMES[s] && IDENTIFIER_SCHEMES[s].system === identSystem)) {
4048
+ const cm = this.identifierMap(moduleId);
4049
+ if (cm && !conceptMaps.some(m => m.url === cm.url)) {
4050
+ conceptMaps.push(cm);
4051
+ }
4052
+ }
4053
+ }
4054
+ }
4055
+
4056
+ /**
4057
+ * The implicit map defined by a module's alternate identifiers, if the module has any
4058
+ * in a scheme we know. Its url follows the edition maps: <source edition>/version/<date>?fhir_cm=<module>
4059
+ */
4060
+ identifierMap(moduleId) {
4061
+ const schemes = [...(this.snomedServices.identifierModules.get(moduleId) || [])].filter(s => IDENTIFIER_SCHEMES[s]);
4062
+ if (schemes.length === 0) {
4063
+ return null;
4064
+ }
4065
+ const systems = schemes.map(s => IDENTIFIER_SCHEMES[s].system);
4066
+ const moduleName = this.snomedServices.conceptExists(moduleId)
4067
+ ? this.snomedServices.getDisplayName(this.snomedServices.concepts.findConcept(BigInt(moduleId)).index) : moduleId;
4068
+ return new ConceptMap({
4069
+ resourceType: 'ConceptMap',
4070
+ internalSource: this,
4071
+ identifierMap: { module: moduleId, schemes: schemes },
4072
+ url: `${this.version()}?fhir_cm=${moduleId}`,
4073
+ name: `SNOMED CT ${moduleName} Concept Map`,
4074
+ description: `The concept map implied by the alternate identifiers (${systems.join(', ')}) of the module ${moduleId} |${moduleName}| in ${this.version()}`,
4075
+ status: 'active',
4076
+ group: [
4077
+ ...systems.map(s => ({ source: this.system(), target: s })),
4078
+ ...systems.map(s => ({ source: s, target: this.system() }))
4079
+ ]
4080
+ });
4081
+ }
4082
+
3826
4083
  /** Whether a source version names this edition: exactly, or the edition without a version */
3827
4084
  isMyEdition(version) {
3828
4085
  const mine = this.version();
@@ -3846,6 +4103,16 @@ class SnomedServicesFactory extends CodeSystemFactoryProvider {
3846
4103
  const dep = this.snomedServices.editionDependency(`http://snomed.info/sct/${url.substring(q + '?fhir_cm=edition/'.length)}`);
3847
4104
  return dep ? this.editionTranslationMap(dep) : null;
3848
4105
  }
4106
+ // <edition[/version/date]>?fhir_cm=<module>: the map implied by a module's alternate
4107
+ // identifiers (e.g. 11010000107, the LOINC Extension's LOINC codes)
4108
+ const qm = url.indexOf('?fhir_cm=');
4109
+ if (qm !== -1 && url.substring(0, qm) !== this.system()) {
4110
+ if (!this.isMyEdition(url.substring(0, qm))) {
4111
+ return null;
4112
+ }
4113
+ await this.#ensureLoaded();
4114
+ return this.identifierMap(url.substring(qm + '?fhir_cm='.length));
4115
+ }
3849
4116
  if (!url.startsWith(this.system()+"?fhir_cm=")) {
3850
4117
  return null;
3851
4118
  }
@@ -0,0 +1,35 @@
1
+ const fs = require('fs');
2
+ const readline = require('readline');
3
+
4
+ /**
5
+ * Yields the records of a CSV file one at a time, as text.
6
+ *
7
+ * A quoted field can contain a line break (LOINC 2.83's ComponentHierarchyBySystem.csv has one
8
+ * in a CODE_TEXT), so a record that ends inside quotes is joined to the next line until the
9
+ * quotes balance. An escaped quote ("") counts twice, so it doesn't upset the count.
10
+ */
11
+ async function* csvRecords(filePath) {
12
+ const rl = readline.createInterface({
13
+ input: fs.createReadStream(filePath),
14
+ crlfDelay: Infinity
15
+ });
16
+
17
+ let pending = null;
18
+ let quotes = 0;
19
+ for await (const line of rl) {
20
+ pending = pending === null ? line : pending + '\n' + line;
21
+ for (let i = 0; i < line.length; i++) {
22
+ if (line.charCodeAt(i) === 34) quotes++;
23
+ }
24
+ if (quotes % 2 === 0) {
25
+ yield pending;
26
+ pending = null;
27
+ quotes = 0;
28
+ }
29
+ }
30
+ if (pending !== null) {
31
+ yield pending;
32
+ }
33
+ }
34
+
35
+ module.exports = { csvRecords };