Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,26 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [1.8.0] - 2026-05-25

### Added

- `FhirResolution` now types the `value_as_concept` and `value_target_field`
fields the resolver returns when a composite concept is decomposed via the
`Maps to value` relationship (HL7 FHIR-to-OMOP IG Value-as-Concept pattern —
e.g. "Allergy to penicillin" → standard "Allergy to drug" + value
"Penicillin G"), plus `concept_map_id` / `mapping_note` for FHIR
administrative-code resolutions. These were already passed through; they are
now part of the typed response shape.
- `resolve()`, `resolve_batch()`, and `resolve_codeable_concept()` accept an
`on_unmapped` argument (`"error"` default / `"sentinel"`). With `"sentinel"`
the resolver returns a `concept_id` 0 record instead of a 404 when nothing
resolves, so ETL callers always get a writable row.
- Coding inputs now carry `user_selected` through to the resolver, and FHIR's
camelCase `userSelected` is mapped to it. A user-selected coding wins over
vocabulary preference in `resolve_codeable_concept()` (FHIR-to-OMOP IG
CodeableConcept pattern).

## [1.7.1] - 2026-05-20

Maintenance release. Dependency and lock-file updates only — there are no
Expand Down
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,24 @@ result = client.fhir.resolve_codeable_concept(
print(result["best_match"]["resolution"]["source_concept"]["vocabulary_id"]) # "SNOMED"
```

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.

```python
# Administrative gender → person.gender_concept_id (via IG ConceptMap)
client.fhir.resolve(system="http://hl7.org/fhir/administrative-gender", code="male")

# A user-selected coding wins over vocabulary preference
client.fhir.resolve_codeable_concept(coding=[
{"system": "http://snomed.info/sct", "code": "44054006"},
{"system": "http://hl7.org/fhir/sid/icd-10-cm", "code": "E11.9", "user_selected": True},
])

# on_unmapped="sentinel" → a concept_id 0 record instead of a 404 (one row per input for ETL)
client.fhir.resolve(system="http://snomed.info/sct", code="00000000", on_unmapped="sentinel")
```

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.

### Type Interoperability

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).
Expand Down
100 changes: 100 additions & 0 deletions examples/fhir_resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -489,6 +489,103 @@ def error_handling_examples() -> None:
client.close()


# ---------------------------------------------------------------------------
# 13. Administrative code via the HL7 FHIR-to-OMOP IG ConceptMap layer
# ---------------------------------------------------------------------------


def resolve_administrative_gender() -> None:
"""Resolve a FHIR administrative code (gender) via the IG ConceptMap layer.

Administrative/structural systems (administrative-gender, v3-ActCode
encounter class, condition-clinical, ...) have no OMOP vocabulary-table
representation and resolve through the IG's ConceptMaps.
"""
print("\n=== 13. Administrative gender (IG ConceptMap) ===")

client = omophub.OMOPHub()
try:
result = client.fhir.resolve(
system="http://hl7.org/fhir/administrative-gender",
code="male",
resource_type="Patient",
)
res = result["resolution"]
std = res["standard_concept"]
print(f" male → {std['concept_name']} ({std['concept_id']})")
print(f" target_table: {res['target_table']}")
print(f" via IG ConceptMap: {res.get('concept_map_id')}")
# Composite source concepts also surface a `value_as_concept`
# (the IG 'Maps to value' / Value-as-Concept pattern) when present.
if res.get("value_as_concept"):
val = res["value_as_concept"]
print(
f" value_as_concept: {val['concept_name']}"
f" → {res.get('value_target_field')}"
)
finally:
client.close()


# ---------------------------------------------------------------------------
# 14. CodeableConcept with user_selected (overrides vocabulary preference)
# ---------------------------------------------------------------------------


def resolve_user_selected() -> None:
"""A coding marked user_selected wins best_match over vocabulary preference."""
print("\n=== 14. CodeableConcept with user_selected ===")

client = omophub.OMOPHub()
try:
result = client.fhir.resolve_codeable_concept(
coding=[
# SNOMED would normally win on OHDSI preference, but the
# user-selected ICD-10-CM coding takes precedence.
{"system": "http://snomed.info/sct", "code": "44054006"},
{
"system": "http://hl7.org/fhir/sid/icd-10-cm",
"code": "E11.9",
"user_selected": True,
},
],
resource_type="Condition",
)
best = result["best_match"]
if best:
src = best["resolution"]["source_concept"]
print(f" best_match source vocabulary: {src['vocabulary_id']}")
finally:
client.close()


# ---------------------------------------------------------------------------
# 15. on_unmapped="sentinel" — a concept_id 0 record instead of a 404
# ---------------------------------------------------------------------------


def resolve_unmapped_sentinel() -> None:
"""With on_unmapped='sentinel', an unresolvable code yields a row, not a 404.

Handy for ETL pipelines that need one output row per input. Works on
resolve(), resolve_batch(), and resolve_codeable_concept().
"""
print("\n=== 15. on_unmapped='sentinel' ===")

client = omophub.OMOPHub()
try:
result = client.fhir.resolve(
system="http://snomed.info/sct",
code="00000000",
on_unmapped="sentinel",
)
res = result["resolution"]
print(f" mapping_type: {res['mapping_type']}")
print(f" standard concept_id: {res['standard_concept']['concept_id']}")
finally:
client.close()


# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
Expand All @@ -505,5 +602,8 @@ def error_handling_examples() -> None:
resolve_batch()
resolve_codeable_concept()
resolve_codeable_concept_text_fallback()
resolve_administrative_gender()
resolve_user_selected()
resolve_unmapped_sentinel()
asyncio.run(async_resolve())
error_handling_examples()
45 changes: 40 additions & 5 deletions src/omophub/resources/fhir.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,30 +43,44 @@ def _extract_coding(


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


def _coding_to_dict(coding_input: object) -> dict[str, Any]:
"""Convert any Coding-like input to a wire-format dict.

Preserves only the keys the resolver endpoint understands
(:data:`_ALLOWED_CODING_KEYS`). Skips keys whose values are ``None``
so the request payload stays tight.
(:data:`_ALLOWED_CODING_KEYS`). FHIR's camelCase ``userSelected`` is
accepted and mapped to ``user_selected``. Skips keys whose values are
``None`` so the request payload stays tight.
"""
if isinstance(coding_input, dict):
src: dict[str, Any] = {
key: coding_input.get(key) for key in _ALLOWED_CODING_KEYS
}
if (
src.get("user_selected") is None
and coding_input.get("userSelected") is not None
):
src["user_selected"] = coding_input.get("userSelected")
else:
src = {key: getattr(coding_input, key, None) for key in _ALLOWED_CODING_KEYS}
if (
src.get("user_selected") is None
and getattr(coding_input, "userSelected", None) is not None
):
src["user_selected"] = getattr(coding_input, "userSelected", None)
return {k: v for k, v in src.items() if v is not None}


Expand Down Expand Up @@ -130,6 +144,7 @@ def _build_resolve_body(
include_recommendations: bool = False,
recommendations_limit: int = 5,
include_quality: bool = False,
on_unmapped: str | None = None,
) -> dict[str, Any]:
body: dict[str, Any] = {}
if system is not None:
Expand All @@ -147,6 +162,8 @@ def _build_resolve_body(
body["recommendations_limit"] = recommendations_limit
if include_quality:
body["include_quality"] = True
if on_unmapped is not None:
body["on_unmapped"] = on_unmapped
return body


Expand Down Expand Up @@ -182,6 +199,7 @@ def resolve(
include_recommendations: bool = False,
recommendations_limit: int = 5,
include_quality: bool = False,
on_unmapped: str | None = None,
) -> FhirResolveResult:
"""Resolve a single FHIR Coding to an OMOP standard concept.

Expand All @@ -204,6 +222,8 @@ def resolve(
include_recommendations: Include Phoebe recommendations
recommendations_limit: Max recommendations to return (1-20)
include_quality: Include mapping quality signal
on_unmapped: ``"error"`` (default) raises 404 when nothing
resolves; ``"sentinel"`` returns a ``concept_id`` 0 record

Returns:
Resolution result with source concept, standard concept,
Expand All @@ -221,6 +241,7 @@ def resolve(
include_recommendations=include_recommendations,
recommendations_limit=recommendations_limit,
include_quality=include_quality,
on_unmapped=on_unmapped,
)
return self._request.post("/fhir/resolve", json_data=body)

Expand All @@ -232,6 +253,7 @@ def resolve_batch(
include_recommendations: bool = False,
recommendations_limit: int = 5,
include_quality: bool = False,
on_unmapped: str | None = None,
) -> FhirBatchResult:
"""Batch-resolve up to 100 FHIR Codings.

Expand Down Expand Up @@ -260,6 +282,8 @@ def resolve_batch(
body["recommendations_limit"] = recommendations_limit
if include_quality:
body["include_quality"] = True
if on_unmapped is not None:
body["on_unmapped"] = on_unmapped
return self._request.post("/fhir/resolve/batch", json_data=body)

def resolve_codeable_concept(
Expand All @@ -271,6 +295,7 @@ def resolve_codeable_concept(
include_recommendations: bool = False,
recommendations_limit: int = 5,
include_quality: bool = False,
on_unmapped: str | None = None,
) -> FhirCodeableConceptResult:
"""Resolve a FHIR CodeableConcept with vocabulary preference.

Expand Down Expand Up @@ -309,6 +334,8 @@ def resolve_codeable_concept(
body["recommendations_limit"] = recommendations_limit
if include_quality:
body["include_quality"] = True
if on_unmapped is not None:
body["on_unmapped"] = on_unmapped
return self._request.post("/fhir/resolve/codeable-concept", json_data=body)


Expand All @@ -333,6 +360,7 @@ async def resolve(
include_recommendations: bool = False,
recommendations_limit: int = 5,
include_quality: bool = False,
on_unmapped: str | None = None,
) -> FhirResolveResult:
"""Resolve a single FHIR Coding to an OMOP standard concept.

Expand All @@ -350,6 +378,7 @@ async def resolve(
include_recommendations=include_recommendations,
recommendations_limit=recommendations_limit,
include_quality=include_quality,
on_unmapped=on_unmapped,
)
return await self._request.post("/fhir/resolve", json_data=body)

Expand All @@ -361,6 +390,7 @@ async def resolve_batch(
include_recommendations: bool = False,
recommendations_limit: int = 5,
include_quality: bool = False,
on_unmapped: str | None = None,
) -> FhirBatchResult:
"""Batch-resolve up to 100 FHIR Codings.

Expand All @@ -374,6 +404,8 @@ async def resolve_batch(
body["recommendations_limit"] = recommendations_limit
if include_quality:
body["include_quality"] = True
if on_unmapped is not None:
body["on_unmapped"] = on_unmapped
return await self._request.post("/fhir/resolve/batch", json_data=body)

async def resolve_codeable_concept(
Expand All @@ -385,6 +417,7 @@ async def resolve_codeable_concept(
include_recommendations: bool = False,
recommendations_limit: int = 5,
include_quality: bool = False,
on_unmapped: str | None = None,
) -> FhirCodeableConceptResult:
"""Resolve a FHIR CodeableConcept with vocabulary preference.

Expand All @@ -402,6 +435,8 @@ async def resolve_codeable_concept(
body["recommendations_limit"] = recommendations_limit
if include_quality:
body["include_quality"] = True
if on_unmapped is not None:
body["on_unmapped"] = on_unmapped
return await self._request.post(
"/fhir/resolve/codeable-concept", json_data=body
)
10 changes: 10 additions & 0 deletions src/omophub/types/fhir.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,11 @@ class FhirResolution(TypedDict):
vocabulary_id: str | None
source_concept: ResolvedConcept
standard_concept: ResolvedConcept
# Value concept for composite source concepts decomposed via the
# ``Maps to value`` relationship (HL7 FHIR-to-OMOP IG Value-as-Concept
# pattern). Present only when the source has a ``Maps to value`` target.
value_as_concept: NotRequired[ResolvedConcept]
value_target_field: NotRequired[str]
mapping_type: str
target_table: str | None
domain_resource_alignment: str
Expand All @@ -47,6 +52,10 @@ class FhirResolution(TypedDict):
quality_note: NotRequired[str]
alternative_standard_concepts: NotRequired[list[ResolvedConcept]]
recommendations: NotRequired[list[RecommendedConceptOutput]]
# Set when a FHIR administrative code resolved via an IG ConceptMap
# (e.g. ``GenderClass``); ``mapping_note`` carries any advisory.
concept_map_id: NotRequired[str]
mapping_note: NotRequired[str]


class FhirResolveResult(TypedDict):
Expand Down Expand Up @@ -91,6 +100,7 @@ class Coding(TypedDict, total=False):
code: str
display: str
version: str
user_selected: bool


class CodeableConcept(TypedDict, total=False):
Expand Down
Loading
Loading