Your spectroscopy classifier works. It takes a raw spectrum, runs it through your model, and returns "Strep A Positive" with 97.3% confidence. Ship it, right?
Not even close. That classification result is sitting on your instrument workstation. The patient's chart is in Epic. The ordering physician is waiting on results in Cerner. The billing system needs a finalized result to trigger reimbursement. None of these systems know your spectrometer exists, and none of them will ever speak your instrument's native protocol.
The gap between "working classifier" and "clinically useful result" is an HL7 message. Specifically, an ORU^R01 - the observation result message that every laboratory information system in the United States has understood since the early 1990s.
This article walks through how to generate properly formatted ORU^R01 messages from spectral classification results, with working Python code and the field-level detail you need to get your messages accepted by real EHR systems.
Why HL7v2 and not FHIR
Let's address the obvious question first. FHIR (Fast Healthcare Interoperability Resources) is the modern standard. It uses JSON over REST. It has a well-designed resource model. It is, by every technical measure, superior to HL7v2.
It also does not matter for instrument integration in 2026.
Here is the reality on the ground: over 95% of US hospital laboratory information systems still use HL7v2 for instrument-to-LIS communication. The HL7v2 interface engine - typically Rhapsody, Mirth Connect, or an Epic Bridges instance - is the backbone of every hospital's lab data flow. These engines receive ORU^R01 messages from analyzers, blood gas machines, hematology instruments, and urinalysis systems. They have done so for decades.
FHIR adoption in the lab space is growing, but it is concentrated in EHR-to-EHR communication, patient portals, and third-party app access. The instrument-to-LIS channel remains overwhelmingly HL7v2. When you connect a new analyzer to a hospital's LIS, the integration team will hand you an HL7v2 interface specification document. Not a FHIR capability statement.
The pragmatic approach: build HL7v2 first, add FHIR as a secondary output format. We cover the FHIR DiagnosticReport structure at the end of this article, but the core implementation is HL7v2.
The ORU^R01 message structure
HL7v2 messages are pipe-delimited text. Each line is a segment, identified by a three-character code. Fields within a segment are separated by |, components within a field by ^, and sub-components by &. The encoding characters are defined in the MSH segment header, but virtually every implementation uses the defaults.
An ORU^R01 message for a spectral classification result needs five segments at minimum, six in practice:
| Segment | Name | Purpose |
|---|---|---|
| MSH | Message Header | Identifies the sending system, receiving system, message type, and encoding rules |
| PID | Patient Identification | The patient's MRN, name, date of birth, and sex - links the result to the correct chart |
| PV1 | Patient Visit | The encounter context - which visit this result belongs to. Critical for inpatient settings |
| OBR | Observation Request | The test that was ordered - order number, specimen information, ordering provider |
| OBX | Observation Result | The actual result. You will typically send multiple OBX segments: one for the classification result, one for the confidence score, and optionally one for spectral quality |
Here is a complete, properly formatted ORU^R01 message for an FTIR-based Strep A classification result:
MSH|^~\&|SPECTRADX|MAIN_LAB|EPIC|HOSPITAL|20260508143022||ORU^R01^ORU_R01|MSG00001|P|2.5.1|||AL|NE||UNICODE UTF-8
PID|1||MRN001234^^^HOSPITAL^MR||DOE^JANE^M||19850315|F|||123 MAIN ST^^SPRINGFIELD^IL^62704
PV1|1|O|CLINIC_A^^^^||||1234567890^SMITH^ROBERT^J^^^MD|||||||||V123456^^^HOSPITAL^VN
OBR|1|ORD98765^SPECTRADX|SPE20260508001^SPECTRADX|87880^Strep A direct antigen^CPT|||20260508142500|||||||20260508142800|THROAT^Throat swab|1234567890^SMITH^ROBERT^J^^^MD||||||20260508143022|||F
OBX|1|CE|6558-6^Streptococcus pyogenes Ag Throat^LN||10828004^Positive^SCT|||A|||F|||20260508143022
OBX|2|NM|SPECTRADX_CONF^Classification Confidence^L||97.3|%|95.0|N|||F|||20260508143022
OBX|3|NM|SPECTRADX_SNR^Spectral SNR^L||42.8||20.0|N|||F|||20260508143022
Let's break down every segment in detail.
MSH: Message Header
MSH|^~\&|SPECTRADX|MAIN_LAB|EPIC|HOSPITAL|20260508143022||ORU^R01^ORU_R01|MSG00001|P|2.5.1|||AL|NE||UNICODE UTF-8
| Field | Position | Value | Meaning |
|---|---|---|---|
| Encoding characters | MSH-2 | ^~\& | Component, repetition, escape, sub-component separators |
| Sending application | MSH-3 | SPECTRADX | Your instrument software identifier |
| Sending facility | MSH-4 | MAIN_LAB | The lab where the instrument sits |
| Receiving application | MSH-5 | EPIC | The target LIS/EHR |
| Receiving facility | MSH-6 | HOSPITAL | The institution |
| Message timestamp | MSH-7 | 20260508143022 | yyyyMMddHHmmss format |
| Message type | MSH-9 | ORU^R01^ORU_R01 | Observation result, unsolicited |
| Message control ID | MSH-10 | MSG00001 | Unique per message - must never repeat |
| Processing ID | MSH-11 | P | P = production, T = training, D = debugging |
| Version | MSH-12 | 2.5.1 | HL7 version - check what your LIS expects |
The message control ID (MSH-10) is critical. Every message you send must have a globally unique control ID. The receiving system uses this for deduplication and acknowledgment tracking. Use a UUID or a monotonically increasing sequence number with a system prefix.
PID: Patient Identification
PID|1||MRN001234^^^HOSPITAL^MR||DOE^JANE^M||19850315|F|||123 MAIN ST^^SPRINGFIELD^IL^62704
The MRN in PID-3 is a composite field: the ID number, then three empty components, then the assigning authority (which hospital system issued this MRN), then the identifier type code (MR for medical record number). Getting this wrong is the single most common cause of message rejection. The assigning authority must match exactly what the receiving system expects.
Patient name in PID-5 follows the format: LAST^FIRST^MIDDLE. Date of birth in PID-7 is yyyyMMdd. Sex in PID-8 uses the HL7 administrative sex table: F, M, O (other), U (unknown).
PV1: Patient Visit
PV1|1|O|CLINIC_A^^^^||||1234567890^SMITH^ROBERT^J^^^MD|||||||||V123456^^^HOSPITAL^VN
PV1-2 is the patient class: I (inpatient), O (outpatient), E (emergency). PV1-3 is the patient location. PV1-7 is the attending physician with their NPI. PV1-19 is the visit number - this ties the result to a specific encounter.
For point-of-care spectroscopy, you will usually be sending outpatient results. The visit number comes from the order that triggered the test.
OBR: Observation Request
OBR|1|ORD98765^SPECTRADX|SPE20260508001^SPECTRADX|87880^Strep A direct antigen^CPT|||20260508142500|||||||20260508142800|THROAT^Throat swab|1234567890^SMITH^ROBERT^J^^^MD||||||20260508143022|||F
| Field | Position | Value | Meaning |
|---|---|---|---|
| Placer order number | OBR-2 | ORD98765^SPECTRADX | The order number from the ordering system |
| Filler order number | OBR-3 | SPE20260508001^SPECTRADX | Your instrument's accession number |
| Universal service ID | OBR-4 | 87880^Strep A direct antigen^CPT | CPT code for the test |
| Observation date/time | OBR-7 | 20260508142500 | When the specimen was collected |
| Specimen received | OBR-14 | 20260508142800 | When the instrument received the specimen |
| Specimen source | OBR-15 | THROAT^Throat swab | Specimen type and source |
| Ordering provider | OBR-16 | NPI and name | Who ordered the test |
| Results date/time | OBR-22 | 20260508143022 | When results were finalized |
| Result status | OBR-25 | F | F = final, P = preliminary, C = corrected |
The placer order number (OBR-2) comes from the LIS - it is the order number assigned when the physician ordered the test. The filler order number (OBR-3) is yours to assign. Together, these two numbers create the bidirectional link between the order and the result.
OBX: Observation Result - the critical segment
This is where your spectral classification result lives. Each OBX segment carries one discrete observation.
OBX|1|CE|6558-6^Streptococcus pyogenes Ag Throat^LN||10828004^Positive^SCT|||A|||F|||20260508143022
| Field | Position | Value | Meaning |
|---|---|---|---|
| Set ID | OBX-1 | 1 | Sequential counter within this group |
| Value type | OBX-2 | CE | Coded entry - the result is a coded value |
| Observation identifier | OBX-3 | 6558-6^Streptococcus pyogenes Ag Throat^LN | LOINC code identifying what was measured |
| Observation value | OBX-5 | 10828004^Positive^SCT | SNOMED CT coded result |
| Abnormal flags | OBX-8 | A | A = abnormal (positive finding) |
| Result status | OBX-11 | F | F = final |
| Observation date | OBX-14 | 20260508143022 | When this observation was produced |
OBX-2 (Value Type) determines how OBX-5 is interpreted:
| Code | Type | When to use |
|---|---|---|
CE | Coded Entry | Categorical results: Positive, Negative, Indeterminate. Value is a code from a standard terminology (SNOMED CT, local codes) |
NM | Numeric | Confidence scores, SNR values, concentration measurements |
ST | String | Free-text results or comments. Avoid when a coded value is available |
TX | Text | Longer narrative observations |
OBX-3 (Observation Identifier) should use LOINC codes whenever possible. For microbiology identification results, common LOINC codes include:
| LOINC Code | Description |
|---|---|
6558-6 | Streptococcus pyogenes Ag, Throat |
5081-0 | Bacterial identification, Culture |
634-6 | Bacteria identified in specimen by Culture |
For novel spectroscopy-based tests that do not have established LOINC codes, use a local code with a L coding system designator: SPECTRADX_FTIR_001^FTIR Bacterial Classification^L. Work with your hospital's lab director to request new LOINC codes through Regenstrief.
OBX-8 (Abnormal Flags) values relevant to spectroscopy results:
N- Normal (e.g., negative result, confidence within expected range)A- Abnormal (e.g., positive pathogen detection)H- High (e.g., confidence score above reference range - less common)- Empty - No abnormality assessment
Confidence score as a separate OBX
OBX|2|NM|SPECTRADX_CONF^Classification Confidence^L||97.3|%|95.0|N|||F|||20260508143022
The confidence score goes in its own OBX segment with value type NM (numeric). OBX-6 carries the units (%), and OBX-7 carries the reference range. Setting the reference range to your model's validation threshold (e.g., 95.0) lets the LIS flag low-confidence results automatically.
This is important: if your classifier returns a result below your confidence threshold, you should either suppress the result entirely and flag it for manual review, or send it with result status P (preliminary) instead of F (final). Sending a low-confidence result as final is a patient safety issue.
Building ORU^R01 messages in Python
Here is a working implementation that generates ORU^R01 messages from spectral classification results. This uses manual string construction rather than a library dependency, because in production you want full control over every field.
from datetime import datetime
import uuid
def generate_message_control_id() -> str:
"""Generate a unique message control ID."""
return f"SDX{uuid.uuid4().hex[:12].upper()}"
def format_hl7_datetime(dt: datetime = None) -> str:
"""Format a datetime as HL7v2 timestamp (yyyyMMddHHmmss)."""
if dt is None:
dt = datetime.now()
return dt.strftime("%Y%m%d%H%M%S")
def generate_oru_r01(
patient_mrn: str,
patient_name: tuple[str, str, str], # (last, first, middle)
patient_dob: str, # yyyyMMdd
patient_sex: str, # F, M, O, U
visit_number: str,
ordering_provider: tuple[str, str, str, str], # (NPI, last, first, mid)
placer_order_number: str,
test_cpt: str,
test_name: str,
specimen_source: str,
specimen_collected: datetime,
result_loinc: str,
result_loinc_name: str,
result_code: str,
result_display: str,
result_code_system: str, # SCT, LN, L, etc.
abnormal_flag: str, # N, A, H, or empty
confidence: float,
confidence_threshold: float,
spectral_snr: float = None,
snr_threshold: float = None,
sending_app: str = "SPECTRADX",
sending_facility: str = "MAIN_LAB",
receiving_app: str = "LIS",
receiving_facility: str = "HOSPITAL",
assigning_authority: str = "HOSPITAL",
) -> str:
"""
Generate an HL7v2 ORU^R01 message from a spectral classification result.
Returns the complete message as a string with \\r segment terminators
(per HL7v2 spec - many systems also accept \\n).
"""
now = datetime.now()
msg_control_id = generate_message_control_id()
result_time = format_hl7_datetime(now)
specimen_time = format_hl7_datetime(specimen_collected)
# Determine result status based on confidence
result_status = "F" if confidence >= confidence_threshold else "P"
# Generate filler order number (our accession number)
filler_order = f"SPE{now.strftime('%Y%m%d')}{uuid.uuid4().hex[:4].upper()}"
last, first, middle = patient_name
prov_npi, prov_last, prov_first, prov_mid = ordering_provider
segments = []
# MSH - Message Header
segments.append(
f"MSH|^~\\&|{sending_app}|{sending_facility}"
f"|{receiving_app}|{receiving_facility}"
f"|{format_hl7_datetime(now)}"
f"||ORU^R01^ORU_R01"
f"|{msg_control_id}|P|2.5.1|||AL|NE||UNICODE UTF-8"
)
# PID - Patient Identification
segments.append(
f"PID|1||{patient_mrn}^^^{assigning_authority}^MR"
f"||{last}^{first}^{middle}"
f"||{patient_dob}|{patient_sex}"
)
# PV1 - Patient Visit
segments.append(
f"PV1|1|O|^^^^"
f"||||{prov_npi}^{prov_last}^{prov_first}^{prov_mid}^^^MD"
f"|||||||||{visit_number}^^^{assigning_authority}^VN"
)
# OBR - Observation Request
segments.append(
f"OBR|1|{placer_order_number}^{sending_app}"
f"|{filler_order}^{sending_app}"
f"|{test_cpt}^{test_name}^CPT"
f"|||{specimen_time}"
f"|||||||{specimen_time}"
f"|{specimen_source}"
f"|{prov_npi}^{prov_last}^{prov_first}^{prov_mid}^^^MD"
f"||||||{result_time}|||{result_status}"
)
# OBX-1 - Classification result (coded entry)
segments.append(
f"OBX|1|CE"
f"|{result_loinc}^{result_loinc_name}^LN"
f"||{result_code}^{result_display}^{result_code_system}"
f"|||{abnormal_flag}|||{result_status}|||{result_time}"
)
# OBX-2 - Confidence score (numeric)
conf_flag = "N" if confidence >= confidence_threshold else "L"
segments.append(
f"OBX|2|NM"
f"|SPECTRADX_CONF^Classification Confidence^L"
f"||{confidence:.1f}|%|{confidence_threshold:.1f}"
f"|{conf_flag}|||{result_status}|||{result_time}"
)
# OBX-3 - Spectral SNR (numeric, optional)
if spectral_snr is not None:
snr_flag = "N" if snr_threshold is None or spectral_snr >= snr_threshold else "L"
snr_ref = f"|{snr_threshold:.1f}" if snr_threshold else "|"
segments.append(
f"OBX|3|NM"
f"|SPECTRADX_SNR^Spectral SNR^L"
f"||{spectral_snr:.1f}|"
f"{snr_ref}"
f"|{snr_flag}|||{result_status}|||{result_time}"
)
# Join with carriage return (HL7v2 segment terminator)
return "\r".join(segments) + "\r"Usage example:
from datetime import datetime
message = generate_oru_r01(
patient_mrn="MRN001234",
patient_name=("DOE", "JANE", "M"),
patient_dob="19850315",
patient_sex="F",
visit_number="V123456",
ordering_provider=("1234567890", "SMITH", "ROBERT", "J"),
placer_order_number="ORD98765",
test_cpt="87880",
test_name="Strep A direct antigen",
specimen_source="THROAT^Throat swab",
specimen_collected=datetime(2026, 5, 8, 14, 25, 0),
result_loinc="6558-6",
result_loinc_name="Streptococcus pyogenes Ag Throat",
result_code="10828004",
result_display="Positive",
result_code_system="SCT",
abnormal_flag="A",
confidence=97.3,
confidence_threshold=95.0,
spectral_snr=42.8,
snr_threshold=20.0,
receiving_app="EPIC",
receiving_facility="HOSPITAL",
)
print(message)A few implementation notes worth emphasizing:
Segment terminators. The HL7v2 specification mandates \r (carriage return, 0x0D) as the segment terminator. Not \n, not \r\n. Many interface engines are tolerant of \n, but some are not. Use \r and you will never have a problem.
Character encoding. HL7v2 traditionally used ASCII. Modern implementations should declare UTF-8 in MSH-18 (UNICODE UTF-8). Verify with your receiving system - some older LIS installations do not handle multi-byte characters correctly in patient names.
Message control ID uniqueness. The implementation above uses a prefix plus a truncated UUID. In production, consider using a persistent counter backed by your database. The control ID must be unique across the lifetime of the interface, not just per session.
Handling result corrections and amendments
When a spectral result needs to be corrected - perhaps after manual review overrides the classifier - you send a new ORU^R01 with OBR-25 set to C (corrected) instead of F (final). The OBR-2 (placer order number) and OBR-3 (filler order number) must match the original message so the LIS can link the correction to the original result.
# For a corrected result, change result_status and resend
# OBR-25 = "C" (corrected)
# Include OBX with updated values
# Same placer and filler order numbers as the originalThe LIS will overwrite the previous result and flag it as corrected in the patient's chart. This audit trail is an FDA requirement for clinical diagnostic devices.
Testing your HL7 messages
Before you connect to a hospital's production interface engine, you need to validate your messages. Here is the testing progression we follow:
1. Structural validation. Use the HAPI HL7v2 test panel or the Mirth Connect message template validator. These tools parse your message and flag structural errors: missing required fields, invalid data types, segment ordering violations. The HAPI test panel is free, Java-based, and handles all HL7v2 versions.
2. Vocabulary validation. Verify that your LOINC codes, SNOMED CT codes, and CPT codes are valid and current. The NLM's LOINC search (https://loinc.org/search/) and the SNOMED CT browser let you confirm code validity. Using an expired or incorrect code will cause silent data quality issues - the message will be accepted, but the result will be miscategorized.
3. Integration testing with a test LIS. Most EHR vendors provide sandbox environments:
- Epic has the Epic Open Sandbox with HL7v2 interface testing capabilities
- Oracle Health (Cerner) provides Cerner Sandbox environments
- Many hospitals have a test instance of their interface engine (Mirth, Rhapsody) available for new instrument onboarding
4. Common rejection reasons. These are the errors we see most often during hospital onboarding:
| Error | Cause | Fix |
|---|---|---|
AE (Application Error) in ACK | MRN not found in target system | Verify PID-3 assigning authority matches |
AR (Application Reject) | Unknown sending application | Register MSH-3 in the interface engine's allowed senders |
| Message accepted but result not filed | OBR-4 CPT code not mapped | Work with lab director to map your test code |
| Duplicate message rejected | Non-unique MSH-10 | Fix your message control ID generator |
| Patient mismatch | Wrong MRN format | Some systems expect leading zeros, some do not |
5. ACK message handling. The receiving system responds to every ORU^R01 with an ACK message. You must parse this response:
def parse_ack(ack_message: str) -> tuple[str, str]:
"""
Parse an HL7v2 ACK message.
Returns (ack_code, error_message).
ACK codes:
AA = Application Accept (success)
AE = Application Error (message received but not processed)
AR = Application Reject (message rejected)
"""
segments = ack_message.strip().split("\r")
msa_segment = None
for seg in segments:
if seg.startswith("MSA"):
msa_segment = seg
break
if not msa_segment:
return ("UNKNOWN", "No MSA segment in ACK")
fields = msa_segment.split("|")
ack_code = fields[1] if len(fields) > 1 else "UNKNOWN"
error_msg = fields[3] if len(fields) > 3 else ""
return (ack_code, error_msg)You must implement retry logic for AE responses (transient errors) and alerting for AR responses (configuration problems that require human intervention).
Transport: TCP/IP with MLLP
HL7v2 messages are transmitted over TCP using the Minimal Lower Layer Protocol (MLLP). MLLP wraps each message with a start byte (0x0B), end byte (0x1C), and a trailing carriage return (0x0D). Every HL7v2 interface engine expects MLLP framing.
import socket
MLLP_START = b"\x0b"
MLLP_END = b"\x1c\x0d"
def send_hl7_message(
message: str,
host: str,
port: int,
timeout: float = 30.0,
) -> str:
"""
Send an HL7v2 message via MLLP and return the ACK response.
"""
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.settimeout(timeout)
sock.connect((host, port))
# Wrap message in MLLP envelope
mllp_message = MLLP_START + message.encode("utf-8") + MLLP_END
sock.sendall(mllp_message)
# Receive ACK (also MLLP-wrapped)
response = b""
while True:
chunk = sock.recv(4096)
if not chunk:
break
response += chunk
if MLLP_END[0:1] in response:
break
# Strip MLLP framing from response
ack = response.replace(MLLP_START, b"").replace(MLLP_END, b"")
return ack.decode("utf-8")In production, you need several additional layers:
- Persistent connection pool to avoid repeated TCP handshakes
- TLS encryption (most hospitals require it)
- Message queue (Redis, RabbitMQ) between your instrument software and the MLLP sender
Never send HL7 messages synchronously from the instrument UI thread - network issues should not block the clinician.
The FHIR alternative: DiagnosticReport
While HL7v2 is the pragmatic choice today, FHIR adoption is accelerating. If your target hospital uses FHIR R4 for lab results - or if you are building for a greenfield deployment - here is the equivalent structure as a FHIR DiagnosticReport resource:
{
"resourceType": "DiagnosticReport",
"id": "spectradx-ftir-001",
"status": "final",
"category": [
{
"coding": [
{
"system": "http://terminology.hl7.org/CodeSystem/v2-0074",
"code": "MB",
"display": "Microbiology"
}
]
}
],
"code": {
"coding": [
{
"system": "http://loinc.org",
"code": "6558-6",
"display": "Streptococcus pyogenes Ag [Presence] in Throat"
}
]
},
"subject": {
"reference": "Patient/mrn001234"
},
"effectiveDateTime": "2026-05-08T14:25:00Z",
"issued": "2026-05-08T14:30:22Z",
"performer": [
{
"reference": "Organization/spectradx-main-lab"
}
],
"result": [
{
"reference": "Observation/strep-a-result"
},
{
"reference": "Observation/classification-confidence"
}
]
}The referenced Observation resource for the classification result:
{
"resourceType": "Observation",
"id": "strep-a-result",
"status": "final",
"code": {
"coding": [
{
"system": "http://loinc.org",
"code": "6558-6",
"display": "Streptococcus pyogenes Ag [Presence] in Throat"
}
]
},
"valueCodeableConcept": {
"coding": [
{
"system": "http://snomed.info/sct",
"code": "10828004",
"display": "Positive"
}
]
},
"interpretation": [
{
"coding": [
{
"system": "http://terminology.hl7.org/CodeSystem/v3-ObservationInterpretation",
"code": "A",
"display": "Abnormal"
}
]
}
]
}The FHIR approach is cleaner. It is self-describing, uses standard REST APIs, and the JSON structure is straightforward to generate and validate. But you will still need the HL7v2 path for the majority of hospital integrations in 2026.
Regulatory considerations
If your spectroscopy instrument is a clinical diagnostic device (and if it is generating results that go into patient charts, it is), your HL7 output is part of your regulatory submission. For FDA 510(k) or De Novo submissions:
- The HL7 message format must be documented in your system design specification
- Message validation must be part of your verification and validation (V&V) protocol
- You must demonstrate that the message accurately represents the instrument's output - no data transformation errors between the classifier output and the HL7 message
- Corrected result handling (OBR-25 = C) must have a documented audit trail
For IVD software under the EU IVDR, the HL7 interface is part of your interoperability documentation and must be validated against the target LIS during clinical evaluation.
What we built
HL7 integration is one of those things that looks simple on paper and consumes months in practice. The message format itself is straightforward. The complexity is in the details: MRN format variations between hospitals, CPT code mapping, vocabulary management, transport reliability, correction workflows, and the sheer number of edge cases that only surface during live hospital onboarding.
SpectraDx handles HL7v2 ORU^R01 generation, MLLP transport, ACK processing, and FHIR DiagnosticReport output as built-in platform capabilities. Every spectral classification result flows through a validated message pipeline that has been tested against Epic, Oracle Health (Cerner), and MEDITECH interfaces.
If you are building spectroscopy-based diagnostics and want to skip the months of HL7 integration work, get in touch. If you are interested in how this fits into the broader clinical workflow, read Building Clinical Workflow Software for Spectroscopy-Based Diagnostics.

