Skip to content

Commit fa31997

Browse files
authored
v1.8.0 prep (#21)
1 parent 1c9ccfe commit fa31997

6 files changed

Lines changed: 352 additions & 24 deletions

File tree

CHANGELOG.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,26 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [1.8.0] - 2026-05-25
9+
10+
### Added
11+
12+
- `FhirResolution` now types the `value_as_concept` and `value_target_field`
13+
fields the resolver returns when a composite concept is decomposed via the
14+
`Maps to value` relationship (HL7 FHIR-to-OMOP IG Value-as-Concept pattern —
15+
e.g. "Allergy to penicillin" → standard "Allergy to drug" + value
16+
"Penicillin G"), plus `concept_map_id` / `mapping_note` for FHIR
17+
administrative-code resolutions. These were already passed through; they are
18+
now part of the typed response shape.
19+
- `resolve()`, `resolve_batch()`, and `resolve_codeable_concept()` accept an
20+
`on_unmapped` argument (`"error"` default / `"sentinel"`). With `"sentinel"`
21+
the resolver returns a `concept_id` 0 record instead of a 404 when nothing
22+
resolves, so ETL callers always get a writable row.
23+
- Coding inputs now carry `user_selected` through to the resolver, and FHIR's
24+
camelCase `userSelected` is mapped to it. A user-selected coding wins over
25+
vocabulary preference in `resolve_codeable_concept()` (FHIR-to-OMOP IG
26+
CodeableConcept pattern).
27+
828
## [1.7.1] - 2026-05-20
929

1030
Maintenance release. Dependency and lock-file updates only — there are no

README.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,24 @@ result = client.fhir.resolve_codeable_concept(
102102
print(result["best_match"]["resolution"]["source_concept"]["vocabulary_id"]) # "SNOMED"
103103
```
104104

105+
The resolver also follows the [HL7 FHIR-to-OMOP IG](https://hl7.org/fhir/uv/omop/INFORMATIVE1/en/): it resolves FHIR administrative codes via the IG ConceptMaps, decomposes composite concepts (`Maps to value`), honors `Coding.userSelected`, and can return a `concept_id` 0 sentinel instead of a 404.
106+
107+
```python
108+
# Administrative gender → person.gender_concept_id (via IG ConceptMap)
109+
client.fhir.resolve(system="http://hl7.org/fhir/administrative-gender", code="male")
110+
111+
# A user-selected coding wins over vocabulary preference
112+
client.fhir.resolve_codeable_concept(coding=[
113+
{"system": "http://snomed.info/sct", "code": "44054006"},
114+
{"system": "http://hl7.org/fhir/sid/icd-10-cm", "code": "E11.9", "user_selected": True},
115+
])
116+
117+
# on_unmapped="sentinel" → a concept_id 0 record instead of a 404 (one row per input for ETL)
118+
client.fhir.resolve(system="http://snomed.info/sct", code="00000000", on_unmapped="sentinel")
119+
```
120+
121+
Composite concepts (e.g. "Allergy to penicillin") additionally surface `resolution["value_as_concept"]` (the IG Value-as-Concept pattern). `on_unmapped` is accepted by `resolve()`, `resolve_batch()`, and `resolve_codeable_concept()` on both the sync and async clients.
122+
105123
### Type Interoperability
106124

107125
The resolver accepts any Coding-like input via duck typing - a plain dict, omophub's lightweight `Coding` TypedDict, or any object with `.system` / `.code` attributes (e.g. `fhir.resources.Coding`, `fhirpy` codings).

examples/fhir_resolver.py

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -489,6 +489,103 @@ def error_handling_examples() -> None:
489489
client.close()
490490

491491

492+
# ---------------------------------------------------------------------------
493+
# 13. Administrative code via the HL7 FHIR-to-OMOP IG ConceptMap layer
494+
# ---------------------------------------------------------------------------
495+
496+
497+
def resolve_administrative_gender() -> None:
498+
"""Resolve a FHIR administrative code (gender) via the IG ConceptMap layer.
499+
500+
Administrative/structural systems (administrative-gender, v3-ActCode
501+
encounter class, condition-clinical, ...) have no OMOP vocabulary-table
502+
representation and resolve through the IG's ConceptMaps.
503+
"""
504+
print("\n=== 13. Administrative gender (IG ConceptMap) ===")
505+
506+
client = omophub.OMOPHub()
507+
try:
508+
result = client.fhir.resolve(
509+
system="http://hl7.org/fhir/administrative-gender",
510+
code="male",
511+
resource_type="Patient",
512+
)
513+
res = result["resolution"]
514+
std = res["standard_concept"]
515+
print(f" male → {std['concept_name']} ({std['concept_id']})")
516+
print(f" target_table: {res['target_table']}")
517+
print(f" via IG ConceptMap: {res.get('concept_map_id')}")
518+
# Composite source concepts also surface a `value_as_concept`
519+
# (the IG 'Maps to value' / Value-as-Concept pattern) when present.
520+
if res.get("value_as_concept"):
521+
val = res["value_as_concept"]
522+
print(
523+
f" value_as_concept: {val['concept_name']}"
524+
f" → {res.get('value_target_field')}"
525+
)
526+
finally:
527+
client.close()
528+
529+
530+
# ---------------------------------------------------------------------------
531+
# 14. CodeableConcept with user_selected (overrides vocabulary preference)
532+
# ---------------------------------------------------------------------------
533+
534+
535+
def resolve_user_selected() -> None:
536+
"""A coding marked user_selected wins best_match over vocabulary preference."""
537+
print("\n=== 14. CodeableConcept with user_selected ===")
538+
539+
client = omophub.OMOPHub()
540+
try:
541+
result = client.fhir.resolve_codeable_concept(
542+
coding=[
543+
# SNOMED would normally win on OHDSI preference, but the
544+
# user-selected ICD-10-CM coding takes precedence.
545+
{"system": "http://snomed.info/sct", "code": "44054006"},
546+
{
547+
"system": "http://hl7.org/fhir/sid/icd-10-cm",
548+
"code": "E11.9",
549+
"user_selected": True,
550+
},
551+
],
552+
resource_type="Condition",
553+
)
554+
best = result["best_match"]
555+
if best:
556+
src = best["resolution"]["source_concept"]
557+
print(f" best_match source vocabulary: {src['vocabulary_id']}")
558+
finally:
559+
client.close()
560+
561+
562+
# ---------------------------------------------------------------------------
563+
# 15. on_unmapped="sentinel" — a concept_id 0 record instead of a 404
564+
# ---------------------------------------------------------------------------
565+
566+
567+
def resolve_unmapped_sentinel() -> None:
568+
"""With on_unmapped='sentinel', an unresolvable code yields a row, not a 404.
569+
570+
Handy for ETL pipelines that need one output row per input. Works on
571+
resolve(), resolve_batch(), and resolve_codeable_concept().
572+
"""
573+
print("\n=== 15. on_unmapped='sentinel' ===")
574+
575+
client = omophub.OMOPHub()
576+
try:
577+
result = client.fhir.resolve(
578+
system="http://snomed.info/sct",
579+
code="00000000",
580+
on_unmapped="sentinel",
581+
)
582+
res = result["resolution"]
583+
print(f" mapping_type: {res['mapping_type']}")
584+
print(f" standard concept_id: {res['standard_concept']['concept_id']}")
585+
finally:
586+
client.close()
587+
588+
492589
# ---------------------------------------------------------------------------
493590
# Main
494591
# ---------------------------------------------------------------------------
@@ -505,5 +602,8 @@ def error_handling_examples() -> None:
505602
resolve_batch()
506603
resolve_codeable_concept()
507604
resolve_codeable_concept_text_fallback()
605+
resolve_administrative_gender()
606+
resolve_user_selected()
607+
resolve_unmapped_sentinel()
508608
asyncio.run(async_resolve())
509609
error_handling_examples()

src/omophub/resources/fhir.py

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -43,30 +43,44 @@ def _extract_coding(
4343

4444

4545
# Keys the FHIR Concept Resolver accepts on a single coding item. Any
46-
# other keys in a dict input (e.g. ``userSelected``, ``extension``,
47-
# ``version`` from ``fhir.resources.Coding.model_dump()``) are dropped
48-
# so the server never sees FHIR metadata it does not understand.
46+
# other keys in a dict input (e.g. ``extension``, ``version`` from
47+
# ``fhir.resources.Coding.model_dump()``) are dropped so the server never
48+
# sees FHIR metadata it does not understand. FHIR's camelCase
49+
# ``userSelected`` is mapped to the API's snake_case ``user_selected`` in
50+
# :func:`_coding_to_dict`.
4951
_ALLOWED_CODING_KEYS: tuple[str, ...] = (
5052
"system",
5153
"code",
5254
"display",
5355
"vocabulary_id",
56+
"user_selected",
5457
)
5558

5659

5760
def _coding_to_dict(coding_input: object) -> dict[str, Any]:
5861
"""Convert any Coding-like input to a wire-format dict.
5962
6063
Preserves only the keys the resolver endpoint understands
61-
(:data:`_ALLOWED_CODING_KEYS`). Skips keys whose values are ``None``
62-
so the request payload stays tight.
64+
(:data:`_ALLOWED_CODING_KEYS`). FHIR's camelCase ``userSelected`` is
65+
accepted and mapped to ``user_selected``. Skips keys whose values are
66+
``None`` so the request payload stays tight.
6367
"""
6468
if isinstance(coding_input, dict):
6569
src: dict[str, Any] = {
6670
key: coding_input.get(key) for key in _ALLOWED_CODING_KEYS
6771
}
72+
if (
73+
src.get("user_selected") is None
74+
and coding_input.get("userSelected") is not None
75+
):
76+
src["user_selected"] = coding_input.get("userSelected")
6877
else:
6978
src = {key: getattr(coding_input, key, None) for key in _ALLOWED_CODING_KEYS}
79+
if (
80+
src.get("user_selected") is None
81+
and getattr(coding_input, "userSelected", None) is not None
82+
):
83+
src["user_selected"] = getattr(coding_input, "userSelected", None)
7084
return {k: v for k, v in src.items() if v is not None}
7185

7286

@@ -130,6 +144,7 @@ def _build_resolve_body(
130144
include_recommendations: bool = False,
131145
recommendations_limit: int = 5,
132146
include_quality: bool = False,
147+
on_unmapped: str | None = None,
133148
) -> dict[str, Any]:
134149
body: dict[str, Any] = {}
135150
if system is not None:
@@ -147,6 +162,8 @@ def _build_resolve_body(
147162
body["recommendations_limit"] = recommendations_limit
148163
if include_quality:
149164
body["include_quality"] = True
165+
if on_unmapped is not None:
166+
body["on_unmapped"] = on_unmapped
150167
return body
151168

152169

@@ -182,6 +199,7 @@ def resolve(
182199
include_recommendations: bool = False,
183200
recommendations_limit: int = 5,
184201
include_quality: bool = False,
202+
on_unmapped: str | None = None,
185203
) -> FhirResolveResult:
186204
"""Resolve a single FHIR Coding to an OMOP standard concept.
187205
@@ -204,6 +222,8 @@ def resolve(
204222
include_recommendations: Include Phoebe recommendations
205223
recommendations_limit: Max recommendations to return (1-20)
206224
include_quality: Include mapping quality signal
225+
on_unmapped: ``"error"`` (default) raises 404 when nothing
226+
resolves; ``"sentinel"`` returns a ``concept_id`` 0 record
207227
208228
Returns:
209229
Resolution result with source concept, standard concept,
@@ -221,6 +241,7 @@ def resolve(
221241
include_recommendations=include_recommendations,
222242
recommendations_limit=recommendations_limit,
223243
include_quality=include_quality,
244+
on_unmapped=on_unmapped,
224245
)
225246
return self._request.post("/fhir/resolve", json_data=body)
226247

@@ -232,6 +253,7 @@ def resolve_batch(
232253
include_recommendations: bool = False,
233254
recommendations_limit: int = 5,
234255
include_quality: bool = False,
256+
on_unmapped: str | None = None,
235257
) -> FhirBatchResult:
236258
"""Batch-resolve up to 100 FHIR Codings.
237259
@@ -260,6 +282,8 @@ def resolve_batch(
260282
body["recommendations_limit"] = recommendations_limit
261283
if include_quality:
262284
body["include_quality"] = True
285+
if on_unmapped is not None:
286+
body["on_unmapped"] = on_unmapped
263287
return self._request.post("/fhir/resolve/batch", json_data=body)
264288

265289
def resolve_codeable_concept(
@@ -271,6 +295,7 @@ def resolve_codeable_concept(
271295
include_recommendations: bool = False,
272296
recommendations_limit: int = 5,
273297
include_quality: bool = False,
298+
on_unmapped: str | None = None,
274299
) -> FhirCodeableConceptResult:
275300
"""Resolve a FHIR CodeableConcept with vocabulary preference.
276301
@@ -309,6 +334,8 @@ def resolve_codeable_concept(
309334
body["recommendations_limit"] = recommendations_limit
310335
if include_quality:
311336
body["include_quality"] = True
337+
if on_unmapped is not None:
338+
body["on_unmapped"] = on_unmapped
312339
return self._request.post("/fhir/resolve/codeable-concept", json_data=body)
313340

314341

@@ -333,6 +360,7 @@ async def resolve(
333360
include_recommendations: bool = False,
334361
recommendations_limit: int = 5,
335362
include_quality: bool = False,
363+
on_unmapped: str | None = None,
336364
) -> FhirResolveResult:
337365
"""Resolve a single FHIR Coding to an OMOP standard concept.
338366
@@ -350,6 +378,7 @@ async def resolve(
350378
include_recommendations=include_recommendations,
351379
recommendations_limit=recommendations_limit,
352380
include_quality=include_quality,
381+
on_unmapped=on_unmapped,
353382
)
354383
return await self._request.post("/fhir/resolve", json_data=body)
355384

@@ -361,6 +390,7 @@ async def resolve_batch(
361390
include_recommendations: bool = False,
362391
recommendations_limit: int = 5,
363392
include_quality: bool = False,
393+
on_unmapped: str | None = None,
364394
) -> FhirBatchResult:
365395
"""Batch-resolve up to 100 FHIR Codings.
366396
@@ -374,6 +404,8 @@ async def resolve_batch(
374404
body["recommendations_limit"] = recommendations_limit
375405
if include_quality:
376406
body["include_quality"] = True
407+
if on_unmapped is not None:
408+
body["on_unmapped"] = on_unmapped
377409
return await self._request.post("/fhir/resolve/batch", json_data=body)
378410

379411
async def resolve_codeable_concept(
@@ -385,6 +417,7 @@ async def resolve_codeable_concept(
385417
include_recommendations: bool = False,
386418
recommendations_limit: int = 5,
387419
include_quality: bool = False,
420+
on_unmapped: str | None = None,
388421
) -> FhirCodeableConceptResult:
389422
"""Resolve a FHIR CodeableConcept with vocabulary preference.
390423
@@ -402,6 +435,8 @@ async def resolve_codeable_concept(
402435
body["recommendations_limit"] = recommendations_limit
403436
if include_quality:
404437
body["include_quality"] = True
438+
if on_unmapped is not None:
439+
body["on_unmapped"] = on_unmapped
405440
return await self._request.post(
406441
"/fhir/resolve/codeable-concept", json_data=body
407442
)

src/omophub/types/fhir.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,11 @@ class FhirResolution(TypedDict):
3737
vocabulary_id: str | None
3838
source_concept: ResolvedConcept
3939
standard_concept: ResolvedConcept
40+
# Value concept for composite source concepts decomposed via the
41+
# ``Maps to value`` relationship (HL7 FHIR-to-OMOP IG Value-as-Concept
42+
# pattern). Present only when the source has a ``Maps to value`` target.
43+
value_as_concept: NotRequired[ResolvedConcept]
44+
value_target_field: NotRequired[str]
4045
mapping_type: str
4146
target_table: str | None
4247
domain_resource_alignment: str
@@ -47,6 +52,10 @@ class FhirResolution(TypedDict):
4752
quality_note: NotRequired[str]
4853
alternative_standard_concepts: NotRequired[list[ResolvedConcept]]
4954
recommendations: NotRequired[list[RecommendedConceptOutput]]
55+
# Set when a FHIR administrative code resolved via an IG ConceptMap
56+
# (e.g. ``GenderClass``); ``mapping_note`` carries any advisory.
57+
concept_map_id: NotRequired[str]
58+
mapping_note: NotRequired[str]
5059

5160

5261
class FhirResolveResult(TypedDict):
@@ -91,6 +100,7 @@ class Coding(TypedDict, total=False):
91100
code: str
92101
display: str
93102
version: str
103+
user_selected: bool
94104

95105

96106
class CodeableConcept(TypedDict, total=False):

0 commit comments

Comments
 (0)