Skip to content

Observation to MEASUREMENT or OBSERVATION

The FHIR Observation resource is enormous in scope. It covers laboratory results, vital signs, social history, survey responses, clinical assessments, imaging findings, and more. OMOP splits that scope across two tables with different structures, and deciding which one gets a given row is the most consequential routing decision in the transformation.

The OHDSI implementer community has flagged this specific question as one of the places the FHIR to OMOP Implementation Guide most needs practitioner input, which is a fair signal that it is genuinely hard rather than merely unfamiliar.


The two targets

MEASUREMENT is for things that were measured and produced a value on a scale. Laboratory results, vital signs, quantitative assessments. Its structure assumes a numeric result with a unit and a reference range.

Column Purpose
measurement_concept_id The standard concept for what was measured
measurement_date, measurement_datetime, measurement_time When
measurement_type_concept_id Provenance
operator_concept_id For results like < 0.5
value_as_number The numeric result
value_as_concept_id A coded result, for qualitative measurements
unit_concept_id UCUM unit as a concept
range_low, range_high Reference range
provider_id, visit_occurrence_id, visit_detail_id Context
measurement_source_value, measurement_source_concept_id Source
unit_source_value, value_source_value Source
measurement_event_id, meas_event_field_concept_id v5.4, links a measurement to another event

OBSERVATION is for clinical facts that are not measurements, conditions, drugs, procedures, or devices. Social history, family history, allergies, survey answers, and assertions about the person.

Column Purpose
observation_concept_id The standard concept
observation_date, observation_datetime When
observation_type_concept_id Provenance
value_as_number Numeric value if any
value_as_string Text value
value_as_concept_id Coded value
qualifier_concept_id A modifier
unit_concept_id Unit if any
provider_id, visit_occurrence_id, visit_detail_id Context
observation_source_value, observation_source_concept_id Source
unit_source_value, qualifier_source_value Source
value_source_value The raw value string
observation_event_id, obs_event_field_concept_id v5.4, links to another event

Note the structural difference. MEASUREMENT has range_low and range_high; OBSERVATION does not. OBSERVATION has value_as_string and qualifier_concept_id; MEASUREMENT does not. Choosing the wrong table means specific columns have nowhere to go.


The routing rule

Same as always. The domain of the standard concept decides.

LOINC 4548-4  Hemoglobin A1c            → domain Measurement  → MEASUREMENT
LOINC 72166-2 Tobacco smoking status    → domain Observation  → OBSERVATION
LOINC 8302-2  Body height               → domain Measurement  → MEASUREMENT
SNOMED 266897007 Family history of CAD  → domain Observation  → OBSERVATION

Both R. Alvarez's A1c and their smoking status arrived as FHIR Observation resources with LOINC codes and went to different tables. The FHIR resource type predicted nothing. Observation.category is a useful hint and is not authoritative; a laboratory category is a strong signal for Measurement, and it is a signal rather than a rule.


Handling the value

FHIR Observation has a choice of value types, and each maps differently.

FHIR element MEASUREMENT OBSERVATION
valueQuantity value_as_number plus unit_concept_id value_as_number plus unit_concept_id
valueCodeableConcept value_as_concept_id value_as_concept_id
valueString no home; consider value_source_value value_as_string
valueBoolean map to a concept for true or false value_as_concept_id
valueRange, valueRatio partial; document the loss partial
valueInteger value_as_number value_as_number
dataAbsentReason see below see below
component[] one row per component one row per component

Three of those deserve expansion.

Units

valueQuantity carries a UCUM code in .code and a display string in .unit. Map from the UCUM code to a unit_concept_id. The display string goes in unit_source_value.

Unit normalization is where quiet damage happens. A serum creatinine reported in mg/dL and one reported in µmol/L are the same measurement at wildly different numeric scales. If your pipeline loads both into value_as_number without converting or without at least populating unit_concept_id correctly, any analysis that averages or thresholds on that value produces nonsense.

Two acceptable approaches. Load values as reported with accurate unit concepts, and let the analyst handle conversion. Or normalize to a canonical unit per measurement concept and record the original in unit_source_value and value_source_value. The first is more common and more conservative. The second is friendlier to analysts and introduces a transformation that must be documented and tested.

What is not acceptable is loading values as reported with missing or wrong unit concepts, because that is indistinguishable from consistent units and produces confidently wrong results.

Components

