hl7kit 0.2.0

Dependency-free HL7 v2.x parser and builder with byte spans for every field, repetition, component and subcomponent
Documentation
//! Imaging-order identifiers as carried by ORM^O01, OMI^O23 and ORU^R01
//! messages.
//!
//! The four fields that link an HL7 message to a DICOM study, per the IHE
//! Radiology Technical Framework (Scheduled Workflow) and the HL7 v2.5.1
//! imaging order messages:
//!
//! | Field | Candidates, in precedence order | DICOM counterpart |
//! | --- | --- | --- |
//! | Patient identifier | `PID-3.1` (first repetition) | Patient ID (0010,0020) |
//! | Accession number | `OBR-18` (Placer Field 1), then `IPC-1.1` | Accession Number (0008,0050) |
//! | Requested procedure ID | `OBR-19` (Placer Field 2), then `IPC-2.1` | Requested Procedure ID (0040,1001) |
//! | Study instance UID | `IPC-3.1`, then `ZDS-1.1`, then an `OBX` carrying DCM 110180 | Study Instance UID (0020,000D) |
//!
//! Three ways the study UID travels, and all three occur in the wild:
//!
//! * `IPC` (Imaging Procedure Control) is a standard segment of the imaging
//!   order messages (OMI^O23, HL7 2.5.1 and later): `IPC-1` is the
//!   accession identifier, `IPC-2` the requested procedure ID, `IPC-3` the
//!   Study Instance UID, `IPC-4` the scheduled procedure step ID. Several
//!   procedure steps give several `IPC` segments whose `IPC-3` must agree.
//! * `ZDS` is a vendor-defined Z-segment from the IHE profile used with
//!   ORM^O01 on older interfaces. `ZDS-1` is `uid^^Application^DICOM`; only
//!   the first component is the UID. Many sites never populate it.
//! * An `OBX` in an ORU^R01 image-availability notification whose `OBX-3`
//!   is the DICOM code `110180` (Study Instance UID, coding system `DCM`),
//!   with the UID in `OBX-5`. This is how a PACS tells the RIS that images
//!   have arrived, and it is the only place some integration engines look.
//!
//! When sources disagree, the first in precedence is used and a
//! [`Warning::ConflictingStudyUid`] is recorded on the [`Order`].
//!
//! In practice the accession number is the key most RIS and order
//! workflows actually use; the study UID is exact when present but is
//! sometimes derived or regenerated by the archive. The linkage logic that
//! consumes an [`Order`] should therefore compare both and say when they
//! disagree with each other, not only fall from one to the other.

use crate::{Message, Span, Warning};

/// DICOM code for "Study Instance UID" in coding scheme `DCM` (PS3.16).
pub const DCM_STUDY_INSTANCE_UID: &str = "110180";

/// One of the four order identifiers.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum OrderField {
    /// `PID-3.1`.
    PatientId,
    /// `OBR-18`, else `IPC-1.1`.
    Accession,
    /// `OBR-19`, else `IPC-2.1`.
    ProcedureId,
    /// `IPC-3.1`, else `ZDS-1.1`, else `OBX-5` with `OBX-3` = DCM 110180.
    StudyUid,
}

impl OrderField {
    /// All four fields, in display order.
    pub const ALL: [OrderField; 4] = [
        OrderField::PatientId,
        OrderField::Accession,
        OrderField::ProcedureId,
        OrderField::StudyUid,
    ];

    /// Candidate query paths in precedence order. The first path with a
    /// non-empty value wins. For [`OrderField::StudyUid`] the `OBX` scan
    /// (see [`StudyUidSource::Obx`]) follows these paths; it cannot be
    /// expressed as a fixed path because the matching `OBX` is found by
    /// its `OBX-3` code.
    pub fn paths(self) -> &'static [&'static str] {
        match self {
            OrderField::PatientId => &["PID-3.1"],
            OrderField::Accession => &["OBR-18", "IPC-1.1"],
            OrderField::ProcedureId => &["OBR-19", "IPC-2.1"],
            OrderField::StudyUid => &["IPC-3.1", "ZDS-1.1"],
        }
    }

    /// The first candidate path. Prefer [`OrderField::paths`].
    #[deprecated(since = "0.2.0", note = "use `paths()`; several paths are consulted")]
    pub fn path(self) -> &'static str {
        self.paths()[0]
    }

    /// Human readable label.
    pub fn label(self) -> &'static str {
        match self {
            OrderField::PatientId => "Patient ID",
            OrderField::Accession => "Accession number",
            OrderField::ProcedureId => "Requested procedure ID",
            OrderField::StudyUid => "Study instance UID",
        }
    }
}

/// Which segment supplied the study instance UID.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum StudyUidSource {
    /// `IPC-3.1`, the standard imaging-order segment.
    Ipc3,
    /// `ZDS-1.1`, the IHE vendor Z-segment.
    Zds1,
    /// `OBX-5` of an observation whose `OBX-3` is DCM 110180
    /// (Study Instance UID), as in ORU^R01 image-availability messages.
    Obx,
}

impl StudyUidSource {
    /// The path that was read.
    pub fn path(self) -> &'static str {
        match self {
            StudyUidSource::Ipc3 => "IPC-3.1",
            StudyUidSource::Zds1 => "ZDS-1.1",
            StudyUidSource::Obx => "OBX-5 (OBX-3 = 110180^Study Instance UID^DCM)",
        }
    }
}

