fhirsmith 0.17.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.
- package/CHANGELOG.md +113 -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 +432 -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 +8 -3
- 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 +10 -0
- package/tx/README.md +52 -0
- package/tx/closure/closure-store.js +14 -0
- package/tx/cs/cs-api.js +92 -14
- package/tx/cs/cs-cs.js +64 -3
- package/tx/cs/cs-icd11.js +366 -112
- package/tx/cs/cs-icf.js +431 -0
- package/tx/cs/cs-loinc.js +296 -27
- package/tx/cs/cs-omop.js +10 -3
- package/tx/cs/cs-snomed.js +1210 -80
- package/tx/cs/cs-ucum.js +6 -0
- package/tx/cs/icd11-expressions.js +191 -78
- package/tx/importers/csv-records.js +35 -0
- package/tx/importers/export-icd11-codesystem.js +58 -12
- package/tx/importers/icd11-schema.md +9 -5
- package/tx/importers/import-loinc.module.js +177 -41
- package/tx/importers/import-sct.module.js +66 -1
- package/tx/importers/subset-loinc.module.js +25 -6
- package/tx/library/codesystem.js +4 -1
- package/tx/library/designations.js +23 -30
- package/tx/library.js +98 -4
- package/tx/openapi-operations.js +6 -1
- package/tx/params.js +8 -1
- package/tx/provider.js +24 -6
- package/tx/sct/documentation.md +251 -0
- package/tx/sct/ecl.js +758 -46
- package/tx/sct/expressions.js +113 -52
- package/tx/sct/structures.js +8 -2
- 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 +3 -4
- package/tx/tx.js +144 -2
- package/tx/vs/vs-vsac.js +25 -6
- package/tx/workers/closure.js +20 -0
- package/tx/workers/expand.js +35 -8
- 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 +138 -6
- package/utilities/make-sct-loinc-subset.js +250 -0
- package/utilities/make-sct-test-extension.js +264 -0
- package/xig/xig.js +32 -0
- package/registry/readme.md +0 -124
package/tx/cs/cs-ucum.js
CHANGED
|
@@ -69,6 +69,12 @@ class UcumCodeSystemProvider extends BaseCSServices {
|
|
|
69
69
|
return 'UCUM';
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
+
/** $lookup's display name: the code system's title. */
|
|
73
|
+
// eslint-disable-next-line no-unused-vars
|
|
74
|
+
lookupName(languages) {
|
|
75
|
+
return 'Unified Code for Units of Measure (UCUM)';
|
|
76
|
+
}
|
|
77
|
+
|
|
72
78
|
totalCount() {
|
|
73
79
|
return -1; // Unbounded due to grammar
|
|
74
80
|
}
|
|
@@ -1,25 +1,37 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* icd11-expressions.js -- postcoordinated ICD-11 expressions: parse, bind to axes,
|
|
3
|
-
*
|
|
4
|
-
* ICD-11 postcoordination has two levels of punctuation and one spelling per linearization:
|
|
2
|
+
* icd11-expressions.js -- postcoordinated ICD-11 expressions: parse, bind to axes, normalise,
|
|
3
|
+
* render.
|
|
5
4
|
*
|
|
6
5
|
* 1A00&XN8P1 MMS: a stem, then a value on one of its axes
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* d5409.qp3 ICF spells
|
|
10
|
-
*
|
|
6
|
+
* NA00.2&XK8G&XJ9NV/PG50&XE3HC&XE266 a root with its qualifiers, then a cluster member with its own
|
|
7
|
+
* CA40.00/MG51.00/1B10.0 a root and two cluster members
|
|
8
|
+
* d5409.qp3 ICF spells a qualifier with a dot
|
|
9
|
+
* 257068234&194483911 the same, written with entity ids (the code form)
|
|
10
|
+
* 1363559646/other&194483911 a residual's id carries its own slash
|
|
11
|
+
*
|
|
12
|
+
* There are no spaces in the syntax, and entity uris are not part of it.
|
|
11
13
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
14
|
+
* How it is read (the October 2026 WHO meeting):
|
|
15
|
+
* - the first code is the root. Everything after a `/` qualifies the root: there is no
|
|
16
|
+
* nesting, and nothing qualifies a qualifier.
|
|
17
|
+
* - `&` (and ICF's `.`) attaches qualifiers to the code before it: the root, or the `/`
|
|
18
|
+
* member they follow. That grouping matters; the order within a group does not, and nor
|
|
19
|
+
* does the order of the `/` members.
|
|
20
|
+
* - a `&` qualifier must be a value on an axis of the code it qualifies, or the expression
|
|
21
|
+
* is wrong. A `/` member is placed on an axis of the root if it fits one, and may fit none.
|
|
18
22
|
*
|
|
19
|
-
* The
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
+
* The normal form (the code) fixes an order that carries no meaning:
|
|
24
|
+
* - the root first;
|
|
25
|
+
* - each group of qualifiers in the order the qualified code defines its axes, and within
|
|
26
|
+
* one axis by entity id (string order - the ids are tails of the entity uris);
|
|
27
|
+
* - the `/` members the same way against the root's axes, a member on no axis after all
|
|
28
|
+
* those that are, each member carrying its own qualifiers, normalised the same way;
|
|
29
|
+
* - duplicates deleted: the same code twice in one group, or two identical members. A member
|
|
30
|
+
* with different qualifiers is a different member, not a duplicate.
|
|
31
|
+
* - the syntax is kept: `&`, `/` and ICF's `.` are not interchangeable. ICF dot qualifiers
|
|
32
|
+
* stay in the order written, directly after the code they qualify.
|
|
33
|
+
* Which axis a value goes on is decided in id order too, so that the binding - and so the
|
|
34
|
+
* normal form - does not depend on the order the codes were written in.
|
|
23
35
|
*/
|
|
24
36
|
|
|
25
37
|
'use strict';
|
|
@@ -34,6 +46,7 @@ class ExpressionToken {
|
|
|
34
46
|
this.text = text;
|
|
35
47
|
this.sep = sep; // null for the first token, otherwise '&', '/' or '.'
|
|
36
48
|
this.concept = null; // the ICD11Concept it names, once resolved
|
|
49
|
+
this.axis = null; // the axis it was bound to, if any
|
|
37
50
|
}
|
|
38
51
|
}
|
|
39
52
|
|
|
@@ -42,26 +55,32 @@ class ExpressionToken {
|
|
|
42
55
|
* The context object the provider hands around for a postcoordinated code.
|
|
43
56
|
*/
|
|
44
57
|
class ICD11Expression {
|
|
45
|
-
constructor(raw, tokens, members, stems) {
|
|
58
|
+
constructor(raw, tokens, members, stems, issues) {
|
|
46
59
|
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
|
|
60
|
+
this.tokens = tokens; // the normal form, in order (idForm and classificationCode)
|
|
61
|
+
this.members = members; // [{ head: Token, tail: [Token] }] -- as written (the display)
|
|
62
|
+
this.stems = stems; // [{ stem: Token, axes, bindings: [{ axis, token }] }] -- as meant: the
|
|
63
|
+
// root first, then one per `/` member
|
|
64
|
+
this.issues = issues; // [{ severity, msgId, params }] - things validation should say
|
|
50
65
|
}
|
|
51
66
|
|
|
52
|
-
/** The
|
|
67
|
+
/** The expression's code: the id form (the October 2026 decisions; see idForm). */
|
|
53
68
|
get canonicalCode() {
|
|
54
|
-
return this.
|
|
69
|
+
return this.idForm;
|
|
55
70
|
}
|
|
56
71
|
|
|
57
72
|
/**
|
|
58
|
-
* The
|
|
59
|
-
*
|
|
73
|
+
* The expression written with short codes (1A00&XN8P1), or null when any part of it has
|
|
74
|
+
* no short code - then there is no classification-code form to report.
|
|
60
75
|
*/
|
|
61
|
-
get
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
76
|
+
get classificationCode() {
|
|
77
|
+
if (!this.tokens.every(t => t.concept.code)) return null;
|
|
78
|
+
return this.tokens.map((t, i) => (i ? t.sep : '') + t.concept.code).join('');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The entity-id spelling, in normal order - the expression's code: 257068234&194483911. */
|
|
82
|
+
get idForm() {
|
|
83
|
+
return this.tokens.map((t, i) => (i ? t.sep : '') + t.concept.canonicalCode).join('');
|
|
65
84
|
}
|
|
66
85
|
|
|
67
86
|
/** True when nothing was postcoordinated after all -- a bare code in expression clothing. */
|
|
@@ -70,28 +89,37 @@ class ICD11Expression {
|
|
|
70
89
|
}
|
|
71
90
|
}
|
|
72
91
|
|
|
92
|
+
// A residual's entity id is its parent's id with /other or /unspecified after it.
|
|
93
|
+
const RESIDUAL = /^\/(other|unspecified)(?=$|[&/.])/;
|
|
94
|
+
|
|
73
95
|
/**
|
|
74
|
-
* Split the written form into tokens
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
96
|
+
* Split the written form into tokens at `&` and `/`. One `/` is not a separator: the one
|
|
97
|
+
* in a residual's entity id, 1363559646/other or 1363559646/unspecified. That is read as
|
|
98
|
+
* part of the id when it follows an all-digit token (an entity id; no short code is all
|
|
99
|
+
* digits) and is followed by the end or a separator, so 1363559646/other&XN8P1 is two
|
|
100
|
+
* tokens and 1363559646/1G41 is a cluster.
|
|
78
101
|
*
|
|
79
|
-
* Returns null if the text is not shaped like an expression at all
|
|
102
|
+
* Returns null if the text is not shaped like an expression at all -- an empty part, or a
|
|
103
|
+
* space anywhere: the syntax has none.
|
|
80
104
|
*/
|
|
81
105
|
function tokenize(text) {
|
|
106
|
+
if (!text || /\s/.test(text)) return null;
|
|
82
107
|
const tokens = [];
|
|
83
108
|
const n = text.length;
|
|
84
109
|
let i = 0, sep = null;
|
|
85
|
-
while (i
|
|
86
|
-
while (i < n && text[i] === ' ') i++;
|
|
87
|
-
if (i >= n) return null; // trailing separator
|
|
110
|
+
while (i <= n) {
|
|
88
111
|
const start = i;
|
|
89
|
-
|
|
90
|
-
|
|
112
|
+
while (i < n && text[i] !== '&' && text[i] !== '/') i++;
|
|
113
|
+
if (i < n && text[i] === '/' && /^\d+$/.test(text.slice(start, i))) {
|
|
114
|
+
const m = RESIDUAL.exec(text.slice(i));
|
|
115
|
+
if (m) {
|
|
116
|
+
i += m[0].length;
|
|
117
|
+
while (i < n && text[i] !== '&' && text[i] !== '/') i++; // e.g. an ICF .qualifier
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
if (i === start) return null; // empty part, or a trailing separator
|
|
91
121
|
tokens.push(new ExpressionToken(text.slice(start, i), sep));
|
|
92
|
-
while (i < n && text[i] === ' ') i++;
|
|
93
122
|
if (i >= n) break;
|
|
94
|
-
if (text[i] !== '&' && text[i] !== '/') return null;
|
|
95
123
|
sep = text[i];
|
|
96
124
|
i++;
|
|
97
125
|
}
|
|
@@ -132,16 +160,14 @@ async function resolveToken(ctx, token) {
|
|
|
132
160
|
}
|
|
133
161
|
|
|
134
162
|
/**
|
|
135
|
-
* The postcoordination axes a stem declares
|
|
136
|
-
* order.
|
|
137
|
-
*
|
|
138
|
-
*
|
|
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.
|
|
163
|
+
* The postcoordination axes a stem declares, in binding preference: required axes first, then
|
|
164
|
+
* WHO's own order. When a value fits more than one free axis, an axis that must be filled is
|
|
165
|
+
* the one a coder is most likely to have been filling, so it gets first refusal. `seq` keeps
|
|
166
|
+
* the definition order, which is the order the normal form sorts by.
|
|
141
167
|
*/
|
|
142
168
|
async function axesOf(ctx, concept) {
|
|
143
169
|
return ctx.all(
|
|
144
|
-
`SELECT id, axis, axis_name, required, allow_multiple, value_set_uri
|
|
170
|
+
`SELECT id, seq, axis, axis_name, required, allow_multiple, value_set_uri
|
|
145
171
|
FROM pc_scale WHERE concept = ? ORDER BY required DESC, seq`, [concept.id]);
|
|
146
172
|
}
|
|
147
173
|
|
|
@@ -155,11 +181,11 @@ async function permitted(ctx, axis, value) {
|
|
|
155
181
|
}
|
|
156
182
|
|
|
157
183
|
/**
|
|
158
|
-
* Put a value on an axis of the stem. An axis not yet used is always preferred over one
|
|
159
|
-
*
|
|
160
|
-
* hasCausingCondition and on hasManifestation
|
|
161
|
-
*
|
|
162
|
-
*
|
|
184
|
+
* Put a value on an axis of the stem. An axis not yet used is always preferred over one that
|
|
185
|
+
* is, which is what keeps the two values of 1D01.0Y/1G41/1G40 apart: both are legal on
|
|
186
|
+
* hasCausingCondition and on hasManifestation. When every axis the value fits is already
|
|
187
|
+
* taken it goes on the first of them anyway; if that axis does not allow several values the
|
|
188
|
+
* expression still parses, and validation says so.
|
|
163
189
|
*
|
|
164
190
|
* Returns the axis, or null when the value is on none of them.
|
|
165
191
|
*/
|
|
@@ -168,24 +194,77 @@ async function bind(ctx, stem, token) {
|
|
|
168
194
|
for (const axis of stem.axes) {
|
|
169
195
|
if (!await permitted(ctx, axis, token.concept)) continue;
|
|
170
196
|
if (!stem.used.has(axis.id)) {
|
|
171
|
-
stem.used.
|
|
197
|
+
stem.used.set(axis.id, 1);
|
|
172
198
|
stem.bindings.push({ axis, token });
|
|
199
|
+
token.axis = axis;
|
|
173
200
|
return axis;
|
|
174
201
|
}
|
|
175
202
|
if (fallback === null) fallback = axis;
|
|
176
203
|
}
|
|
177
204
|
if (fallback === null) return null;
|
|
178
|
-
|
|
179
|
-
// that is legal; nothing in the suite pins it yet, so the value is kept rather than lost.
|
|
205
|
+
stem.used.set(fallback.id, stem.used.get(fallback.id) + 1);
|
|
180
206
|
stem.bindings.push({ axis: fallback, token });
|
|
207
|
+
token.axis = fallback;
|
|
181
208
|
return fallback;
|
|
182
209
|
}
|
|
183
210
|
|
|
211
|
+
/** Entity ids compare as strings: they are the tails of the entity uris. */
|
|
212
|
+
const byId = (a, b) => {
|
|
213
|
+
const x = a.concept.entityId, y = b.concept.entityId;
|
|
214
|
+
return x < y ? -1 : x > y ? 1 : 0;
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
/** Normal-form order of things bound (or not) to one code's axes: axis definition order, then id. */
|
|
218
|
+
const byAxisThenId = (a, b) => {
|
|
219
|
+
const sa = a.axis ? a.axis.seq : Infinity, sb = b.axis ? b.axis.seq : Infinity;
|
|
220
|
+
return sa !== sb ? sa - sb : byId(a, b);
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
/** A group with its duplicates deleted - the same code with the same separator. */
|
|
224
|
+
function dedupe(tokens) {
|
|
225
|
+
const seen = new Set();
|
|
226
|
+
return tokens.filter(t => {
|
|
227
|
+
const k = t.sep + '|' + t.concept.id;
|
|
228
|
+
if (seen.has(k)) return false;
|
|
229
|
+
seen.add(k);
|
|
230
|
+
return true;
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* A qualifier group in normal order. ICF's dot qualifiers keep the order written and come
|
|
236
|
+
* first; the `&` ones are sorted by axis, then id.
|
|
237
|
+
*/
|
|
238
|
+
function orderGroup(tokens) {
|
|
239
|
+
return [...tokens.filter(t => t.sep === '.'), ...tokens.filter(t => t.sep !== '.').sort(byAxisThenId)];
|
|
240
|
+
}
|
|
241
|
+
|
|
184
242
|
/**
|
|
185
|
-
*
|
|
186
|
-
* the
|
|
187
|
-
|
|
188
|
-
|
|
243
|
+
* Bind one code's `&`/`.` qualifiers to its axes, in id order. Throws when one is on none of
|
|
244
|
+
* them: the `&` asserted it was.
|
|
245
|
+
*/
|
|
246
|
+
async function bindQualifiers(ctx, stem, tail) {
|
|
247
|
+
for (const t of [...tail].sort(byId)) {
|
|
248
|
+
if (!await bind(ctx, stem, t)) throw notOnAnyAxis(ctx, stem, t);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** What validation has to say about a stem: several values on an axis that takes one. */
|
|
253
|
+
function axisIssues(stem, issues) {
|
|
254
|
+
for (const axis of stem.axes) {
|
|
255
|
+
if ((stem.used.get(axis.id) || 0) > 1 && axis.allow_multiple === 'NotAllowed') {
|
|
256
|
+
const values = stem.bindings.filter(b => b.axis === axis).map(b => b.token.text);
|
|
257
|
+
issues.push({ severity: 'warning', msgId: 'ICD11_PC_AXIS_NOT_MULTIPLE',
|
|
258
|
+
params: [stem.stem.text, axis.axis_name, values.join(', ')] });
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Parse, bind and normalise. Returns an ICD11Expression, or null when the text does not
|
|
265
|
+
* resolve (which the caller reports as an ordinary unknown code). Throws an Issue when the
|
|
266
|
+
* text does resolve but says something that is not true -- a qualifier on no axis of the
|
|
267
|
+
* code it qualifies -- because "not found" would hide the reason.
|
|
189
268
|
*/
|
|
190
269
|
async function parseExpression(ctx, text) {
|
|
191
270
|
const raw = tokenize(text);
|
|
@@ -199,30 +278,49 @@ async function parseExpression(ctx, text) {
|
|
|
199
278
|
}
|
|
200
279
|
if (tokens.length === 1) return null; // a plain code, already handled
|
|
201
280
|
|
|
202
|
-
// syntax:
|
|
281
|
+
// syntax: the members as written, split at '/'; the first is the root
|
|
203
282
|
const members = [];
|
|
204
283
|
for (const t of tokens) {
|
|
205
284
|
if (t.sep === '/' || members.length === 0) members.push({ head: t, tail: [] });
|
|
206
285
|
else members[members.length - 1].tail.push(t);
|
|
207
286
|
}
|
|
208
287
|
|
|
209
|
-
//
|
|
210
|
-
const
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
for (const t of m.tail) {
|
|
220
|
-
if (!await bind(ctx, cur, t)) {
|
|
221
|
-
throw notOnAnyAxis(ctx, cur, t);
|
|
222
|
-
}
|
|
223
|
-
}
|
|
288
|
+
// duplicates go before anything is bound, so a repeated code never takes a second axis
|
|
289
|
+
const groups = members.map(m => ({ head: m.head, tail: dedupe(m.tail) }));
|
|
290
|
+
const root = groups[0];
|
|
291
|
+
const others = [];
|
|
292
|
+
const seenMember = new Set();
|
|
293
|
+
for (const g of groups.slice(1)) {
|
|
294
|
+
const k = g.head.concept.id + '|' + g.tail.map(t => t.sep + t.concept.id).sort().join(',');
|
|
295
|
+
if (seenMember.has(k)) continue;
|
|
296
|
+
seenMember.add(k);
|
|
297
|
+
others.push(g);
|
|
224
298
|
}
|
|
225
|
-
|
|
299
|
+
|
|
300
|
+
// semantics: the root's qualifiers, then the members, go on the root's axes; each member's
|
|
301
|
+
// own qualifiers go on its axes. All in id order, so the order written changes nothing.
|
|
302
|
+
const issues = [];
|
|
303
|
+
const rootStem = { stem: root.head, axes: await axesOf(ctx, root.head.concept), bindings: [], used: new Map() };
|
|
304
|
+
await bindQualifiers(ctx, rootStem, root.tail);
|
|
305
|
+
for (const g of [...others].sort((a, b) => byId(a.head, b.head))) {
|
|
306
|
+
await bind(ctx, rootStem, g.head); // may fit no axis of the root: that is allowed
|
|
307
|
+
}
|
|
308
|
+
// every member is a code in its own right, with its own axes (and its qualifiers on them)
|
|
309
|
+
const stems = [rootStem];
|
|
310
|
+
for (const g of others) {
|
|
311
|
+
const st = { stem: g.head, axes: await axesOf(ctx, g.head.concept), bindings: [], used: new Map() };
|
|
312
|
+
await bindQualifiers(ctx, st, g.tail);
|
|
313
|
+
stems.push(st);
|
|
314
|
+
}
|
|
315
|
+
for (const st of stems) axisIssues(st, issues);
|
|
316
|
+
|
|
317
|
+
// the normal form
|
|
318
|
+
const memberKey = g => g.head.concept.entityId + orderGroup(g.tail).map(t => t.sep + t.concept.entityId).join('');
|
|
319
|
+
const ordered = [...others].sort((a, b) => byAxisThenId(a.head, b.head) || (memberKey(a) < memberKey(b) ? -1 : memberKey(a) > memberKey(b) ? 1 : 0));
|
|
320
|
+
const normal = [root.head, ...orderGroup(root.tail)];
|
|
321
|
+
for (const g of ordered) normal.push(g.head, ...orderGroup(g.tail));
|
|
322
|
+
|
|
323
|
+
return new ICD11Expression(text, normal, members, stems, issues);
|
|
226
324
|
}
|
|
227
325
|
|
|
228
326
|
/**
|
|
@@ -239,4 +337,19 @@ function notOnAnyAxis(ctx, stem, token) {
|
|
|
239
337
|
'invalid-code', 400).handleAsOO(400);
|
|
240
338
|
}
|
|
241
339
|
|
|
242
|
-
|
|
340
|
+
/**
|
|
341
|
+
* A display for an axis, from WHO's name for it - WHO publishes no labels for the axes:
|
|
342
|
+
* specificAnatomy -> Specific anatomy, hasGCSEyeScore -> Has GCS eye score.
|
|
343
|
+
*/
|
|
344
|
+
function axisLabel(name) {
|
|
345
|
+
const words = name
|
|
346
|
+
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
|
|
347
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
|
348
|
+
.replace(/([A-Za-z])(\d)/g, '$1 $2')
|
|
349
|
+
.split(' ')
|
|
350
|
+
.map(w => (w.length > 1 && w === w.toUpperCase()) ? w : w.toLowerCase());
|
|
351
|
+
const text = words.join(' ');
|
|
352
|
+
return text.charAt(0).toUpperCase() + text.slice(1);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
module.exports = { ICD11Expression, ExpressionToken, parseExpression, tokenize, axisLabel, SCHEMA_NS };
|
|
@@ -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 };
|
|
@@ -23,6 +23,7 @@ const zlib = require('zlib');
|
|
|
23
23
|
const crypto = require('crypto');
|
|
24
24
|
const Database = require('better-sqlite3');
|
|
25
25
|
const { Command } = require('commander');
|
|
26
|
+
const { axisLabel } = require('../cs/icd11-expressions');
|
|
26
27
|
|
|
27
28
|
const TOOL_VERSION = '1.0.0';
|
|
28
29
|
const SCHEMA_NS = 'http://id.who.int/icd/schema';
|
|
@@ -158,9 +159,13 @@ class CodeSystemExporter {
|
|
|
158
159
|
* short : the short code; concepts without one are dropped from the resource
|
|
159
160
|
* uri : the unversioned entity uri, always
|
|
160
161
|
* id : the bare entity id, always (straw poll option 4)
|
|
162
|
+
*
|
|
163
|
+
* Without --code-form, each system gets the form the server uses for it: id for MMS and
|
|
164
|
+
* ICF (the October 2026 WHO meeting), uri for the Foundation, which that decision did not
|
|
165
|
+
* cover.
|
|
161
166
|
*/
|
|
162
167
|
codeOf(c) {
|
|
163
|
-
switch (this.
|
|
168
|
+
switch (this.codeForm) {
|
|
164
169
|
case 'short': return c.code;
|
|
165
170
|
case 'uri': return c.uri;
|
|
166
171
|
case 'id': return c.entity_id;
|
|
@@ -194,13 +199,13 @@ class CodeSystemExporter {
|
|
|
194
199
|
{ code: 'parent', uri: 'http://hl7.org/fhir/concept-properties#parent', type: 'code',
|
|
195
200
|
description: 'A parent of this concept.' },
|
|
196
201
|
];
|
|
197
|
-
if (this.
|
|
198
|
-
p.push({ code: '
|
|
199
|
-
description: 'The short
|
|
202
|
+
if (this.codeForm !== 'short') {
|
|
203
|
+
p.push({ code: 'classificationCode', uri: `${SCHEMA_NS}/code`, type: 'code',
|
|
204
|
+
description: 'The short classification code (e.g. 1A00), where the entity has one. Short codes may change between releases; entity ids do not.' });
|
|
200
205
|
}
|
|
201
206
|
if (sys.code !== 'foundation') {
|
|
202
207
|
p.push(
|
|
203
|
-
{ code: 'foundationUri', uri: `${SCHEMA_NS}/source`, type: '
|
|
208
|
+
{ code: 'foundationUri', uri: `${SCHEMA_NS}/source`, type: 'Coding',
|
|
204
209
|
description: 'The Foundation entity this linearization entry was derived from.' },
|
|
205
210
|
{ code: 'blockId', uri: `${SCHEMA_NS}/blockId`, type: 'string' },
|
|
206
211
|
{ code: 'codeRange', uri: `${SCHEMA_NS}/codeRange`, type: 'string' },
|
|
@@ -300,13 +305,14 @@ class CodeSystemExporter {
|
|
|
300
305
|
const put = (c_, k, v) => { if (v != null && v !== '') property.push({ code: c_, [k]: v }); };
|
|
301
306
|
put('entityId', 'valueString', c.entity_id);
|
|
302
307
|
put('uri', 'valueString', c.uri);
|
|
303
|
-
if (this.
|
|
308
|
+
if (this.codeForm !== 'short') put('classificationCode', 'valueCode', c.code);
|
|
304
309
|
if (c.class_kind) property.push({ code: 'classKind',
|
|
305
310
|
valueCoding: { system: SCHEMA_NS, code: c.class_kind } });
|
|
306
311
|
if (!c.selectable) property.push({ code: 'notSelectable', valueBoolean: true });
|
|
307
312
|
put('blockId', 'valueString', c.block_id);
|
|
308
313
|
put('codeRange', 'valueString', c.code_range);
|
|
309
|
-
|
|
314
|
+
if (c.source_uri) property.push({ code: 'foundationUri',
|
|
315
|
+
valueCoding: { system: 'http://id.who.int/icd/entity', code: c.source_uri } });
|
|
310
316
|
put('browserUrl', 'valueString', c.browser_url);
|
|
311
317
|
for (const s of ctx.scales.get(c.id) || []) {
|
|
312
318
|
property.push({ code: 'postcoordinationScale', valueString: s.value_set_uri });
|
|
@@ -391,7 +397,6 @@ class CodeSystemExporter {
|
|
|
391
397
|
// Depth-first, emitting each concept with its children inline. Children are written
|
|
392
398
|
// by hand rather than nested into the parent object, so no subtree is ever fully
|
|
393
399
|
// materialised in memory.
|
|
394
|
-
const self = this;
|
|
395
400
|
const walk = async (id, depth) => {
|
|
396
401
|
const c = ctx.byId.get(id);
|
|
397
402
|
const json = this.conceptJson(c, ctx, { nested: true });
|
|
@@ -430,7 +435,7 @@ class CodeSystemExporter {
|
|
|
430
435
|
await finished;
|
|
431
436
|
const size = fs.statSync(target).size;
|
|
432
437
|
this.log(` ${path.basename(target)} ${written} concepts, ${(size / 1048576).toFixed(1)} MB`
|
|
433
|
-
+ ` [${nested ? 'nested' : 'flat'}, ${this.
|
|
438
|
+
+ ` [${nested ? 'nested' : 'flat'}, ${this.codeForm}]`);
|
|
434
439
|
return { target, written, ctx };
|
|
435
440
|
}
|
|
436
441
|
|
|
@@ -532,6 +537,12 @@ class CodeSystemExporter {
|
|
|
532
537
|
{ code: 'block', display: 'Block', definition: 'A grouper. Has no short code and cannot be used to code with.' },
|
|
533
538
|
{ code: 'window', display: 'Window', definition: 'A presentation grouper. Cannot be used to code with.' },
|
|
534
539
|
{ code: 'category', display: 'Category', definition: 'A codeable entity.' },
|
|
540
|
+
// the postcoordination axes, named as WHO names them; $lookup on an expression
|
|
541
|
+
// reports each axis as a Coding in this code system. WHO publishes no labels for
|
|
542
|
+
// them, so the display is made from the name (specificAnatomy -> Specific anatomy)
|
|
543
|
+
...this.db.prepare('SELECT DISTINCT axis_name FROM pc_scale ORDER BY axis_name').all()
|
|
544
|
+
.map(r => ({ code: r.axis_name, display: axisLabel(r.axis_name),
|
|
545
|
+
definition: 'A postcoordination axis.' })),
|
|
535
546
|
],
|
|
536
547
|
};
|
|
537
548
|
cs.count = cs.concept.length;
|
|
@@ -540,6 +551,40 @@ class CodeSystemExporter {
|
|
|
540
551
|
this.index.push({ filename: 'CodeSystem-icd11-schema.json', resourceType: 'CodeSystem',
|
|
541
552
|
id: cs.id, url: cs.url, version: cs.version });
|
|
542
553
|
this.log(` ${path.basename(file)} ${cs.count} concepts [schema vocabulary]`);
|
|
554
|
+
this.writeAllowMultipleCodeSystem();
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* The values of a postcoordination axis's allowMultipleValues - WHO's own three codes. $lookup
|
|
559
|
+
* on an expression reports them as Codings in this code system.
|
|
560
|
+
*/
|
|
561
|
+
writeAllowMultipleCodeSystem() {
|
|
562
|
+
const cs = {
|
|
563
|
+
resourceType: 'CodeSystem',
|
|
564
|
+
id: 'icd11-allow-multiple-values',
|
|
565
|
+
url: `${SCHEMA_NS}/allowMultipleValues`,
|
|
566
|
+
version: this.meta('release') || '2026-01',
|
|
567
|
+
name: 'ICD11AllowMultipleValues',
|
|
568
|
+
title: 'ICD-11 postcoordination: whether an axis takes several values',
|
|
569
|
+
status: 'active',
|
|
570
|
+
experimental: false,
|
|
571
|
+
publisher: 'World Health Organization',
|
|
572
|
+
description: 'Whether a postcoordination axis of an ICD-11 entity may be given more than one value.',
|
|
573
|
+
caseSensitive: true,
|
|
574
|
+
content: 'complete',
|
|
575
|
+
concept: [
|
|
576
|
+
{ code: 'AllowAlways', display: 'Allow always', definition: 'The axis may take any number of values.' },
|
|
577
|
+
{ code: 'NotAllowed', display: 'Not allowed', definition: 'The axis takes at most one value.' },
|
|
578
|
+
{ code: 'AllowedExceptFromSameBlock', display: 'Allowed except from the same block',
|
|
579
|
+
definition: 'The axis may take several values, but not two from the same block.' },
|
|
580
|
+
],
|
|
581
|
+
};
|
|
582
|
+
cs.count = cs.concept.length;
|
|
583
|
+
const file = path.join(this.dir, 'CodeSystem-icd11-allow-multiple-values.json');
|
|
584
|
+
fs.writeFileSync(file, JSON.stringify(cs, null, 2) + '\n');
|
|
585
|
+
this.index.push({ filename: 'CodeSystem-icd11-allow-multiple-values.json', resourceType: 'CodeSystem',
|
|
586
|
+
id: cs.id, url: cs.url, version: cs.version });
|
|
587
|
+
this.log(` ${path.basename(file)} ${cs.count} concepts [allowMultipleValues]`);
|
|
543
588
|
}
|
|
544
589
|
|
|
545
590
|
// -- postcoordination value sets -----------------------------------------
|
|
@@ -745,7 +790,7 @@ class CodeSystemExporter {
|
|
|
745
790
|
this.log(`ICD-11 FHIR export`);
|
|
746
791
|
this.log(` source : ${this.opts.src}`);
|
|
747
792
|
this.log(` out : ${this.dir}`);
|
|
748
|
-
this.log(` code form : ${this.opts.codeForm}`);
|
|
793
|
+
this.log(` code form : ${this.opts.codeForm || 'id (mms, icf), uri (foundation)'}`);
|
|
749
794
|
this.log(` languages : ${languages.join(', ')} (base ${this.baseLang})`);
|
|
750
795
|
this.log(` value sets: ${this.opts.valueSets ? 'yes' : 'no'}`
|
|
751
796
|
+ ` package: ${this.opts.package ? `${this.opts.packageId}#${this.opts.packageVersion}` : 'no'}`);
|
|
@@ -759,6 +804,7 @@ class CodeSystemExporter {
|
|
|
759
804
|
const sys = this.db.prepare('SELECT * FROM system WHERE code=?').get(code);
|
|
760
805
|
if (!sys) { this.log(` ! ${code} is not in this database, skipping`); continue; }
|
|
761
806
|
this.log(`\n${sys.code} -- ${sys.title}`);
|
|
807
|
+
this.codeForm = this.opts.codeForm || (sys.code === 'foundation' ? 'uri' : 'id');
|
|
762
808
|
const { ctx } = await this.exportSystem(sys, languages);
|
|
763
809
|
for (const lang of languages.slice(1)) await this.exportSupplement(sys, lang, ctx);
|
|
764
810
|
if (this.opts.valueSets) this.writeValueSets(sys);
|
|
@@ -792,7 +838,7 @@ function main(argv) {
|
|
|
792
838
|
.requiredOption('-o, --out <dir>', 'output directory')
|
|
793
839
|
.option('--systems <list>', 'which code systems to export', 'mms,icf,foundation')
|
|
794
840
|
.option('-l, --languages <list>', "comma separated, or 'all'; the first is the base language", 'all')
|
|
795
|
-
.option('--code-form <form>', '
|
|
841
|
+
.option('--code-form <form>', 'id | uri | hybrid | short -- what goes in concept.code (default: id, uri for the Foundation)')
|
|
796
842
|
.option('--hierarchy <how>', 'nested | flat (the Foundation is always flat)', 'nested')
|
|
797
843
|
.option('--no-value-set', 'do not declare an all-codes value set in CodeSystem.valueSet')
|
|
798
844
|
.option('--case-sensitive <b>', 'value for CodeSystem.caseSensitive', v => v !== 'false', true)
|
|
@@ -805,7 +851,7 @@ function main(argv) {
|
|
|
805
851
|
|
|
806
852
|
program.parse(argv);
|
|
807
853
|
const opts = program.opts();
|
|
808
|
-
if (!['hybrid', 'short', 'uri', 'id'].includes(opts.codeForm)) {
|
|
854
|
+
if (opts.codeForm && !['hybrid', 'short', 'uri', 'id'].includes(opts.codeForm)) {
|
|
809
855
|
throw new Error(`--code-form must be hybrid, short, uri or id`);
|
|
810
856
|
}
|
|
811
857
|
// A package holds plain JSON; the tarball does the compressing.
|
|
@@ -190,16 +190,20 @@ tree and avoids repeating parent codes; the Foundation is a DAG so it is emitted
|
|
|
190
190
|
keeps the base file usable on its own and matches WHO treating translations as separately
|
|
191
191
|
governed. `--languages en,fr` picks a subset.
|
|
192
192
|
|
|
193
|
-
**Codes.** `--code-form` decides what goes in `concept.code
|
|
193
|
+
**Codes.** `--code-form` decides what goes in `concept.code`. The October 2026 decisions
|
|
194
|
+
made the bare entity id the code in MMS and ICF (the uri is the system url + `/` + the id)
|
|
195
|
+
and the short code its `classificationCode`. The Foundation was not part of that decision
|
|
196
|
+
and keeps the entity uri. Without `--code-form` each system gets its decided form; the
|
|
197
|
+
option forces one form for all of them, for comparison:
|
|
194
198
|
|
|
195
199
|
| form | `concept.code` | notes |
|
|
196
200
|
|---|---|---|
|
|
197
|
-
| `
|
|
201
|
+
| `id` (default for MMS, ICF) | the bare entity id, always | the decided form; Geneva straw poll option 4, which won 7-1 |
|
|
202
|
+
| `uri` (default for the Foundation) | the unversioned entity uri, always | uniform, always present |
|
|
203
|
+
| `hybrid` | short code where there is one, entity uri otherwise | what the WHO FHIR server did before the decisions |
|
|
198
204
|
| `short` | the short code | drops the 1,519 MMS concepts that have none |
|
|
199
|
-
| `uri` | the unversioned entity uri, always | uniform, always present |
|
|
200
|
-
| `id` | the bare entity id, always | Geneva straw poll option 4, which won 7-1 |
|
|
201
205
|
|
|
202
|
-
Whichever is chosen, the others are still available as `entityId`, `uri` and `
|
|
206
|
+
Whichever is chosen, the others are still available as `entityId`, `uri` and `classificationCode`
|
|
203
207
|
properties, so nothing is lost and the four can be generated and compared.
|
|
204
208
|
|
|
205
209
|
**Declarations.** The exported resources declare `filter` (concept is-a/descendent-of,
|