A blood pressure Observation typically has a single code with two components, systolic and diastolic. A panel may have many components.

Each component becomes its own row, with the component's code as the concept and the component's value as the value. In v5.4, measurement_event_id and meas_event_field_concept_id can link the component rows back to a parent, which preserves the grouping that would otherwise be lost.

A pipeline that reads only the top-level code and value on a component-bearing Observation will produce a row with a concept and no value, which is a common and easily missed defect.

Absent values

dataAbsentReason says why there is no value: not-performed, masked, unknown, not-applicable.

This is meaningful information and OMOP has nowhere natural to put it. A measurement was ordered and not performed is different from never ordered, and both are different from performed with an unknown result.

Options: skip the record, load with null value and record the reason in value_source_value, or map the absent reason to a value concept where a sensible one exists. Whatever you choose, do not load a null value with no indication that it was deliberately absent, because that is indistinguishable from a parsing failure.


Status

Observation.status includes registered, preliminary, final, amended, corrected, cancelled, entered-in-error, unknown.

entered-in-error and cancelled should not be loaded as facts. preliminary results may be superseded by finals, and loading both produces duplicate results with different values. amended and corrected indicate a prior version existed.

A defensible policy: load final, amended, and corrected. Exclude entered-in-error and cancelled. Decide on preliminary based on whether your source reliably sends the corresponding final, and document the decision with counts.


The local code case

R. Alvarez's obs-local-frailty uses http://hospital.example.org/codes code FRLTY-7, with a value of 4 and unit {score}.

No standard concept exists. The options from domain routing apply, and there is an additional wrinkle: with no concept, you do not know the domain, so you do not know which table it belongs in.

Practical resolution: for unmapped codes with a numeric value and a unit, MEASUREMENT with concept_id = 0 is the more natural home, because the structure fits. For unmapped codes with a coded or text value, OBSERVATION fits better. Either way, preserve measurement_source_value or observation_source_value so the row can be recovered when a mapping becomes available.

Document the rule. An instance where unmapped observations are split across two tables by an undocumented heuristic is confusing to everyone including its authors six months later.


Where this quietly breaks

Routing everything to OBSERVATION because the resource is called Observation. Puts laboratory results somewhere with no reference range columns, and makes them invisible to every measurement-based analysis.

Routing everything to MEASUREMENT because most volume is laboratory. Puts social history and family history into a table whose semantics do not fit and whose analyses will misinterpret them.

Dropping units. Covered above. The single most damaging quiet failure in this chapter.

Ignoring components. Produces valueless rows for blood pressure and panels.

Loading preliminary and final as separate facts. Duplicates results, and the duplicates have different values, so any aggregate is wrong in a way that looks like measurement variability.

Losing the reference range. referenceRange maps directly to range_low and range_high. It is easy to skip and it is what lets an analyst identify abnormal results without importing external range tables.

Ignoring operator_concept_id. A result of "<0.5" loaded as 0.5 asserts a precision the laboratory did not claim. FHIR carries this in valueQuantity.comparator and OMOP has a column for it.

Losing time of day. MEASUREMENT has a measurement_time column in addition to the datetime. For measurements where diurnal variation is clinically meaningful, this column earns its place.


Practice

R. Alvarez's three Observations:

MEASUREMENT
  measurement_concept_id      = 3004410      Hemoglobin A1c/Hemoglobin.total in Blood
  measurement_date            = 2024-09-09
  measurement_datetime        = 2024-09-09 06:15
  measurement_type_concept_id = 32856        Lab
  value_as_number             = 7.8
  unit_concept_id             = 8554         percent
  range_low                   = 4.0
  range_high                  = 5.6
  visit_occurrence_id         = 1
  measurement_source_value    = '4548-4'

OBSERVATION
  observation_concept_id      = 4041306      Tobacco smoking status
  observation_date            = 2024-09-08
  observation_type_concept_id = 32817        EHR
  value_as_concept_id         = 4310250      Former smoker
  visit_occurrence_id         = 1
  observation_source_value    = '72166-2'

MEASUREMENT   (unmapped, numeric with unit)
  measurement_concept_id      = 0
  measurement_date            = 2024-09-08
  value_as_number             = 4
  unit_concept_id             = 0
  measurement_source_value    = 'FRLTY-7'
  unit_source_value           = '{score}'

Three FHIR Observations, two OMOP tables, one row that is present and analytically invisible until somebody maps it.


Next