Skip to main content

1. Exchanging Data vs. Exchanging Meaning

FHIR solved the data exchange problem. A hospital can now send a FHIR Observation resource to a research platform, a FHIR MedicationStatement to a safety database, or a FHIR Condition to a registry. The structure is standardized. The JSON is valid. The API works. But open the payload and look at the CodeableConcept - the field that carries the clinical meaning. You’ll find LOINC codes from one system, proprietary local codes from another, and sometimes just a text field with “Blood Sugar” and no structured code at all. The data exchanged successfully. The meaning got lost in translation. This is the semantic interoperability gap. FHIR standardizes structure; OMOP standardizes vocabulary. To go from exchanged FHIR data to analyzable OMOP records, you need to resolve every CodeableConcept to a standard OMOP concept ID. OMOPHub handles that resolution step. It’s a vocabulary API - you give it a code or a search term, it gives you back the standard OMOP concept. It doesn’t parse FHIR resources, transform data structures, or load OMOP tables. But it answers the question every FHIR-to-OMOP pipeline needs answered: “What OMOP concept ID does this CodeableConcept map to?” This article is a cookbook: specific FHIR resource types, specific OMOPHub API calls, specific OMOP CDM outputs.
Note: This article complements the EHR Integration article, which covers the architectural patterns. This one goes resource-by-resource with concrete examples.

2. The Core Pattern: Resolving a FHIR CodeableConcept

Every FHIR clinical resource contains CodeableConcept fields. The resolution pattern is the same regardless of resource type: Step 1: Check the coding array for known vocabularies. The coding array may contain codes from LOINC, SNOMED, RxNorm, ICD-10-CM, NDC, or local systems. You need an explicit mapping from FHIR system URIs to OMOP vocabulary IDs:
Python
FHIR_TO_OMOP_VOCAB = {
    "http://loinc.org": "LOINC",
    "http://snomed.info/sct": "SNOMED",
    "http://www.nlm.nih.gov/research/umls/rxnorm": "RxNorm",
    "http://hl7.org/fhir/sid/icd-10-cm": "ICD10CM",
    "http://hl7.org/fhir/sid/ndc": "NDC",
    "http://www.ama-assn.org/go/cpt": "CPT4",
}
For each recognized coding entry, search OMOPHub by code + vocabulary to get the OMOP concept ID. Step 2: If only local/unknown codes exist, fallback to the text field. Search OMOPHub with the human-readable text using search.basic() or search.semantic(), filtered to the expected vocabulary and domain. Step 3: Get the standard concept. If the found concept is non-standard (e.g., an ICD-10-CM source concept), call mappings.get() to find the “Maps to” standard equivalent (usually SNOMED for conditions, RxNorm for drugs, LOINC for measurements).

3. Use Case A: FHIR Observation → OMOP Measurement (Labs & Vitals)

The most common FHIR-to-OMOP mapping. Lab results and vital signs arrive as Observation resources and need to become OMOP measurement records. The Scenario: A blood glucose Observation arrives with LOINC code 2339-0. Resolve it to an OMOP concept and build a measurement record.
pip install omophub
Python
import omophub
import json

client = omophub.OMOPHub()

# FHIR system URI → OMOP vocabulary ID
FHIR_TO_OMOP_VOCAB = {
    "http://loinc.org": "LOINC",
    "http://snomed.info/sct": "SNOMED",
    "http://www.nlm.nih.gov/research/umls/rxnorm": "RxNorm",
    "http://hl7.org/fhir/sid/ndc": "NDC",
}

# Sample FHIR Observation (simplified R4)
fhir_obs = {
    "resourceType": "Observation",
    "id": "glucose-obs",
    "status": "final",
    "code": {
        "coding": [
            {
                "system": "http://loinc.org",
                "code": "2339-0",
                "display": "Glucose [Mass/volume] in Blood",
            }
        ],
        "text": "Blood Glucose",
    },
    "valueQuantity": {
        "value": 120,
        "unit": "mg/dL",
        "system": "http://unitsofmeasure.org",
        "code": "mg/dL",
    },
    "effectiveDateTime": "2023-10-26T09:30:00Z",
}

print("Resolving FHIR Observation → OMOP Measurement\n")

# --- Step 1: Parse FHIR resource (YOUR code, not OMOPHub) ---
fhir_codings = fhir_obs["code"].get("coding", [])
fhir_text = fhir_obs["code"].get("text", "")

omop_concept_id = None
omop_concept_name = None

