diff --git a/openspec/changes/archive/2026-08-17-fix-pilot-fhir-conformance-defects/.openspec.yaml b/openspec/changes/archive/2026-08-17-fix-pilot-fhir-conformance-defects/.openspec.yaml new file mode 100644 index 000000000..149631464 --- /dev/null +++ b/openspec/changes/archive/2026-08-17-fix-pilot-fhir-conformance-defects/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-17 diff --git a/openspec/changes/archive/2026-08-17-fix-pilot-fhir-conformance-defects/design.md b/openspec/changes/archive/2026-08-17-fix-pilot-fhir-conformance-defects/design.md new file mode 100644 index 000000000..e04daed8c --- /dev/null +++ b/openspec/changes/archive/2026-08-17-fix-pilot-fhir-conformance-defects/design.md @@ -0,0 +1,119 @@ +## Context + +See `proposal.md` — Why, and the spec delta under `specs/location-composite-conversion/` for the behaviour being +contracted. + +`DatatypeConverter.toLocationFromComposite` holds the IG's component mapping in a local `int[] c` and then never +reads it — the loop indexes the composite with its own counter. The array and the method's Javadoc agree with the +IG's `datatype-pl-to-location` ConceptMap, so the mapping is already correct and only the access is wrong. +Everything else in the method (the `partOf` nesting, `mode`, the `operationalStatus` / `type` assignments) is +sound. + +The reviewed Nevada bundle is the worked example throughout: `RXA-11` is +`IZGATEWAYART^^^IZGATEWAY-AART^^^IZ GATEWAY AART TEST^^330 C ST SW UNIT 7^^UNKNOWN^VA^20201`, an `LA2`, and it +converted to `IZGATEWAYART` labelled `bd`/Bed, `IZGATEWAY-AART` labelled `lvl`/Level, no Building at all, and +`description` = `330 C ST SW UNIT 7`. + +## Goals / Non-Goals + +**Goals:** + +- Make `Location.physicalType` a fact the sending system asserted, never one the converter inferred. +- Preserve the IG mapping as the readable source of truth in the code, rather than encoding it + implicitly in loop order. +- Read components 9 and beyond according to the datatype of the field being converted. + +**Non-Goals:** + +- Re-designing `Location` conversion beyond these two defects. The nesting behaviour, `mode`, address handling + and the `operationalStatus` / `type` assignments are unchanged. +- Producing `ImmunizationEvaluation`, or moving `59781-5`, `30982-3`, `30956-7` or `30973-2` anywhere. See + proposal, Out of Scope. +- Introducing the terminology package's injectable abstractions into this call site. It uses the static + `Systems` utility today; this change stays on that path so it does not collide with the in-flight + `vocabulary-management-integration` work. + +## Decisions + +### Index the composite by the mapping array, and let a component opt out of a code + +Replace the three parallel locals with class-level constants and index by the mapping array: + +```java +private static final int[] LOC_COMPONENTS = { 2, 1, 0, 7, 6, 3 }; +private static final String[] LOC_CODES = { "bd", "ro", null, "lvl", "bu", "si" }; +private static final String[] LOC_DISPLAYS = { "Bed", "Room", null, "Level", "Building", "Site" }; +``` + +The component order is Keith's original and is left alone: it runs Bed, Room, Point of Care, Floor, +Building, Facility, which is most specific to least, so the `partOf` chain it builds nests correctly. +The `si` display stays "Site", the code system's own display for that code, rather than being changed +to "Facility" to match the source component — the value set's display is the conventional choice and +changing it buys nothing. + +A `null` code means "produce the named `Location`, emit no `physicalType`". That is how Point of Care +is handled: the IG leaves that cell unresolved — it names an extension rather than a code — and no +concept in FHIR R4's `location-physical-type` describes a point of care. `Location.physicalType` is +`0..1`, so a `Location` without one is conformant. + +*Alternatives considered.* Renumbering the code arrays so the existing `i` indexing becomes correct +would work but discards the IG mapping as documentation — the next reader would have no way to see +which component each code came from. Defining our own point-of-care extension follows the IG's stated +intent, but the IG never finished defining it, so we would be inventing a canonical and repeating the +mistake this same review flagged in our use of the core `originalText` extension. Keeping `wa`/Ward is +rejected outright: `wa` is a real `location-physical-type` code, but neither the message nor the IG +assigns it to Point of Care, so emitting it asserts a physical type the sender never sent — the same +invented-data problem the review reported, merely relabelled. + +### Components 9 and beyond move out of the shared helper + +`toLocationFromComposite` is shared by `PL`, `LA1` and `LA2`, which agree only through component 8. +The helper currently sets `description` from component 9 and `identifier` from component 10 for all +three, which is right only for `PL`: + +- `LA1`-9 is an `AD` Address, and `LA2`-9 through -16 are the address components. So today an `LA1` or + `LA2` gets its address text stuffed into `description` as well as into `Location.address`, which + `AddressParser` already parses from component 9 onward. `LA1` has no component 10 at all. +- For `PL`, `DatatypeConverter.toLocation`'s own `case "PL"` then overwrites `description` with + component 10 — the `EI` identifier — so PL-9 Location Description never reaches FHIR, and the + identifier text lands in `description` beside the parsed `identifier`. + +So the `description` and `identifier` assignments move out of the helper and into the `PL` case, and the +redundant `setDescription` line in that case goes away. `LA1` and `LA2` keep only their address, which +they already get. This is a second defect in the same method, found while contracting its behaviour, and +it is fixed here because the spec for this capability has to state which component supplies +`description` either way. + +## Risks / Trade-offs + +- **`Location` output changes for every existing consumer, not just the pilot.** `PL`, `LA1` and `LA2` + are reached by any message with a person-location field. → The proposal marks this BREAKING, and the + version it ships under says so. The change is a correction toward the IG, + so no consumer has a conformant reason to depend on the old values. +- **`Normalizer` dedupes Locations on `physicalType` + `name` + `mode`, so the number of `Location` + resources in a bundle can change.** No test asserts on `Location` at all and there are no + expected-output fixtures, but a test that counts bundle entries would move. → Run the full suite as its + own task before writing new tests, so any movement is attributed to the fix rather than to new coverage. +- **`Location.description` changes for `PL`, `LA1` and `LA2` alike.** `PL` gains PL-9 where it used to + carry PL-10's identifier text; `LA1` and `LA2` lose a `description` that duplicated their address. → + Part of the same breaking `Location` correction, so it ships under the same version. + +## Migration Plan + +No data migration and no configuration change. The library is a build-time dependency, so the change +ships by version. + +The work is carried on `2.5.2-SNAPSHOT` for pilot testing and released as `2.6.0`, because the `Location` +corrections are breaking and the project states it follows SemVer. `izgw-transform` pins +`2.5.1-SNAPSHOT`, so it has to move to `2.5.2-SNAPSHOT` to pick this up. + +`RELEASE_NOTES.md` is not edited by hand in this repository — a GitHub Actions workflow writes it when a +release is cut — so no task here touches it. + +Rollback is a dependency-version revert in the consuming service. + +## Open Questions + +- Whether the `location-physical-type` gap for a point of care is worth raising with the v2-to-FHIR IG + authors, given their own ConceptMap leaves the cell unresolved. Answering it later does not change + what we emit now, which is no code. diff --git a/openspec/changes/archive/2026-08-17-fix-pilot-fhir-conformance-defects/proposal.md b/openspec/changes/archive/2026-08-17-fix-pilot-fhir-conformance-defects/proposal.md new file mode 100644 index 000000000..6dd0a2a5b --- /dev/null +++ b/openspec/changes/archive/2026-08-17-fix-pilot-fhir-conformance-defects/proposal.md @@ -0,0 +1,79 @@ +## Why + +Independent review of a real Z42 (`RSP^K11`, evaluated history + forecast) response from the Nevada IIS test system, +for the eHealth Exchange pilot, reported "extra data not returned in the Z42: physicalType: BD Bed, lvl Level". +That is a real defect, it is not specific to Z42, and it is pre-existing — it affects every message carrying a +person-location field. + +`DatatypeConverter.toLocationFromComposite` transcribes the v2-to-FHIR IG's `datatype-pl-to-location` ConceptMap +into `int[] c = { 2, 1, 0, 7, 6, 3 }`, and the method's own Javadoc restates the same mapping — but the loop reads +the composite with the loop counter instead of `c[i]`, so the array is never used. In the reviewed bundle that puts +`bd`/Bed on `IZGATEWAYART` (LA2-1 Point of Care) and `lvl`/Level on `IZGATEWAY-AART` (LA2-4 Facility), while +`IZ GATEWAY AART TEST` (LA2-7 Building) is never read at all. Both codes the reviewer saw were invented by the +converter. PL-5 and PL-6 are also emitted twice — once as a mislabelled nested `Location`, once correctly as +`operationalStatus` and `type`. + +The same method reads components 9 and 10 as Location Description and Comprehensive Location Identifier for all +three datatypes, which holds only for `PL`. `LA1`-9 is an Address and `LA2`-9 onward are the address components, so +an `LA1` or `LA2` duplicates its address text into `Location.description` — visible in the reviewed bundle as +`"description": "330 C ST SW UNIT 7"`. For `PL`, `toLocation`'s own `case "PL"` then overwrites `description` with +component 10, the identifier, so PL-9 Location Description never reaches FHIR at all. + +## What Changes + +- **BREAKING** `Location.physicalType` is corrected to the component mapping the IG specifies: + `bd` from PL-3, `ro` from PL-2, `lvl` from PL-8, `bu` from PL-7, `si` from PL-4. Existing consumers see + different `physicalType` codes and different `Location.name` values on the same input, because both are set + from the component now read correctly. PL-5 and PL-6 stop being emitted as nested `Location` resources and are + carried only as `operationalStatus` and `type`. +- PL-1 Point of Care has no standard `physicalType` code — the IG specifies an extension there rather than a + code from `location-physical-type`. The current `wa`/Ward is an approximation with no basis in the IG, so no + code is emitted for that component. +- **BREAKING** `Location.description` and `Location.identifier` are read per datatype: from PL-9 and PL-10 for a + `PL`, and from neither for an `LA1` or `LA2`, whose components 9 and beyond are the address they already + populate. A `PL` now carries PL-9 in `description` instead of PL-10's identifier text, and an `LA1`/`LA2` no + longer carries a `description` that duplicated its address. + +## Out of Scope + +`ImmunizationEvaluation` is **not** produced. The review asked for `59781-5^Dose Validity^LN` and +`30982-3^Reason for invalid dose^LN` on an `ImmunizationEvaluation`, and an earlier draft of this change planned +exactly that. Our FHIR lead ruled it out: the resource is maturity level 0 in R4, the HL7 v2-to-FHIR spec does not +map it, and it needs substantial work in the Public Health Workgroup. It will not be supported by v2-to-FHIR until +R6 progresses, and IZ Gateway targets R4 only. All `ImmunizationEvaluation` work — the resource, `doseStatus`, +`doseStatusReason`, `targetDisease` from `30956-7`, and +`ImmunizationRecommendation.recommendation.supportingImmunization` — is therefore dropped from this change rather +than deferred to a later one. + +Two review items need no converter change: + +- `59781-5` and `30982-3` are already carried in the converted bundle as `Observation` resources, with + `Observation.code` holding the LOINC code, the value in `Observation.value[x]`, `subject` referencing the + `Patient` and `partOf` referencing the group's `Immunization`. They are absent from the reviewed bundle because + the Transformation Service's FHIR searchset filter removes resources it has not marked, not because the + converter drops them. Making them visible is an `izgw-transform` filter decision. +- `30973-2^Dose Number in Series^LN` is present in the Nevada sample and already maps to + `Immunization.protocolApplied.doseNumber[x]` — the reviewed bundle shows `"doseNumberPositiveInt": 1` on both + history groups. + +## Capabilities + +### New Capabilities +- `location-composite-conversion`: conversion of the HL7 v2 `PL`, `LA1` and `LA2` composites to `Location`, + covering which component supplies `name`, the `physicalType` code per component, the `partOf` nesting of + multiple populated components, which components are carried as `operationalStatus` and `type` instead, and + which element components 9 and beyond supply for each of the three datatypes. No existing spec covers + `Location`. + +## Impact + +- `converter/DatatypeConverter.toLocationFromComposite` and its `case "PL"` caller — the indexing fix and the + per-datatype reading of components 9 and 10. Reached by every `PL`, `LA1` and `LA2` conversion, so the blast + radius is every message with a person-location field, not only immunization messages. +- New `Location` coverage. No test asserts on any part of `Location` today, so coverage is added rather than + adjusted. +- `Normalizer` dedupes Locations on `physicalType` + `name` + `mode`, so the number of `Location` resources in a + bundle can change for the same input. +- Consumers of the emitted bundle see changed `Location` content: different `physicalType` codes, a `Location` + per component that the message actually populated, and no `description` on an `LA1`/`LA2`. +- No new dependency, no configuration change, and no change to any immunization resource. diff --git a/openspec/changes/archive/2026-08-17-fix-pilot-fhir-conformance-defects/specs/location-composite-conversion/spec.md b/openspec/changes/archive/2026-08-17-fix-pilot-fhir-conformance-defects/specs/location-composite-conversion/spec.md new file mode 100644 index 000000000..c9ce929e1 --- /dev/null +++ b/openspec/changes/archive/2026-08-17-fix-pilot-fhir-conformance-defects/specs/location-composite-conversion/spec.md @@ -0,0 +1,147 @@ +## Purpose + +Defines how the HL7 V2 person-location composites (`PL`, `LA1` and `LA2`) become FHIR R4 `Location` +resources — which component names each `Location`, which physical-location code each component +carries, how several populated components nest into a containment chain, and which components are +not physical locations at all — so that a consumer can trust that a `Location.physicalType` it +receives was actually asserted by the sending system. It also fixes which components 9 and beyond +supply, since `PL`, `LA1` and `LA2` agree only through component 8. + +## ADDED Requirements + +### Requirement: Physical location components carry the code the v2-to-FHIR IG assigns them + +The converter SHALL derive `Location.physicalType` from the component that the HL7 v2-to-FHIR +Implementation Guide's `datatype-pl-to-location` ConceptMap assigns to that code, using the +`http://terminology.hl7.org/CodeSystem/location-physical-type` code system: + +| Component | `physicalType` code | +| --- | --- | +| `PL`/`LA1`/`LA2`-2 Room | `ro` | +| `PL`/`LA1`/`LA2`-3 Bed | `bd` | +| `PL`/`LA1`/`LA2`-4 Facility | `si` | +| `PL`/`LA1`/`LA2`-7 Building | `bu` | +| `PL`/`LA1`/`LA2`-8 Floor | `lvl` | + +The converter SHALL NOT emit a `physicalType` code for a component the message left empty, and +SHALL NOT emit a code the message does not support. A `Location` the converter produces from one of +these components SHALL take its `name` from that same component. + +#### Scenario: Facility and building are labelled from their own components + +- **WHEN** an `RXA-11` carries + `IZGATEWAYART^^^IZGATEWAY-AART^^^IZ GATEWAY AART TEST^^330 C ST SW UNIT 7^^UNKNOWN^VA^20201` +- **THEN** a `Location` named `IZ GATEWAY AART TEST` carries `physicalType` `bu` (Building) +- **AND** a `Location` named `IZGATEWAY-AART` carries `physicalType` `si` + +#### Scenario: An empty component produces no Location + +- **WHEN** a person-location composite leaves Room, Bed and Floor empty +- **THEN** the converted bundle contains no `Location` carrying `physicalType` `ro`, `bd` or `lvl` + derived from that composite + +#### Scenario: Bed and Room are labelled from their own components + +- **WHEN** a person-location composite populates component 2 with `RM101` and component 3 with `B2` +- **THEN** a `Location` named `RM101` carries `physicalType` `ro` and a `Location` named `B2` carries + `physicalType` `bd` + +### Requirement: Point of Care carries no physical type code + +The v2-to-FHIR IG leaves the code for `PL`/`LA1`/`LA2`-1 Point of Care unresolved, naming an +extension rather than a code, and no concept in FHIR R4's `location-physical-type` code system +describes a point of care. `Location.physicalType` is `0..1`, so a `Location` without one is +conformant. The converter SHALL produce a `Location` named from the Point of Care component with no +`physicalType` element at all, rather than substituting an approximate code such as `wa`/Ward: that +code is valid in itself, but nothing in the message or the IG assigns it to this component, so +emitting it would assert a physical type the sending system never sent. + +#### Scenario: Point of Care is named but unlabelled + +- **WHEN** an `RXA-11` carries `IZGATEWAYART` in component 1 +- **THEN** the converted bundle contains a `Location` named `IZGATEWAYART` +- **AND** that `Location` has no `physicalType` element, empty or otherwise + +#### Scenario: No physical type code is substituted for a point of care + +- **WHEN** a person-location composite populates only the Point of Care component +- **THEN** no `Location` derived from that composite carries `physicalType` `wa`, `bd` or any other + code from `location-physical-type` + +### Requirement: Multiple populated components nest from most specific to least + +When a person-location composite populates more than one physical-location component, the converter +SHALL produce one `Location` per populated component and SHALL relate them through `Location.partOf`, +running from the most specific component to the least specific in the order Bed, Room, Point of Care, +Floor, Building, Facility. Each `Location` the converter produces from these components SHALL have +`mode` `instance`. + +#### Scenario: Point of care nests inside building inside facility + +- **WHEN** an `RXA-11` populates Point of Care `IZGATEWAYART`, Facility `IZGATEWAY-AART` and Building + `IZ GATEWAY AART TEST` +- **THEN** the `Location` named `IZGATEWAYART` is `partOf` the `Location` named + `IZ GATEWAY AART TEST`, which is `partOf` the `Location` named `IZGATEWAY-AART` + +#### Scenario: A single populated component produces no containment chain + +- **WHEN** a person-location composite populates only the Facility component +- **THEN** exactly one `Location` is produced from that composite and it has no `partOf` + +### Requirement: Location status and person location type are not physical locations + +The `PL`/`LA1`/`LA2`-5 Location Status and `PL`/`LA1`/`LA2`-6 Person Location Type components describe +a location's state and kind, not a place within a containment hierarchy. The converter SHALL carry +Location Status on `Location.operationalStatus` and Person Location Type on `Location.type`, each +exactly once, and SHALL NOT produce a separate `Location` resource or a `physicalType` code from +either component. + +#### Scenario: Location status is not duplicated as a nested Location + +- **WHEN** a person-location composite populates the Location Status component +- **THEN** the value appears on `Location.operationalStatus` +- **AND** no additional `Location` resource is produced from that component + +#### Scenario: Person location type is not duplicated as a nested Location + +- **WHEN** a person-location composite populates the Person Location Type component +- **THEN** the value appears on `Location.type` +- **AND** no additional `Location` resource is produced from that component + +### Requirement: Components 9 and beyond are read according to the composite's own datatype + +`PL`, `LA1` and `LA2` agree on components 1 through 8 and diverge from component 9 onward. The +converter SHALL read those components according to the datatype of the field being converted, and +SHALL NOT carry an address component as `Location.description` or as `Location.identifier`: + +| Datatype | Component | Element | +| --- | --- | --- | +| `PL` | PL-9 Location Description | `Location.description` | +| `PL` | PL-10 Comprehensive Location Identifier | `Location.identifier` | +| `LA1` | LA1-9 Address (`AD`) | `Location.address` | +| `LA2` | LA2-9 Street Address through LA2-16 Other Geographic Designation | `Location.address` | + +`LA1` and `LA2` have no Location Description component and no Comprehensive Location Identifier +component, so a `Location` converted from either SHALL have no `description` and no `identifier` +derived from a component. The `Location` a `PL` converts to SHALL take `description` from PL-9 — +not from PL-10, whose value is the identifier. + +#### Scenario: A PL carries its description and identifier + +- **WHEN** a `PL` field carries `EMERGENCY ROOM ENTRANCE` in PL-9 and `4707` in PL-10 +- **THEN** the resulting `Location` has `description` `EMERGENCY ROOM ENTRANCE` +- **AND** the resulting `Location` has an `identifier` with value `4707` + +#### Scenario: An LA2 street address is an address, not a description + +- **WHEN** an `RXA-11` carries + `IZGATEWAYART^^^IZGATEWAY-AART^^^IZ GATEWAY AART TEST^^330 C ST SW UNIT 7^^UNKNOWN^VA^20201` +- **THEN** the resulting `Location` has `address` with line `330 C ST SW UNIT 7`, city `UNKNOWN`, + state `VA` and postal code `20201` +- **AND** no `Location` produced from that field has a `description` +- **AND** no `Location` produced from that field has an `identifier` + +#### Scenario: Absent PL description and identifier are omitted + +- **WHEN** a `PL` field leaves PL-9 and PL-10 empty +- **THEN** the resulting `Location` has no `description` and no `identifier` diff --git a/openspec/changes/archive/2026-08-17-fix-pilot-fhir-conformance-defects/tasks.md b/openspec/changes/archive/2026-08-17-fix-pilot-fhir-conformance-defects/tasks.md new file mode 100644 index 000000000..952822369 --- /dev/null +++ b/openspec/changes/archive/2026-08-17-fix-pilot-fhir-conformance-defects/tasks.md @@ -0,0 +1,33 @@ +## 1. Baseline + +- [x] 1.1 Run `mvn clean test` on the unmodified branch and record the passing test count, so any later movement in `Location` counts is attributable to the fix rather than to new coverage — baseline: 24517 tests, 0 failures, 3 skipped, BUILD SUCCESS +- [x] 1.2 Confirm no existing test asserts on `Location.physicalType`, `Location.description` or `Location.identifier`, or counts bundle entries in a way the fix will move; note any that do — no test in `src/test` references `Location` at all + +## 2. Location physicalType fix + +- [x] 2.1 In `DatatypeConverter`, promote the three parallel locals in `toLocationFromComposite` to class-level constants `LOC_COMPONENTS`, `LOC_CODES`, `LOC_DISPLAYS`, keeping the existing component order (Bed, Room, Point of Care, Floor, Building, Facility) and the existing `si`/"Site" display +- [x] 2.2 Change the loop to read `ParserUtils.getComponent(comp, LOC_COMPONENTS[i])` instead of `getComponent(comp, i)` +- [x] 2.3 Set the Point of Care entry's code and display to `null`, and skip `setPhysicalType` when the code is `null`, so Point of Care yields a named `Location` with no `physicalType` element +- [x] 2.4 Update the method Javadoc so it documents the mapping as implemented and cites the IG's `datatype-pl-to-location` ConceptMap as its source +- [x] 2.5 Verify PL-5 and PL-6 are no longer emitted as nested `Location` resources and are still carried once each on `operationalStatus` and `type` +- [x] 2.6 Verify PL-7 Building and PL-8 Floor are now read, using the reviewed Nevada `RXA-11` as the worked example +- [x] 2.7 Move the `setDescription` and `addIdentifier` calls out of `toLocationFromComposite` into `toLocation`'s `case "PL"`, so only a `PL` reads components 9 and 10 +- [x] 2.8 In `case "PL"`, set `description` from component index 8 (PL-9) and `identifier` from index 9 (PL-10), replacing the existing `setDescription(ParserUtils.toString(comp, 9))` that overwrote the description with the identifier +- [x] 2.9 Verify an `LA1` and an `LA2` now produce no `description` and no `identifier` from components 9 and 10, and still produce the same `Location.address` as before + +## 3. Location tests + +- [x] 3.1 Add a test converting a person-location composite that populates Point of Care, Facility and Building, asserting the codes, the names, and the `partOf` chain order +- [x] 3.2 Add a test asserting a Point of Care `Location` has no `physicalType` element, empty or otherwise, and carries no code from `location-physical-type` +- [x] 3.3 Add a test asserting empty Room, Bed and Floor components produce no `Location` carrying `ro`, `bd` or `lvl` +- [x] 3.4 Add a test asserting Location Status and Person Location Type reach `operationalStatus` and `type` exactly once and produce no additional `Location` +- [x] 3.5 Add a test asserting a `PL` carries PL-9 in `description` and PL-10 in `identifier`, and that both are absent when those components are empty +- [x] 3.6 Add a test asserting an `LA2` `RXA-11` puts its street address, city, state and postal code on `Location.address` and produces no `description` and no `identifier` +- [x] 3.7 Add a test asserting an `LA1` produces its `Location.address` from LA1-9 and no `description` + +## 4. Verification and version + +- [x] 4.1 Run `mvn clean install` and compare the test count and results against the 1.1 baseline, accounting for every difference — 24529 tests, 0 failures, 3 skipped: baseline 24517 plus the 12 new Location tests +- [x] 4.2 Convert the reviewed Nevada capture and confirm each `RXA-11` yields `IZGATEWAYART` with no `physicalType`, `IZ GATEWAY AART TEST` with `bu`, and `IZGATEWAY-AART` with `si`, that no `bd` or `lvl` remains, and that no `Location` carries `330 C ST SW UNIT 7` as a `description` — confirmed, and the address stays on the Point of Care Location +- [x] 4.3 Settle the version the breaking `Location` corrections ship under — pom moved to `2.5.2-SNAPSHOT` for pilot testing, to be released as `2.6.0`. `RELEASE_NOTES.md` is written by the release workflow, not by hand, so it is not touched here +- [x] 4.4 Move `izgw-transform`'s `v2tofhir` dependency from `2.5.1-SNAPSHOT` to `2.5.2-SNAPSHOT` so the service picks this up (separate repo) — `izgw-transform/pom.xml:111` now pins `2.5.2-SNAPSHOT`, verified end to end by converting the Nevada Z42 through the service diff --git a/openspec/specs/location-composite-conversion/spec.md b/openspec/specs/location-composite-conversion/spec.md new file mode 100644 index 000000000..487dfd29c --- /dev/null +++ b/openspec/specs/location-composite-conversion/spec.md @@ -0,0 +1,147 @@ +## Purpose + +Defines how the HL7 V2 person-location composites (`PL`, `LA1` and `LA2`) become FHIR R4 `Location` +resources — which component names each `Location`, which physical-location code each component +carries, how several populated components nest into a containment chain, and which components are +not physical locations at all — so that a consumer can trust that a `Location.physicalType` it +receives was actually asserted by the sending system. It also fixes which components 9 and beyond +supply, since `PL`, `LA1` and `LA2` agree only through component 8. + +## Requirements + +### Requirement: Physical location components carry the code the v2-to-FHIR IG assigns them + +The converter SHALL derive `Location.physicalType` from the component that the HL7 v2-to-FHIR +Implementation Guide's `datatype-pl-to-location` ConceptMap assigns to that code, using the +`http://terminology.hl7.org/CodeSystem/location-physical-type` code system: + +| Component | `physicalType` code | +| --- | --- | +| `PL`/`LA1`/`LA2`-2 Room | `ro` | +| `PL`/`LA1`/`LA2`-3 Bed | `bd` | +| `PL`/`LA1`/`LA2`-4 Facility | `si` | +| `PL`/`LA1`/`LA2`-7 Building | `bu` | +| `PL`/`LA1`/`LA2`-8 Floor | `lvl` | + +The converter SHALL NOT emit a `physicalType` code for a component the message left empty, and +SHALL NOT emit a code the message does not support. A `Location` the converter produces from one of +these components SHALL take its `name` from that same component. + +#### Scenario: Facility and building are labelled from their own components + +- **WHEN** an `RXA-11` carries + `IZGATEWAYART^^^IZGATEWAY-AART^^^IZ GATEWAY AART TEST^^330 C ST SW UNIT 7^^UNKNOWN^VA^20201` +- **THEN** a `Location` named `IZ GATEWAY AART TEST` carries `physicalType` `bu` (Building) +- **AND** a `Location` named `IZGATEWAY-AART` carries `physicalType` `si` + +#### Scenario: An empty component produces no Location + +- **WHEN** a person-location composite leaves Room, Bed and Floor empty +- **THEN** the converted bundle contains no `Location` carrying `physicalType` `ro`, `bd` or `lvl` + derived from that composite + +#### Scenario: Bed and Room are labelled from their own components + +- **WHEN** a person-location composite populates component 2 with `RM101` and component 3 with `B2` +- **THEN** a `Location` named `RM101` carries `physicalType` `ro` and a `Location` named `B2` carries + `physicalType` `bd` + +### Requirement: Point of Care carries no physical type code + +The v2-to-FHIR IG leaves the code for `PL`/`LA1`/`LA2`-1 Point of Care unresolved, naming an +extension rather than a code, and no concept in FHIR R4's `location-physical-type` code system +describes a point of care. `Location.physicalType` is `0..1`, so a `Location` without one is +conformant. The converter SHALL produce a `Location` named from the Point of Care component with no +`physicalType` element at all, rather than substituting an approximate code such as `wa`/Ward: that +code is valid in itself, but nothing in the message or the IG assigns it to this component, so +emitting it would assert a physical type the sending system never sent. + +#### Scenario: Point of Care is named but unlabelled + +- **WHEN** an `RXA-11` carries `IZGATEWAYART` in component 1 +- **THEN** the converted bundle contains a `Location` named `IZGATEWAYART` +- **AND** that `Location` has no `physicalType` element, empty or otherwise + +#### Scenario: No physical type code is substituted for a point of care + +- **WHEN** a person-location composite populates only the Point of Care component +- **THEN** no `Location` derived from that composite carries `physicalType` `wa`, `bd` or any other + code from `location-physical-type` + +### Requirement: Multiple populated components nest from most specific to least + +When a person-location composite populates more than one physical-location component, the converter +SHALL produce one `Location` per populated component and SHALL relate them through `Location.partOf`, +running from the most specific component to the least specific in the order Bed, Room, Point of Care, +Floor, Building, Facility. Each `Location` the converter produces from these components SHALL have +`mode` `instance`. + +#### Scenario: Point of care nests inside building inside facility + +- **WHEN** an `RXA-11` populates Point of Care `IZGATEWAYART`, Facility `IZGATEWAY-AART` and Building + `IZ GATEWAY AART TEST` +- **THEN** the `Location` named `IZGATEWAYART` is `partOf` the `Location` named + `IZ GATEWAY AART TEST`, which is `partOf` the `Location` named `IZGATEWAY-AART` + +#### Scenario: A single populated component produces no containment chain + +- **WHEN** a person-location composite populates only the Facility component +- **THEN** exactly one `Location` is produced from that composite and it has no `partOf` + +### Requirement: Location status and person location type are not physical locations + +The `PL`/`LA1`/`LA2`-5 Location Status and `PL`/`LA1`/`LA2`-6 Person Location Type components describe +a location's state and kind, not a place within a containment hierarchy. The converter SHALL carry +Location Status on `Location.operationalStatus` and Person Location Type on `Location.type`, each +exactly once, and SHALL NOT produce a separate `Location` resource or a `physicalType` code from +either component. + +#### Scenario: Location status is not duplicated as a nested Location + +- **WHEN** a person-location composite populates the Location Status component +- **THEN** the value appears on `Location.operationalStatus` +- **AND** no additional `Location` resource is produced from that component + +#### Scenario: Person location type is not duplicated as a nested Location + +- **WHEN** a person-location composite populates the Person Location Type component +- **THEN** the value appears on `Location.type` +- **AND** no additional `Location` resource is produced from that component + +### Requirement: Components 9 and beyond are read according to the composite's own datatype + +`PL`, `LA1` and `LA2` agree on components 1 through 8 and diverge from component 9 onward. The +converter SHALL read those components according to the datatype of the field being converted, and +SHALL NOT carry an address component as `Location.description` or as `Location.identifier`: + +| Datatype | Component | Element | +| --- | --- | --- | +| `PL` | PL-9 Location Description | `Location.description` | +| `PL` | PL-10 Comprehensive Location Identifier | `Location.identifier` | +| `LA1` | LA1-9 Address (`AD`) | `Location.address` | +| `LA2` | LA2-9 Street Address through LA2-16 Other Geographic Designation | `Location.address` | + +`LA1` and `LA2` have no Location Description component and no Comprehensive Location Identifier +component, so a `Location` converted from either SHALL have no `description` and no `identifier` +derived from a component. The `Location` a `PL` converts to SHALL take `description` from PL-9 — +not from PL-10, whose value is the identifier. + +#### Scenario: A PL carries its description and identifier + +- **WHEN** a `PL` field carries `EMERGENCY ROOM ENTRANCE` in PL-9 and `4707` in PL-10 +- **THEN** the resulting `Location` has `description` `EMERGENCY ROOM ENTRANCE` +- **AND** the resulting `Location` has an `identifier` with value `4707` + +#### Scenario: An LA2 street address is an address, not a description + +- **WHEN** an `RXA-11` carries + `IZGATEWAYART^^^IZGATEWAY-AART^^^IZ GATEWAY AART TEST^^330 C ST SW UNIT 7^^UNKNOWN^VA^20201` +- **THEN** the resulting `Location` has `address` with line `330 C ST SW UNIT 7`, city `UNKNOWN`, + state `VA` and postal code `20201` +- **AND** no `Location` produced from that field has a `description` +- **AND** no `Location` produced from that field has an `identifier` + +#### Scenario: Absent PL description and identifier are omitted + +- **WHEN** a `PL` field leaves PL-9 and PL-10 empty +- **THEN** the resulting `Location` has no `description` and no `identifier` diff --git a/pom.xml b/pom.xml index db69e79b6..bd70017c1 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ 1.13.0 v2tofhir - 2.5.1-SNAPSHOT + 2.5.2-SNAPSHOT HL7 Version 2 to FHIR Conversion This is the v2tofhir Converter core code for the IZ Gateway Transformation Service http://github.com/IZGateway/v2tofhir diff --git a/src/main/java/gov/cdc/izgw/v2tofhir/converter/DatatypeConverter.java b/src/main/java/gov/cdc/izgw/v2tofhir/converter/DatatypeConverter.java index 32a42cc39..3e5b9ef23 100644 --- a/src/main/java/gov/cdc/izgw/v2tofhir/converter/DatatypeConverter.java +++ b/src/main/java/gov/cdc/izgw/v2tofhir/converter/DatatypeConverter.java @@ -1175,7 +1175,10 @@ public static Location toLocation(Type t) { case "PL": location.setMode(LocationMode.INSTANCE); toLocationFromComposite(location, comp); - location.setDescription(ParserUtils.toString(comp, 9)); + // Only a PL has these. LA1-9 is an Address and LA2-9 through LA2-16 are the address + // components, both of which become Location.address below. + location.setDescription(ParserUtils.toString(comp, 8)); // PL-9 Location Description + location.addIdentifier(toIdentifier(ParserUtils.getComponent(comp, 9))); // PL-10 break; case "LA1": location.setMode(LocationMode.INSTANCE); @@ -1228,29 +1231,49 @@ public static T markDeleted(T r } /** - * Convert the bulk of a PL, LA1 or LA2 to a location. These all look almost the - * same. PL/LA1/LA2.3 - Bed HD/IS 0304 PL/LA1/LA2.2 - Room HD/IS 0303 - * PL/LA1/LA2.1 - Point Of Care HD/IS 0302 PL/LA1/LA2.8 - Floor HD/IS 0308 - * PL/LA1/LA2.7 - Building HD/IS 0307 PL/LA1/LA2.4 - Facility HD/IS - * - * PL/LA1/LA2.5 - Location Status IS O - 0306 PL/LA1/LA2.6 - Person Location - * Type IS O - 0305 - * + * The PL/LA1/LA2 components that name a physical location, most specific first, so that the + * Location resources built from them nest correctly through Location.partOf. + * @see #LOC_CODES + */ + private static final int[] LOC_COMPONENTS = { 2, 1, 0, 7, 6, 3 }; + /** + * The location-physical-type code for each component in {@link #LOC_COMPONENTS}, transcribed from + * the HL7 V2-to-FHIR IG ConceptMap datatype-pl-to-location. A null code means the component names + * a Location that carries no physicalType: FHIR R4's location-physical-type code system has no + * concept for a point of care, and the IG leaves that cell unresolved. + */ + private static final String[] LOC_CODES = { "bd", "ro", null, "lvl", "bu", "si" }; + /** The display for each code in {@link #LOC_CODES}, as the code system gives it */ + private static final String[] LOC_DISPLAYS = { "Bed", "Room", null, "Level", "Building", "Site" }; + + /** + * Convert the bulk of a PL, LA1 or LA2 to a location. These all look almost the same through + * component 8, and the HL7 V2-to-FHIR IG ConceptMap datatype-pl-to-location gives the + * location-physical-type code for each one: + * + * PL/LA1/LA2-3 Bed = bd, PL/LA1/LA2-2 Room = ro, PL/LA1/LA2-1 Point of Care = no code, + * PL/LA1/LA2-8 Floor = lvl, PL/LA1/LA2-7 Building = bu, PL/LA1/LA2-4 Facility = si. + * + * Each populated component names its own Location, and they are chained through + * Location.partOf from the most specific component to the least. + * + * PL/LA1/LA2-5 Location Status (table 0306) and PL/LA1/LA2-6 Person Location Type (table 0305) + * describe the location rather than naming a place, so they become operationalStatus and type on + * the Location itself and never a Location of their own. + * + * Components 9 and up differ between the three datatypes, so the caller reads them. + * * @param location The location * @param comp The composite to convert */ private static void toLocationFromComposite(Location location, Composite comp) { - String[] d = { "Bed", "Room", "Ward", "Level", "Building", "Site" }; - String[] n = { "bd", "ro", "wa", "lvl", "bu", "si" }; - int[] c = { 2, 1, 0, 7, 6, 3 }; Location curl = location; - for (int i = 0; i < c.length; i++) { - Type t1 = ParserUtils.getComponent(comp, i); + for (int i = 0; i < LOC_COMPONENTS.length; i++) { + Type t1 = ParserUtils.getComponent(comp, LOC_COMPONENTS[i]); if (ParserUtils.isEmpty(t1)) { continue; } - CodeableConcept cc = new CodeableConcept().addCoding(new Coding(Systems.LOCATION_TYPE, n[i], d[i])); if (curl.hasName()) { Location partOf = new Location(); @@ -1259,14 +1282,15 @@ private static void toLocationFromComposite(Location location, Composite comp) { curl = partOf; } curl.setMode(LocationMode.INSTANCE); - curl.setPhysicalType(cc); + if (LOC_CODES[i] != null) { + curl.setPhysicalType( + new CodeableConcept().addCoding(new Coding(Systems.LOCATION_TYPE, LOC_CODES[i], LOC_DISPLAYS[i]))); + } curl.setName(ParserUtils.toString(t1)); ParserUtils.toReference(curl, null, "partof"); // Update reference } location.setOperationalStatus(toCoding(ParserUtils.getComponent(comp, 4), "0306")); location.addType(toCodeableConcept(ParserUtils.getComponent(comp, 5), "0305")); - location.setDescription(ParserUtils.toString(comp, 8)); - location.addIdentifier(toIdentifier(ParserUtils.getComponent(comp, 9))); } /** diff --git a/src/test/java/test/gov/cdc/izgateway/v2tofhir/LocationCompositeTests.java b/src/test/java/test/gov/cdc/izgateway/v2tofhir/LocationCompositeTests.java new file mode 100644 index 000000000..c4708102e --- /dev/null +++ b/src/test/java/test/gov/cdc/izgateway/v2tofhir/LocationCompositeTests.java @@ -0,0 +1,235 @@ +package test.gov.cdc.izgateway.v2tofhir; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import org.hl7.fhir.r4.model.Location; +import org.hl7.fhir.r4.model.Location.LocationMode; +import org.junit.jupiter.api.Test; + +import ca.uhn.hl7v2.model.Segment; +import ca.uhn.hl7v2.model.Type; +import gov.cdc.izgw.v2tofhir.converter.DatatypeConverter; + +/** + * Tests for the conversion of the HL7 V2 person-location composites (PL, LA1 and LA2) to + * Location, covering which component names each Location, which location-physical-type code it + * carries, how several populated components nest through Location.partOf, and which element + * components 9 and beyond supply for each of the three datatypes. + * + * The physical-type codes come from the HL7 V2-to-FHIR IG ConceptMap datatype-pl-to-location: + * PL-3 Bed = bd, PL-2 Room = ro, PL-1 Point of Care = no code, PL-8 Floor = lvl, + * PL-7 Building = bu, PL-4 Facility = si. + * + * @author Audacious Inquiry + */ +class LocationCompositeTests extends TestBase { + private static final String PHYSICAL_TYPE = "http://terminology.hl7.org/CodeSystem/location-physical-type"; + /** The RXA-11 from the eHealth Exchange pilot response the conformance review was written against */ + private static final String REVIEWED_RXA_11 = + "IZGATEWAYART^^^IZGATEWAY-AART^^^IZ GATEWAY AART TEST^^330 C ST SW UNIT 7^^UNKNOWN^VA^20201"; + + /** + * The reviewed RXA-11: Point of Care, Facility and Building are populated, so each names its own + * Location and they nest from the most specific component to the least. The review reported + * bd/Bed on the Point of Care and lvl/Level on the Facility, neither of which the message sent. + */ + @Test + void testReviewedRxa11IsLabelledFromItsOwnComponents() { + List chain = chainOf(toLa2(REVIEWED_RXA_11)); + assertEquals(3, chain.size(), "One Location per populated component"); + + assertEquals(Arrays.asList("IZGATEWAYART", "IZ GATEWAY AART TEST", "IZGATEWAY-AART"), namesOf(chain), + "Point of Care, then Building, then Facility"); + assertEquals(Arrays.asList(null, "bu", "si"), codesOf(chain)); + assertFalse(codesOf(chain).contains("bd"), "Nothing sent a Bed"); + assertFalse(codesOf(chain).contains("lvl"), "Nothing sent a Floor"); + for (Location location : chain) { + assertEquals(LocationMode.INSTANCE, location.getMode()); + } + } + + /** The same three components in a PL, which is where most person locations arrive. */ + @Test + void testPointOfCareFacilityAndBuilding() { + List chain = chainOf(toPl("IZGATEWAYART^^^IZGATEWAY-AART^^^IZ GATEWAY AART TEST")); + assertEquals(Arrays.asList("IZGATEWAYART", "IZ GATEWAY AART TEST", "IZGATEWAY-AART"), namesOf(chain)); + assertEquals(Arrays.asList(null, "bu", "si"), codesOf(chain)); + } + + /** Point of Care has no code in location-physical-type, and none is invented for it. */ + @Test + void testPointOfCareCarriesNoPhysicalType() { + Location location = toLocation(toPl("IZGATEWAYART")); + assertEquals("IZGATEWAYART", location.getName()); + assertFalse(location.hasPhysicalType(), "No physicalType element, empty or otherwise"); + assertFalse(location.hasPartOf(), "A single populated component builds no containment chain"); + } + + /** Bed, Room and Floor carry their own codes, most specific first. */ + @Test + void testBedRoomAndFloor() { + List chain = chainOf(toPl("^RM101^B2^^^^^FL3")); + assertEquals(Arrays.asList("B2", "RM101", "FL3"), namesOf(chain)); + assertEquals(Arrays.asList("bd", "ro", "lvl"), codesOf(chain)); + } + + /** An empty component names nothing, so it produces no Location and no code. */ + @Test + void testEmptyComponentsProduceNoLocation() { + List chain = chainOf(toPl("IZGATEWAYART^^^IZGATEWAY-AART")); + assertEquals(2, chain.size()); + List codes = codesOf(chain); + assertFalse(codes.contains("ro"), "No Room was sent"); + assertFalse(codes.contains("bd"), "No Bed was sent"); + assertFalse(codes.contains("lvl"), "No Floor was sent"); + } + + /** + * PL-5 Location Status and PL-6 Person Location Type describe the location rather than naming a + * place, so each is carried once on the Location and neither produces one of its own. + */ + @Test + void testLocationStatusAndPersonLocationTypeAreNotLocations() { + Location location = toLocation(toPl("IZGATEWAYART^^^^A^E")); + assertEquals("A", location.getOperationalStatus().getCode(), "From PL-5"); + assertEquals(1, location.getType().size(), "PL-6 once"); + assertEquals("E", location.getTypeFirstRep().getCodingFirstRep().getCode(), "From PL-6"); + + List chain = chainOf(location); + assertEquals(1, chain.size(), "Only Point of Care names a Location"); + assertFalse(codesOf(chain).contains("A")); + assertFalse(codesOf(chain).contains("E")); + } + + /** Only a PL has a Location Description and a Comprehensive Location Identifier. */ + @Test + void testPlDescriptionAndIdentifier() { + Location location = toLocation(toPl("IZGATEWAYART^^^^^^^^EMERGENCY ROOM ENTRANCE^4707")); + assertEquals("EMERGENCY ROOM ENTRANCE", location.getDescription(), "From PL-9, not PL-10"); + assertEquals("4707", location.getIdentifierFirstRep().getValue(), "From PL-10"); + } + + /** Absent PL-9 and PL-10 produce no description and no identifier. */ + @Test + void testPlWithoutDescriptionOrIdentifier() { + Location location = toLocation(toPl("IZGATEWAYART^^^IZGATEWAY-AART")); + assertFalse(location.hasDescription()); + assertFalse(location.hasIdentifier()); + } + + /** + * LA2-9 through LA2-16 are the address, which is where they go. An LA2 has no Location + * Description component and no Comprehensive Location Identifier component, so it produces + * neither -- the review saw the street address of the reviewed RXA-11 as a description. + */ + @Test + void testLa2CarriesAnAddressAndNoDescription() { + Location location = toLocation(toLa2(REVIEWED_RXA_11)); + assertEquals("330 C ST SW UNIT 7", location.getAddress().getLine().get(0).getValue()); + assertEquals("UNKNOWN", location.getAddress().getCity()); + assertEquals("VA", location.getAddress().getState()); + assertEquals("20201", location.getAddress().getPostalCode()); + for (Location inChain : chainOf(location)) { + assertFalse(inChain.hasDescription(), "LA2-9 is a street address, not a description"); + assertFalse(inChain.hasIdentifier(), "LA2-10 is another designation, not an identifier"); + } + } + + /** LA1-9 is an Address, so it becomes Location.address and never a description. */ + @Test + void testLa1CarriesAnAddressAndNoDescription() { + Location location = toLocation(toLa1( + "IZGATEWAYART^^^IZGATEWAY-AART^^^^^123 MAIN ST&APT 2&SPRINGFIELD&IL&62701")); + assertEquals("SPRINGFIELD", location.getAddress().getCity()); + for (Location inChain : chainOf(location)) { + assertFalse(inChain.hasDescription(), "LA1-9 is an address, not a description"); + assertFalse(inChain.hasIdentifier(), "LA1 has no identifier component"); + } + } + + /** The physical type codes come from the standard code system. */ + @Test + void testPhysicalTypeUsesTheStandardCodeSystem() { + for (Location location : chainOf(toPl("^RM101^B2^FAC^^^BLDG^FL3"))) { + if (location.hasPhysicalType()) { + assertEquals(PHYSICAL_TYPE, location.getPhysicalType().getCodingFirstRep().getSystem()); + } + } + } + + /** Every named Location in a chain is an instance, not a kind */ + @Test + void testEveryLocationInAChainIsAnInstance() { + for (Location location : chainOf(toPl("POC^RM101^B2^FAC^^^BLDG^FL3"))) { + assertTrue(LocationMode.INSTANCE.equals(location.getMode()), "mode is instance"); + } + } + + private static Location toLocation(Type type) { + Location location = DatatypeConverter.toLocation(type); + assertNotNull(location, "The composite converts to a Location"); + return location; + } + + /** PL is PV1-3 Assigned Patient Location */ + private static Type toPl(String pl) { + return fieldOf("PV1|1|I|" + pl, 3); + } + + /** LA2 is RXA-11 Administered-at Location */ + private static Type toLa2(String la2) { + return fieldOf("RXA|0|1|20220810|20220810|09^Td (adult), adsorbed^CVX|0.5|mL^MilliLiter^UCUM|||" + + "01^Historical Information - Source Unspecified^NIP001|" + la2, 11); + } + + /** LA1 is RXE-8 Deliver-To Location */ + private static Type toLa1(String la1) { + return fieldOf("RXE||||||||" + la1, 8); + } + + private static Type fieldOf(String segment, int field) { + try { + Segment seg = parseSegment(segment); + Type type = seg.getField(field, 0); + assertNotNull(type); + return type; + } catch (Exception e) { + throw new AssertionError("Cannot parse " + segment, e); + } + } + + /** The Location and everything it is partOf, in order */ + private static List chainOf(Type type) { + return chainOf(toLocation(type)); + } + + private static List chainOf(Location location) { + List chain = new ArrayList<>(); + Location current = location; + while (current != null) { + chain.add(current); + current = current.hasPartOf() ? (Location) current.getPartOf().getResource() : null; + } + return chain; + } + + private static List namesOf(List chain) { + return chain.stream().map(Location::getName).toList(); + } + + /** The physicalType code of each Location in the chain, null where it carries none */ + private static List codesOf(List chain) { + List codes = new ArrayList<>(); + for (Location location : chain) { + codes.add(location.hasPhysicalType() ? location.getPhysicalType().getCodingFirstRep().getCode() : null); + } + return codes; + } +}