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
9 changes: 6 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,11 @@ results = client.search.basic("metformin", vocabulary_ids=["RxNorm"], domain_ids
for c in results["concepts"]:
print(f"{c['concept_id']}: {c['concept_name']}")

# Map ICD-10 code to SNOMED
mappings = client.mappings.get_by_code("ICD10CM", "E11.9", target_vocabulary="SNOMED")
# Map an ICD-10 code to SNOMED: look the code up, then map its concept.
# (`Maps to` points at *standard* concepts, so SNOMED is a valid target here
# while the reverse, SNOMED -> ICD10CM, would return nothing.)
icd = client.concepts.get_by_code("ICD10CM", "E11.9")
mappings = client.mappings.get(icd["concept_id"], target_vocabulary="SNOMED")

# Navigate concept hierarchy
ancestors = client.hierarchy.ancestors(201826, max_levels=3)
Expand Down Expand Up @@ -323,7 +326,7 @@ suggestions = client.concepts.suggest("diab", vocabulary_ids=["SNOMED"], page_si
| `concepts` | Concept lookup and batch operations | `get()`, `get_by_code()`, `batch()`, `suggest()` |
| `search` | Full-text and semantic search | `basic()`, `advanced()`, `semantic()`, `similar()`, `bulk_basic()`, `bulk_semantic()` |
| `hierarchy` | Navigate concept relationships | `ancestors()`, `descendants()` |
| `mappings` | Cross-vocabulary mappings | `get()`, `map()` |
| `mappings` | Cross-vocabulary mappings | `get()`, `get_iter()`, `map()` |
| `vocabularies` | Vocabulary metadata | `list()`, `get()`, `stats()` |
| `domains` | Domain information | `list()`, `get()`, `concepts()` |
| `fhir` | FHIR-to-OMOP resolution | `resolve()`, `resolve_batch()`, `resolve_codeable_concept()` |
Expand Down
168 changes: 152 additions & 16 deletions examples/map_between_vocabularies.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,34 +5,166 @@


def get_mappings() -> None:
"""Get mappings for a concept to other vocabularies."""
"""Get the mappings defined for a concept."""
print("=== Concept Mappings ===")

client = omophub.OMOPHub()

try:
# Type 2 diabetes mellitus (SNOMED)
# Type 2 diabetes mellitus (SNOMED, standard)
concept_id = 201826

# `get()` returns ONE page, and it returns the response's `data` field
# only -- the `meta.pagination` that would say whether more pages exist
# is not part of what you get back. See get_every_mapping() below.
result = client.mappings.get(concept_id)
mappings = result.get("mappings", [])

print(f"Mappings for concept {concept_id} (this page: {len(mappings)}):")
for m in mappings[:10]:
# `Mapping` types vocabulary_id / concept_code as NotRequired
# because it is shared with `mappings.map()`, which does populate
# them. THIS endpoint does not: it projects each row down to
# source/target id + name, relationship_id and confidence. Resolve
# a code with `concepts.get(target_concept_id)` -- see
# map_to_a_specific_vocabulary() below.
print(
f" {m.get('relationship_id')}: "
f"{m.get('target_concept_id')} {m.get('target_concept_name')}"
)
except omophub.OMOPHubError as e:
print(f"API error: {e.message}")
finally:
client.close()


def map_to_a_specific_vocabulary() -> None:
"""Find which ICD-10-CM codes correspond to a SNOMED concept.

Note the DIRECTION. `Maps to` always points at a *standard* concept, and
ICD-10-CM is non-standard, so `target_vocabulary="ICD10CM"` on the default
relationship matches nothing -- it returns an empty list rather than an
error. The codes that roll up INTO a standard concept are reached with
`Mapped from`.
"""
print("\n=== Mapping to a Specific Vocabulary ===")

client = omophub.OMOPHub()

try:
concept_id = 201826

empty = client.mappings.get(concept_id, target_vocabulary="ICD10CM")
print(
f" 'Maps to' + ICD10CM: {len(empty.get('mappings', []))} rows (as expected)"
)

icd_codes = list(
client.mappings.get_iter(
concept_id,
relationship_ids=["Mapped from"],
target_vocabulary="ICD10CM",
)
)
print(f" 'Mapped from' + ICD10CM: {len(icd_codes)} rows")

# The mapping row has the target's id and name but not its code, so
# resolve the first few. One request each -- fine for five rows, not
# for the full 74.
for m in icd_codes[:5]:
target_id = m.get("target_concept_id")
if target_id is None:
continue
target = client.concepts.get(target_id)
print(
f" <- [{target.get('vocabulary_id', '?')}] "
f"{target.get('concept_code', '?')} {target.get('concept_name', '?')}"
)
except omophub.OMOPHubError as e:
print(f"API error: {e.message}")
finally:
client.close()


def get_every_mapping() -> None:
"""Walk every page instead of trusting the first one.

This is the one to copy when building a code list: a partial code list is
wrong in a way nothing in the result reveals.
"""
print("\n=== Every Mapping (all pages) ===")

client = omophub.OMOPHub()

try:
concept_id = 201826

# get_iter() follows has_next to the end; it never has to guess from
# the page length. It yields one mapping at a time, so you can consume
# the whole set without holding it -- count here, but this is where a
# real code list would accumulate what it needs.
#
# `list(client.mappings.get_iter(...))` materialises the same walk if
# you do want them all at once; do not do both, it is two round trips
# over every page.
total = 0
for m in client.mappings.get_iter(concept_id):
_ = m.get("target_concept_name")
total += 1
print(f" {total} mappings in total")
except omophub.OMOPHubError as e:
print(f"API error: {e.message}")
finally:
client.close()


def value_as_concept() -> None:
"""Composite concepts decompose across TWO relationships.

The default returns only the first, so you learn the patient is allergic
to *a drug* but not *which* drug.
"""
print("\n=== Value-as-Concept ===")

client = omophub.OMOPHub()

try:
# Allergy to penicillin G
result = client.mappings.get(
concept_id,
target_vocabulary="ICD10CM",
4167462,
relationship_ids=["Maps to", "Maps to value"],
)

source = result.get("source_concept", {})
mappings = result.get("mappings", [])
summary = result.get("mapping_summary", {})
for m in result.get("mappings", []):
# `Maps to` -> the OMOP concept column;
# `Maps to value` -> value_as_concept_id.
column = (
"value_as_concept_id"
if m.get("relationship_id") == "Maps to value"
else "concept_id"
)
print(
f" {m.get('relationship_id')}: {m.get('target_concept_name')} -> {column}"
)
except omophub.OMOPHubError as e:
print(f"API error: {e.message}")
finally:
client.close()

source_name = source.get("concept_name", "Unknown") if source else "Unknown"
print(f"Mappings for '{source_name}':")
print(f" Total mappings: {summary.get('total_mappings', len(mappings))}")

for m in mappings[:10]:
target_vocab = m.get("target_vocabulary_id", "?")
target_code = m.get("target_concept_code", "?")
target_name = m.get("target_concept_name", "?")
print(f"\n [{target_vocab}] {target_code}")
print(f" Name: {target_name}")
def exclude_invalid() -> None:
"""Deprecated mappings come back by default; pass False to drop them."""
print("\n=== Valid Mappings Only ===")

client = omophub.OMOPHub()

try:
concept_id = 201826
with_invalid = list(client.mappings.get_iter(concept_id))
valid_only = list(client.mappings.get_iter(concept_id, include_invalid=False))

print(f" default (includes deprecated): {len(with_invalid)}")
print(f" include_invalid=False: {len(valid_only)}")
except omophub.OMOPHubError as e:
print(f"API error: {e.message}")
finally:
Expand Down Expand Up @@ -99,5 +231,9 @@ def lookup_by_code() -> None:

if __name__ == "__main__":
get_mappings()
map_to_a_specific_vocabulary()
get_every_mapping()
value_as_concept()
exclude_invalid()
map_concepts()
lookup_by_code()
13 changes: 13 additions & 0 deletions src/omophub/types/mapping.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,19 @@ class MappingContext(TypedDict, total=False):
class Mapping(TypedDict):
"""Concept mapping to another vocabulary.

The optional fields are optional because this type is shared by two
endpoints that populate different subsets -- not because the server
decides case by case:

- ``mappings.get`` (``GET /concepts/{id}/mappings``) returns exactly
``source_concept_id``, ``source_concept_name``, ``target_concept_id``,
``target_concept_name``, ``relationship_id`` and ``confidence``.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The new docstring lists a top-level confidence field among the fields that mappings.get returns, but the Mapping type defines no such field — confidence only exists as quality.confidence_score and, per the note two lines below, is populated only when include_mapping_quality=True is requested. Listing bare confidence as a guaranteed return field is internally inconsistent with both the type and the note, and will lead readers to write m["confidence"] (a KeyError / missing type member). Either drop confidence from the list, name it quality.confidence_score and qualify it with the include_mapping_quality=True condition, or add the missing top-level field to the Mapping type. The same claim appears in the comment added to examples/map_between_vocabularies.py in this PR, so both should be aligned if corrected.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/omophub/types/mapping.py, line 40:

<comment>The new docstring lists a top-level ``confidence`` field among the fields that ``mappings.get`` returns, but the ``Mapping`` type defines no such field — confidence only exists as ``quality.confidence_score`` and, per the note two lines below, is populated only when ``include_mapping_quality=True`` is requested. Listing bare ``confidence`` as a guaranteed return field is internally inconsistent with both the type and the note, and will lead readers to write ``m["confidence"]`` (a KeyError / missing type member). Either drop ``confidence`` from the list, name it ``quality.confidence_score`` and qualify it with the ``include_mapping_quality=True`` condition, or add the missing top-level field to the ``Mapping`` type. The same claim appears in the comment added to examples/map_between_vocabularies.py in this PR, so both should be aligned if corrected.</comment>

<file context>
@@ -31,6 +31,19 @@ class MappingContext(TypedDict, total=False):
+
+    - ``mappings.get`` (``GET /concepts/{id}/mappings``) returns exactly
+      ``source_concept_id``, ``source_concept_name``, ``target_concept_id``,
+      ``target_concept_name``, ``relationship_id`` and ``confidence``.
+      Supplying ``target_vocabulary`` does NOT add the vocabulary/code fields
+      -- measured against production 2026-08-12. Resolve a target's vocabulary
</file context>
Suggested change
``target_concept_name``, ``relationship_id`` and ``confidence``.
``target_concept_name``, ``relationship_id``, and ``quality.confidence_score``
(the last only when ``include_mapping_quality=True`` is requested).

Supplying ``target_vocabulary`` does NOT add the vocabulary/code fields
-- measured against production 2026-08-12. Resolve a target's vocabulary
and code with ``concepts.get(target_concept_id)``.
- ``mappings.map`` (``POST /mappings/map``) additionally returns the
``source_*`` / ``target_*`` ``vocabulary_id`` and ``concept_code``.

Note: Confidence score should be accessed via `quality.confidence_score`
when include_mapping_quality=True is requested.
"""
Expand Down
Loading