Coverage for src/lumen/fhir/terminology.py: 91%
117 statements
« prev ^ index » next coverage.py v7.16.0, created at 2026-08-31 18:46 +0000
« prev ^ index » next coverage.py v7.16.0, created at 2026-08-31 18:46 +0000
1"""
2This module contains core terminology logic for FHIR CodeSystems, ValueSets, and ConceptMaps.
3It handles operations like code lookup, value set expansion, and concept translation.
4"""
6from __future__ import annotations
8from collections.abc import Iterator
9from typing import Any, cast
11JsonDict = dict[str, Any]
14class TerminologyError(Exception):
15 """An unsupported or invalid terminology request (mapped to OperationOutcome)."""
17 def __init__(self, code: str, message: str) -> None:
18 self.code = code
19 super().__init__(message)
22def _dicts(value: Any) -> list[JsonDict]:
23 """Return ``value`` as a list of dicts, dropping non-dict / non-list inputs."""
24 if not isinstance(value, list):
25 return []
26 return [cast("JsonDict", item) for item in cast("list[Any]", value) if isinstance(item, dict)]
29# ── CodeSystem / $lookup ───────────────────────────────────────────────────────
32def _iter_concepts(concepts: list[JsonDict]) -> Iterator[JsonDict]:
33 """Yield every concept in a (possibly nested) CodeSystem.concept tree."""
34 for concept in concepts:
35 yield concept
36 yield from _iter_concepts(_dicts(concept.get("concept")))
39def lookup_concept(code_system: JsonDict, code: str) -> JsonDict | None:
40 """Return the concept with ``code`` from a CodeSystem, or None."""
41 for concept in _iter_concepts(_dicts(code_system.get("concept"))):
42 if concept.get("code") == code:
43 return concept
44 return None
47# ── ValueSet / $expand ─────────────────────────────────────────────────────────
50def referenced_systems(value_set: JsonDict) -> set[str]:
51 """Collect every CodeSystem url referenced by a ValueSet's compose rules."""
52 systems: set[str] = set()
53 compose = value_set.get("compose")
54 if not isinstance(compose, dict):
55 return systems
56 compose = cast("JsonDict", compose)
57 for key in ("include", "exclude"):
58 for rule in _dicts(compose.get(key)):
59 system = rule.get("system")
60 if isinstance(system, str):
61 systems.add(system)
62 return systems
65def _explicit_rule_codes(
66 explicit: list[Any],
67 system: str,
68 code_system: JsonDict | None,
69) -> list[tuple[str, str, str | None]]:
70 out: list[tuple[str, str, str | None]] = []
71 for concept in _dicts(explicit):
72 code = concept.get("code")
73 if not isinstance(code, str):
74 continue
75 display = concept.get("display")
76 if display is None and code_system is not None:
77 resolved = lookup_concept(code_system, code)
78 display = resolved.get("display") if resolved else None
79 out.append((system, code, display if isinstance(display, str) else None))
80 return out
83def _rule_codes(rule: JsonDict, systems: dict[str, JsonDict]) -> list[tuple[str, str, str | None]]:
84 """Expand one include/exclude rule to (system, code, display) tuples."""
85 if "valueSet" in rule:
86 raise TerminologyError("not-supported", "ValueSet import in compose is not supported")
87 if "filter" in rule:
88 raise TerminologyError("not-supported", "compose filters are not supported")
90 system = rule.get("system")
91 if not isinstance(system, str):
92 raise TerminologyError("invalid", "compose rule without a system is not supported")
94 explicit = rule.get("concept")
95 if isinstance(explicit, list):
96 code_system = systems.get(system)
97 return _explicit_rule_codes(cast("list[Any]", explicit), system, code_system)
99 # No explicit concepts → the entire CodeSystem.
100 code_system = systems.get(system)
101 if code_system is None:
102 raise TerminologyError("not-found", f"CodeSystem {system} not found for expansion")
103 out: list[tuple[str, str, str | None]] = []
104 for concept in _iter_concepts(_dicts(code_system.get("concept"))):
105 code = concept.get("code")
106 if isinstance(code, str):
107 display = concept.get("display")
108 out.append((system, code, display if isinstance(display, str) else None))
109 return out
112def expand_valueset(value_set: JsonDict, systems: dict[str, JsonDict]) -> list[JsonDict]:
113 """Return the ``expansion.contains`` list for a ValueSet given its CodeSystems."""
114 compose = value_set.get("compose")
115 if not isinstance(compose, dict):
116 return []
117 compose = cast("JsonDict", compose)
119 included: list[tuple[str, str, str | None]] = []
120 for rule in _dicts(compose.get("include")):
121 included.extend(_rule_codes(rule, systems))
123 excluded: set[tuple[str, str]] = set()
124 for rule in _dicts(compose.get("exclude")):
125 excluded.update((s, c) for s, c, _ in _rule_codes(rule, systems))
127 contains: list[JsonDict] = []
128 seen: set[tuple[str, str]] = set()
129 for system, code, display in included:
130 key = (system, code)
131 if key in excluded or key in seen:
132 continue
133 seen.add(key)
134 entry: JsonDict = {"system": system, "code": code}
135 code_system_version = systems.get(system, {}).get("version")
136 if isinstance(code_system_version, str):
137 entry["version"] = code_system_version
138 if display is not None:
139 entry["display"] = display
140 contains.append(entry)
141 return contains
144# ── ConceptMap / $translate ────────────────────────────────────────────────────
147def _translate_group_elements(
148 group: JsonDict,
149 code: str,
150 group_target: str | None,
151 matches: list[JsonDict],
152) -> None:
153 for element in _dicts(group.get("element")):
154 if element.get("code") != code:
155 continue
156 for tgt in _dicts(element.get("target")):
157 tgt_code = tgt.get("code")
158 if not isinstance(tgt_code, str):
159 continue
160 concept: JsonDict = {"system": group_target, "code": tgt_code}
161 display = tgt.get("display")
162 if isinstance(display, str):
163 concept["display"] = display
164 equivalence = tgt.get("equivalence")
165 matches.append(
166 {
167 "equivalence": equivalence if isinstance(equivalence, str) else "relatedto",
168 "concept": concept,
169 }
170 )
173def translate(
174 concept_map: JsonDict,
175 system: str | None,
176 code: str,
177 target_system: str | None = None,
178) -> list[JsonDict]:
179 """Return target matches for a source code, each as {equivalence, concept}.
181 ``equivalence`` carries the R4B relationship (the ``equivalence`` field on a
182 ConceptMap target element).
183 """
184 matches: list[JsonDict] = []
185 for group in _dicts(concept_map.get("group")):
186 if system is not None and group.get("source") != system:
187 continue
188 group_target = group.get("target")
189 if target_system is not None and group_target != target_system:
190 continue
191 _translate_group_elements(group, code, group_target, matches)
192 return matches