# --- Step 2: Try each coding entry against OMOPHub ---
for coding in fhir_codings:
    system = coding.get("system", "")
    code = coding.get("code", "")
    omop_vocab = FHIR_TO_OMOP_VOCAB.get(system)

    if not omop_vocab:
        print(f"  Skipping unrecognized system: {system}")
        continue

    print(f"  Trying {omop_vocab} code '{code}'...")

    try:
        results = client.search.basic(
            code,
            vocabulary_ids=[omop_vocab],
            page_size=1,
        )
        candidates = results.get("concepts", []) if results else []

        if candidates:
            concept = candidates[0]
            omop_concept_id = concept["concept_id"]
            omop_concept_name = concept.get("concept_name")
            print(f"  -> Found: {omop_concept_name} (OMOP ID: {omop_concept_id})")

            # If non-standard concept, get the standard "Maps to" equivalent
            if concept.get("standard_concept") != "S":
                print(f"     Non-standard. Looking up 'Maps to' mapping...")
                std_mappings = client.mappings.get(omop_concept_id, target_vocabulary=omop_vocab)
                map_list = (
                    std_mappings if isinstance(std_mappings, list)
                    else std_mappings.get("concepts", std_mappings.get("mappings", []))
                ) if std_mappings else []
                if map_list:
                    std = map_list[0]
                    omop_concept_id = std["concept_id"]
                    omop_concept_name = std.get("concept_name")
                    print(f"     -> Standard: {omop_concept_name} (OMOP ID: {omop_concept_id})")
            break  # Found a match, stop iterating
    except omophub.APIError as e:
        print(f"  -> API error: {e.message}")

# --- Step 3: Fallback to text search ---
if omop_concept_id is None and fhir_text:
    print(f"  No coded match. Searching by text: '{fhir_text}'")
    try:
        results = client.search.basic(
            fhir_text,
            vocabulary_ids=["LOINC"],
            domain_ids=["Measurement"],
            page_size=3,
        )
        candidates = results.get("concepts", []) if results else []

        if not candidates:
            semantic = client.search.semantic(fhir_text, vocabulary_ids=["LOINC"], domain_ids=["Measurement"], page_size=3)
            candidates = (semantic.get("results", semantic.get("concepts", [])) if semantic else [])

        if candidates:
            concept = candidates[0]
            omop_concept_id = concept["concept_id"]
            omop_concept_name = concept.get("concept_name")
            print(f"  -> Text match: {omop_concept_name} (OMOP ID: {omop_concept_id})")
    except omophub.APIError as e:
        print(f"  -> API error: {e.message}")

# --- Build the OMOP measurement record ---
if omop_concept_id:
    omop_measurement = {
        "measurement_concept_id": omop_concept_id,
        "measurement_source_value": fhir_text or fhir_codings[0].get("display", ""),
        "value_as_number": fhir_obs["valueQuantity"]["value"],
        "unit_source_value": fhir_obs["valueQuantity"].get("unit"),
        "measurement_date": fhir_obs.get("effectiveDateTime", "")[:10],
    }
    print(f"\n--- OMOP Measurement Record ---")
    print(json.dumps(omop_measurement, indent=2))
else:
    print("\n  Could not resolve to OMOP concept - needs manual mapping")
The Key Insight: The LOINC code 2339-0 may already be the standard OMOP concept (LOINC codes are often standard in OMOP for measurements). The search.basic() call with vocabulary_ids=["LOINC"] finds it directly. No “transformation” needed - just vocabulary resolution. The fallback to text search handles the cases where EHRs send local codes instead of LOINC.

4. Use Case B: FHIR MedicationStatement → OMOP Drug Exposure (The Ingredient Bridge)

Medication reconciliation requires rolling specific product codes up to their active ingredient for class-level analysis. The Scenario: A FHIR MedicationStatement arrives with NDC code “0071-0155-01” (Lipitor 10mg). For a drug safety study, you need the active ingredient (Atorvastatin), not the branded product.
Python
import omophub

client = omophub.OMOPHub()

FHIR_TO_OMOP_VOCAB = {
    "http://hl7.org/fhir/sid/ndc": "NDC",
    "http://www.nlm.nih.gov/research/umls/rxnorm": "RxNorm",
}

# FHIR MedicationStatement (simplified)
fhir_med = {
    "resourceType": "MedicationStatement",
    "id": "med-001",
    "medicationCodeableConcept": {
        "coding": [
            {
                "system": "http://hl7.org/fhir/sid/ndc",
                "code": "0071-0155-01",
                "display": "Lipitor 10 MG Oral Tablet",
            }
        ]
    },
}

print("Resolving FHIR MedicationStatement → OMOP Drug + Ingredient\n")

ndc_code = fhir_med["medicationCodeableConcept"]["coding"][0]["code"]
ndc_system = fhir_med["medicationCodeableConcept"]["coding"][0]["system"]
omop_vocab = FHIR_TO_OMOP_VOCAB.get(ndc_system)

if not omop_vocab:
    print(f"  Unrecognized system: {ndc_system}")