/// The identifiers extracted from an order message.
///
/// Values are escape-decoded and whitespace-trimmed; empty values become
/// `None`. `spans` point at the raw (undecoded) text for highlighting.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Order {
    /// `PID-3.1`.
    pub patient_id: Option<String>,
    /// `OBR-18` or `IPC-1.1`.
    pub accession: Option<String>,
    /// `OBR-19` or `IPC-2.1`.
    pub procedure_id: Option<String>,
    /// `IPC-3.1`, `ZDS-1.1` or the DCM 110180 `OBX`.
    pub study_uid: Option<String>,
    /// Which segment supplied `study_uid`, when present.
    pub study_uid_source: Option<StudyUidSource>,
    /// Byte spans of the fields that were present, for highlighting. For
    /// each field this is the span of the path that matched.
    pub spans: Vec<(Span, OrderField)>,
    /// Non-fatal findings about the order, such as disagreeing study UIDs
    /// across `IPC` segments or between `IPC`/`ZDS` and an `OBX`.
    pub warnings: Vec<Warning>,
    /// The path that supplied each present field; see [`Order::source_path`].
    pub sources: Vec<(OrderField, &'static str)>,
}

impl Order {
    /// Extract the order identifiers from a message.
    pub fn extract(msg: &Message) -> Order {
        let mut order = Order::default();
        for field in OrderField::ALL {
            for path in field.paths() {
                let Some(span) = msg.get_span(path) else {
                    continue;
                };
                let value = msg.decode(span.slice(msg.raw()));
                let value = value.trim();
                if value.is_empty() {
                    continue;
                }
                order.record(field, span, path, value);
                if field == OrderField::StudyUid {
                    order.study_uid_source = Some(match *path {
                        "IPC-3.1" => StudyUidSource::Ipc3,
                        _ => StudyUidSource::Zds1,
                    });
                }
                break;
            }
        }

        // Study UIDs carried by observations: ORU^R01 image availability.
        let obx_uids = obx_study_uids(msg);
        if order.study_uid.is_none() {
            if let Some((span, value)) = obx_uids.first() {
                order.record(
                    OrderField::StudyUid,
                    *span,
                    StudyUidSource::Obx.path(),
                    value,
                );
                order.study_uid_source = Some(StudyUidSource::Obx);
            }
        }

        // Every source that carries a study UID must agree with the one
        // taken: several IPC segments, or IPC/ZDS against OBX.
        if let Some(taken) = order.study_uid.clone() {
            let taken_span = order
                .spans
                .iter()
                .find(|(_, f)| *f == OrderField::StudyUid)
                .map(|(s, _)| *s)
                .unwrap_or_default();
            let others = msg
                .segments_named("IPC")
                .filter_map(|seg| seg.field(3)?.component(1))
                .map(|c| (c.span(), msg.decode(c.value()).trim().to_string()))
                .chain(obx_uids.iter().cloned())
                .filter(|(_, v)| !v.is_empty());
            for (span, value) in others {
                if value != taken {
                    order.warnings.push(Warning::ConflictingStudyUid {
                        first: taken_span,
                        other: span,
                    });
                    break;
                }
            }
        }

        // Some sites carry the study UID in OBR-3 (Filler Order Number). If a
        // fallback is ever needed it belongs here, gated behind an explicit
        // option so the source stays auditable. Not implemented.
        order
    }

    fn record(&mut self, field: OrderField, span: Span, path: &'static str, value: &str) {
        self.spans.push((span, field));
        self.sources.push((field, path));
        let slot = match field {
            OrderField::PatientId => &mut self.patient_id,
            OrderField::Accession => &mut self.accession,
            OrderField::ProcedureId => &mut self.procedure_id,
            OrderField::StudyUid => &mut self.study_uid,
        };
        *slot = Some(value.to_string());
    }

    /// Value of one field.
    pub fn get(&self, field: OrderField) -> Option<&str> {
        match field {
            OrderField::PatientId => self.patient_id.as_deref(),
            OrderField::Accession => self.accession.as_deref(),
            OrderField::ProcedureId => self.procedure_id.as_deref(),
            OrderField::StudyUid => self.study_uid.as_deref(),
        }
    }

    /// The path that supplied `field`, when it was found.
    pub fn source_path(&self, field: OrderField) -> Option<&'static str> {
        self.sources
            .iter()
            .find(|(f, _)| *f == field)
            .map(|(_, p)| *p)
    }
}

/// `OBX-5.1` of every `OBX` whose `OBX-3` identifies the Study Instance
/// UID: code `110180` in `DCM`, or an observation text naming it. In
/// message order, with the span of the value.
fn obx_study_uids(msg: &Message) -> Vec<(Span, String)> {
    msg.segments_named("OBX")
        .filter(|seg| {
            let Some(id) = seg.field(3) else {
                return false;
            };
            let code = id
                .component(1)
                .map(|c| c.value().trim().to_string())
                .unwrap_or_default();
            let text = id
                .component(2)
                .map(|c| c.value().trim().to_ascii_lowercase())
                .unwrap_or_default();
            code == DCM_STUDY_INSTANCE_UID || text.contains("study instance uid")
        })
        .filter_map(|seg| {
            let value = seg.field(5)?.component(1)?;
            let text = msg.decode(value.value()).trim().to_string();
            (!text.is_empty()).then_some((value.span(), text))
        })
        .collect()
}