Mapping HL7 v2 OBX Segments to LIMS Result Fields
Problem Statement
The OBX segment is the only place in an HL7 v2 ORU message where a numeric analyzer result actually lives, and it is deceptively hostile: OBX-2 declares a value type that changes how OBX-5 must be parsed, OBX-3 carries the observation’s LOINC identity buried inside a coded-element’s components, OBX-6 carries units that may or may not be UCUM, OBX-8 carries abnormal flags that some analyzers populate and some leave empty, and OBX-11 carries a result status that decides whether the value is even final. A mapper that reads OBX-5 as a float without first checking OBX-2, or that stores the whole 2823-3^Potassium^LN triplet as a test code instead of extracting the LOINC part, corrupts every downstream validator. This page maps each OBX field to a typed LIMS result attribute with hl7apy, casting by declared value type, isolating the LOINC identity, and refusing any non-final status rather than releasing a preliminary value as verified.
Prerequisites
Before wiring an OBX mapper into the ingestion path, confirm the following baseline:
- Runtime: Python 3.11+,
hl7apy>=1.3for parsing framed ER7 messages,pydantic>=2.6for the canonical result model, andpytest>=8for the mapping fixtures. - Upstream framing: messages arrive already un-framed and syntactically validated by the acquisition layer — MLLP listeners or the serial / FTP polling architectures — so this stage receives a parseable ER7 string, not a raw socket buffer.
- Terminology service: OBX-3 coding-system checks assume a resolver from Test Code Taxonomy & Standards can confirm that a
LNsystem code really is LOINC before the identity is trusted. - Regulatory baseline: an append-only audit sink able to preserve the original OBX bytes for the CLIA §493.1105 retention window, since the mapper must keep the wire form for every field it transforms.
Step-by-Step Implementation
Step 1: Model the canonical LIMS result field
Model the target as a Pydantic v2 LimsResultField so that a mapping which produces an impossible combination — a numeric value type with a non-numeric value, a missing LOINC — fails at construction rather than downstream. The model keeps the raw OBX-5 bytes alongside the parsed value so the transform is reversible for audit.
from __future__ import annotations
from decimal import Decimal
from enum import Enum
from pydantic import BaseModel, Field, model_validator
class ResultStatus(str, Enum):
preliminary = "P"
final = "F"
corrected = "C"
not_available = "X"
class LimsResultField(BaseModel):
set_id: int = Field(ge=1)
value_type: str # OBX-2, e.g. "NM", "ST", "CWE"
analyte_loinc: str # OBX-3.1
analyte_name: str | None # OBX-3.2
value_numeric: Decimal | None = None
value_text: str | None = None
units_ucum: str | None = None # OBX-6.1
abnormal_flag: str | None = None # OBX-8
status: ResultStatus # OBX-11
raw_obx5: str # original OBX-5 bytes, preserved for audit
@model_validator(mode="after")
def _one_value_present(self) -> "LimsResultField":
if self.value_numeric is None and self.value_text is None:
raise ValueError(f"OBX set {self.set_id}: no value parsed from OBX-5")
return self
Step 2: Read OBX components without trusting position
hl7apy exposes coded-element components by their data-type names, so OBX-3.1 is the identifier component and OBX-3.3 is name_of_coding_system. Wrap that access in a helper that returns None on an absent component instead of raising ChildNotFound, because empty OBX-8 and OBX-6 are routine.
from typing import Any
def component(field: Any, attr: str) -> str | None:
"""Return a coded-element sub-component's value, or None if absent."""
try:
value = getattr(field, attr).value
except Exception: # hl7apy raises ChildNotFound for empty components
return None
return value or None
def scalar(segment: Any, field_name: str) -> str | None:
"""Return a simple field's ER7 value, or None if the field is empty."""
try:
value = getattr(segment, field_name).value
except Exception:
return None
return value or None
Step 3: Cast OBX-5 according to OBX-2
The value type in OBX-2 is the contract for OBX-5. NM (numeric) and SN (structured numeric) must parse to a Decimal — Decimal, not float, so that a reported 4.30 keeps its trailing-zero significance and never drifts through binary rounding. ST, TX, and FT are free text and stay as strings. A declared NM that fails to parse is a hard mapping error, not a silent fallback to text, because a numeric analyte that arrives unparseable must be held.
from decimal import Decimal, InvalidOperation
NUMERIC_TYPES = {"NM", "SN"}
def cast_obx5(value_type: str, raw: str) -> tuple[Decimal | None, str | None]:
"""Return (numeric, text) with exactly one populated, keyed on OBX-2."""
if value_type in NUMERIC_TYPES:
try:
return Decimal(raw.strip()), None
except (InvalidOperation, AttributeError) as exc:
raise ValueError(f"OBX-2={value_type} but OBX-5 {raw!r} is not numeric") from exc
return None, raw
Step 4: Map one OBX segment to a canonical field
The mapper reads OBX-11 first and refuses to emit a releasable field for a non-final, non-corrected status: a preliminary (P) result must never reach the validator as though it were verified. OBX-3’s identity is trusted only when OBX-3.3 names LOINC (LN); any other coding system routes the segment to the terminology crosswalk before it can be keyed.
def map_obx(segment: object) -> LimsResultField:
status = ResultStatus(scalar(segment, "obx_11") or "F")
coding_system = component(segment.obx_3, "name_of_coding_system")
loinc = component(segment.obx_3, "identifier")
if loinc is None:
raise ValueError("OBX-3 has no observation identifier")
if coding_system not in {"LN", "LOINC"}:
# local code — must pass through the taxonomy crosswalk, not trusted here
raise ValueError(f"OBX-3 coding system {coding_system!r} is not LOINC")
value_type = scalar(segment, "obx_2") or "ST"
raw_obx5 = scalar(segment, "obx_5") or ""
numeric, text = cast_obx5(value_type, raw_obx5)
return LimsResultField(
set_id=int(scalar(segment, "obx_1") or "1"),
value_type=value_type,
analyte_loinc=loinc,
analyte_name=component(segment.obx_3, "text"),
value_numeric=numeric,
value_text=text,
units_ucum=component(segment.obx_6, "identifier"),
abnormal_flag=scalar(segment, "obx_8"),
status=status,
raw_obx5=raw_obx5,
)
def map_message(er7: str) -> list[LimsResultField]:
from hl7apy.parser import parse_message
message = parse_message(er7, find_groups=False)
obx_segments = [s for s in message.children if s.name == "OBX"]
return [map_obx(obx) for obx in obx_segments]
OBX Field-to-LIMS Contract
The mapping is fixed and testable. Each row states the source OBX field, the component read, the transform, and the destination attribute.
| OBX field | Component | Transform | LIMS attribute |
|---|---|---|---|
| OBX-1 Set ID | whole field | int() |
set_id |
| OBX-2 Value Type | whole field | passthrough; selects OBX-5 cast | value_type |
| OBX-3 Observation ID | .1 identifier |
extract; require .3 = LN |
analyte_loinc |
| OBX-3 Observation ID | .2 text |
passthrough | analyte_name |
| OBX-5 Observation Value | whole field | Decimal if OBX-2 ∈ {NM, SN}, else text |
value_numeric / value_text |
| OBX-6 Units | .1 identifier |
extract UCUM code | units_ucum |
| OBX-8 Abnormal Flags | whole field | passthrough (advisory) | abnormal_flag |
| OBX-11 Result Status | whole field | enum; gate on F/C |
status |
Verification & Testing
Prove the two rules that most often break: a numeric value type must reject non-numeric OBX-5, and a preliminary status must not present as final. Use a minimal ER7 fixture so the test is legible.
import pytest
POTASSIUM = (
"MSH|^~\\&|ANALYZER|LAB|LIS|LAB|20260709120000||ORU^R01|MSG1|P|2.5.1\r"
"OBX|1|NM|2823-3^Potassium^LN||4.30|mmol/L^^UCUM|3.5-5.1|N|||F\r"
)
def test_numeric_value_keeps_significance():
fields = map_message(POTASSIUM)
assert fields[0].analyte_loinc == "2823-3"
assert fields[0].units_ucum == "mmol/L"
assert str(fields[0].value_numeric) == "4.30" # trailing zero preserved
assert fields[0].status is ResultStatus.final
def test_numeric_type_rejects_text_value():
bad = POTASSIUM.replace("|4.30|", "|PENDING|")
with pytest.raises(ValueError, match="not numeric"):
map_message(bad)
Expected: both tests pass. test_numeric_value_keeps_significance confirms LOINC extraction, UCUM unit isolation, and Decimal significance; test_numeric_type_rejects_text_value confirms an NM-typed OBX-5 that is not numeric raises rather than silently degrading to text. A third fixture flipping OBX-11 to P should assert the caller routes the field to a hold rather than the validator.
Compliance Note
CLIA §493.1291 requires that the test report contain the result and its units, and that corrected reports be identifiable — which is why OBX-11’s C status must be carried through, not flattened into F. Preserving raw_obx5 and the original OBX-3 triplet satisfies the 21 CFR Part 11 expectation that an electronic record be attributable and reconstructable from its source, and writing those bytes to an append-only sink under the CLIA §493.1105 retention window keeps the original observation available for the required years. Refusing to trust a non-LN coding system before the terminology crosswalk resolves it prevents a local analyzer code from masquerading as a universal identifier on the patient report.
Troubleshooting
A numeric result arrives as text and the validator can't compare it.
Root cause: OBX-5 was stored as a string without consulting OBX-2, so 4.30 and >10 were both kept as text. Fix: cast in cast_obx5 keyed on the OBX-2 value type — parse NM/SN to Decimal and raise on failure, so a genuinely non-numeric value like >10 under an NM type surfaces as a mapping error to be held, not a silent text result.
The analyte identity is the whole coded triplet instead of the LOINC code.
Root cause: OBX-3 was read with .value on the field, which returns 2823-3^Potassium^LN, and stored as the test code. Fix: read the identifier component (OBX-3.1) for the LOINC code and text (OBX-3.2) for the display name, and verify name_of_coding_system (OBX-3.3) is LN before trusting it.
A preliminary result was auto-verified as final.
Root cause: OBX-11 was ignored, so a P (preliminary) value entered the validator as though it were F. Fix: parse OBX-11 into the ResultStatus enum and gate release on F or C only; route P and X to a hold. The status must travel with the value all the way to release.
Units are empty or land in the wrong field after mapping.
Root cause: OBX-6 is a coded element, and reading the whole field yields mmol/L^^UCUM; some analyzers also leave OBX-6 empty for qualitative results. Fix: extract the identifier component (OBX-6.1) for the UCUM code and let component() return None when the field is absent, so a qualitative result maps cleanly with units_ucum=None rather than an empty string.
Related
- HL7 v2 Segment Mapping — the mapping subsystem this OBX handler plugs into, alongside the MSH/PID/ORC/OBR handlers.
- HL7 v2 vs FHIR R4 for LIMS Integration — how the same OBX-carried observation looks as a FHIR R4 Observation and when to prefer each transport.
- Test Code Taxonomy & Standards — resolves a non-LOINC OBX-3 coding system before the identity is keyed.
- Converting Legacy CSV Instrument Logs to HL7 ORU Messages — produces the OBX segments this page consumes from analyzers with no native HL7.
- How to Map LOINC Codes to LIMS Test Panels — groups the LOINC-identified results into orderable panels after mapping.
Part of: HL7 v2 Segment Mapping.