else:
    try:
        # Step 1: Find the NDC concept in OMOP
        print(f"  NDC: {ndc_code}")
        results = client.search.basic(
            ndc_code,
            vocabulary_ids=[omop_vocab],
            page_size=1,
        )
        ndc_candidates = results.get("concepts", []) if results else []

        if not ndc_candidates:
            print(f"  -> NDC not found in OMOPHub")
        else:
            ndc_concept = ndc_candidates[0]
            ndc_omop_id = ndc_concept["concept_id"]
            print(f"  -> NDC concept: {ndc_concept.get('concept_name')} (OMOP: {ndc_omop_id})")

            # Step 2: Map NDC → standard RxNorm concept
            rxnorm_mappings = client.mappings.get(ndc_omop_id, target_vocabulary="RxNorm")
            rxn_list = (
                rxnorm_mappings if isinstance(rxnorm_mappings, list)
                else rxnorm_mappings.get("concepts", rxnorm_mappings.get("mappings", []))
            ) if rxnorm_mappings else []

            if not rxn_list:
                print(f"  -> No RxNorm mapping found")
            else:
                rxnorm_concept = rxn_list[0]
                rxnorm_id = rxnorm_concept["concept_id"]
                rxnorm_name = rxnorm_concept.get("concept_name", "Unknown")
                print(f"  -> RxNorm: {rxnorm_name} (OMOP: {rxnorm_id})")

                # Step 3: Find the ingredient via concept relationships
                # "Has ingredient" is a non-hierarchical relationship, NOT an ancestor traversal
                rels = client.concepts.relationships(rxnorm_id)
                rel_list = (
                    rels if isinstance(rels, list)
                    else rels.get("relationships", [])
                ) if rels else []

                # Look for "RxNorm has ing" or "Has ingredient" relationship
                ingredients = [
                    r for r in rel_list
                    if "ingredient" in r.get("relationship_id", "").lower()
                    or r.get("concept_class_id") == "Ingredient"
                ]

                if ingredients:
                    ing = ingredients[0]
                    ing_id = ing.get("concept_id") or ing.get("concept_id_2")
                    ing_name = ing.get("concept_name")
                    print(f"  -> Ingredient: {ing_name} (OMOP: {ing_id})")

                    # Alternative: traverse "Is a" hierarchy to find Ingredient class
                else:
                    print(f"  -> No ingredient relationship found. Trying hierarchy...")
                    try:
                        ancestors = client.hierarchy.ancestors(
                            rxnorm_id,
                            max_levels=5,
                            relationship_types=["Is a"],
                        )
                        anc_list = (
                            ancestors if isinstance(ancestors, list)
                            else ancestors.get("concepts", [])
                        ) if ancestors else []
                        ing_ancestors = [
                            a for a in anc_list if a.get("concept_class_id") == "Ingredient"
                        ]
                        if ing_ancestors:
                            ing = ing_ancestors[0]
                            print(f"  -> Ingredient (via hierarchy): {ing.get('concept_name')} (OMOP: {ing['concept_id']})")
                        else:
                            print(f"  -> Could not resolve to ingredient")
                    except omophub.APIError:
                        print(f"  -> Hierarchy lookup failed")

    except omophub.APIError as e:
        print(f"  -> API error: {e.message}")
The Key Insight: The NDC → RxNorm → Ingredient pipeline is three OMOPHub calls: search.basic() to find the NDC concept, mappings.get() to cross to RxNorm, and concepts.relationships() to find the ingredient. “Has ingredient” is a relationship, not a hierarchical ancestor - an important distinction. The OMOP vocabulary encodes drug composition as relationships between concepts, not as parent-child hierarchy levels. Your pharmacy sees “Lipitor 10mg.” Your research database needs “Atorvastatin.” OMOPHub bridges the gap.

5. Handling FHIR Extensions and Profiles

Real-world FHIR uses extensions - extra data fields not in the base spec. The US Core Profile, for example, adds detailed race and ethnicity extensions to Patient resources. How this maps to OMOP:
  • Smoking status extension → OBSERVATION table with a standard SNOMED concept
  • Detailed race/ethnicity → person.race_concept_id and person.ethnicity_concept_id
  • Patient-reported outcomes → OBSERVATION or MEASUREMENT depending on type
OMOPHub’s role: When extensions contain CodeableConcept values, the same resolution pattern applies - extract the code, search OMOPHub, get the standard concept ID. When extensions contain text or categorical values, you may need to search OMOPHub by the display text to find the appropriate OMOP concept. The parsing of FHIR extensions (identifying them by URL, extracting their values) is application code - OMOPHub handles the vocabulary resolution for whatever coded or text values you extract.

6. Conclusion: From Structure to Meaning

FHIR gives you structured data exchange. OMOP gives you standardized vocabularies. OMOPHub bridges the vocabulary gap between them - one CodeableConcept at a time. The pattern is the same for every FHIR resource type: extract the coding array → look up each code in OMOPHub → get the standard OMOP concept → fall back to text search if needed. Whether it’s an Observation becoming a measurement, a MedicationStatement becoming a drug_exposure, or a Condition becoming a condition_occurrence, the vocabulary resolution step is OMOPHub’s job. Build the FHIR-system-to-OMOP-vocabulary lookup table. Implement the code-first, text-fallback resolution pattern. Let OMOPHub handle the vocabulary mapping while your application handles the FHIR parsing and OMOP loading. That separation of concerns is what makes the pipeline maintainable across EHR installations that all speak slightly different FHIR dialects.