aimedicalcoding 0.3.1__py3-none-any.whl
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.
- aimedicalcoding/__init__.py +29 -0
- aimedicalcoding/__main__.py +313 -0
- aimedicalcoding/adapters/__init__.py +3 -0
- aimedicalcoding/adapters/base.py +25 -0
- aimedicalcoding/adapters/ccda/__init__.py +3 -0
- aimedicalcoding/adapters/ccda/adapter.py +294 -0
- aimedicalcoding/adapters/ccda/codes.py +198 -0
- aimedicalcoding/adapters/ccda/narrative.py +121 -0
- aimedicalcoding/adapters/ccda/sections.py +44 -0
- aimedicalcoding/adapters/custom_json/__init__.py +3 -0
- aimedicalcoding/adapters/custom_json/adapter.py +30 -0
- aimedicalcoding/adapters/fhir/__init__.py +3 -0
- aimedicalcoding/adapters/fhir/adapter.py +326 -0
- aimedicalcoding/adapters/fhir/codes.py +211 -0
- aimedicalcoding/adapters/fhir/mapping.py +68 -0
- aimedicalcoding/api.py +163 -0
- aimedicalcoding/core/__init__.py +34 -0
- aimedicalcoding/core/checkdigits.py +103 -0
- aimedicalcoding/core/models.py +149 -0
- aimedicalcoding/core/system_registry.py +185 -0
- aimedicalcoding/pipeline/__init__.py +3 -0
- aimedicalcoding/pipeline/orchestrator.py +33 -0
- aimedicalcoding/recovery/__init__.py +3 -0
- aimedicalcoding/recovery/engine.py +16 -0
- aimedicalcoding/serialize.py +257 -0
- aimedicalcoding/systems/__init__.py +3 -0
- aimedicalcoding/systems/base.py +43 -0
- aimedicalcoding/systems/cvx/__init__.py +15 -0
- aimedicalcoding/systems/cvx/handler.py +55 -0
- aimedicalcoding/systems/cvx/normalize.py +75 -0
- aimedicalcoding/systems/cvx/patterns.py +134 -0
- aimedicalcoding/systems/icd10cm/__init__.py +16 -0
- aimedicalcoding/systems/icd10cm/handler.py +55 -0
- aimedicalcoding/systems/icd10cm/icdformat.py +43 -0
- aimedicalcoding/systems/icd10cm/patterns.py +133 -0
- aimedicalcoding/systems/loinc/__init__.py +14 -0
- aimedicalcoding/systems/loinc/axes.py +199 -0
- aimedicalcoding/systems/loinc/handler.py +80 -0
- aimedicalcoding/systems/loinc/normalize.py +81 -0
- aimedicalcoding/systems/loinc/patterns.py +166 -0
- aimedicalcoding/systems/rxnorm/__init__.py +13 -0
- aimedicalcoding/systems/rxnorm/handler.py +55 -0
- aimedicalcoding/systems/rxnorm/normalize.py +88 -0
- aimedicalcoding/systems/rxnorm/patterns.py +143 -0
- aimedicalcoding/systems/snomed/__init__.py +15 -0
- aimedicalcoding/systems/snomed/handler.py +60 -0
- aimedicalcoding/systems/snomed/hierarchy.py +96 -0
- aimedicalcoding/systems/snomed/patterns.py +166 -0
- aimedicalcoding/systems/snomed/qualifiers.py +90 -0
- aimedicalcoding/terminology/__init__.py +3 -0
- aimedicalcoding/terminology/service.py +31 -0
- aimedicalcoding-0.3.1.dist-info/METADATA +276 -0
- aimedicalcoding-0.3.1.dist-info/RECORD +57 -0
- aimedicalcoding-0.3.1.dist-info/WHEEL +5 -0
- aimedicalcoding-0.3.1.dist-info/entry_points.txt +2 -0
- aimedicalcoding-0.3.1.dist-info/licenses/LICENSE +21 -0
- aimedicalcoding-0.3.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
"""FHIR R4 Bundle adapter (Trove flavor).
|
|
2
|
+
|
|
3
|
+
Walks a single R4 Bundle in two passes: first build the reference index
|
|
4
|
+
(fullUrl + ``ResourceType/id``) and the ``hasMember``/``DiagnosticReport.result``
|
|
5
|
+
member->panel map, then emit one ``ClinicalStatement`` per coded clinical
|
|
6
|
+
resource. Slot rules mirror the CCDA adapter exactly:
|
|
7
|
+
|
|
8
|
+
Observation.code -> codings (question slot), value[x] -> value
|
|
9
|
+
Condition / FamilyMemberHistory concept -> value.codings (value-slot contexts)
|
|
10
|
+
Medication* / Immunization / Procedure / allergy substance -> codings
|
|
11
|
+
AllergyIntolerance reaction manifestations -> separate allergy_reaction
|
|
12
|
+
statements with the concept in value.codings
|
|
13
|
+
|
|
14
|
+
Trove's literal ``"unknown"`` null marker is normalized in ``codes.py``.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
from typing import Iterable, Optional
|
|
21
|
+
|
|
22
|
+
from ...core.models import (
|
|
23
|
+
ClinicalStatement,
|
|
24
|
+
CodingCandidate,
|
|
25
|
+
System,
|
|
26
|
+
ValueInfo,
|
|
27
|
+
NOM,
|
|
28
|
+
)
|
|
29
|
+
from ..base import FormatAdapter
|
|
30
|
+
from ..ccda.codes import best_loinc
|
|
31
|
+
from . import codes as C
|
|
32
|
+
from . import mapping as M
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class FhirAdapter(FormatAdapter):
|
|
36
|
+
format_key = "fhir"
|
|
37
|
+
|
|
38
|
+
# ------------------------------------------------------------------ #
|
|
39
|
+
def detect(self, document: str | bytes) -> bool:
|
|
40
|
+
try:
|
|
41
|
+
obj = json.loads(document)
|
|
42
|
+
except (ValueError, TypeError):
|
|
43
|
+
return False
|
|
44
|
+
return isinstance(obj, dict) and obj.get("resourceType") == "Bundle"
|
|
45
|
+
|
|
46
|
+
def parse(self, document: str | bytes) -> Iterable[ClinicalStatement]:
|
|
47
|
+
bundle = json.loads(document)
|
|
48
|
+
entries = bundle.get("entry") or []
|
|
49
|
+
|
|
50
|
+
# pass 1: reference index + member->panel map
|
|
51
|
+
self._index: dict[str, dict] = {}
|
|
52
|
+
self._refs: list[tuple[str, dict]] = [] # (fullUrl, resource) for suffix match
|
|
53
|
+
for i, entry in enumerate(entries):
|
|
54
|
+
res = entry.get("resource") or {}
|
|
55
|
+
full_url = entry.get("fullUrl")
|
|
56
|
+
if full_url:
|
|
57
|
+
self._index[full_url] = res
|
|
58
|
+
self._refs.append((full_url, res))
|
|
59
|
+
rt, rid = res.get("resourceType"), res.get("id")
|
|
60
|
+
if rt and rid:
|
|
61
|
+
self._index[f"{rt}/{rid}"] = res
|
|
62
|
+
|
|
63
|
+
panels = self._build_panel_map(entries)
|
|
64
|
+
|
|
65
|
+
# pass 2: emit statements
|
|
66
|
+
statements: list[ClinicalStatement] = []
|
|
67
|
+
for i, entry in enumerate(entries):
|
|
68
|
+
res = entry.get("resource") or {}
|
|
69
|
+
ref = entry.get("fullUrl") or f"Bundle.entry[{i}]"
|
|
70
|
+
rt = res.get("resourceType")
|
|
71
|
+
if not rt or rt in M.SKIP_RESOURCES:
|
|
72
|
+
continue
|
|
73
|
+
if rt == "Observation":
|
|
74
|
+
statements.append(self._observation(res, ref, panels))
|
|
75
|
+
elif rt == "Condition":
|
|
76
|
+
statements.append(self._condition(res, ref))
|
|
77
|
+
elif rt in ("MedicationRequest", "MedicationAdministration",
|
|
78
|
+
"MedicationStatement", "MedicationDispense"):
|
|
79
|
+
statements.append(self._medication(res, ref, rt))
|
|
80
|
+
elif rt == "Immunization":
|
|
81
|
+
statements.append(self._immunization(res, ref))
|
|
82
|
+
elif rt == "Procedure":
|
|
83
|
+
statements.append(self._procedure(res, ref))
|
|
84
|
+
elif rt == "AllergyIntolerance":
|
|
85
|
+
statements.extend(self._allergy(res, ref))
|
|
86
|
+
elif rt == "FamilyMemberHistory":
|
|
87
|
+
statements.extend(self._family_history(res, ref))
|
|
88
|
+
return statements
|
|
89
|
+
|
|
90
|
+
# ------------------------------------------------------------------ #
|
|
91
|
+
# reference / panel plumbing
|
|
92
|
+
# ------------------------------------------------------------------ #
|
|
93
|
+
def _resolve(self, reference: Optional[dict]) -> Optional[dict]:
|
|
94
|
+
"""Resolve a Reference across the Bundle: exact fullUrl or Type/id,
|
|
95
|
+
else a fullUrl whose path ends with the relative reference."""
|
|
96
|
+
if not reference:
|
|
97
|
+
return None
|
|
98
|
+
ref = reference.get("reference")
|
|
99
|
+
if not ref:
|
|
100
|
+
return None
|
|
101
|
+
hit = self._index.get(ref)
|
|
102
|
+
if hit is not None:
|
|
103
|
+
return hit
|
|
104
|
+
return next((r for url, r in self._refs if url.endswith("/" + ref)), None)
|
|
105
|
+
|
|
106
|
+
def _build_panel_map(self, entries: list) -> dict[str, dict]:
|
|
107
|
+
"""member reference -> {panel, context, hint, specimen} from parent
|
|
108
|
+
Observations (hasMember) and DiagnosticReports (result[])."""
|
|
109
|
+
panels: dict[str, dict] = {}
|
|
110
|
+
for entry in entries:
|
|
111
|
+
res = entry.get("resource") or {}
|
|
112
|
+
rt = res.get("resourceType")
|
|
113
|
+
if rt == "Observation" and res.get("hasMember"):
|
|
114
|
+
members = res["hasMember"]
|
|
115
|
+
concept = res.get("code")
|
|
116
|
+
context, hint = M.route_observation(self._category_codes(res),
|
|
117
|
+
self._meta_tag(res)[0])
|
|
118
|
+
elif rt == "DiagnosticReport" and res.get("result"):
|
|
119
|
+
members = res["result"]
|
|
120
|
+
concept = res.get("code")
|
|
121
|
+
context, hint = ("result", System.LOINC)
|
|
122
|
+
else:
|
|
123
|
+
continue
|
|
124
|
+
codings, _ = C.concept_codings(concept)
|
|
125
|
+
panel = best_loinc(codings) or next(
|
|
126
|
+
(c for c in codings if c.code), None)
|
|
127
|
+
info = {
|
|
128
|
+
"panel": panel,
|
|
129
|
+
"context": context,
|
|
130
|
+
"hint": hint,
|
|
131
|
+
"specimen": self._specimen(res),
|
|
132
|
+
}
|
|
133
|
+
for member in members:
|
|
134
|
+
ref = member.get("reference")
|
|
135
|
+
if ref:
|
|
136
|
+
panels[ref] = info
|
|
137
|
+
# index rows are keyed by relative Type/id too
|
|
138
|
+
resolved = self._resolve(member)
|
|
139
|
+
if resolved and resolved.get("id"):
|
|
140
|
+
panels[f"{resolved['resourceType']}/{resolved['id']}"] = info
|
|
141
|
+
return panels
|
|
142
|
+
|
|
143
|
+
def _specimen(self, res: dict) -> Optional[dict]:
|
|
144
|
+
spec = self._resolve(res.get("specimen"))
|
|
145
|
+
if not spec:
|
|
146
|
+
return None
|
|
147
|
+
codings, text = C.concept_codings(spec.get("type"))
|
|
148
|
+
primary = next((c for c in codings if c.code), None)
|
|
149
|
+
display = C.best_label(codings, text)
|
|
150
|
+
if not primary and not display:
|
|
151
|
+
return None
|
|
152
|
+
return {"code": primary.code if primary else None, "display": display}
|
|
153
|
+
|
|
154
|
+
# ------------------------------------------------------------------ #
|
|
155
|
+
# shared statement assembly
|
|
156
|
+
# ------------------------------------------------------------------ #
|
|
157
|
+
@staticmethod
|
|
158
|
+
def _category_codes(res: dict) -> list[str]:
|
|
159
|
+
return [C.clean(c.get("code")) for cat in res.get("category") or []
|
|
160
|
+
for c in cat.get("coding") or [] if C.clean(c.get("code"))]
|
|
161
|
+
|
|
162
|
+
@staticmethod
|
|
163
|
+
def _meta_tag(res: dict) -> tuple[Optional[str], Optional[str]]:
|
|
164
|
+
"""(code, display) of the CCDA-section LOINC Trove stamps in meta.tag."""
|
|
165
|
+
for tag in (res.get("meta") or {}).get("tag") or []:
|
|
166
|
+
code = C.clean(tag.get("code"))
|
|
167
|
+
if code:
|
|
168
|
+
return code, C.clean(tag.get("display"))
|
|
169
|
+
return None, None
|
|
170
|
+
|
|
171
|
+
def _section(self, res: dict) -> tuple[Optional[str], Optional[str], Optional[str]]:
|
|
172
|
+
"""(section_code, section_name, section_system) from meta.tag, else the
|
|
173
|
+
Observation category as a lower-fidelity stand-in."""
|
|
174
|
+
code, display = self._meta_tag(res)
|
|
175
|
+
if code:
|
|
176
|
+
return code, display, "LOINC"
|
|
177
|
+
cats = self._category_codes(res)
|
|
178
|
+
if cats:
|
|
179
|
+
return cats[0], cats[0].replace("-", " ").title(), None
|
|
180
|
+
return None, None, None
|
|
181
|
+
|
|
182
|
+
def _statement(self, res: dict, ref: str, context: str,
|
|
183
|
+
hint: Optional[System], codings: list[CodingCandidate],
|
|
184
|
+
text: Optional[str], value: Optional[ValueInfo] = None,
|
|
185
|
+
companion: Optional[dict] = None,
|
|
186
|
+
panel: Optional[CodingCandidate] = None) -> ClinicalStatement:
|
|
187
|
+
sec_code, sec_name, sec_system = self._section(res)
|
|
188
|
+
display_name = next(
|
|
189
|
+
(c.display for c in codings if not c.from_translation and c.display), None)
|
|
190
|
+
return ClinicalStatement(
|
|
191
|
+
source_format=self.format_key,
|
|
192
|
+
source_ref=ref,
|
|
193
|
+
context=context,
|
|
194
|
+
section_code=sec_code,
|
|
195
|
+
section_name=sec_name,
|
|
196
|
+
section_system=sec_system,
|
|
197
|
+
role="value",
|
|
198
|
+
target_system_hint=hint,
|
|
199
|
+
raw_text=C.best_label(codings, text),
|
|
200
|
+
display_name=display_name,
|
|
201
|
+
original_text=text,
|
|
202
|
+
codings=codings,
|
|
203
|
+
value=value,
|
|
204
|
+
effective_time=C.extract_effective_time(res),
|
|
205
|
+
panel=panel,
|
|
206
|
+
companion=companion or {},
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
# ------------------------------------------------------------------ #
|
|
210
|
+
# per-resource emitters
|
|
211
|
+
# ------------------------------------------------------------------ #
|
|
212
|
+
def _observation(self, res: dict, ref: str,
|
|
213
|
+
panels: dict[str, dict]) -> ClinicalStatement:
|
|
214
|
+
parent = panels.get(f"Observation/{res.get('id')}") or panels.get(ref)
|
|
215
|
+
cats = self._category_codes(res)
|
|
216
|
+
if cats:
|
|
217
|
+
context, hint = M.route_observation(cats, self._meta_tag(res)[0])
|
|
218
|
+
elif parent:
|
|
219
|
+
# derived panel child (no category, no meta.tag): inherit the panel's
|
|
220
|
+
context, hint = parent["context"], parent["hint"]
|
|
221
|
+
else:
|
|
222
|
+
context, hint = M.route_observation([], self._meta_tag(res)[0])
|
|
223
|
+
|
|
224
|
+
codings, text = C.concept_codings(res.get("code"))
|
|
225
|
+
value = C.extract_value(res)
|
|
226
|
+
companion = C.extract_companion(res)
|
|
227
|
+
specimen = self._specimen(res) or (parent["specimen"] if parent else None)
|
|
228
|
+
if specimen:
|
|
229
|
+
companion["specimen"] = specimen.get("display")
|
|
230
|
+
companion["specimen_code"] = specimen.get("code")
|
|
231
|
+
|
|
232
|
+
return self._statement(res, ref, context, hint, codings, text,
|
|
233
|
+
value=value, companion=companion,
|
|
234
|
+
panel=parent["panel"] if parent else None)
|
|
235
|
+
|
|
236
|
+
def _condition(self, res: dict, ref: str) -> ClinicalStatement:
|
|
237
|
+
# value-slot context: the SNOMED/ICD concept goes into value.codings
|
|
238
|
+
codings, text = C.concept_codings(res.get("code"))
|
|
239
|
+
coded = next((c for c in codings if not c.from_translation),
|
|
240
|
+
codings[0] if codings else None)
|
|
241
|
+
value = ValueInfo(type=NOM, raw=C.best_label(codings, text),
|
|
242
|
+
coded_value=coded, codings=codings, original_text=text)
|
|
243
|
+
context, hint = M.RESOURCE_ROUTING["Condition"]
|
|
244
|
+
stmt = self._statement(res, ref, context, hint, codings=[], text=text,
|
|
245
|
+
value=value)
|
|
246
|
+
stmt.raw_text = value.raw # concept label lives on the value slot
|
|
247
|
+
return stmt
|
|
248
|
+
|
|
249
|
+
def _medication(self, res: dict, ref: str, rt: str) -> ClinicalStatement:
|
|
250
|
+
# drug concept: inline medicationCodeableConcept, or on the referenced
|
|
251
|
+
# Medication resource's code
|
|
252
|
+
concept = res.get("medicationCodeableConcept")
|
|
253
|
+
if concept is None:
|
|
254
|
+
med = self._resolve(res.get("medicationReference"))
|
|
255
|
+
concept = (med or {}).get("code")
|
|
256
|
+
if concept is None:
|
|
257
|
+
# display on the reference is the only label we have
|
|
258
|
+
mref = res.get("medicationReference") or {}
|
|
259
|
+
concept = {"text": mref.get("display")} if mref.get("display") else None
|
|
260
|
+
codings, text = C.concept_codings(concept)
|
|
261
|
+
companion = {}
|
|
262
|
+
if res.get("doNotPerform") or res.get("status") == "not-done":
|
|
263
|
+
companion["negation"] = True
|
|
264
|
+
context, hint = M.RESOURCE_ROUTING[rt]
|
|
265
|
+
return self._statement(res, ref, context, hint, codings, text,
|
|
266
|
+
companion=companion)
|
|
267
|
+
|
|
268
|
+
def _immunization(self, res: dict, ref: str) -> ClinicalStatement:
|
|
269
|
+
codings, text = C.concept_codings(res.get("vaccineCode"))
|
|
270
|
+
companion = {}
|
|
271
|
+
if res.get("status") == "not-done":
|
|
272
|
+
companion["negation"] = True
|
|
273
|
+
context, hint = M.RESOURCE_ROUTING["Immunization"]
|
|
274
|
+
return self._statement(res, ref, context, hint, codings, text,
|
|
275
|
+
companion=companion)
|
|
276
|
+
|
|
277
|
+
def _procedure(self, res: dict, ref: str) -> ClinicalStatement:
|
|
278
|
+
codings, text = C.concept_codings(res.get("code"))
|
|
279
|
+
context, hint = M.RESOURCE_ROUTING["Procedure"]
|
|
280
|
+
return self._statement(res, ref, context, hint, codings, text)
|
|
281
|
+
|
|
282
|
+
def _allergy(self, res: dict, ref: str) -> list[ClinicalStatement]:
|
|
283
|
+
out: list[ClinicalStatement] = []
|
|
284
|
+
codings, text = C.concept_codings(res.get("code"))
|
|
285
|
+
if codings or text:
|
|
286
|
+
out.append(self._statement(res, ref, "allergy_substance",
|
|
287
|
+
System.RXNORM, codings, text))
|
|
288
|
+
for i, reaction in enumerate(res.get("reaction") or []):
|
|
289
|
+
for j, manifestation in enumerate(reaction.get("manifestation") or []):
|
|
290
|
+
m_codings, m_text = C.concept_codings(manifestation)
|
|
291
|
+
if not m_codings and not m_text:
|
|
292
|
+
continue
|
|
293
|
+
coded = next((c for c in m_codings if not c.from_translation),
|
|
294
|
+
m_codings[0] if m_codings else None)
|
|
295
|
+
value = ValueInfo(type=NOM, raw=C.best_label(m_codings, m_text),
|
|
296
|
+
coded_value=coded, codings=m_codings,
|
|
297
|
+
original_text=m_text)
|
|
298
|
+
stmt = self._statement(
|
|
299
|
+
res, f"{ref}/reaction[{i}]/manifestation[{j}]",
|
|
300
|
+
"allergy_reaction", System.SNOMEDCT, codings=[],
|
|
301
|
+
text=m_text, value=value)
|
|
302
|
+
stmt.raw_text = value.raw
|
|
303
|
+
out.append(stmt)
|
|
304
|
+
return out
|
|
305
|
+
|
|
306
|
+
def _family_history(self, res: dict, ref: str) -> list[ClinicalStatement]:
|
|
307
|
+
out: list[ClinicalStatement] = []
|
|
308
|
+
rel_codings, rel_text = C.concept_codings(res.get("relationship"))
|
|
309
|
+
relationship = C.best_label(rel_codings, rel_text)
|
|
310
|
+
context, hint = M.RESOURCE_ROUTING["FamilyMemberHistory"]
|
|
311
|
+
for i, cond in enumerate(res.get("condition") or []):
|
|
312
|
+
codings, text = C.concept_codings(cond.get("code"))
|
|
313
|
+
if not codings and not text:
|
|
314
|
+
continue
|
|
315
|
+
coded = next((c for c in codings if not c.from_translation),
|
|
316
|
+
codings[0] if codings else None)
|
|
317
|
+
value = ValueInfo(type=NOM, raw=C.best_label(codings, text),
|
|
318
|
+
coded_value=coded, codings=codings,
|
|
319
|
+
original_text=text)
|
|
320
|
+
stmt = self._statement(res, f"{ref}/condition[{i}]", context, hint,
|
|
321
|
+
codings=[], text=text, value=value)
|
|
322
|
+
stmt.raw_text = value.raw
|
|
323
|
+
if relationship:
|
|
324
|
+
stmt.companion["relationship"] = relationship
|
|
325
|
+
out.append(stmt)
|
|
326
|
+
return out
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""CodeableConcept / value[x] extraction for FHIR resources.
|
|
2
|
+
|
|
3
|
+
FHIR mirror of ``adapters/ccda/codes.py`` (scan-and-identify): enumerate every
|
|
4
|
+
``coding[]`` entry, canonicalize each system, and let downstream pick.
|
|
5
|
+
|
|
6
|
+
THE Trove quirk handled here: the literal string ``"unknown"`` is Trove's null
|
|
7
|
+
marker — it appears in ``code``, ``display``, ``system`` and ``text`` wherever a
|
|
8
|
+
value is missing. Every ``"unknown"`` is normalized to ``None``; a CodeableConcept
|
|
9
|
+
left with no real code yields a ``System.NULLFLAVOR`` candidate (the CCDA
|
|
10
|
+
``nullFlavor`` equivalent) so the gap extractors classify it correctly.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from typing import Any, Optional
|
|
16
|
+
|
|
17
|
+
from ...core.checkdigits import is_valid_loinc, is_valid_sctid
|
|
18
|
+
from ...core.models import (
|
|
19
|
+
CodingCandidate,
|
|
20
|
+
System,
|
|
21
|
+
ValueInfo,
|
|
22
|
+
QN,
|
|
23
|
+
ORD,
|
|
24
|
+
NOM,
|
|
25
|
+
NAR,
|
|
26
|
+
)
|
|
27
|
+
from ...core.system_registry import canonicalize
|
|
28
|
+
|
|
29
|
+
_UNKNOWN = "unknown"
|
|
30
|
+
|
|
31
|
+
# same ordinal cue list the CCDA adapter uses for CD/CE values
|
|
32
|
+
_ORDINAL_WORDS = {"positive", "negative", "reactive", "non-reactive",
|
|
33
|
+
"detected", "not detected", "present", "absent"}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def clean(s: Optional[str]) -> Optional[str]:
|
|
37
|
+
"""Normalize Trove's ``"unknown"`` null marker (and empty strings) to None."""
|
|
38
|
+
if not isinstance(s, str):
|
|
39
|
+
return s
|
|
40
|
+
s = s.strip()
|
|
41
|
+
if not s or s.lower() == _UNKNOWN:
|
|
42
|
+
return None
|
|
43
|
+
return s
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def coding_to_candidate(coding: dict, from_translation: bool) -> CodingCandidate:
|
|
47
|
+
raw_system = clean(coding.get("system"))
|
|
48
|
+
system = canonicalize(raw_system)
|
|
49
|
+
code = clean(coding.get("code"))
|
|
50
|
+
cand = CodingCandidate(
|
|
51
|
+
raw_system=raw_system,
|
|
52
|
+
system=system,
|
|
53
|
+
code=code,
|
|
54
|
+
display=clean(coding.get("display")),
|
|
55
|
+
from_translation=from_translation,
|
|
56
|
+
user_selected=bool(coding.get("userSelected")),
|
|
57
|
+
)
|
|
58
|
+
if code:
|
|
59
|
+
if system == System.LOINC:
|
|
60
|
+
cand.check_digit_valid = is_valid_loinc(code)
|
|
61
|
+
elif system == System.SNOMEDCT:
|
|
62
|
+
cand.check_digit_valid = is_valid_sctid(code)
|
|
63
|
+
return cand
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def concept_codings(cc: Optional[dict]) -> tuple[list[CodingCandidate], Optional[str]]:
|
|
67
|
+
"""Return (codings, text) for a CodeableConcept.
|
|
68
|
+
|
|
69
|
+
The primary coding (``userSelected`` if any, else the first) gets
|
|
70
|
+
``from_translation=False``; the rest are translations — the FHIR analog of
|
|
71
|
+
CCDA root ``<code>`` + ``<translation>`` children. If no coding carries a
|
|
72
|
+
real code but the concept explicitly said ``"unknown"``, a NULLFLAVOR
|
|
73
|
+
candidate is appended so the absence stays an explicit signal.
|
|
74
|
+
"""
|
|
75
|
+
if not cc:
|
|
76
|
+
return [], None
|
|
77
|
+
text = clean(cc.get("text"))
|
|
78
|
+
raw_codings = cc.get("coding") or []
|
|
79
|
+
|
|
80
|
+
saw_unknown = (cc.get("text") == _UNKNOWN)
|
|
81
|
+
kept: list[tuple[CodingCandidate, bool]] = [] # (candidate, user_selected)
|
|
82
|
+
for coding in raw_codings:
|
|
83
|
+
if coding.get("code") == _UNKNOWN or coding.get("system") == _UNKNOWN \
|
|
84
|
+
or coding.get("display") == _UNKNOWN:
|
|
85
|
+
saw_unknown = True
|
|
86
|
+
cand = coding_to_candidate(coding, from_translation=True)
|
|
87
|
+
if cand.code is None and cand.display is None and cand.raw_system is None:
|
|
88
|
+
continue # fully-null coding ({"system":"unknown","code":"unknown"})
|
|
89
|
+
kept.append((cand, bool(coding.get("userSelected"))))
|
|
90
|
+
|
|
91
|
+
# primary = userSelected among the SURVIVING codings, else the first one
|
|
92
|
+
# (so a dropped all-"unknown" first coding doesn't leave only translations)
|
|
93
|
+
out = [cand for cand, _ in kept]
|
|
94
|
+
if out:
|
|
95
|
+
primary_idx = next((i for i, (_c, sel) in enumerate(kept) if sel), 0)
|
|
96
|
+
out[primary_idx].from_translation = False
|
|
97
|
+
|
|
98
|
+
if saw_unknown and not any(c.code for c in out):
|
|
99
|
+
out.append(CodingCandidate(system=System.NULLFLAVOR, raw_system=_UNKNOWN,
|
|
100
|
+
from_translation=False))
|
|
101
|
+
return out, text
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def best_label(codings: list[CodingCandidate], text: Optional[str]) -> Optional[str]:
|
|
105
|
+
"""raw_text priority, mirroring CCDA: primary display -> text -> any display."""
|
|
106
|
+
primary = next((c for c in codings if not c.from_translation and c.display), None)
|
|
107
|
+
if primary:
|
|
108
|
+
return primary.display
|
|
109
|
+
if text:
|
|
110
|
+
return text
|
|
111
|
+
return next((c.display for c in codings if c.display), None)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
# --------------------------------------------------------------------------
|
|
115
|
+
# value[x] extraction
|
|
116
|
+
# --------------------------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
def _quantity_value(qty: dict) -> ValueInfo:
|
|
119
|
+
raw = qty.get("value")
|
|
120
|
+
unit = clean(qty.get("code")) or clean(qty.get("unit"))
|
|
121
|
+
return ValueInfo(
|
|
122
|
+
type=QN,
|
|
123
|
+
raw=(str(raw) if raw is not None else None),
|
|
124
|
+
num=_to_float(raw),
|
|
125
|
+
unit=unit,
|
|
126
|
+
unit_system="UCUM" if unit else None,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def extract_value(resource: dict) -> Optional[ValueInfo]:
|
|
131
|
+
"""Map FHIR ``value[x]`` (or ``dataAbsentReason``) to a ValueInfo."""
|
|
132
|
+
if "valueQuantity" in resource:
|
|
133
|
+
return _quantity_value(resource["valueQuantity"])
|
|
134
|
+
if "valueInteger" in resource:
|
|
135
|
+
raw = resource["valueInteger"]
|
|
136
|
+
return ValueInfo(type=QN, raw=str(raw), num=_to_float(raw))
|
|
137
|
+
if "valueCodeableConcept" in resource:
|
|
138
|
+
codings, text = concept_codings(resource["valueCodeableConcept"])
|
|
139
|
+
coded = next((c for c in codings if not c.from_translation),
|
|
140
|
+
codings[0] if codings else None)
|
|
141
|
+
label = best_label(codings, text)
|
|
142
|
+
vtype = ORD if (label or "").lower() in _ORDINAL_WORDS else NOM
|
|
143
|
+
return ValueInfo(type=vtype, raw=label, coded_value=coded,
|
|
144
|
+
codings=codings, original_text=text)
|
|
145
|
+
if "valueString" in resource:
|
|
146
|
+
return ValueInfo(type=NAR, raw=clean(resource["valueString"]))
|
|
147
|
+
if "valueBoolean" in resource:
|
|
148
|
+
return ValueInfo(type=ORD, raw=str(resource["valueBoolean"]))
|
|
149
|
+
if "valueDateTime" in resource:
|
|
150
|
+
return ValueInfo(type=NAR, raw=resource["valueDateTime"])
|
|
151
|
+
if "valueRange" in resource:
|
|
152
|
+
low = (resource["valueRange"].get("low") or {})
|
|
153
|
+
return _quantity_value(low) if low else None
|
|
154
|
+
# value explicitly absent — FHIR's nullFlavor for the value slot
|
|
155
|
+
dar = resource.get("dataAbsentReason")
|
|
156
|
+
if dar:
|
|
157
|
+
reason = None
|
|
158
|
+
for coding in dar.get("coding") or []:
|
|
159
|
+
reason = clean(coding.get("code")) or reason
|
|
160
|
+
reason = reason or clean(dar.get("text")) or "unknown"
|
|
161
|
+
return ValueInfo(type=None,
|
|
162
|
+
coded_value=CodingCandidate(system=System.NULLFLAVOR,
|
|
163
|
+
raw_system=reason))
|
|
164
|
+
return None
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
# --------------------------------------------------------------------------
|
|
168
|
+
# companion extraction
|
|
169
|
+
# --------------------------------------------------------------------------
|
|
170
|
+
|
|
171
|
+
def extract_companion(resource: dict) -> dict:
|
|
172
|
+
"""interpretation + reference range (Observation-shaped resources)."""
|
|
173
|
+
out: dict[str, Any] = {}
|
|
174
|
+
for interp in resource.get("interpretation") or []:
|
|
175
|
+
code = next((clean(c.get("code")) for c in interp.get("coding") or []
|
|
176
|
+
if clean(c.get("code"))), None)
|
|
177
|
+
if code:
|
|
178
|
+
out["interpretation"] = code
|
|
179
|
+
break
|
|
180
|
+
for rr in resource.get("referenceRange") or []:
|
|
181
|
+
low, high = rr.get("low") or {}, rr.get("high") or {}
|
|
182
|
+
if low or high:
|
|
183
|
+
lo = low.get("value", "")
|
|
184
|
+
hi = high.get("value", "")
|
|
185
|
+
unit = clean(low.get("unit")) or clean(high.get("unit")) or \
|
|
186
|
+
clean(low.get("code")) or clean(high.get("code"))
|
|
187
|
+
out["reference_range"] = f"{lo}-{hi}" + (f" {unit}" if unit else "")
|
|
188
|
+
break
|
|
189
|
+
if clean(rr.get("text")):
|
|
190
|
+
out["reference_range"] = clean(rr.get("text"))
|
|
191
|
+
break
|
|
192
|
+
return out
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def extract_effective_time(resource: dict) -> Optional[str]:
|
|
196
|
+
for key in ("effectiveDateTime", "occurrenceDateTime", "performedDateTime",
|
|
197
|
+
"onsetDateTime", "authoredOn", "recordedDate"):
|
|
198
|
+
if resource.get(key):
|
|
199
|
+
return resource[key]
|
|
200
|
+
for key in ("effectivePeriod", "performedPeriod"):
|
|
201
|
+
period = resource.get(key)
|
|
202
|
+
if period and period.get("start"):
|
|
203
|
+
return period["start"]
|
|
204
|
+
return None
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _to_float(v) -> Optional[float]:
|
|
208
|
+
try:
|
|
209
|
+
return float(v)
|
|
210
|
+
except (TypeError, ValueError):
|
|
211
|
+
return None
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""FHIR routing: resource type / Observation.category / meta.tag -> context.
|
|
2
|
+
|
|
3
|
+
The FHIR analog of ``adapters/ccda/sections.py``. Trove bundles are generated
|
|
4
|
+
from CCDA, and every directly-extracted resource carries the *original CCDA
|
|
5
|
+
section LOINC code* in ``meta.tag`` — so we reuse the CCDA ``SECTION_ROUTING``
|
|
6
|
+
table verbatim as a fallback router. Primary routing is by resource type and
|
|
7
|
+
``Observation.category`` (meta.tag is absent on derived resources such as
|
|
8
|
+
``hasMember`` panel children).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from typing import Optional
|
|
14
|
+
|
|
15
|
+
from ...core.models import System
|
|
16
|
+
from ..ccda.sections import route_section # keyed by section LOINC — shared knowledge
|
|
17
|
+
|
|
18
|
+
# Observation.category code -> (context, target_system_hint)
|
|
19
|
+
CATEGORY_ROUTING: dict[str, tuple[str, Optional[System]]] = {
|
|
20
|
+
"laboratory": ("result", System.LOINC),
|
|
21
|
+
"vital-signs": ("vital", System.LOINC),
|
|
22
|
+
"social-history": ("social_history", System.LOINC),
|
|
23
|
+
"survey": ("survey", System.LOINC),
|
|
24
|
+
"functional-status": ("functional_status", System.LOINC),
|
|
25
|
+
"exam": ("exam", None), # not a gap-extractor context; kept for provenance
|
|
26
|
+
"imaging": ("imaging", None),
|
|
27
|
+
"procedure": ("procedure_note", None), # Observation-flavored, not a Procedure act
|
|
28
|
+
"activity": ("activity", None),
|
|
29
|
+
"therapy": ("therapy", None),
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
# non-Observation resource type -> (context, target_system_hint)
|
|
33
|
+
RESOURCE_ROUTING: dict[str, tuple[str, Optional[System]]] = {
|
|
34
|
+
"Condition": ("problem", System.SNOMEDCT),
|
|
35
|
+
"MedicationRequest": ("medication", System.RXNORM),
|
|
36
|
+
"MedicationAdministration": ("medication", System.RXNORM),
|
|
37
|
+
"MedicationStatement": ("medication", System.RXNORM),
|
|
38
|
+
"MedicationDispense": ("medication", System.RXNORM),
|
|
39
|
+
"Immunization": ("immunization", System.CVX),
|
|
40
|
+
"Procedure": ("procedure", System.CPT),
|
|
41
|
+
"FamilyMemberHistory": ("family_history", System.SNOMEDCT),
|
|
42
|
+
# AllergyIntolerance handled specially: allergy_substance + allergy_reaction
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
# administrative / non-coded resources the adapter never emits statements for
|
|
46
|
+
SKIP_RESOURCES = frozenset({
|
|
47
|
+
"Patient", "Encounter", "Organization", "Practitioner", "PractitionerRole",
|
|
48
|
+
"Location", "Coverage", "RelatedPerson", "CareTeam", "CarePlan",
|
|
49
|
+
"ServiceRequest", "DocumentReference", "Provenance", "Communication",
|
|
50
|
+
"Specimen", "Medication", # consumed via references, not emitted directly
|
|
51
|
+
"DiagnosticReport", # used only to build the member->panel map
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def route_observation(
|
|
56
|
+
category_codes: list[str],
|
|
57
|
+
meta_tag_code: Optional[str] = None,
|
|
58
|
+
) -> tuple[str, Optional[System]]:
|
|
59
|
+
"""Route an Observation: category first, else the CCDA section LOINC that
|
|
60
|
+
Trove stamps into meta.tag, else a neutral context no extractor touches."""
|
|
61
|
+
for code in category_codes:
|
|
62
|
+
if code in CATEGORY_ROUTING:
|
|
63
|
+
return CATEGORY_ROUTING[code]
|
|
64
|
+
if meta_tag_code:
|
|
65
|
+
context, hint = route_section(meta_tag_code)
|
|
66
|
+
if context != "section":
|
|
67
|
+
return (context, hint)
|
|
68
|
+
return ("observation", None)
|