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.