From 16760335d63b5e9f4c9bbbdd0c418f89929f924e Mon Sep 17 00:00:00 2001 From: Ilia Pasechnikov Date: Thu, 6 Aug 2026 00:31:08 +0900 Subject: [PATCH 1/4] feat(utils): ship the HL7v2 simulator as a workspace utility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vendors HealthSamurai/hl7v2-simulator into utils/hl7v2-simulator so workspace users can generate synthetic HL7v2 traffic without an upstream system. Closes the "make it usable outside the team" half of interbox#93. It stays a self-contained package with its own package.json and lockfile rather than joining the root install: a workspace that never simulates traffic shouldn't carry faker and friends on every `bun install`. `bun run simulator` from the root installs it and opens the UI, already pointed at the MLLP port docker-compose publishes. Packaging changes on top of upstream: - Bind the UI to loopback by default (HOST to override). Bun.serve otherwise binds 0.0.0.0, and this server has no auth — /export writes and, with clean:true, deletes files at a path taken from the request body, and /probe opens TCP connections on request. Fine on a dev box, not something to hand to the network the moment we tell customers to run it. - Resolve default paths against the package root instead of the caller's cwd, so starting from the workspace root works and state doesn't scatter. - Make the hardcoded INTERBOX MSH-5/6 overridable via RECEIVING_APP / RECEIVING_FACILITY, for stands that route on the receiver fields. - Drop --hot from `bun run ui` (kept as ui:dev); the server holds a listener and per-source actors that a reload leaks. - Move typescript from peer to dev deps so the new CI job can typecheck. Root wiring: bunfig.toml scopes `bun test` to test/ (bare `bun test` would otherwise collect the simulator's suite, which can't resolve imports without that package's own install), and CI gets a separate job for it. Also corrects two stale README claims found while documenting: `bun run bundle` is not a script (the engine bundles at boot), and the simulator's `--load` flag has no handler. --- .github/workflows/ci.yml | 18 + README.md | 28 +- bunfig.toml | 14 + package.json | 3 +- utils/hl7v2-simulator/.gitignore | 9 + utils/hl7v2-simulator/README.md | 222 + utils/hl7v2-simulator/bun.lock | 32 + .../fixtures/profile.example.json | 1227 +++ utils/hl7v2-simulator/fixtures/profile.json | 7844 +++++++++++++++++ utils/hl7v2-simulator/package.json | 24 + utils/hl7v2-simulator/src/cli.ts | 122 + utils/hl7v2-simulator/src/gen/assemble.ts | 34 + utils/hl7v2-simulator/src/gen/faults.ts | 44 + .../src/gen/grammar/message-types.ts | 259 + utils/hl7v2-simulator/src/gen/names.ts | 16 + utils/hl7v2-simulator/src/gen/rng.ts | 26 + utils/hl7v2-simulator/src/gen/row.ts | 37 + .../src/gen/sample/identity.ts | 58 + .../hl7v2-simulator/src/gen/sample/sampler.ts | 93 + utils/hl7v2-simulator/src/hl7/message.ts | 26 + utils/hl7v2-simulator/src/paths.ts | 27 + utils/hl7v2-simulator/src/profile/schema.ts | 129 + utils/hl7v2-simulator/src/send-cli.ts | 346 + utils/hl7v2-simulator/src/send/mllp.ts | 347 + .../hl7v2-simulator/src/validate/classify.ts | 21 + .../hl7v2-simulator/src/validate/selftest.ts | 65 + utils/hl7v2-simulator/test/classify.test.ts | 34 + .../test/corpus-builders.test.ts | 49 + utils/hl7v2-simulator/test/faults.test.ts | 17 + utils/hl7v2-simulator/test/generation.test.ts | 63 + utils/hl7v2-simulator/test/message.test.ts | 21 + utils/hl7v2-simulator/test/mllp.test.ts | 184 + utils/hl7v2-simulator/test/names.test.ts | 12 + utils/hl7v2-simulator/test/nte.test.ts | 46 + utils/hl7v2-simulator/test/rng.test.ts | 23 + utils/hl7v2-simulator/test/sources.test.ts | 133 + utils/hl7v2-simulator/tsconfig.json | 13 + utils/hl7v2-simulator/ui/actor.ts | 249 + utils/hl7v2-simulator/ui/bus.ts | 56 + utils/hl7v2-simulator/ui/generator.ts | 305 + utils/hl7v2-simulator/ui/page.ts | 1169 +++ utils/hl7v2-simulator/ui/server.ts | 387 + utils/hl7v2-simulator/ui/sources.ts | 264 + utils/hl7v2-simulator/ui/stream.ts | 54 + utils/hl7v2-simulator/ui/topology.ts | 743 ++ 45 files changed, 14891 insertions(+), 2 deletions(-) create mode 100644 bunfig.toml create mode 100644 utils/hl7v2-simulator/.gitignore create mode 100644 utils/hl7v2-simulator/README.md create mode 100644 utils/hl7v2-simulator/bun.lock create mode 100644 utils/hl7v2-simulator/fixtures/profile.example.json create mode 100644 utils/hl7v2-simulator/fixtures/profile.json create mode 100644 utils/hl7v2-simulator/package.json create mode 100644 utils/hl7v2-simulator/src/cli.ts create mode 100644 utils/hl7v2-simulator/src/gen/assemble.ts create mode 100644 utils/hl7v2-simulator/src/gen/faults.ts create mode 100644 utils/hl7v2-simulator/src/gen/grammar/message-types.ts create mode 100644 utils/hl7v2-simulator/src/gen/names.ts create mode 100644 utils/hl7v2-simulator/src/gen/rng.ts create mode 100644 utils/hl7v2-simulator/src/gen/row.ts create mode 100644 utils/hl7v2-simulator/src/gen/sample/identity.ts create mode 100644 utils/hl7v2-simulator/src/gen/sample/sampler.ts create mode 100644 utils/hl7v2-simulator/src/hl7/message.ts create mode 100644 utils/hl7v2-simulator/src/paths.ts create mode 100644 utils/hl7v2-simulator/src/profile/schema.ts create mode 100644 utils/hl7v2-simulator/src/send-cli.ts create mode 100644 utils/hl7v2-simulator/src/send/mllp.ts create mode 100644 utils/hl7v2-simulator/src/validate/classify.ts create mode 100644 utils/hl7v2-simulator/src/validate/selftest.ts create mode 100644 utils/hl7v2-simulator/test/classify.test.ts create mode 100644 utils/hl7v2-simulator/test/corpus-builders.test.ts create mode 100644 utils/hl7v2-simulator/test/faults.test.ts create mode 100644 utils/hl7v2-simulator/test/generation.test.ts create mode 100644 utils/hl7v2-simulator/test/message.test.ts create mode 100644 utils/hl7v2-simulator/test/mllp.test.ts create mode 100644 utils/hl7v2-simulator/test/names.test.ts create mode 100644 utils/hl7v2-simulator/test/nte.test.ts create mode 100644 utils/hl7v2-simulator/test/rng.test.ts create mode 100644 utils/hl7v2-simulator/test/sources.test.ts create mode 100644 utils/hl7v2-simulator/tsconfig.json create mode 100644 utils/hl7v2-simulator/ui/actor.ts create mode 100644 utils/hl7v2-simulator/ui/bus.ts create mode 100644 utils/hl7v2-simulator/ui/generator.ts create mode 100644 utils/hl7v2-simulator/ui/page.ts create mode 100644 utils/hl7v2-simulator/ui/server.ts create mode 100644 utils/hl7v2-simulator/ui/sources.ts create mode 100644 utils/hl7v2-simulator/ui/stream.ts create mode 100644 utils/hl7v2-simulator/ui/topology.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c24f6d3..1f0fb88 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,6 +8,24 @@ on: jobs: test: runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: oven-sh/setup-bun@v2 + - run: bun install --frozen-lockfile + - run: bun run typecheck + # Scoped to test/ by bunfig.toml, so this never reaches into + # utils/hl7v2-simulator/ — that package is checked by the job below. + - run: bun test + + # The bundled HL7v2 simulator (utils/hl7v2-simulator) is a self-contained + # package with its own lockfile, so it installs and checks separately. Keeping + # it out of the root install is deliberate: a workspace user who never touches + # the simulator shouldn't pay for faker et al. on every `bun install`. + simulator: + runs-on: ubuntu-latest + defaults: + run: + working-directory: utils/hl7v2-simulator steps: - uses: actions/checkout@v5 - uses: oven-sh/setup-bun@v2 diff --git a/README.md b/README.md index d39342d..e01d3e7 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,27 @@ activation screen there. Send HL7v2 over MLLP to `localhost:2575` and watch messages flow through to the FHIR server. +## Send test traffic + +If you don't have a real upstream to point at it yet, the bundled HL7v2 +simulator generates synthetic traffic (ADT / ORU / SIU / MDM / RDE / RAS) from +several independent sources at once — each with its own pace, MSH identity and +MRN pool, so routing and per-source error handling get exercised rather than a +single uniform stream. With the stack up: + +```bash +bun run simulator +``` + +That installs the simulator and opens its UI on http://localhost:4003, already +pointed at the MLLP port compose publishes. Click **Start all** and watch +messages land in the dashboard. + +See [`utils/hl7v2-simulator/`](utils/hl7v2-simulator/) for targets, source types, +fault injection, and the generator CLI. It's a self-contained package with its own +dependencies — deliberately outside the root install, so it costs you nothing if +you never use it. + ## Deploy on Kubernetes For a cluster deployment with the dashboard properly secured — internal ingress, @@ -111,5 +132,10 @@ Pipelines load once at engine boot; restart the engine to pick up changes. ```bash bun install bun run typecheck -bun run bundle +bun test ``` + +Tests live in `test/` — `bun test` is scoped to that directory (see +`bunfig.toml`), so a test file placed next to the code it covers will not run. +The bundled simulator under `utils/` is a separate package with its own +dependencies and its own suite; see its README. diff --git a/bunfig.toml b/bunfig.toml new file mode 100644 index 0000000..45d48be --- /dev/null +++ b/bunfig.toml @@ -0,0 +1,14 @@ +# ── Bun configuration ──────────────────────────────────────────────────────── + +[test] +# Confine `bun test` to this workspace's own tests. +# +# Left to itself, `bun test` walks the entire tree and would also collect +# utils/hl7v2-simulator/test/. That package is deliberately NOT part of the root +# install — it carries its own package.json and lockfile so a workspace user who +# never touches the simulator doesn't pay for its dependencies on every +# `bun install` — so those tests can't resolve their imports from here and fail. +# +# Run the simulator's suite in its own directory instead: +# cd utils/hl7v2-simulator && bun install && bun test +root = "test" diff --git a/package.json b/package.json index 22bb4b7..4b7d7d6 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,8 @@ "scripts": { "typecheck": "tsc --noEmit", "test": "bun test", - "interbox-cli": "interbox" + "interbox-cli": "interbox", + "simulator": "bun install --cwd utils/hl7v2-simulator && bun run --cwd utils/hl7v2-simulator ui" }, "dependencies": { "@health-samurai/interbox": "^1.0.0" diff --git a/utils/hl7v2-simulator/.gitignore b/utils/hl7v2-simulator/.gitignore new file mode 100644 index 0000000..6c2fadc --- /dev/null +++ b/utils/hl7v2-simulator/.gitignore @@ -0,0 +1,9 @@ +node_modules/ +*.log +# UI state: persisted source definitions (recreated on next start). +data/ +# Generated corpora — `--out-dir`, `--output`, and the UI's /export. +out/ +out.csv +out.jsonl +*-out/ diff --git a/utils/hl7v2-simulator/README.md b/utils/hl7v2-simulator/README.md new file mode 100644 index 0000000..c6c1560 --- /dev/null +++ b/utils/hl7v2-simulator/README.md @@ -0,0 +1,222 @@ +# HL7v2 simulator + +Synthetic HL7v2 traffic for testing an Interbox pipeline end to end — ADT, ORU, +SIU, MDM, RDE and RAS, generated from a statistical profile and pushed over MLLP. + +It is a **multi-source** simulator: it runs several independent upstream senders +at once, each with its own pace, its own TCP connection, its own MSH identity and +its own MRN pool. Downstream, they are indistinguishable from distinct real +systems — which is what you need to exercise routing, patient matching and +per-source error handling rather than a single well-behaved firehose. + +No real data is involved. Content is synthesized from aggregate distributions +plus faker-generated identities; the bundled profile ships with the repo. + +> **Not part of the root install.** This is a self-contained package with its own +> `package.json` and lockfile, so a workspace that never uses the simulator +> doesn't carry its dependencies. Install it separately, as shown below. + +## Quick start + +You need [Bun](https://bun.sh) (`curl -fsSL https://bun.sh/install | bash`). + +From the workspace root, with the dev stack already up (`docker compose up`): + +```bash +bun run simulator +``` + +That installs the simulator's dependencies and starts its UI on +**http://localhost:4003**. Open it, click **Start all**, and watch messages +arrive in the Interbox dashboard at http://localhost:3001. + +The equivalent long form, if you prefer to work inside this directory: + +```bash +cd utils/hl7v2-simulator +bun install +bun run ui +``` + +The default target is `127.0.0.1:2575` — the MLLP port the workspace's +`docker-compose.yaml` publishes, so out of the box the simulator points at your +local engine with no configuration. + +## Point it at a target + +The UI's target selector switches between the configured MLLP targets. Three +ship by default: + +| Target | Address | What | +| --- | --- | --- | +| **Engine** | `127.0.0.1:2575` | the workspace dev stack (default) | +| **Engine (alt)** | `127.0.0.1:2576` | a second local listener | +| **Mock target** | — | ACKs in-process; no network, no listener needed | + +Use **Mock target** to exercise the generator and watch the UI without anything +listening — useful for a first look, or for generating a corpus offline. + +Override the list with `TARGETS`, a comma-separated `label:host:port`: + +```bash +TARGETS="Staging:hl7.internal:2575,Local:127.0.0.1:2575" bun run ui +``` + +The first entry becomes the default selection. Individual sources can override +just the port (see the inspector panel, or `targetPort` below) while sharing the +selected host — handy when one pipeline listens on its own port. + +Other environment knobs: + +| Variable | Default | What | +| --- | --- | --- | +| `PORT` | `4003` | port the simulator UI listens on | +| `HOST` | `127.0.0.1` | interface the UI binds to — see the warning below | +| `TARGETS` | the three above | MLLP targets, `label:host:port,…` | +| `PROFILE_PATH` | this package's `fixtures/profile.json` | generator profile | +| `PROFILE_NAME` | `default` | profile label shown in the UI | +| `SOURCES_PATH` | this package's `data/sources.json` | where source definitions persist | +| `EXPORT_DIR` | this package's `batch-out/` | directory prefilled in the export form | +| `MAX_STREAM_RATE` | `1000` | per-source msg/s ceiling | +| `RECEIVING_APP` / `RECEIVING_FACILITY` | `INTERBOX` | MSH-5 / MSH-6 on generated messages | + +Path defaults resolve inside this package, so the simulator behaves the same +whether you start it from here or from the workspace root. A path you pass +explicitly is used as given — a relative one resolves against your shell's +working directory, as usual. + +> **Bind address.** The simulator listens on loopback only, because it has no +> authentication of any kind: `/export` writes — and with `clean: true`, deletes +> — files at a path taken straight from the request body, and `/probe` opens TCP +> connections on request. Setting `HOST=0.0.0.0` hands those to anyone who can +> reach the port. Do it only on a network you control, and never on a shared or +> internet-facing host. + +## Choose source types + +A source is a persisted definition driven by its own actor: + +```ts +SourceDef { id, name, type, rate, faultRate, targetPort?, msgTypes? } +``` + +`type` picks the sending application (MSH-3) and the message mix: + +| Type | MSH-3 | Message mix | +| --- | --- | --- | +| `lab` | `LAB_IF` | ORU^R01 75% · ORM^O01 10% · ADT^A08 15% | +| `clinic` | `CLINIC_EHR` | ADT^A08 55% · SIU^S12 45% | +| `hospital` | `HOSP_ADT` | ADT^A01 30% · ADT^A03 20% · ADT^A08 20% · ORU^R01 20% · MDM^T02 10% | +| `pharmacy` | `PHARM_SYS` | RDE^O11 50% · RAS^O17 35% · ADT^A08 15% | + +Add one in the UI with **Add source** — name it, pick a type, set `rate` +(messages per second) and `faultRate` (0–1, the fraction deliberately corrupted). +Or over HTTP: + +```bash +curl -X POST localhost:4003/sources \ + -H 'content-type: application/json' \ + -d '{"name":"Sunrise Lab","type":"lab","rate":5,"faultRate":0.05}' +``` + +To override the preset mix, pass `msgTypes` — an explicit, equally weighted set +drawn from `ORU^R01`, `ORM^O01`, `ADT^A01`, `ADT^A03`, `ADT^A08`, `SIU^S12`, +`MDM^T02`, `MDM^T07`, `MDM^T11`, `RDE^O01`, `RDE^O11`, `RAS^O17`: + +```bash +curl -X POST localhost:4003/sources \ + -H 'content-type: application/json' \ + -d '{"name":"ADT Only","type":"hospital","rate":2,"faultRate":0, + "msgTypes":["ADT^A01","ADT^A03"]}' +``` + +**Identity.** Each source stamps its own MSH-3 (sending application) and MSH-4 +(sending facility, from the name) and draws MRNs from its own pool under its own +assigning authority. + +**Isolation.** Every source gets its own Poisson loop, its own TCP socket and its +own seeded RNG, so streams interleave on the wire and one source failing does not +disturb the others. Fault injection is per-source via `faultRate`. + +## Faults + +`faultRate` is the fraction of messages deliberately broken before sending — +truncated segments, bad field counts, unparseable timestamps and the like. The +simulator classifies each locally, so its summary previews how the engine will +bucket them (`parse_error` / `map_error` / `data_quality`, or benign-but-valid). +Set it to `0` for a clean stream, or crank it to see the dashboard's error views +populate. + +## The two views + +| Route | View | +| --- | --- | +| `/` | **Topology** — sources around the engine hub, live traffic as moving dots, per-source inspector | +| `/classic` | Single stream — EKG-style waveform, one generator, one target | + +> `/classic` loads Alpine.js and Geist from public CDNs, so it needs internet. +> The default topology view at `/` is fully self-contained and works offline. + +## CLI + +The UI is optional — the generator and sender are usable on their own. + +```bash +# Generate and inspect (count, faultRate, seed) +bun run gen 1000 0.05 42 + +# Write a corpus: out.jsonl / out.csv, or one .hl7 file per message +bun run gen 1000 0.05 42 --output jsonl +bun run gen 500 0 42 --out-dir ./corpus --clean +bun run gen 200 0 42 --types ADT^A01,ORU^R01 # force an even mix + +# Send over MLLP to a running engine +bun run send batch --count 500 --months 3 # fixed count, MSH-7 spread over 3 months +bun run send stream --rate 20 --poisson # paced live stream until Ctrl-C +bun run send --help # per-mode flags + +bun run selftest # generate and validate against the real parser +bun test +bun run typecheck +``` + +`--out-dir` writes CR-separated `.hl7` files with no MLLP framing — the shape a +folder source ingests, for testing that path without a socket. + +## HTTP API + +| Method | Path | What | +| --- | --- | --- | +| GET · POST | `/sources` | list · create | +| PATCH · DELETE | `/sources/:id` | update · remove | +| POST | `/sources/:id/send` | one-off send from this source | +| POST | `/sources/:id/stream` | start / stop this source | +| POST | `/sources/stream-all` | start / stop every source | +| GET | `/events` | SSE — per-send ticks and counters | +| GET | `/msg-types` | message types available for hand-picking | +| GET | `/probe?port=N` | TCP reachability check for a target port | +| GET · POST | `/export` | folder-streamer state · write `.hl7` files to a directory | +| POST | `/export/stream` | start / stop trickling `.hl7` files into a directory | +| GET · POST | `/targets` | list configured targets · switch the active one | +| POST · PATCH | `/send`, `/stream/start`, `/stream/stop`, `/stream` | single-stream API behind `/classic` | +| GET | `/health` | liveness + active target | + +## What's where + +| Path | What | +| --- | --- | +| `ui/server.ts` | Bun.serve — the routes above | +| `ui/topology.ts` | Topology view — hub, source nodes, flow curves, inspector | +| `ui/sources.ts` | `SourceRegistry` — definitions, presets, persistence | +| `ui/actor.ts` | `SourceActor` — per-source loop, socket, counters | +| `ui/bus.ts` | SSE pub/sub | +| `ui/page.ts` | Classic single-stream page | +| `ui/generator.ts` | Thin wrapper around `src/gen/*` + `src/send/mllp.ts` | +| `ui/stream.ts` | Server-side Poisson loop for the classic view | +| `src/cli.ts` | CLI generator (`bun run gen`) | +| `src/send-cli.ts` | MLLP sender CLI, `batch` / `stream` (`bun run send`) | +| `src/gen/` | Message synthesis (profile-driven, no real data) | +| `src/send/mllp.ts` | MLLP transport — fire-and-forget, reliable (ACK-aware), live stream | +| `src/paths.ts` | Package-relative defaults for profile / state / export paths | +| `fixtures/` | `profile.json` (the shipped generator profile) and `profile.example.json` (a smaller one used by the tests) | +| `test/` | Unit tests, incl. `sources.test.ts` for the registry | diff --git a/utils/hl7v2-simulator/bun.lock b/utils/hl7v2-simulator/bun.lock new file mode 100644 index 0000000..710073d --- /dev/null +++ b/utils/hl7v2-simulator/bun.lock @@ -0,0 +1,32 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "hl7v2-simulator", + "dependencies": { + "@atomic-ehr/hl7v2": "^0.0.1", + "@faker-js/faker": "^10.4.0", + }, + "devDependencies": { + "@types/bun": "^1.3.14", + "typescript": "^5.9.3", + }, + }, + }, + "packages": { + "@atomic-ehr/hl7v2": ["@atomic-ehr/hl7v2@0.0.1", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-qUOYUX6lI9cdb98ThXBcCWqruFVFzG8H6NnOTlS2Rgg7kSenjxwpDEBh8pjvmhaiWAfUqY/B7W0+agmxIoJRWg=="], + + "@faker-js/faker": ["@faker-js/faker@10.5.0", "", {}, "sha512-bsxD8WLS5lIj7aaoCx1YJkktqYj5vlBUE6HWzu2Q51ksrGJ0H737ECCKlFU7Yf8Br45z9t99frBp/J7kzbMPAg=="], + + "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], + + "@types/node": ["@types/node@26.1.2", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg=="], + + "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], + } +} diff --git a/utils/hl7v2-simulator/fixtures/profile.example.json b/utils/hl7v2-simulator/fixtures/profile.example.json new file mode 100644 index 0000000..35a50a6 --- /dev/null +++ b/utils/hl7v2-simulator/fixtures/profile.example.json @@ -0,0 +1,1227 @@ +{ + "version": 1, + "minSupport": 11, + "messageTypes": [ + [ + "ORU^R01", + 0.45 + ], + [ + "ADT^A08", + 0.4 + ], + [ + "SIU^S12", + 0.15 + ] + ], + "segmentReps": { + "ORU^R01": { + "OBR": [ + [ + 1, + 0.9 + ], + [ + 2, + 0.1 + ] + ] + } + }, + "fields": { + "PID-8": { + "kind": "categorical", + "dist": [ + [ + "F", + 0.51 + ], + [ + "M", + 0.48 + ], + [ + "U", + 0.01 + ] + ] + }, + "PV1-2": { + "kind": "categorical", + "dist": [ + [ + "O", + 0.5 + ], + [ + "I", + 0.45 + ], + [ + "E", + 0.05 + ] + ] + } + }, + "panels": { + "BMP^BASIC METABOLIC PANEL^L": { + "obx": [ + "NA^SODIUM", + "K^POTASSIUM", + "CL^CHLORIDE", + "CO2^CARBON DIOXIDE", + "BUN^UREA NITROGEN", + "CRE^CREATININE", + "GLU^GLUCOSE", + "CA^CALCIUM" + ], + "values": { + "NA^SODIUM": { + "kind": "numeric", + "units": "mmol/L", + "ref": "136-145", + "mean": 140, + "sd": 2.5, + "min": 120, + "max": 160, + "abnormalRate": 0.05 + }, + "K^POTASSIUM": { + "kind": "numeric", + "units": "mmol/L", + "ref": "3.5-5.1", + "mean": 4.2, + "sd": 0.4, + "min": 2.5, + "max": 6.5, + "abnormalRate": 0.06 + }, + "CL^CHLORIDE": { + "kind": "numeric", + "units": "mmol/L", + "ref": "98-107", + "mean": 102, + "sd": 2.5, + "min": 85, + "max": 120, + "abnormalRate": 0.05 + }, + "CO2^CARBON DIOXIDE": { + "kind": "numeric", + "units": "mmol/L", + "ref": "21-31", + "mean": 26, + "sd": 2.5, + "min": 10, + "max": 40, + "abnormalRate": 0.05 + }, + "BUN^UREA NITROGEN": { + "kind": "numeric", + "units": "mg/dL", + "ref": "7-25", + "mean": 15, + "sd": 5, + "min": 3, + "max": 80, + "abnormalRate": 0.1 + }, + "CRE^CREATININE": { + "kind": "numeric", + "units": "mg/dL", + "ref": "0.6-1.2", + "mean": 0.9, + "sd": 0.25, + "min": 0.3, + "max": 5, + "abnormalRate": 0.08 + }, + "GLU^GLUCOSE": { + "kind": "numeric", + "units": "mg/dL", + "ref": "71-99", + "mean": 95, + "sd": 25, + "min": 40, + "max": 400, + "abnormalRate": 0.2 + }, + "CA^CALCIUM": { + "kind": "numeric", + "units": "mg/dL", + "ref": "8.6-10.3", + "mean": 9.4, + "sd": 0.5, + "min": 6, + "max": 13, + "abnormalRate": 0.05 + } + } + }, + "CBC^COMPLETE BLOOD COUNT^L": { + "obx": [ + "WBC^WHITE BLOOD CELL", + "RBC^RED BLOOD CELL", + "HGB^HEMOGLOBIN", + "HCT^HEMATOCRIT", + "PLT^PLATELET" + ], + "values": { + "WBC^WHITE BLOOD CELL": { + "kind": "numeric", + "units": "10*3/uL", + "ref": "4.0-11.0", + "mean": 7, + "sd": 2, + "min": 1, + "max": 30, + "abnormalRate": 0.1 + }, + "RBC^RED BLOOD CELL": { + "kind": "numeric", + "units": "10*6/uL", + "ref": "4.2-5.9", + "mean": 4.8, + "sd": 0.5, + "min": 2, + "max": 7, + "abnormalRate": 0.08 + }, + "HGB^HEMOGLOBIN": { + "kind": "numeric", + "units": "g/dL", + "ref": "13.5-17.5", + "mean": 14.5, + "sd": 1.5, + "min": 5, + "max": 20, + "abnormalRate": 0.1 + }, + "HCT^HEMATOCRIT": { + "kind": "numeric", + "units": "%", + "ref": "41-50", + "mean": 43, + "sd": 4, + "min": 20, + "max": 60, + "abnormalRate": 0.1 + }, + "PLT^PLATELET": { + "kind": "numeric", + "units": "10*3/uL", + "ref": "150-400", + "mean": 250, + "sd": 60, + "min": 20, + "max": 700, + "abnormalRate": 0.08 + } + } + } + }, + "panelMix": [ + [ + "BMP^BASIC METABOLIC PANEL^L", + 0.6 + ], + [ + "CBC^COMPLETE BLOOD COUNT^L", + 0.4 + ] + ], + "catalogs": { + "facility": [ + [ + "NORTHGATE LABORATORY", + 0.45 + ], + [ + "CEDAR VALLEY HEALTH", + 0.35 + ], + [ + "RIVERSIDE DIAGNOSTICS", + 0.2 + ] + ], + "assigningAuthority": [ + [ + "NORTHGATE_EHR", + 0.5 + ], + [ + "CEDAR_MRN", + 0.3 + ], + [ + "RIVERSIDE_REG", + 0.2 + ] + ], + "lis": [ + [ + "LABCORE", + 0.7 + ], + [ + "PATHSYS", + 0.3 + ] + ], + "provider": [ + [ + "1001^WELLINGTON^SARAH", + 0.3 + ], + [ + "1002^OKONKWO^DAVID", + 0.3 + ], + [ + "1003^NAKAMURA^LENA", + 0.2 + ], + [ + "1004^FAULKNER^OMAR", + 0.2 + ] + ], + "app": [ + [ + "LAB_IF", + 0.5 + ], + [ + "ADT_GW", + 0.3 + ], + [ + "SCHED_SYS", + 0.2 + ] + ] + }, + "temporal": { + "sendYearRange": [ + 2021, + 2026 + ], + "collectToResultMins": { + "mean": 63, + "sd": 35 + } + }, + "idFormats": { + "mrn": "########", + "controlId": "GEN-##########", + "placer": "PL#########", + "filler": "FL#########", + "visit": "V#########" + }, + "codeMap": [ + { + "local": { + "code": "LC0O79H", + "system": "", + "text": "HPV HIGH RISK DNA WITH HPV GENOTYPES 16 AND 18" + }, + "loinc": null, + "value": { + "kind": "coded", + "valueType": "TX", + "dist": [ + [ + "Canceled", + 1 + ] + ] + }, + "weight": 0.026 + }, + { + "local": { + "code": "87402", + "system": "", + "text": "PSA TOTAL" + }, + "loinc": { + "code": "2857-1", + "text": "PSA SerPl-mCnc", + "system": "LN" + }, + "value": { + "kind": "numeric", + "units": "ng/mL", + "ref": "No established reference range.", + "mean": 10, + "sd": 1, + "min": 10, + "max": 10, + "abnormalRate": 0.15 + }, + "weight": 0.013 + }, + { + "local": { + "code": "58494", + "system": "", + "text": "PSA FREE" + }, + "loinc": { + "code": "10886-0", + "text": "PSA Free SerPl-mCnc", + "system": "LN" + }, + "value": { + "kind": "numeric", + "units": "ng/mL", + "ref": "", + "mean": 2, + "sd": 1, + "min": 2, + "max": 2, + "abnormalRate": 0.15 + }, + "weight": 0.013 + }, + { + "local": { + "code": "59753", + "system": "", + "text": "% FREE PSA" + }, + "loinc": { + "code": "12841-3", + "text": "PSA Free MFr SerPl", + "system": "LN" + }, + "value": { + "kind": "numeric", + "units": "%", + "ref": ">25", + "mean": 20, + "sd": 1, + "min": 20, + "max": 20, + "abnormalRate": 0.15 + }, + "weight": 0.013 + }, + { + "local": { + "code": "LCKDJ", + "system": "", + "text": "Hematology Consult Interpretation" + }, + "loinc": null, + "value": { + "kind": "coded", + "valueType": "CE", + "dist": [ + [ + "[result removed]", + 1 + ] + ] + }, + "weight": 0.013 + }, + { + "local": { + "code": "89094", + "system": "", + "text": "DEXAMETHASONE" + }, + "loinc": { + "code": "14062-4", + "text": "Dexamethasone SerPl-mCnc", + "system": "LN" + }, + "value": { + "kind": "coded", + "valueType": "ST", + "dist": [ + [ + "18", + 1 + ] + ] + }, + "weight": 0.013 + }, + { + "local": { + "code": "48722", + "system": "", + "text": "SHIGA TOXIN EIA" + }, + "loinc": { + "code": "21262-1", + "text": "E coli SXT Stl Ql IA", + "system": "LN" + }, + "value": { + "kind": "coded", + "valueType": "ST", + "dist": [ + [ + "In Progress", + 0.5 + ], + [ + "Positive", + 0.5 + ] + ] + }, + "weight": 0.026 + }, + { + "local": { + "code": "LCEM83", + "system": "", + "text": "CBC WITH DIFFERENTIAL" + }, + "loinc": null, + "value": { + "kind": "coded", + "valueType": "TX", + "dist": [ + [ + "Canceled", + 1 + ] + ] + }, + "weight": 0.013 + }, + { + "local": { + "code": "87611", + "system": "", + "text": "PROTIME" + }, + "loinc": { + "code": "5902-2", + "text": "Prothrombin time", + "system": "LN" + }, + "value": { + "kind": "numeric", + "units": "sec", + "ref": "9.0-13.0", + "mean": 56, + "sd": 1, + "min": 56, + "max": 56, + "abnormalRate": 0.15 + }, + "weight": 0.013 + }, + { + "local": { + "code": "72340", + "system": "", + "text": "INR" + }, + "loinc": { + "code": "6301-6", + "text": "INR PPP", + "system": "LN" + }, + "value": { + "kind": "numeric", + "units": "", + "ref": "0.93-1.29", + "mean": 5.74, + "sd": 1, + "min": 5.74, + "max": 5.74, + "abnormalRate": 0.15 + }, + "weight": 0.013 + }, + { + "local": { + "code": "45536", + "system": "", + "text": "HPV HIGH RISK" + }, + "loinc": { + "code": "30167-1", + "text": "HPV I/H Risk 1 DNA Cvx Ql Probe+sig amp", + "system": "LN" + }, + "value": { + "kind": "coded", + "valueType": "ST", + "dist": [ + [ + "Negative", + 1 + ] + ] + }, + "weight": 0.013 + }, + { + "local": { + "code": "7722", + "system": "", + "text": "HPV 16" + }, + "loinc": { + "code": "59263-4", + "text": "HPV16 DNA Cvx Ql Probe+sig amp", + "system": "LN" + }, + "value": { + "kind": "coded", + "valueType": "ST", + "dist": [ + [ + "Positive", + 1 + ] + ] + }, + "weight": 0.013 + }, + { + "local": { + "code": "5071", + "system": "", + "text": "HPV 18" + }, + "loinc": { + "code": "59264-2", + "text": "HPV18 DNA Cvx Ql Probe+sig amp", + "system": "LN" + }, + "value": { + "kind": "coded", + "valueType": "ST", + "dist": [ + [ + "Negative", + 1 + ] + ] + }, + "weight": 0.013 + }, + { + "local": { + "code": "1512679595", + "system": "LCLAB2", + "text": "ALDS ALDOSTERONE, S" + }, + "loinc": null, + "value": { + "kind": "coded", + "valueType": "ST", + "dist": [ + [ + "<4.0", + 1 + ] + ] + }, + "weight": 0.013 + }, + { + "local": { + "code": "5217655084", + "system": "LCLAB2", + "text": "HBA1C" + }, + "loinc": null, + "value": { + "kind": "numeric", + "units": "%", + "ref": "4.0-6.0", + "mean": 6.2, + "sd": 1, + "min": 6.2, + "max": 6.2, + "abnormalRate": 0.15 + }, + "weight": 0.013 + }, + { + "local": { + "code": "1206", + "system": "", + "text": "NEUTROPHILS C MAN (DIFF)" + }, + "loinc": { + "code": "23761-0", + "text": "Neutrophils NFr Bld Manual", + "system": "LN" + }, + "value": { + "kind": "numeric", + "units": "%", + "ref": "40-75", + "mean": 72, + "sd": 1, + "min": 72, + "max": 72, + "abnormalRate": 0.15 + }, + "weight": 0.013 + }, + { + "local": { + "code": "5671", + "system": "", + "text": "BANDS C MAN (DIFF)" + }, + "loinc": { + "code": "764-1", + "text": "Neuts Band NFr Bld Manual", + "system": "LN" + }, + "value": { + "kind": "numeric", + "units": "%", + "ref": "0-5", + "mean": 1, + "sd": 1, + "min": 1, + "max": 1, + "abnormalRate": 0.15 + }, + "weight": 0.013 + }, + { + "local": { + "code": "8289", + "system": "", + "text": "LYMPHOCYTES C MAN (DIFF)" + }, + "loinc": { + "code": "737-7", + "text": "Lymphocytes NFr Bld Manual", + "system": "LN" + }, + "value": { + "kind": "numeric", + "units": "%", + "ref": "20-45", + "mean": 20, + "sd": 1, + "min": 20, + "max": 20, + "abnormalRate": 0.15 + }, + "weight": 0.013 + }, + { + "local": { + "code": "1113", + "system": "", + "text": "MONOCYTES C MAN (DIFF)" + }, + "loinc": { + "code": "744-3", + "text": "Monocytes NFr Bld Manual", + "system": "LN" + }, + "value": { + "kind": "numeric", + "units": "%", + "ref": "3-12", + "mean": 6, + "sd": 1, + "min": 6, + "max": 6, + "abnormalRate": 0.15 + }, + "weight": 0.013 + }, + { + "local": { + "code": "7052", + "system": "", + "text": "EOSINOPHILS C MAN (DIFF)" + }, + "loinc": { + "code": "714-6", + "text": "Eosinophil NFr Bld Manual", + "system": "LN" + }, + "value": { + "kind": "numeric", + "units": "%", + "ref": "0-6", + "mean": 1, + "sd": 1, + "min": 1, + "max": 1, + "abnormalRate": 0.15 + }, + "weight": 0.013 + }, + { + "local": { + "code": "7181", + "system": "", + "text": "BASOPHILS C MAN (DIFF)" + }, + "loinc": { + "code": "707-0", + "text": "Basophils NFr Bld Manual", + "system": "LN" + }, + "value": { + "kind": "numeric", + "units": "%", + "ref": "0-2", + "mean": 0, + "sd": 1, + "min": 0, + "max": 0, + "abnormalRate": 0.15 + }, + "weight": 0.013 + }, + { + "local": { + "code": "5043", + "system": "", + "text": "ABS NEUTROPHIL CT" + }, + "loinc": { + "code": "753-4", + "text": "Neutrophils # Bld Manual", + "system": "LN" + }, + "value": { + "kind": "numeric", + "units": "K/uL", + "ref": "1.8-7.7", + "mean": 5.9, + "sd": 1, + "min": 5.9, + "max": 5.9, + "abnormalRate": 0.15 + }, + "weight": 0.013 + }, + { + "local": { + "code": "9233", + "system": "", + "text": "ABS LYMPHOCYTE CT" + }, + "loinc": { + "code": "732-8", + "text": "Lymphocytes # Bld Manual", + "system": "LN" + }, + "value": { + "kind": "numeric", + "units": "K/uL", + "ref": "1.0-4.8", + "mean": 1.6, + "sd": 1, + "min": 1.6, + "max": 1.6, + "abnormalRate": 0.15 + }, + "weight": 0.013 + }, + { + "local": { + "code": "6799", + "system": "", + "text": "ABS MONOCYTE CT" + }, + "loinc": { + "code": "743-5", + "text": "Monocytes # Bld Manual", + "system": "LN" + }, + "value": { + "kind": "numeric", + "units": "K/uL", + "ref": "0.1-1.0", + "mean": 0.5, + "sd": 1, + "min": 0.5, + "max": 0.5, + "abnormalRate": 0.15 + }, + "weight": 0.013 + }, + { + "local": { + "code": "1047", + "system": "", + "text": "ABS EOSINOPHIL CT" + }, + "loinc": { + "code": "712-0", + "text": "Eosinophil # Bld Manual", + "system": "LN" + }, + "value": { + "kind": "numeric", + "units": "K/uL", + "ref": "0.0-0.5", + "mean": 0.1, + "sd": 1, + "min": 0.1, + "max": 0.1, + "abnormalRate": 0.15 + }, + "weight": 0.013 + }, + { + "local": { + "code": "5359", + "system": "", + "text": "ABS BASOPHIL CT" + }, + "loinc": { + "code": "705-4", + "text": "Basophils # Bld Manual", + "system": "LN" + }, + "value": { + "kind": "numeric", + "units": "K/uL", + "ref": "0.0-0.2", + "mean": 0, + "sd": 1, + "min": 0, + "max": 0, + "abnormalRate": 0.15 + }, + "weight": 0.013 + }, + { + "local": { + "code": "87338", + "system": "", + "text": "SMEAR EVAL" + }, + "loinc": { + "code": "9317-9", + "text": "Platelet Bld Ql Smear", + "system": "LN" + }, + "value": { + "kind": "coded", + "valueType": "ST", + "dist": [ + [ + "[result removed]", + 1 + ] + ] + }, + "weight": 0.013 + }, + { + "local": { + "code": "79055", + "system": "", + "text": "SYPHILIS (T.PALLIDUM) IGG/IGM" + }, + "loinc": { + "code": "34147-9", + "text": "T pallidum IgG+IgM Ser Ql", + "system": "LN" + }, + "value": { + "kind": "coded", + "valueType": "ST", + "dist": [ + [ + "REACTIVE", + 1 + ] + ] + }, + "weight": 0.013 + }, + { + "local": { + "code": "LCNR7L", + "system": "", + "text": "ENA Screen" + }, + "loinc": null, + "value": { + "kind": "coded", + "valueType": "CE", + "dist": [ + [ + "[result removed]", + 1 + ] + ] + }, + "weight": 0.013 + }, + { + "local": { + "code": "LCZ6K1", + "system": "", + "text": "BF Type" + }, + "loinc": null, + "value": { + "kind": "coded", + "valueType": "TXT", + "dist": [ + [ + "Other", + 1 + ] + ] + }, + "weight": 0.013 + }, + { + "local": { + "code": "LCS6J", + "system": "", + "text": "pH BF" + }, + "loinc": null, + "value": { + "kind": "coded", + "valueType": "NUM", + "dist": [ + [ + "6.9", + 1 + ] + ] + }, + "weight": 0.013 + }, + { + "local": { + "code": "6726755", + "system": "LCLAB4", + "text": "ANA" + }, + "loinc": { + "code": "31545-7", + "text": "Nuclear IgG Ab [Presence] in Serum", + "system": "LN" + }, + "value": { + "kind": "coded", + "valueType": "ST", + "dist": [ + [ + "Negative", + 1 + ] + ] + }, + "weight": 0.013 + }, + { + "local": { + "code": "1518893", + "system": "LCLAB4", + "text": "N gonorrhoeae, DNA Probe" + }, + "loinc": { + "code": "24111-7", + "text": "Neisseria gonorrhoeae DNA [Presence] in Specimen by NAA with probe detection", + "system": "LN" + }, + "value": { + "kind": "coded", + "valueType": "ST", + "dist": [ + [ + "Not Detected", + 1 + ] + ] + }, + "weight": 0.013 + }, + { + "local": { + "code": "6684819", + "system": "LCLAB4", + "text": "Chlamydia, DNA Probe" + }, + "loinc": { + "code": "21613-5", + "text": "Chlamydia trachomatis DNA [Presence] in Specimen by NAA with probe detection", + "system": "LN" + }, + "value": { + "kind": "coded", + "valueType": "ST", + "dist": [ + [ + "Detected", + 1 + ] + ] + }, + "weight": 0.013 + }, + { + "local": { + "code": "LCM1A2POD6", + "system": "LCLAB6", + "text": "" + }, + "loinc": null, + "value": { + "kind": "coded", + "valueType": "TX", + "dist": [ + [ + "[result removed]", + 0.8 + ], + [ + "Microscopic Description", + 0.067 + ], + [ + "Clinical History", + 0.067 + ], + [ + "Gross Description", + 0.067 + ] + ] + }, + "weight": 0.372 + }, + { + "local": { + "code": "2292462980", + "system": "LCLAB8", + "text": "JAK2 V617F" + }, + "loinc": null, + "value": { + "kind": "coded", + "valueType": "ST", + "dist": [ + [ + "Detected", + 1 + ] + ] + }, + "weight": 0.013 + }, + { + "local": { + "code": "1748823727", + "system": "LCLAB8", + "text": "VARIANT ALLELE FREQUENCY (VAF) % (V617F)" + }, + "loinc": null, + "value": { + "kind": "numeric", + "units": "%", + "ref": "", + "mean": 1, + "sd": 1, + "min": 1, + "max": 1, + "abnormalRate": 0.15 + }, + "weight": 0.013 + }, + { + "local": { + "code": "4592967448", + "system": "LCLAB8", + "text": "JAK2 EXONS 12, 13, 14 (NOT V617F), 15" + }, + "loinc": null, + "value": { + "kind": "coded", + "valueType": "ST", + "dist": [ + [ + "[result removed]", + 1 + ] + ] + }, + "weight": 0.013 + }, + { + "local": { + "code": "8172660267", + "system": "LCLAB8", + "text": "VARIANT ALLELE FREQUENCY (VAF) % (EXONS 12-15)" + }, + "loinc": null, + "value": { + "kind": "numeric", + "units": "%", + "ref": "", + "mean": 1, + "sd": 1, + "min": 1, + "max": 1, + "abnormalRate": 0.15 + }, + "weight": 0.013 + }, + { + "local": { + "code": "2725081650", + "system": "LCLAB8", + "text": "JAK2 12-15 MUTATION NARRATIVE" + }, + "loinc": null, + "value": { + "kind": "coded", + "valueType": "ST", + "dist": [ + [ + "[result removed]", + 1 + ] + ] + }, + "weight": 0.103 + }, + { + "local": { + "code": "LCYXMZCWF8XO2", + "system": "", + "text": "CBC WITH AUTO DIFFERENTIAL" + }, + "loinc": null, + "value": { + "kind": "coded", + "valueType": "TX", + "dist": [ + [ + "[result removed]", + 1 + ] + ] + }, + "weight": 0.013 + } + ], + "localCodeRate": 0.4, + "mappedRate": 0.5 +} \ No newline at end of file diff --git a/utils/hl7v2-simulator/fixtures/profile.json b/utils/hl7v2-simulator/fixtures/profile.json new file mode 100644 index 0000000..4e3fd5c --- /dev/null +++ b/utils/hl7v2-simulator/fixtures/profile.json @@ -0,0 +1,7844 @@ +{ + "version": 1, + "minSupport": 1, + "messageTypes": [ + [ + "ORU^R01", + 0.050683 + ], + [ + "ORM^O01", + 0.000497 + ], + [ + "SIU^S12", + 0.049689 + ], + [ + "RAS^O17", + 0.024845 + ], + [ + "SIU^S13", + 0.024845 + ], + [ + "SIU^S16", + 0.023851 + ], + [ + "SIU^S14", + 0.049689 + ], + [ + "SIU^S15", + 0.049689 + ], + [ + "ADT^A08", + 0.049689 + ], + [ + "ADT^A31", + 0.074534 + ], + [ + "RDE^O01", + 0.024845 + ], + [ + "ADT^A28", + 0.024845 + ], + [ + "ADT^A01", + 0.049441 + ], + [ + "ADT^A03", + 0.047453 + ], + [ + "ADT^A02", + 0.042733 + ], + [ + "ADT^A12", + 0.020373 + ], + [ + "ADT^A06", + 0.024845 + ], + [ + "ADT^A07", + 0.020373 + ], + [ + "ADT^A13", + 0.049689 + ], + [ + "ADT^A05", + 0.049689 + ], + [ + "ADT^A11", + 0.049689 + ], + [ + "RDE^O11", + 0.024845 + ], + [ + "ADT^A04", + 0.049689 + ], + [ + "MDM^T02", + 0.024099 + ], + [ + "SIU^S26", + 0.024845 + ], + [ + "SIU^S23", + 0.024845 + ], + [ + "MDM^T11", + 0.024845 + ], + [ + "MDM^T07", + 0.024845 + ] + ], + "segmentReps": {}, + "fields": { + "PID-8": { + "kind": "categorical", + "dist": [ + [ + "M", + 0.44336 + ], + [ + "F", + 0.556369 + ], + [ + "U", + 0.000271 + ] + ] + }, + "PV1-2": { + "kind": "categorical", + "dist": [ + [ + "1", + 0.003653 + ], + [ + "O", + 0.45175 + ], + [ + "Outreach", + 0.000609 + ], + [ + "OP", + 0.000304 + ], + [ + "P", + 0.14825 + ], + [ + "I", + 0.271233 + ], + [ + "E", + 0.117504 + ], + [ + "R", + 0.002435 + ], + [ + "N", + 0.000304 + ], + [ + "B", + 0.003957 + ] + ] + } + }, + "panels": { + "LC0O79H^HPV HIGH RISK DNA WITH HPV GENOTYPES 16 AND 18": { + "obx": [ + "LC0O79H^HPV HIGH RISK DNA WITH HPV GENOTYPES 16 AND 18", + "45536^HPV HIGH RISK^^30167-1^HPV I/H Risk 1 DNA Cvx Ql Probe+sig amp", + "7722^HPV 16^^59263-4^HPV16 DNA Cvx Ql Probe+sig amp", + "5071^HPV 18^^59264-2^HPV18 DNA Cvx Ql Probe+sig amp" + ], + "values": { + "LC0O79H^HPV HIGH RISK DNA WITH HPV GENOTYPES 16 AND 18": { + "kind": "coded", + "valueType": "TX", + "dist": [ + [ + "Canceled", + 1 + ] + ] + }, + "45536^HPV HIGH RISK^^30167-1^HPV I/H Risk 1 DNA Cvx Ql Probe+sig amp": { + "kind": "coded", + "valueType": "ST", + "dist": [ + [ + "Negative", + 1 + ] + ] + }, + "7722^HPV 16^^59263-4^HPV16 DNA Cvx Ql Probe+sig amp": { + "kind": "coded", + "valueType": "ST", + "dist": [ + [ + "Positive", + 1 + ] + ] + }, + "5071^HPV 18^^59264-2^HPV18 DNA Cvx Ql Probe+sig amp": { + "kind": "coded", + "valueType": "ST", + "dist": [ + [ + "Negative", + 1 + ] + ] + } + } + }, + "LCP2RR^PSA, FREE AND TOTAL": { + "obx": [ + "87402^PSA TOTAL^^2857-1^PSA SerPl-mCnc", + "58494^PSA FREE^^10886-0^PSA Free SerPl-mCnc", + "59753^% FREE PSA^^12841-3^PSA Free MFr SerPl" + ], + "values": { + "87402^PSA TOTAL^^2857-1^PSA SerPl-mCnc": { + "kind": "numeric", + "units": "ng/mL", + "ref": "No established reference range.", + "mean": 10, + "sd": 0, + "min": 10, + "max": 10, + "abnormalRate": 1 + }, + "58494^PSA FREE^^10886-0^PSA Free SerPl-mCnc": { + "kind": "numeric", + "units": "ng/mL", + "ref": "", + "mean": 2, + "sd": 0, + "min": 2, + "max": 2, + "abnormalRate": 0 + }, + "59753^% FREE PSA^^12841-3^PSA Free MFr SerPl": { + "kind": "numeric", + "units": "%", + "ref": ">25", + "mean": 20, + "sd": 0, + "min": 20, + "max": 20, + "abnormalRate": 1 + } + } + }, + "LCKDJ^Hematology Consult": { + "obx": [ + "LCKDJ^Hematology Consult Interpretation" + ], + "values": { + "LCKDJ^Hematology Consult Interpretation": { + "kind": "coded", + "valueType": "CE", + "dist": [ + [ + "[result removed]", + 1 + ] + ] + } + } + }, + "LCI0YT0^DEXAMETHASONE, SERUM": { + "obx": [ + "89094^DEXAMETHASONE^^14062-4^Dexamethasone SerPl-mCnc" + ], + "values": { + "89094^DEXAMETHASONE^^14062-4^Dexamethasone SerPl-mCnc": { + "kind": "coded", + "valueType": "ST", + "dist": [ + [ + "18", + 1 + ] + ] + } + } + }, + "LCURPVB^SHIGA TOXIN EIA": { + "obx": [ + "48722^SHIGA TOXIN EIA^^21262-1^E coli SXT Stl Ql IA" + ], + "values": { + "48722^SHIGA TOXIN EIA^^21262-1^E coli SXT Stl Ql IA": { + "kind": "coded", + "valueType": "ST", + "dist": [ + [ + "In Progress", + 0.5 + ], + [ + "Positive", + 0.5 + ] + ] + } + } + }, + "LCEM83^CBC WITH DIFFERENTIAL": { + "obx": [ + "LCEM83^CBC WITH DIFFERENTIAL" + ], + "values": { + "LCEM83^CBC WITH DIFFERENTIAL": { + "kind": "coded", + "valueType": "TX", + "dist": [ + [ + "Canceled", + 1 + ] + ] + } + } + }, + "LCMZJI^PT-INR": { + "obx": [ + "87611^PROTIME^^5902-2^Prothrombin time", + "72340^INR^^6301-6^INR PPP" + ], + "values": { + "87611^PROTIME^^5902-2^Prothrombin time": { + "kind": "numeric", + "units": "sec", + "ref": "9.0-13.0", + "mean": 56, + "sd": 0, + "min": 56, + "max": 56, + "abnormalRate": 1 + }, + "72340^INR^^6301-6^INR PPP": { + "kind": "numeric", + "units": "", + "ref": "0.93-1.29", + "mean": 5.74, + "sd": 0, + "min": 5.74, + "max": 5.74, + "abnormalRate": 1 + } + } + }, + "LCP8KC^ALDOSTERONE^LCLAB1^^^": { + "obx": [ + "1512679595^ALDS ALDOSTERONE, S^LCLAB2^^^" + ], + "values": { + "1512679595^ALDS ALDOSTERONE, S^LCLAB2^^^": { + "kind": "coded", + "valueType": "ST", + "dist": [ + [ + "<4.0", + 1 + ] + ] + } + }, + "specimen": "Blood" + }, + "LCCHQ^HEMOGLOBIN A1C^LCLAB1^^^": { + "obx": [ + "5217655084^HBA1C^LCLAB2^^^" + ], + "values": { + "5217655084^HBA1C^LCLAB2^^^": { + "kind": "numeric", + "units": "%", + "ref": "4.0-6.0", + "mean": 6.2, + "sd": 0, + "min": 6.2, + "max": 6.2, + "abnormalRate": 1 + } + }, + "specimen": "Blood" + }, + "LCBIOR4^MANUAL DIFFERENTIAL - CELLAVISION": { + "obx": [ + "1206^NEUTROPHILS C MAN (DIFF)^^23761-0^Neutrophils NFr Bld Manual", + "5671^BANDS C MAN (DIFF)^^764-1^Neuts Band NFr Bld Manual", + "8289^LYMPHOCYTES C MAN (DIFF)^^737-7^Lymphocytes NFr Bld Manual", + "1113^MONOCYTES C MAN (DIFF)^^744-3^Monocytes NFr Bld Manual", + "7052^EOSINOPHILS C MAN (DIFF)^^714-6^Eosinophil NFr Bld Manual", + "7181^BASOPHILS C MAN (DIFF)^^707-0^Basophils NFr Bld Manual", + "5043^ABS NEUTROPHIL CT^^753-4^Neutrophils # Bld Manual", + "9233^ABS LYMPHOCYTE CT^^732-8^Lymphocytes # Bld Manual", + "6799^ABS MONOCYTE CT^^743-5^Monocytes # Bld Manual", + "1047^ABS EOSINOPHIL CT^^712-0^Eosinophil # Bld Manual", + "5359^ABS BASOPHIL CT^^705-4^Basophils # Bld Manual", + "87338^SMEAR EVAL^^9317-9^Platelet Bld Ql Smear" + ], + "values": { + "1206^NEUTROPHILS C MAN (DIFF)^^23761-0^Neutrophils NFr Bld Manual": { + "kind": "numeric", + "units": "%", + "ref": "40-75", + "mean": 72, + "sd": 0, + "min": 72, + "max": 72, + "abnormalRate": 0 + }, + "5671^BANDS C MAN (DIFF)^^764-1^Neuts Band NFr Bld Manual": { + "kind": "numeric", + "units": "%", + "ref": "0-5", + "mean": 1, + "sd": 0, + "min": 1, + "max": 1, + "abnormalRate": 0 + }, + "8289^LYMPHOCYTES C MAN (DIFF)^^737-7^Lymphocytes NFr Bld Manual": { + "kind": "numeric", + "units": "%", + "ref": "20-45", + "mean": 20, + "sd": 0, + "min": 20, + "max": 20, + "abnormalRate": 0 + }, + "1113^MONOCYTES C MAN (DIFF)^^744-3^Monocytes NFr Bld Manual": { + "kind": "numeric", + "units": "%", + "ref": "3-12", + "mean": 6, + "sd": 0, + "min": 6, + "max": 6, + "abnormalRate": 0 + }, + "7052^EOSINOPHILS C MAN (DIFF)^^714-6^Eosinophil NFr Bld Manual": { + "kind": "numeric", + "units": "%", + "ref": "0-6", + "mean": 1, + "sd": 0, + "min": 1, + "max": 1, + "abnormalRate": 0 + }, + "7181^BASOPHILS C MAN (DIFF)^^707-0^Basophils NFr Bld Manual": { + "kind": "numeric", + "units": "%", + "ref": "0-2", + "mean": 0, + "sd": 0, + "min": 0, + "max": 0, + "abnormalRate": 0 + }, + "5043^ABS NEUTROPHIL CT^^753-4^Neutrophils # Bld Manual": { + "kind": "numeric", + "units": "K/uL", + "ref": "1.8-7.7", + "mean": 5.9, + "sd": 0, + "min": 5.9, + "max": 5.9, + "abnormalRate": 0 + }, + "9233^ABS LYMPHOCYTE CT^^732-8^Lymphocytes # Bld Manual": { + "kind": "numeric", + "units": "K/uL", + "ref": "1.0-4.8", + "mean": 1.6, + "sd": 0, + "min": 1.6, + "max": 1.6, + "abnormalRate": 0 + }, + "6799^ABS MONOCYTE CT^^743-5^Monocytes # Bld Manual": { + "kind": "numeric", + "units": "K/uL", + "ref": "0.1-1.0", + "mean": 0.5, + "sd": 0, + "min": 0.5, + "max": 0.5, + "abnormalRate": 0 + }, + "1047^ABS EOSINOPHIL CT^^712-0^Eosinophil # Bld Manual": { + "kind": "numeric", + "units": "K/uL", + "ref": "0.0-0.5", + "mean": 0.1, + "sd": 0, + "min": 0.1, + "max": 0.1, + "abnormalRate": 0 + }, + "5359^ABS BASOPHIL CT^^705-4^Basophils # Bld Manual": { + "kind": "numeric", + "units": "K/uL", + "ref": "0.0-0.2", + "mean": 0, + "sd": 0, + "min": 0, + "max": 0, + "abnormalRate": 0 + }, + "87338^SMEAR EVAL^^9317-9^Platelet Bld Ql Smear": { + "kind": "coded", + "valueType": "ST", + "dist": [ + [ + "[result removed]", + 1 + ] + ] + } + } + }, + "LCRFTC7^SYPHILIS (T. PALLIDUM) IGG/IGM": { + "obx": [ + "79055^SYPHILIS (T.PALLIDUM) IGG/IGM^^34147-9^T pallidum IgG+IgM Ser Ql" + ], + "values": { + "79055^SYPHILIS (T.PALLIDUM) IGG/IGM^^34147-9^T pallidum IgG+IgM Ser Ql": { + "kind": "coded", + "valueType": "ST", + "dist": [ + [ + "REACTIVE", + 1 + ] + ] + } + } + }, + "LC5Q6^Extractable Nuclear Antibodies": { + "obx": [ + "LCNR7L^ENA Screen" + ], + "values": { + "LCNR7L^ENA Screen": { + "kind": "coded", + "valueType": "CE", + "dist": [ + [ + "[result removed]", + 1 + ] + ] + } + } + }, + "LCH47^pH Body Fluid": { + "obx": [ + "LCZ6K1^BF Type", + "LCS6J^pH BF" + ], + "values": { + "LCZ6K1^BF Type": { + "kind": "coded", + "valueType": "TXT", + "dist": [ + [ + "Other", + 1 + ] + ] + }, + "LCS6J^pH BF": { + "kind": "coded", + "valueType": "NUM", + "dist": [ + [ + "6.9", + 1 + ] + ] + } + } + }, + "LC8EZBZ^Antinuclear Abs w rflx Titer and Pattern^LCLAB3^29950-3^Nuclear IgG Ab [Presence] in Serum by Immunoassay^LN": { + "obx": [ + "6726755^ANA^LCLAB4^31545-7^Nuclear IgG Ab [Presence] in Serum^LN" + ], + "values": { + "6726755^ANA^LCLAB4^31545-7^Nuclear IgG Ab [Presence] in Serum^LN": { + "kind": "coded", + "valueType": "ST", + "dist": [ + [ + "Negative", + 1 + ] + ] + } + } + }, + "LCO8FDC^C. trachomatis / N. gonorrhoeae, DNA probe^LCLAB3^36902-5^Chlamydia trachomatis+Neisseria gonorrhoeae DNA [Presence] in Specimen by NAA with probe detection^LN": { + "obx": [ + "1518893^N gonorrhoeae, DNA Probe^LCLAB4^24111-7^Neisseria gonorrhoeae DNA [Presence] in Specimen by NAA with probe detection^LN", + "6684819^Chlamydia, DNA Probe^LCLAB4^21613-5^Chlamydia trachomatis DNA [Presence] in Specimen by NAA with probe detection^LN" + ], + "values": { + "1518893^N gonorrhoeae, DNA Probe^LCLAB4^24111-7^Neisseria gonorrhoeae DNA [Presence] in Specimen by NAA with probe detection^LN": { + "kind": "coded", + "valueType": "ST", + "dist": [ + [ + "Not Detected", + 1 + ] + ] + }, + "6684819^Chlamydia, DNA Probe^LCLAB4^21613-5^Chlamydia trachomatis DNA [Presence] in Specimen by NAA with probe detection^LN": { + "kind": "coded", + "valueType": "ST", + "dist": [ + [ + "Detected", + 1 + ] + ] + } + } + }, + "LCZ17F3^Surgical^LCLAB5": { + "obx": [ + "LCM1A2POD6^^LCLAB6" + ], + "values": { + "LCM1A2POD6^^LCLAB6": { + "kind": "coded", + "valueType": "TX", + "dist": [ + [ + "[result removed]", + 0.8 + ], + [ + "Microscopic Description", + 0.067 + ], + [ + "Clinical History", + 0.067 + ], + [ + "Gross Description", + 0.067 + ] + ] + } + } + }, + "LCY3OHR^JAK 2 EXONS 12-15 MUTATION ANALYSIS (INCLUDES V617F)^LCLAB7^^^": { + "obx": [ + "2725081650^JAK2 12-15 MUTATION NARRATIVE^LCLAB8^^^", + "2292462980^JAK2 V617F^LCLAB8^^^", + "1748823727^VARIANT ALLELE FREQUENCY (VAF) % (V617F)^LCLAB8^^^", + "4592967448^JAK2 EXONS 12, 13, 14 (NOT V617F), 15^LCLAB8^^^", + "8172660267^VARIANT ALLELE FREQUENCY (VAF) % (EXONS 12-15)^LCLAB8^^^" + ], + "values": { + "2725081650^JAK2 12-15 MUTATION NARRATIVE^LCLAB8^^^": { + "kind": "coded", + "valueType": "ST", + "dist": [ + [ + "[result removed]", + 1 + ] + ] + }, + "2292462980^JAK2 V617F^LCLAB8^^^": { + "kind": "coded", + "valueType": "ST", + "dist": [ + [ + "Detected", + 1 + ] + ] + }, + "1748823727^VARIANT ALLELE FREQUENCY (VAF) % (V617F)^LCLAB8^^^": { + "kind": "numeric", + "units": "%", + "ref": "", + "mean": 1, + "sd": 0, + "min": 1, + "max": 1, + "abnormalRate": 0 + }, + "4592967448^JAK2 EXONS 12, 13, 14 (NOT V617F), 15^LCLAB8^^^": { + "kind": "coded", + "valueType": "ST", + "dist": [ + [ + "[result removed]", + 1 + ] + ] + }, + "8172660267^VARIANT ALLELE FREQUENCY (VAF) % (EXONS 12-15)^LCLAB8^^^": { + "kind": "numeric", + "units": "%", + "ref": "", + "mean": 1, + "sd": 0, + "min": 1, + "max": 1, + "abnormalRate": 0 + } + }, + "specimen": "Blood" + }, + "LCYXMZCWF8XO2^CBC WITH AUTO DIFFERENTIAL^^^^": { + "obx": [ + "LCYXMZCWF8XO2^CBC WITH AUTO DIFFERENTIAL^" + ], + "values": { + "LCYXMZCWF8XO2^CBC WITH AUTO DIFFERENTIAL^": { + "kind": "coded", + "valueType": "TX", + "dist": [ + [ + "[result removed]", + 1 + ] + ] + } + }, + "specimen": "Blood" + }, + "10054^BASIC METABOLIC PANEL^LAB": { + "obx": [ + "NA^SODIUM", + "K^POTASSIUM", + "CL^CHLORIDE", + "CO2^CO2", + "AGAP^ANION GAP", + "GLUC^GLUCOSE", + "BUN^BLOOD UREA NITROGEN", + "CRET^CREATININE", + "CA^CALCIUM", + "GFRE^GLOMERULAR FILTRATION RATE" + ], + "values": { + "NA^SODIUM": { + "kind": "numeric", + "units": "mEq/L", + "ref": "136-145", + "mean": 137.71, + "sd": 9.5, + "min": 121, + "max": 140, + "abnormalRate": 0.43 + }, + "K^POTASSIUM": { + "kind": "numeric", + "units": "mEq/L", + "ref": "3.5-5.1", + "mean": 3.97, + "sd": 0.76, + "min": 3.4, + "max": 4.2, + "abnormalRate": 0.43 + }, + "CL^CHLORIDE": { + "kind": "numeric", + "units": "mEq/L", + "ref": "98-107", + "mean": 106.86, + "sd": 9.91, + "min": 94, + "max": 107, + "abnormalRate": 0.29 + }, + "CO2^CO2": { + "kind": "numeric", + "units": "mEq/L", + "ref": "21-31", + "mean": 22.71, + "sd": 3.45, + "min": 19, + "max": 27, + "abnormalRate": 0.43 + }, + "AGAP^ANION GAP": { + "kind": "numeric", + "units": "mEq/L", + "ref": "5-16", + "mean": 8.14, + "sd": 1.36, + "min": 7, + "max": 9, + "abnormalRate": 0 + }, + "GLUC^GLUCOSE": { + "kind": "numeric", + "units": "mg/dL", + "ref": "71-99", + "mean": 148, + "sd": 53.93, + "min": 82, + "max": 190, + "abnormalRate": 0.86 + }, + "BUN^BLOOD UREA NITROGEN": { + "kind": "numeric", + "units": "mg/dL", + "ref": "7-25", + "mean": 22.86, + "sd": 12.94, + "min": 9, + "max": 36, + "abnormalRate": 0.43 + }, + "CRET^CREATININE": { + "kind": "numeric", + "units": "mg/dL", + "ref": "0.6-1.2", + "mean": 0.9, + "sd": 0.46, + "min": 0.6, + "max": 0.9, + "abnormalRate": 0.14 + }, + "CA^CALCIUM": { + "kind": "numeric", + "units": "mg/dL", + "ref": "8.6-10.3", + "mean": 8.84, + "sd": 0.79, + "min": 7.4, + "max": 9.4, + "abnormalRate": 0.29 + }, + "GFRE^GLOMERULAR FILTRATION RATE": { + "kind": "numeric", + "units": "ml/min/1.73m2", + "ref": "", + "mean": 91.29, + "sd": 32.79, + "min": 34, + "max": 115, + "abnormalRate": 0 + } + } + }, + "10054^BASIC METABOLIC PANEL^LAF": { + "obx": [ + "NA^SODIUM", + "K^POTASSIUM", + "CL^CHLORIDE", + "CO2^CO2", + "AGAP^ANION GAP", + "GLUC^GLUCOSE", + "BUN^BLOOD UREA NITROGEN", + "CRET^CREATININE", + "CA^CALCIUM", + "GFRE^GLOMERULAR FILTRATION RATE" + ], + "values": { + "NA^SODIUM": { + "kind": "numeric", + "units": "mEq/L", + "ref": "136-145", + "mean": 134.6, + "sd": 4.41, + "min": 126, + "max": 137, + "abnormalRate": 0.4 + }, + "K^POTASSIUM": { + "kind": "numeric", + "units": "mEq/L", + "ref": "3.5-5.1", + "mean": 4.06, + "sd": 0.37, + "min": 3.4, + "max": 4.4, + "abnormalRate": 0.2 + }, + "CL^CHLORIDE": { + "kind": "numeric", + "units": "mEq/L", + "ref": "98-107", + "mean": 99.8, + "sd": 5.53, + "min": 90, + "max": 102, + "abnormalRate": 0.2 + }, + "CO2^CO2": { + "kind": "numeric", + "units": "mEq/L", + "ref": "21-31", + "mean": 24.4, + "sd": 2.94, + "min": 20, + "max": 26, + "abnormalRate": 0.2 + }, + "AGAP^ANION GAP": { + "kind": "numeric", + "units": "mEq/L", + "ref": "5-16", + "mean": 10.4, + "sd": 2.42, + "min": 8, + "max": 10, + "abnormalRate": 0 + }, + "GLUC^GLUCOSE": { + "kind": "numeric", + "units": "mg/dL", + "ref": "71-99", + "mean": 129.8, + "sd": 33.27, + "min": 93, + "max": 164, + "abnormalRate": 0.8 + }, + "BUN^BLOOD UREA NITROGEN": { + "kind": "numeric", + "units": "mg/dL", + "ref": "7-25", + "mean": 13, + "sd": 3.69, + "min": 9, + "max": 12, + "abnormalRate": 0 + }, + "CRET^CREATININE": { + "kind": "numeric", + "units": "mg/dL", + "ref": "0.6-1.2", + "mean": 0.92, + "sd": 0.28, + "min": 0.6, + "max": 1.2, + "abnormalRate": 0.2 + }, + "CA^CALCIUM": { + "kind": "numeric", + "units": "mg/dL", + "ref": "8.6-10.3", + "mean": 9.6, + "sd": 0.72, + "min": 8.4, + "max": 9.9, + "abnormalRate": 0.4 + }, + "GFRE^GLOMERULAR FILTRATION RATE": { + "kind": "numeric", + "units": "ml/min/1.73m2", + "ref": "", + "mean": 98.4, + "sd": 22.63, + "min": 63, + "max": 116, + "abnormalRate": 0 + } + } + }, + "10052^LIVER (HEPATIC) FUNCTION PANEL^LAF": { + "obx": [ + "TP^PROTEIN, TOTAL", + "ALB^ALBUMIN", + "OT^SGOT", + "SGPT^SGPT", + "TBIL^BILIRUBIN,TOTAL", + "DBIL^BILIRUBIN,DIRECT", + "ALKP^ALKALINE PHOSPHATASE" + ], + "values": { + "TP^PROTEIN, TOTAL": { + "kind": "numeric", + "units": "gm/dL", + "ref": "6.4-8.9", + "mean": 9, + "sd": 0.6, + "min": 8.4, + "max": 8.4, + "abnormalRate": 0.5 + }, + "ALB^ALBUMIN": { + "kind": "numeric", + "units": "gm/dL", + "ref": "3.5-5.7", + "mean": 5.45, + "sd": 0.35, + "min": 5.1, + "max": 5.1, + "abnormalRate": 0.5 + }, + "OT^SGOT": { + "kind": "numeric", + "units": "U/L", + "ref": "13-39", + "mean": 18.5, + "sd": 2.5, + "min": 16, + "max": 16, + "abnormalRate": 0 + }, + "SGPT^SGPT": { + "kind": "numeric", + "units": "U/L", + "ref": "7-52", + "mean": 17, + "sd": 1, + "min": 16, + "max": 16, + "abnormalRate": 0 + }, + "TBIL^BILIRUBIN,TOTAL": { + "kind": "numeric", + "units": "mg/dL", + "ref": "0.3-1.0", + "mean": 0.75, + "sd": 0.25, + "min": 0.5, + "max": 0.5, + "abnormalRate": 0 + }, + "DBIL^BILIRUBIN,DIRECT": { + "kind": "numeric", + "units": "mg/dl", + "ref": "0.0-0.2", + "mean": 0.1, + "sd": 0.06, + "min": 0.04, + "max": 0.04, + "abnormalRate": 0 + }, + "ALKP^ALKALINE PHOSPHATASE": { + "kind": "numeric", + "units": "U/L", + "ref": "34-104", + "mean": 89.5, + "sd": 19.5, + "min": 70, + "max": 70, + "abnormalRate": 0.5 + } + } + }, + "99187^TYPE AND SCREEN (HOLD XM-CONVERTIBLE)^LAB": { + "obx": [ + "%UN^UNIT NUMBER", + "%CT^BLOOD COMPONENT TYPE", + "%UDIV^UNIT DIVISION", + "%ST^STATUS OF UNIT", + "%TS^TRANSFUSION STATUS", + "%XM^CROSSMATCH RESULT", + "%AO^UNIT ANTIGEN TESTING", + "%CM^UNIT TAG COMMENT", + "BBC^BLOOD BANK COMMENT-1", + "%EXX^CROSSMATCH EXPIRATION", + "%ABR^ABO/RH(D)", + "%AS^ANTIBODY SCREEN", + "XMC^NOTE", + "%ABI^ANTIBODY IDENTIFICATION" + ], + "values": { + "%UN^UNIT NUMBER": { + "kind": "coded", + "valueType": "ST", + "dist": [ + [ + "W186826183080", + 0.138 + ], + [ + "W182726280846", + 0.138 + ], + [ + "W186826181987", + 0.138 + ], + [ + "W182726314102", + 0.138 + ], + [ + "W186826200531", + 0.138 + ], + [ + "W186826195920", + 0.138 + ], + [ + "W182726308298", + 0.069 + ], + [ + "W182226610865", + 0.069 + ], + [ + "W186826183173", + 0.034 + ] + ] + }, + "%CT^BLOOD COMPONENT TYPE": { + "kind": "coded", + "valueType": "CE", + "dist": [ + [ + "LEUKOREDUCED RBCS, BAG2", + 0.414 + ], + [ + "LEUKOREDUCED RBCS ADSOL", + 0.379 + ], + [ + "LEUKOREDUCED RBCS, BAG1", + 0.207 + ] + ] + }, + "%UDIV^UNIT DIVISION": { + "kind": "numeric", + "units": "", + "ref": "", + "mean": 0, + "sd": 0, + "min": 0, + "max": 0, + "abnormalRate": 0 + }, + "%ST^STATUS OF UNIT": { + "kind": "coded", + "valueType": "CE", + "dist": [ + [ + "ALLOCATED", + 0.379 + ], + [ + "REL FROM ALLOC", + 0.276 + ], + [ + "ISSUED", + 0.207 + ], + [ + "ISSUED,FINAL", + 0.138 + ] + ] + }, + "%TS^TRANSFUSION STATUS": { + "kind": "coded", + "valueType": "CE", + "dist": [ + [ + "OK TO TRANSFUSE", + 1 + ] + ] + }, + "%XM^CROSSMATCH RESULT": { + "kind": "coded", + "valueType": "CE", + "dist": [ + [ + "IMMEDIATE SPIN COMPATIBLE", + 0.966 + ], + [ + "Electronically Compatible", + 0.034 + ] + ] + }, + "%AO^UNIT ANTIGEN TESTING": { + "kind": "coded", + "valueType": "CE", + "dist": [ + [ + "NEG FOR E ANTIGEN", + 1 + ] + ] + }, + "%CM^UNIT TAG COMMENT": { + "kind": "coded", + "valueType": "CE", + "dist": [ + [ + "COMPATIBLE AT IGG", + 1 + ] + ] + }, + "BBC^BLOOD BANK COMMENT-1": { + "kind": "coded", + "valueType": "CE", + "dist": [ + [ + "NORTH ER BELAK 4/5/26 0145 JEB", + 1 + ] + ] + }, + "%EXX^CROSSMATCH EXPIRATION": { + "kind": "coded", + "valueType": "ST", + "dist": [ + [ + "04/08/2026,2359", + 0.8 + ], + [ + "04/09/2026,2359", + 0.2 + ] + ] + }, + "%ABR^ABO/RH(D)": { + "kind": "coded", + "valueType": "CE", + "dist": [ + [ + "O POSITIVE", + 0.8 + ], + [ + "A POSITIVE", + 0.2 + ] + ] + }, + "%AS^ANTIBODY SCREEN": { + "kind": "coded", + "valueType": "CE", + "dist": [ + [ + "POSITIVE", + 0.8 + ], + [ + "NEGATIVE", + 0.2 + ] + ] + }, + "XMC^NOTE": { + "kind": "coded", + "valueType": "CE", + "dist": [ + [ + "TYPE", + 1 + ] + ] + }, + "%ABI^ANTIBODY IDENTIFICATION": { + "kind": "coded", + "valueType": "CE", + "dist": [ + [ + "ANTI-E PRESENT", + 1 + ] + ] + } + } + }, + "8888^CBC/AUTOMATED DIFF^LAF": { + "obx": [ + "WBC^WBC COUNT", + "RBC^RBC COUNT", + "HGB^HEMOGLOBIN", + "HCT^HEMATOCRIT", + "MCV^MCV", + "MCH^MCH", + "MCHC^MCHC", + "RDW^RDW", + "PLT^PLATELET COUNT", + "MPV^MPV", + "SEG^SEG", + "LY^LYMPH", + "MONO^MONOCYTE", + "EOS^EOSINOPHIL", + "BASO^BASOPHIL", + "IG^IMMATURE GRANULOCYTES", + "SEGNO^Abs Seg Neutrophils", + "LYNO^Abs Lymphocytes", + "EONO^Abs Eosinophils", + "BASONO^Abs Basophils", + "MONNO^Abs Monocytes", + "IGNO^Abs Imm Granulocytes" + ], + "values": { + "WBC^WBC COUNT": { + "kind": "numeric", + "units": "10*3/uL", + "ref": "3.0-10.2", + "mean": 5.48, + "sd": 0.17, + "min": 5.31, + "max": 5.31, + "abnormalRate": 0 + }, + "RBC^RBC COUNT": { + "kind": "numeric", + "units": "10*6/uL", + "ref": "4.00-5.20", + "mean": 4.39, + "sd": 0.15, + "min": 4.25, + "max": 4.25, + "abnormalRate": 0 + }, + "HGB^HEMOGLOBIN": { + "kind": "numeric", + "units": "g/dl", + "ref": "11.7-15.8", + "mean": 13.5, + "sd": 0.2, + "min": 13.3, + "max": 13.3, + "abnormalRate": 0 + }, + "HCT^HEMATOCRIT": { + "kind": "numeric", + "units": "%", + "ref": "36.6-47.7", + "mean": 38.55, + "sd": 0.35, + "min": 38.2, + "max": 38.2, + "abnormalRate": 0.5 + }, + "MCV^MCV": { + "kind": "numeric", + "units": "fl", + "ref": "81.0-101.0", + "mean": 87.8, + "sd": 3.7, + "min": 84.1, + "max": 84.1, + "abnormalRate": 0 + }, + "MCH^MCH": { + "kind": "numeric", + "units": "pg", + "ref": "26.0-34.0", + "mean": 30.75, + "sd": 1.45, + "min": 29.3, + "max": 29.3, + "abnormalRate": 0 + }, + "MCHC^MCHC": { + "kind": "numeric", + "units": "g/dl", + "ref": "30.9-34.5", + "mean": 35, + "sd": 0.2, + "min": 34.8, + "max": 34.8, + "abnormalRate": 0.5 + }, + "RDW^RDW": { + "kind": "numeric", + "units": "%", + "ref": "11.5-15.5", + "mean": 12.3, + "sd": 0.2, + "min": 12.1, + "max": 12.1, + "abnormalRate": 0 + }, + "PLT^PLATELET COUNT": { + "kind": "numeric", + "units": "10*3/uL", + "ref": "130-400", + "mean": 236.5, + "sd": 27.5, + "min": 209, + "max": 209, + "abnormalRate": 0 + }, + "MPV^MPV": { + "kind": "numeric", + "units": "fl", + "ref": "9.0-12.5", + "mean": 9.8, + "sd": 0.7, + "min": 9.1, + "max": 9.1, + "abnormalRate": 0 + }, + "SEG^SEG": { + "kind": "numeric", + "units": "%", + "ref": "45-65", + "mean": 52, + "sd": 6, + "min": 46, + "max": 46, + "abnormalRate": 0 + }, + "LY^LYMPH": { + "kind": "numeric", + "units": "%", + "ref": "20-40", + "mean": 30, + "sd": 6, + "min": 24, + "max": 24, + "abnormalRate": 0 + }, + "MONO^MONOCYTE": { + "kind": "numeric", + "units": "%", + "ref": "3-9", + "mean": 12.5, + "sd": 4.5, + "min": 8, + "max": 8, + "abnormalRate": 0.5 + }, + "EOS^EOSINOPHIL": { + "kind": "numeric", + "units": "%", + "ref": "0-4", + "mean": 4.5, + "sd": 4.5, + "min": 0, + "max": 0, + "abnormalRate": 0.5 + }, + "BASO^BASOPHIL": { + "kind": "numeric", + "units": "%", + "ref": "0-1", + "mean": 1, + "sd": 0, + "min": 1, + "max": 1, + "abnormalRate": 0 + }, + "IG^IMMATURE GRANULOCYTES": { + "kind": "numeric", + "units": "%", + "ref": "0-0.4", + "mean": 0.2, + "sd": 0, + "min": 0.2, + "max": 0.2, + "abnormalRate": 0 + }, + "SEGNO^Abs Seg Neutrophils": { + "kind": "numeric", + "units": "10*3/uL", + "ref": "2.0-6.5", + "mean": 2.85, + "sd": 0.45, + "min": 2.4, + "max": 2.4, + "abnormalRate": 0 + }, + "LYNO^Abs Lymphocytes": { + "kind": "numeric", + "units": "10*3/uL", + "ref": "0.9-4.0", + "mean": 1.6, + "sd": 0.3, + "min": 1.3, + "max": 1.3, + "abnormalRate": 0 + }, + "EONO^Abs Eosinophils": { + "kind": "numeric", + "units": "10*3/uL", + "ref": "0.0-0.4", + "mean": 0.25, + "sd": 0.25, + "min": 0, + "max": 0, + "abnormalRate": 0.5 + }, + "BASONO^Abs Basophils": { + "kind": "numeric", + "units": "10*3/uL", + "ref": "0.0-0.1", + "mean": 0.1, + "sd": 0, + "min": 0.1, + "max": 0.1, + "abnormalRate": 0 + }, + "MONNO^Abs Monocytes": { + "kind": "numeric", + "units": "10*3/uL", + "ref": "0.1-0.9", + "mean": 0.7, + "sd": 0.3, + "min": 0.4, + "max": 0.4, + "abnormalRate": 0.5 + }, + "IGNO^Abs Imm Granulocytes": { + "kind": "numeric", + "units": "10*3/uL", + "ref": "0-0.03", + "mean": 0.01, + "sd": 0, + "min": 0.01, + "max": 0.01, + "abnormalRate": 0 + } + } + }, + "89048^INFLUENZA A AND B POC PCR^LAF": { + "obx": [ + "POCFAR^INFLUENZA A POC PCR", + "POCFBR^INFLUENZA B POC PCR" + ], + "values": { + "POCFAR^INFLUENZA A POC PCR": { + "kind": "coded", + "valueType": "TX", + "dist": [ + [ + "NEGATIVE", + 1 + ] + ] + }, + "POCFBR^INFLUENZA B POC PCR": { + "kind": "coded", + "valueType": "TX", + "dist": [ + [ + "NEGATIVE", + 1 + ] + ] + } + } + }, + "99684^GLUCOSE, POC^LAB": { + "obx": [ + "PCGLUC^GLUCOSE, POC" + ], + "values": { + "PCGLUC^GLUCOSE, POC": { + "kind": "numeric", + "units": "mg/dl", + "ref": "71-99", + "mean": 142.27, + "sd": 84.9, + "min": 37, + "max": 265, + "abnormalRate": 1 + } + } + }, + "10052^LIVER (HEPATIC) FUNCTION PANEL^LLA": { + "obx": [ + "TP^PROTEIN, TOTAL", + "ALB^ALBUMIN", + "OT^SGOT", + "SGPT^SGPT", + "TBIL^BILIRUBIN,TOTAL", + "DBIL^BILIRUBIN,DIRECT", + "ALKP^ALKALINE PHOSPHATASE" + ], + "values": { + "TP^PROTEIN, TOTAL": { + "kind": "numeric", + "units": "gm/dL", + "ref": "6.4-8.9", + "mean": 5.5, + "sd": 0, + "min": 5.5, + "max": 5.5, + "abnormalRate": 1 + }, + "ALB^ALBUMIN": { + "kind": "numeric", + "units": "gm/dL", + "ref": "3.5-5.7", + "mean": 3.6, + "sd": 0, + "min": 3.6, + "max": 3.6, + "abnormalRate": 0 + }, + "OT^SGOT": { + "kind": "numeric", + "units": "U/L", + "ref": "13-39", + "mean": 64, + "sd": 0, + "min": 64, + "max": 64, + "abnormalRate": 1 + }, + "SGPT^SGPT": { + "kind": "numeric", + "units": "U/L", + "ref": "7-52", + "mean": 33, + "sd": 0, + "min": 33, + "max": 33, + "abnormalRate": 0 + }, + "TBIL^BILIRUBIN,TOTAL": { + "kind": "numeric", + "units": "mg/dL", + "ref": "0.3-1.0", + "mean": 0.3, + "sd": 0, + "min": 0.3, + "max": 0.3, + "abnormalRate": 0 + }, + "DBIL^BILIRUBIN,DIRECT": { + "kind": "numeric", + "units": "mg/dl", + "ref": "0.0-0.2", + "mean": 0.03, + "sd": 0, + "min": 0.03, + "max": 0.03, + "abnormalRate": 0 + }, + "ALKP^ALKALINE PHOSPHATASE": { + "kind": "numeric", + "units": "U/L", + "ref": "34-104", + "mean": 128, + "sd": 0, + "min": 128, + "max": 128, + "abnormalRate": 1 + } + } + }, + "307^PHOSPHOROUS^LAB": { + "obx": [ + "PHOS^PHOSPHOROUS" + ], + "values": { + "PHOS^PHOSPHOROUS": { + "kind": "numeric", + "units": "mg/dl", + "ref": "2.5-5.0", + "mean": 3.4, + "sd": 0, + "min": 3.4, + "max": 3.4, + "abnormalRate": 0 + } + } + }, + "99145^TROPONIN I^LLA": { + "obx": [ + "TRPI^TROPONIN I" + ], + "values": { + "TRPI^TROPONIN I": { + "kind": "numeric", + "units": "pg/mL", + "ref": "<15", + "mean": 441.67, + "sd": 611.89, + "min": 6, + "max": 12, + "abnormalRate": 0.33 + } + } + }, + "10052^LIVER (HEPATIC) FUNCTION PANEL^LAB": { + "obx": [ + "TP^PROTEIN, TOTAL", + "ALB^ALBUMIN", + "OT^SGOT", + "SGPT^SGPT", + "TBIL^BILIRUBIN,TOTAL", + "DBIL^BILIRUBIN,DIRECT", + "ALKP^ALKALINE PHOSPHATASE" + ], + "values": { + "TP^PROTEIN, TOTAL": { + "kind": "numeric", + "units": "gm/dL", + "ref": "6.4-8.9", + "mean": 7, + "sd": 0, + "min": 7, + "max": 7, + "abnormalRate": 0 + }, + "ALB^ALBUMIN": { + "kind": "numeric", + "units": "gm/dL", + "ref": "3.5-5.7", + "mean": 4.2, + "sd": 0, + "min": 4.2, + "max": 4.2, + "abnormalRate": 0 + }, + "OT^SGOT": { + "kind": "numeric", + "units": "U/L", + "ref": "13-39", + "mean": 64, + "sd": 0, + "min": 64, + "max": 64, + "abnormalRate": 1 + }, + "SGPT^SGPT": { + "kind": "numeric", + "units": "U/L", + "ref": "7-52", + "mean": 43, + "sd": 0, + "min": 43, + "max": 43, + "abnormalRate": 0 + }, + "TBIL^BILIRUBIN,TOTAL": { + "kind": "numeric", + "units": "mg/dL", + "ref": "0.3-1.0", + "mean": 0.7, + "sd": 0, + "min": 0.7, + "max": 0.7, + "abnormalRate": 0 + }, + "DBIL^BILIRUBIN,DIRECT": { + "kind": "numeric", + "units": "mg/dl", + "ref": "0.0-0.2", + "mean": 0.32, + "sd": 0, + "min": 0.32, + "max": 0.32, + "abnormalRate": 1 + }, + "ALKP^ALKALINE PHOSPHATASE": { + "kind": "numeric", + "units": "U/L", + "ref": "34-104", + "mean": 89, + "sd": 0, + "min": 89, + "max": 89, + "abnormalRate": 0 + } + } + }, + "89048^INFLUENZA A AND B POC PCR^LAB": { + "obx": [ + "POCFAR^INFLUENZA A POC PCR", + "POCFBR^INFLUENZA B POC PCR" + ], + "values": { + "POCFAR^INFLUENZA A POC PCR": { + "kind": "coded", + "valueType": "TX", + "dist": [ + [ + "NEGATIVE", + 1 + ] + ] + }, + "POCFBR^INFLUENZA B POC PCR": { + "kind": "coded", + "valueType": "TX", + "dist": [ + [ + "NEGATIVE", + 1 + ] + ] + } + } + }, + "99145^TROPONIN I^LAF": { + "obx": [ + "TRPI^TROPONIN I" + ], + "values": { + "TRPI^TROPONIN I": { + "kind": "numeric", + "units": "pg/mL", + "ref": "<20", + "mean": 5.5, + "sd": 0.5, + "min": 5, + "max": 5, + "abnormalRate": 0 + } + } + }, + "2032^Acetylcholine Receptor Blocking Antibodies^LAB": { + "obx": [ + "AARB^Acetylcholine Receptor Blockin" + ], + "values": { + "AARB^Acetylcholine Receptor Blockin": { + "kind": "coded", + "valueType": "ST", + "dist": [ + [ + "<15", + 1 + ] + ] + } + } + }, + "99698^Vitamin D, 25-Hydroxy, LC/MS/MS^LAB": { + "obx": [ + "VD25T^Vitamin D, 25-OH, Total", + "VD25D3^Vitamin D, 25-OH, D3", + "VD25D2^Vitamin D, 25-OH, D2" + ], + "values": { + "VD25T^Vitamin D, 25-OH, Total": { + "kind": "numeric", + "units": "ng/mL", + "ref": "", + "mean": 75, + "sd": 0, + "min": 75, + "max": 75, + "abnormalRate": 0 + }, + "VD25D3^Vitamin D, 25-OH, D3": { + "kind": "numeric", + "units": "ng/mL", + "ref": "", + "mean": 75, + "sd": 0, + "min": 75, + "max": 75, + "abnormalRate": 0 + }, + "VD25D2^Vitamin D, 25-OH, D2": { + "kind": "coded", + "valueType": "ST", + "dist": [ + [ + "<4", + 1 + ] + ] + } + } + }, + "99730^DRUG SCREEN, URINE^LAB": { + "obx": [ + "AMPM^AMPHETAMINE", + "BARB^BARBITURATE", + "BENZ^BENZODIAZEPINES", + "THC^CANNABINOIDS", + "COCN^COCAINE", + "OP^OPIATES", + "PCP^PHENCYCLIDINE", + "OXY^OXYCODONE LEVEL", + "METD^METHADONE", + "FENTD^FENTANYL", + "DSPGR^SPECIFIC GRAVITY" + ], + "values": { + "AMPM^AMPHETAMINE": { + "kind": "coded", + "valueType": "TX", + "dist": [ + [ + "NEGATIVE", + 1 + ] + ] + }, + "BARB^BARBITURATE": { + "kind": "coded", + "valueType": "TX", + "dist": [ + [ + "NEGATIVE", + 1 + ] + ] + }, + "BENZ^BENZODIAZEPINES": { + "kind": "coded", + "valueType": "TX", + "dist": [ + [ + "NEGATIVE", + 1 + ] + ] + }, + "THC^CANNABINOIDS": { + "kind": "coded", + "valueType": "TX", + "dist": [ + [ + "POSITIVE", + 0.5 + ], + [ + "NEGATIVE", + 0.5 + ] + ] + }, + "COCN^COCAINE": { + "kind": "coded", + "valueType": "TX", + "dist": [ + [ + "NEGATIVE", + 1 + ] + ] + }, + "OP^OPIATES": { + "kind": "coded", + "valueType": "TX", + "dist": [ + [ + "NEGATIVE", + 1 + ] + ] + }, + "PCP^PHENCYCLIDINE": { + "kind": "coded", + "valueType": "TX", + "dist": [ + [ + "NEGATIVE", + 1 + ] + ] + }, + "OXY^OXYCODONE LEVEL": { + "kind": "coded", + "valueType": "TX", + "dist": [ + [ + "NEGATIVE", + 1 + ] + ] + }, + "METD^METHADONE": { + "kind": "coded", + "valueType": "TX", + "dist": [ + [ + "NEGATIVE", + 1 + ] + ] + }, + "FENTD^FENTANYL": { + "kind": "coded", + "valueType": "TX", + "dist": [ + [ + "NEGATIVE", + 1 + ] + ] + }, + "DSPGR^SPECIFIC GRAVITY": { + "kind": "numeric", + "units": "", + "ref": "1.003-1.030", + "mean": 1.02, + "sd": 0.01, + "min": 1.012, + "max": 1.012, + "abnormalRate": 0.5 + } + } + }, + "89096^PSA, Free and Total^LAB": { + "obx": [ + "PSA^PSA TOTAL/DIAGNOSTIC", + "PSAFRE^PSA, Free", + "PSAPFR^PSA, Percent Free" + ], + "values": { + "PSA^PSA TOTAL/DIAGNOSTIC": { + "kind": "numeric", + "units": "ng/mL", + "ref": "0-4", + "mean": 5.9, + "sd": 0, + "min": 5.9, + "max": 5.9, + "abnormalRate": 1 + }, + "PSAFRE^PSA, Free": { + "kind": "numeric", + "units": "ng/mL", + "ref": "", + "mean": 0.76, + "sd": 0, + "min": 0.76, + "max": 0.76, + "abnormalRate": 0 + }, + "PSAPFR^PSA, Percent Free": { + "kind": "numeric", + "units": "%", + "ref": "", + "mean": 13, + "sd": 0, + "min": 13, + "max": 13, + "abnormalRate": 0 + } + } + }, + "267^FERRITIN^LAB": { + "obx": [ + "FERR^FERRITIN" + ], + "values": { + "FERR^FERRITIN": { + "kind": "numeric", + "units": "ng/mL", + "ref": "11-306.8", + "mean": 8, + "sd": 0, + "min": 8, + "max": 8, + "abnormalRate": 1 + } + } + }, + "355^MAGNESIUM^LLA": { + "obx": [ + "MG^MAGNESIUM" + ], + "values": { + "MG^MAGNESIUM": { + "kind": "numeric", + "units": "mg/dL", + "ref": "1.6-2.6", + "mean": 1.8, + "sd": 0, + "min": 1.8, + "max": 1.8, + "abnormalRate": 0 + } + } + }, + "99145^TROPONIN I^LAB": { + "obx": [ + "TRPI^TROPONIN I" + ], + "values": { + "TRPI^TROPONIN I": { + "kind": "numeric", + "units": "pg/mL", + "ref": "<20", + "mean": 5124, + "sd": 10061.11, + "min": 7, + "max": 307, + "abnormalRate": 0.6 + } + } + }, + "327^LIPASE^LAF": { + "obx": [ + "LPSE^LIPASE" + ], + "values": { + "LPSE^LIPASE": { + "kind": "numeric", + "units": "U/L", + "ref": "11-82", + "mean": 15, + "sd": 1, + "min": 14, + "max": 14, + "abnormalRate": 0 + } + } + }, + "99684^GLUCOSE, POC^LAF": { + "obx": [ + "PCGLUC^GLUCOSE, POC" + ], + "values": { + "PCGLUC^GLUCOSE, POC": { + "kind": "numeric", + "units": "mg/dl", + "ref": "71-99", + "mean": 143, + "sd": 35, + "min": 108, + "max": 108, + "abnormalRate": 1 + } + } + }, + "355^MAGNESIUM^LAB": { + "obx": [ + "MG^MAGNESIUM" + ], + "values": { + "MG^MAGNESIUM": { + "kind": "numeric", + "units": "mg/dL", + "ref": "1.6-2.6", + "mean": 1.83, + "sd": 0.29, + "min": 1.5, + "max": 1.8, + "abnormalRate": 0.33 + } + } + }, + "99271^HEPARIN TEST (ANTI XA)^LAB": { + "obx": [ + "HEPAXA^HEPARIN TEST (ANTI XA)" + ], + "values": { + "HEPAXA^HEPARIN TEST (ANTI XA)": { + "kind": "numeric", + "units": "IU/ml", + "ref": "0.30-0.70", + "mean": 0.49, + "sd": 0.17, + "min": 0.25, + "max": 0.58, + "abnormalRate": 0.33 + } + } + }, + "99776^B-TYPE NATRIURETIC PEPTIDE^LAF": { + "obx": [ + "BNP^B-TYPE NATRIURETIC PEPTIDE" + ], + "values": { + "BNP^B-TYPE NATRIURETIC PEPTIDE": { + "kind": "numeric", + "units": "pg/mL", + "ref": "<100", + "mean": 54, + "sd": 0, + "min": 54, + "max": 54, + "abnormalRate": 0 + } + } + }, + "8888^CBC/AUTOMATED DIFF^LAB": { + "obx": [ + "WBC^WBC COUNT", + "RBC^RBC COUNT", + "HGB^HEMOGLOBIN", + "HCT^HEMATOCRIT", + "MCV^MCV", + "MCH^MCH", + "MCHC^MCHC", + "RDW^RDW", + "PLT^PLATELET COUNT", + "MPV^MPV", + "SEG^SEG", + "LY^LYMPH", + "MONO^MONOCYTE", + "EOS^EOSINOPHIL", + "BASO^BASOPHIL", + "IG^IMMATURE GRANULOCYTES", + "SEGNO^Abs Seg Neutrophils", + "LYNO^Abs Lymphocytes", + "EONO^Abs Eosinophils", + "BASONO^Abs Basophils", + "MONNO^Abs Monocytes", + "IGNO^Abs Imm Granulocytes" + ], + "values": { + "WBC^WBC COUNT": { + "kind": "numeric", + "units": "10*3/uL", + "ref": "3.0-10.2", + "mean": 9.87, + "sd": 0.96, + "min": 8.91, + "max": 8.91, + "abnormalRate": 0.5 + }, + "RBC^RBC COUNT": { + "kind": "numeric", + "units": "10*6/uL", + "ref": "4.00-5.20", + "mean": 4.1, + "sd": 0.16, + "min": 3.94, + "max": 3.94, + "abnormalRate": 0.5 + }, + "HGB^HEMOGLOBIN": { + "kind": "numeric", + "units": "g/dl", + "ref": "11.7-15.8", + "mean": 12.55, + "sd": 0.55, + "min": 12, + "max": 12, + "abnormalRate": 0 + }, + "HCT^HEMATOCRIT": { + "kind": "numeric", + "units": "%", + "ref": "36.6-47.7", + "mean": 36.55, + "sd": 1.65, + "min": 34.9, + "max": 34.9, + "abnormalRate": 0.5 + }, + "MCV^MCV": { + "kind": "numeric", + "units": "fl", + "ref": "81.0-101.0", + "mean": 89.25, + "sd": 0.65, + "min": 88.6, + "max": 88.6, + "abnormalRate": 0 + }, + "MCH^MCH": { + "kind": "numeric", + "units": "pg", + "ref": "26.0-34.0", + "mean": 30.65, + "sd": 0.15, + "min": 30.5, + "max": 30.5, + "abnormalRate": 0 + }, + "MCHC^MCHC": { + "kind": "numeric", + "units": "g/dl", + "ref": "30.9-34.5", + "mean": 34.35, + "sd": 0.05, + "min": 34.3, + "max": 34.3, + "abnormalRate": 0 + }, + "RDW^RDW": { + "kind": "numeric", + "units": "%", + "ref": "11.5-15.5", + "mean": 12.1, + "sd": 0.4, + "min": 11.7, + "max": 11.7, + "abnormalRate": 0 + }, + "PLT^PLATELET COUNT": { + "kind": "numeric", + "units": "10*3/uL", + "ref": "130-400", + "mean": 244.5, + "sd": 21.5, + "min": 223, + "max": 223, + "abnormalRate": 0 + }, + "MPV^MPV": { + "kind": "numeric", + "units": "fl", + "ref": "9.0-12.5", + "mean": 9.4, + "sd": 0.5, + "min": 8.9, + "max": 8.9, + "abnormalRate": 0.5 + }, + "SEG^SEG": { + "kind": "numeric", + "units": "%", + "ref": "45-65", + "mean": 70.5, + "sd": 5.5, + "min": 65, + "max": 65, + "abnormalRate": 0.5 + }, + "LY^LYMPH": { + "kind": "numeric", + "units": "%", + "ref": "20-40", + "mean": 19, + "sd": 7, + "min": 12, + "max": 12, + "abnormalRate": 0.5 + }, + "MONO^MONOCYTE": { + "kind": "numeric", + "units": "%", + "ref": "3-9", + "mean": 10, + "sd": 2, + "min": 8, + "max": 8, + "abnormalRate": 0.5 + }, + "EOS^EOSINOPHIL": { + "kind": "numeric", + "units": "%", + "ref": "0-4", + "mean": 0.5, + "sd": 0.5, + "min": 0, + "max": 0, + "abnormalRate": 0 + }, + "BASO^BASOPHIL": { + "kind": "numeric", + "units": "%", + "ref": "0-1", + "mean": 0, + "sd": 0, + "min": 0, + "max": 0, + "abnormalRate": 0 + }, + "IG^IMMATURE GRANULOCYTES": { + "kind": "numeric", + "units": "%", + "ref": "0-0.4", + "mean": 0.25, + "sd": 0.05, + "min": 0.2, + "max": 0.2, + "abnormalRate": 0 + }, + "SEGNO^Abs Seg Neutrophils": { + "kind": "numeric", + "units": "10*3/uL", + "ref": "2.0-6.5", + "mean": 6.95, + "sd": 1.15, + "min": 5.8, + "max": 5.8, + "abnormalRate": 0.5 + }, + "LYNO^Abs Lymphocytes": { + "kind": "numeric", + "units": "10*3/uL", + "ref": "0.9-4.0", + "mean": 1.8, + "sd": 0.5, + "min": 1.3, + "max": 1.3, + "abnormalRate": 0 + }, + "EONO^Abs Eosinophils": { + "kind": "numeric", + "units": "10*3/uL", + "ref": "0.0-0.4", + "mean": 0.05, + "sd": 0.05, + "min": 0, + "max": 0, + "abnormalRate": 0 + }, + "BASONO^Abs Basophils": { + "kind": "numeric", + "units": "10*3/uL", + "ref": "0.0-0.1", + "mean": 0, + "sd": 0, + "min": 0, + "max": 0, + "abnormalRate": 0 + }, + "MONNO^Abs Monocytes": { + "kind": "numeric", + "units": "10*3/uL", + "ref": "0.1-0.9", + "mean": 1, + "sd": 0.3, + "min": 0.7, + "max": 0.7, + "abnormalRate": 0.5 + }, + "IGNO^Abs Imm Granulocytes": { + "kind": "numeric", + "units": "10*3/uL", + "ref": "0-0.03", + "mean": 0.03, + "sd": 0, + "min": 0.02, + "max": 0.02, + "abnormalRate": 0 + } + } + }, + "439^CBC/NO DIFF^LAB": { + "obx": [ + "WBC^WBC COUNT", + "RBC^RBC COUNT", + "HGB^HEMOGLOBIN", + "HCT^HEMATOCRIT", + "MCV^MCV", + "MCH^MCH", + "MCHC^MCHC", + "RDW^RDW", + "PLT^PLATELET COUNT", + "MPV^MPV" + ], + "values": { + "WBC^WBC COUNT": { + "kind": "numeric", + "units": "10*3/uL", + "ref": "3.0-10.2", + "mean": 13.37, + "sd": 0, + "min": 13.37, + "max": 13.37, + "abnormalRate": 1 + }, + "RBC^RBC COUNT": { + "kind": "numeric", + "units": "10*6/uL", + "ref": "4.00-5.20", + "mean": 3.05, + "sd": 0, + "min": 3.05, + "max": 3.05, + "abnormalRate": 1 + }, + "HGB^HEMOGLOBIN": { + "kind": "numeric", + "units": "g/dl", + "ref": "11.7-15.8", + "mean": 10.5, + "sd": 0, + "min": 10.5, + "max": 10.5, + "abnormalRate": 1 + }, + "HCT^HEMATOCRIT": { + "kind": "numeric", + "units": "%", + "ref": "36.6-47.7", + "mean": 30, + "sd": 0, + "min": 30, + "max": 30, + "abnormalRate": 1 + }, + "MCV^MCV": { + "kind": "numeric", + "units": "fl", + "ref": "81.0-101.0", + "mean": 98.4, + "sd": 0, + "min": 98.4, + "max": 98.4, + "abnormalRate": 0 + }, + "MCH^MCH": { + "kind": "numeric", + "units": "pg", + "ref": "26.0-34.0", + "mean": 34.4, + "sd": 0, + "min": 34.4, + "max": 34.4, + "abnormalRate": 1 + }, + "MCHC^MCHC": { + "kind": "numeric", + "units": "g/dl", + "ref": "30.9-34.5", + "mean": 35, + "sd": 0, + "min": 35, + "max": 35, + "abnormalRate": 1 + }, + "RDW^RDW": { + "kind": "numeric", + "units": "%", + "ref": "11.5-15.5", + "mean": 13.2, + "sd": 0, + "min": 13.2, + "max": 13.2, + "abnormalRate": 0 + }, + "PLT^PLATELET COUNT": { + "kind": "numeric", + "units": "10*3/uL", + "ref": "130-400", + "mean": 151, + "sd": 0, + "min": 151, + "max": 151, + "abnormalRate": 0 + }, + "MPV^MPV": { + "kind": "numeric", + "units": "fl", + "ref": "9.0-12.5", + "mean": 10.1, + "sd": 0, + "min": 10.1, + "max": 10.1, + "abnormalRate": 0 + } + } + }, + "1477^BETA-HCG^LAB": { + "obx": [ + "BHCG^BETA-HCG" + ], + "values": { + "BHCG^BETA-HCG": { + "kind": "numeric", + "units": "mIU/ml", + "ref": "0-5", + "mean": 19013, + "sd": 0, + "min": 19013, + "max": 19013, + "abnormalRate": 1 + } + } + }, + "226^SMEAR FOR MORPHOLOGY^LLA": { + "obx": [ + "RCOM^RBC MORPHOLOGY", + "PLREV^PLT MORPHOLOGY" + ], + "values": { + "RCOM^RBC MORPHOLOGY": { + "kind": "coded", + "valueType": "TX", + "dist": [ + [ + "FEW", + 0.5 + ], + [ + "HYPOCHROMIA", + 0.5 + ] + ] + }, + "PLREV^PLT MORPHOLOGY": { + "kind": "coded", + "valueType": "TX", + "dist": [ + [ + "NO COMMENT", + 1 + ] + ] + } + } + }, + "344^IRON^LAB": { + "obx": [ + "FE^IRON" + ], + "values": { + "FE^IRON": { + "kind": "numeric", + "units": "ug/dl", + "ref": "50-170", + "mean": 58, + "sd": 0, + "min": 58, + "max": 58, + "abnormalRate": 0 + } + } + }, + "9872^TSH^LAB": { + "obx": [ + "TSH^TSH" + ], + "values": { + "TSH^TSH": { + "kind": "numeric", + "units": "uIU/ml", + "ref": "0.45-5.33", + "mean": 1.9, + "sd": 0, + "min": 1.9, + "max": 1.9, + "abnormalRate": 0 + } + } + }, + "701^URINALYSIS, ROUTINE^LAB": { + "obx": [ + "UCLAR^URINE CLARITY", + "UCOL^URINE COLOR", + "UPH^URINE PH", + "UTP^URINE TOTAL PROTEIN", + "UGL^URINE GLUCOSE", + "UKET^URINE KETONES", + "UBIL^URINE BILIRUBIN", + "UHGB^URINE HEMOGLOBIN", + "UROB^URINE UROBILINOGEN", + "ULEU^URINE LEUKOCYTES", + "UNIT^URINE NITRITE", + "USPG^URINE SPEC GRAVITY", + "UWBC^URINE WBC'S", + "URBC^URINE RBC'S", + "EPI^SQUAMOUS EPITH. CELLS", + "BACT^BACTERIA", + "MUCUR^MUCOUS" + ], + "values": { + "UCLAR^URINE CLARITY": { + "kind": "coded", + "valueType": "TX", + "dist": [ + [ + "CLEAR", + 0.5 + ], + [ + "TURBID", + 0.5 + ] + ] + }, + "UCOL^URINE COLOR": { + "kind": "coded", + "valueType": "TX", + "dist": [ + [ + "PALE YELLOW", + 0.5 + ], + [ + "YELLOW", + 0.5 + ] + ] + }, + "UPH^URINE PH": { + "kind": "numeric", + "units": "", + "ref": "5.0-8.0", + "mean": 6.75, + "sd": 0.75, + "min": 6, + "max": 6, + "abnormalRate": 0 + }, + "UTP^URINE TOTAL PROTEIN": { + "kind": "coded", + "valueType": "TX", + "dist": [ + [ + "NEGATIVE", + 1 + ] + ] + }, + "UGL^URINE GLUCOSE": { + "kind": "coded", + "valueType": "TX", + "dist": [ + [ + "NORMAL", + 1 + ] + ] + }, + "UKET^URINE KETONES": { + "kind": "coded", + "valueType": "TX", + "dist": [ + [ + "NEGATIVE", + 0.5 + ], + [ + "3+", + 0.5 + ] + ] + }, + "UBIL^URINE BILIRUBIN": { + "kind": "coded", + "valueType": "TX", + "dist": [ + [ + "NEGATIVE", + 1 + ] + ] + }, + "UHGB^URINE HEMOGLOBIN": { + "kind": "coded", + "valueType": "TX", + "dist": [ + [ + "NEGATIVE", + 0.5 + ], + [ + "1+", + 0.5 + ] + ] + }, + "UROB^URINE UROBILINOGEN": { + "kind": "coded", + "valueType": "TX", + "dist": [ + [ + "NORMAL", + 1 + ] + ] + }, + "ULEU^URINE LEUKOCYTES": { + "kind": "coded", + "valueType": "TX", + "dist": [ + [ + "NEGATIVE", + 1 + ] + ] + }, + "UNIT^URINE NITRITE": { + "kind": "coded", + "valueType": "TX", + "dist": [ + [ + "NEGATIVE", + 1 + ] + ] + }, + "USPG^URINE SPEC GRAVITY": { + "kind": "numeric", + "units": "", + "ref": "1.003-1.030", + "mean": 1.02, + "sd": 0.01, + "min": 1.012, + "max": 1.012, + "abnormalRate": 0.5 + }, + "UWBC^URINE WBC'S": { + "kind": "coded", + "valueType": "TX", + "dist": [ + [ + "0-5", + 0.5 + ], + [ + "6-10", + 0.5 + ] + ] + }, + "URBC^URINE RBC'S": { + "kind": "coded", + "valueType": "TX", + "dist": [ + [ + "0-3", + 1 + ] + ] + }, + "EPI^SQUAMOUS EPITH. CELLS": { + "kind": "coded", + "valueType": "TX", + "dist": [ + [ + "NONE SEEN", + 0.5 + ], + [ + "SMALL", + 0.5 + ] + ] + }, + "BACT^BACTERIA": { + "kind": "coded", + "valueType": "TX", + "dist": [ + [ + "NONE SEEN", + 1 + ] + ] + }, + "MUCUR^MUCOUS": { + "kind": "coded", + "valueType": "TX", + "dist": [ + [ + "SMALL", + 0.5 + ], + [ + "LARGE", + 0.5 + ] + ] + } + } + }, + "89154^Tick Borne Disease Abs w RFLX^LAB": { + "obx": [ + "APIGS^A. phagocytoph,IgG,Screen", + "APIMS^A.phagocytoph,IgM,Screen", + "BMIGS^Babesia microti Ab IgG Screen", + "BMIMS^Babesia microti Ab IgM Screen", + "ECIGS^E.chaffeensis Ab,IgG,Scr", + "ECIMS^E.chaffeensis Ab,IgM,Scr", + "LYABSC^Lyme Ab, Screen" + ], + "values": { + "APIGS^A. phagocytoph,IgG,Screen": { + "kind": "coded", + "valueType": "TX", + "dist": [ + [ + "Not Detected", + 1 + ] + ] + }, + "APIMS^A.phagocytoph,IgM,Screen": { + "kind": "coded", + "valueType": "TX", + "dist": [ + [ + "Not Detected", + 1 + ] + ] + }, + "BMIGS^Babesia microti Ab IgG Screen": { + "kind": "coded", + "valueType": "TX", + "dist": [ + [ + "Not Detected", + 1 + ] + ] + }, + "BMIMS^Babesia microti Ab IgM Screen": { + "kind": "coded", + "valueType": "TX", + "dist": [ + [ + "Not Detected", + 1 + ] + ] + }, + "ECIGS^E.chaffeensis Ab,IgG,Scr": { + "kind": "coded", + "valueType": "TX", + "dist": [ + [ + "Not Detected", + 1 + ] + ] + }, + "ECIMS^E.chaffeensis Ab,IgM,Scr": { + "kind": "coded", + "valueType": "TX", + "dist": [ + [ + "Not Detected", + 1 + ] + ] + }, + "LYABSC^Lyme Ab, Screen": { + "kind": "coded", + "valueType": "ST", + "dist": [ + [ + "<0.90", + 1 + ] + ] + } + } + }, + "99271^HEPARIN TEST (ANTI XA)^LLA": { + "obx": [ + "HEPAXA^HEPARIN TEST (ANTI XA)" + ], + "values": { + "HEPAXA^HEPARIN TEST (ANTI XA)": { + "kind": "numeric", + "units": "IU/ml", + "ref": "0.30-0.70", + "mean": 0, + "sd": 0, + "min": 0, + "max": 0, + "abnormalRate": 1 + } + } + }, + "1611^CORTISOL^LAB": { + "obx": [ + "CORTS^CORTISOL" + ], + "values": { + "CORTS^CORTISOL": { + "kind": "numeric", + "units": "ug/dl", + "ref": "0.4-22.6", + "mean": 41.5, + "sd": 0, + "min": 41.5, + "max": 41.5, + "abnormalRate": 1 + } + } + }, + "327^LIPASE^LAB": { + "obx": [ + "LPSE^LIPASE" + ], + "values": { + "LPSE^LIPASE": { + "kind": "numeric", + "units": "U/L", + "ref": "11-82", + "mean": 34, + "sd": 0, + "min": 34, + "max": 34, + "abnormalRate": 0 + } + } + }, + "427^PTT^LAB": { + "obx": [ + "PTT^PTT" + ], + "values": { + "PTT^PTT": { + "kind": "numeric", + "units": "sec", + "ref": "25.1-36.5", + "mean": 52.7, + "sd": 4.7, + "min": 48, + "max": 48, + "abnormalRate": 1 + } + } + }, + "894^SERUM PREGNANCY TEST^LAF": { + "obx": [ + "SPRG^SERUM PREGNANCY TEST" + ], + "values": { + "SPRG^SERUM PREGNANCY TEST": { + "kind": "coded", + "valueType": "TX", + "dist": [ + [ + "NEGATIVE", + 1 + ] + ] + } + } + }, + "99947^CCMP and LFS^LAB": { + "obx": [ + "NA^SODIUM", + "K^POTASSIUM", + "CL^CHLORIDE", + "CO2^CO2", + "AGAP^ANION GAP", + "BUN^BLOOD UREA NITROGEN", + "CRET^CREATININE", + "BCR^BUN/CREAT RATIO", + "GFRE^GLOMERULAR FILTRATION RATE", + "GLUC^GLUCOSE", + "COSM^OSMOLALITY, CALC", + "CA^CALCIUM", + "TP^PROTEIN, TOTAL", + "ALB^ALBUMIN", + "GLOB^GLOBULIN", + "ALGLR^ALB/GLOB RATIO", + "ALKP^ALKALINE PHOSPHATASE", + "OT^SGOT", + "SGPT^SGPT", + "TBIL^BILIRUBIN,TOTAL", + "DBIL^BILIRUBIN,DIRECT" + ], + "values": { + "NA^SODIUM": { + "kind": "numeric", + "units": "mEq/L", + "ref": "136-145", + "mean": 141, + "sd": 0, + "min": 141, + "max": 141, + "abnormalRate": 0 + }, + "K^POTASSIUM": { + "kind": "numeric", + "units": "mEq/L", + "ref": "3.5-5.1", + "mean": 3.8, + "sd": 0, + "min": 3.8, + "max": 3.8, + "abnormalRate": 0 + }, + "CL^CHLORIDE": { + "kind": "numeric", + "units": "mEq/L", + "ref": "98-107", + "mean": 109, + "sd": 0, + "min": 109, + "max": 109, + "abnormalRate": 1 + }, + "CO2^CO2": { + "kind": "numeric", + "units": "mEq/L", + "ref": "21-31", + "mean": 24, + "sd": 0, + "min": 24, + "max": 24, + "abnormalRate": 0 + }, + "AGAP^ANION GAP": { + "kind": "numeric", + "units": "mEq/L", + "ref": "5-16", + "mean": 8, + "sd": 0, + "min": 8, + "max": 8, + "abnormalRate": 0 + }, + "BUN^BLOOD UREA NITROGEN": { + "kind": "numeric", + "units": "mg/dL", + "ref": "7-25", + "mean": 14, + "sd": 0, + "min": 14, + "max": 14, + "abnormalRate": 0 + }, + "CRET^CREATININE": { + "kind": "numeric", + "units": "mg/dL", + "ref": "0.7-1.3", + "mean": 1.1, + "sd": 0, + "min": 1.1, + "max": 1.1, + "abnormalRate": 0 + }, + "BCR^BUN/CREAT RATIO": { + "kind": "numeric", + "units": "", + "ref": "8-20", + "mean": 13, + "sd": 0, + "min": 13, + "max": 13, + "abnormalRate": 0 + }, + "GFRE^GLOMERULAR FILTRATION RATE": { + "kind": "numeric", + "units": "ml/min/1.73m2", + "ref": "", + "mean": 71, + "sd": 0, + "min": 71, + "max": 71, + "abnormalRate": 0 + }, + "GLUC^GLUCOSE": { + "kind": "numeric", + "units": "mg/dL", + "ref": "71-99", + "mean": 122, + "sd": 0, + "min": 122, + "max": 122, + "abnormalRate": 1 + }, + "COSM^OSMOLALITY, CALC": { + "kind": "numeric", + "units": "mOs/kg", + "ref": "278-305", + "mean": 283, + "sd": 0, + "min": 283, + "max": 283, + "abnormalRate": 0 + }, + "CA^CALCIUM": { + "kind": "numeric", + "units": "mg/dL", + "ref": "8.6-10.3", + "mean": 7.2, + "sd": 0, + "min": 7.2, + "max": 7.2, + "abnormalRate": 1 + }, + "TP^PROTEIN, TOTAL": { + "kind": "numeric", + "units": "gm/dL", + "ref": "6.4-8.9", + "mean": 4.5, + "sd": 0, + "min": 4.5, + "max": 4.5, + "abnormalRate": 1 + }, + "ALB^ALBUMIN": { + "kind": "numeric", + "units": "gm/dL", + "ref": "3.5-5.7", + "mean": 3, + "sd": 0, + "min": 3, + "max": 3, + "abnormalRate": 1 + }, + "GLOB^GLOBULIN": { + "kind": "numeric", + "units": "gm/dL", + "ref": "1.5-4.2", + "mean": 1.5, + "sd": 0, + "min": 1.5, + "max": 1.5, + "abnormalRate": 0 + }, + "ALGLR^ALB/GLOB RATIO": { + "kind": "numeric", + "units": "", + "ref": "1.0-2.2", + "mean": 2, + "sd": 0, + "min": 2, + "max": 2, + "abnormalRate": 0 + }, + "ALKP^ALKALINE PHOSPHATASE": { + "kind": "numeric", + "units": "U/L", + "ref": "34-104", + "mean": 53, + "sd": 0, + "min": 53, + "max": 53, + "abnormalRate": 0 + }, + "OT^SGOT": { + "kind": "numeric", + "units": "U/L", + "ref": "13-39", + "mean": 62, + "sd": 0, + "min": 62, + "max": 62, + "abnormalRate": 1 + }, + "SGPT^SGPT": { + "kind": "numeric", + "units": "U/L", + "ref": "7-52", + "mean": 39, + "sd": 0, + "min": 39, + "max": 39, + "abnormalRate": 0 + }, + "TBIL^BILIRUBIN,TOTAL": { + "kind": "numeric", + "units": "mg/dL", + "ref": "0.3-1.0", + "mean": 0.9, + "sd": 0, + "min": 0.9, + "max": 0.9, + "abnormalRate": 0 + }, + "DBIL^BILIRUBIN,DIRECT": { + "kind": "numeric", + "units": "mg/dl", + "ref": "0.0-0.2", + "mean": 0.36, + "sd": 0, + "min": 0.36, + "max": 0.36, + "abnormalRate": 1 + } + } + }, + "701^URINALYSIS, ROUTINE^LAF": { + "obx": [ + "UCLAR^URINE CLARITY", + "UCOL^URINE COLOR", + "UPH^URINE PH", + "UTP^URINE TOTAL PROTEIN", + "UGL^URINE GLUCOSE", + "UKET^URINE KETONES", + "UBIL^URINE BILIRUBIN", + "UHGB^URINE HEMOGLOBIN", + "UROB^URINE UROBILINOGEN", + "ULEU^URINE LEUKOCYTES", + "UNIT^URINE NITRITE", + "USPG^URINE SPEC GRAVITY", + "UWBC^URINE WBC'S", + "URBC^URINE RBC'S", + "EPI^SQUAMOUS EPITH. CELLS", + "BACT^BACTERIA", + "MUCUR^MUCOUS" + ], + "values": { + "UCLAR^URINE CLARITY": { + "kind": "coded", + "valueType": "TX", + "dist": [ + [ + "CLEAR", + 1 + ] + ] + }, + "UCOL^URINE COLOR": { + "kind": "coded", + "valueType": "TX", + "dist": [ + [ + "YELLOW", + 1 + ] + ] + }, + "UPH^URINE PH": { + "kind": "numeric", + "units": "", + "ref": "5.0-8.0", + "mean": 6, + "sd": 0, + "min": 6, + "max": 6, + "abnormalRate": 0 + }, + "UTP^URINE TOTAL PROTEIN": { + "kind": "numeric", + "units": "mg/dL", + "ref": "NEG", + "mean": 50, + "sd": 0, + "min": 50, + "max": 50, + "abnormalRate": 1 + }, + "UGL^URINE GLUCOSE": { + "kind": "coded", + "valueType": "TX", + "dist": [ + [ + "NORMAL", + 1 + ] + ] + }, + "UKET^URINE KETONES": { + "kind": "coded", + "valueType": "TX", + "dist": [ + [ + "1+", + 1 + ] + ] + }, + "UBIL^URINE BILIRUBIN": { + "kind": "coded", + "valueType": "TX", + "dist": [ + [ + "NEGATIVE", + 1 + ] + ] + }, + "UHGB^URINE HEMOGLOBIN": { + "kind": "coded", + "valueType": "TX", + "dist": [ + [ + "3+", + 1 + ] + ] + }, + "UROB^URINE UROBILINOGEN": { + "kind": "coded", + "valueType": "TX", + "dist": [ + [ + "NORMAL", + 1 + ] + ] + }, + "ULEU^URINE LEUKOCYTES": { + "kind": "coded", + "valueType": "TX", + "dist": [ + [ + "NEGATIVE", + 1 + ] + ] + }, + "UNIT^URINE NITRITE": { + "kind": "coded", + "valueType": "TX", + "dist": [ + [ + "NEGATIVE", + 1 + ] + ] + }, + "USPG^URINE SPEC GRAVITY": { + "kind": "coded", + "valueType": "ST", + "dist": [ + [ + ">1.050", + 1 + ] + ] + }, + "UWBC^URINE WBC'S": { + "kind": "coded", + "valueType": "TX", + "dist": [ + [ + "0-5", + 1 + ] + ] + }, + "URBC^URINE RBC'S": { + "kind": "coded", + "valueType": "TX", + "dist": [ + [ + ">50", + 1 + ] + ] + }, + "EPI^SQUAMOUS EPITH. CELLS": { + "kind": "coded", + "valueType": "TX", + "dist": [ + [ + "SMALL", + 1 + ] + ] + }, + "BACT^BACTERIA": { + "kind": "coded", + "valueType": "TX", + "dist": [ + [ + "SMALL", + 1 + ] + ] + }, + "MUCUR^MUCOUS": { + "kind": "coded", + "valueType": "TX", + "dist": [ + [ + "SMALL", + 1 + ] + ] + } + } + }, + "2031^Acetylcholine Receptor Mod Ab^LAB": { + "obx": [ + "AARM^Acetylcholine Receptor Mod Ab" + ], + "values": { + "AARM^Acetylcholine Receptor Mod Ab": { + "kind": "coded", + "valueType": "ST", + "dist": [ + [ + "<1", + 1 + ] + ] + } + } + }, + "1560^ANCA SCREEN W MPA PR3AB^LAB": { + "obx": [ + "ANCAS^ANCA SCREEN", + "MPA^Myeloperoxidase Antibody", + "PR3AB^Proteinase-3 Ab" + ], + "values": { + "ANCAS^ANCA SCREEN": { + "kind": "coded", + "valueType": "TX", + "dist": [ + [ + "Negative", + 1 + ] + ] + }, + "MPA^Myeloperoxidase Antibody": { + "kind": "coded", + "valueType": "ST", + "dist": [ + [ + "<1.0", + 1 + ] + ] + }, + "PR3AB^Proteinase-3 Ab": { + "kind": "coded", + "valueType": "ST", + "dist": [ + [ + "<1.0", + 1 + ] + ] + } + } + }, + "99684^GLUCOSE, POC^LLA": { + "obx": [ + "PCGLUC^GLUCOSE, POC" + ], + "values": { + "PCGLUC^GLUCOSE, POC": { + "kind": "numeric", + "units": "mg/dl", + "ref": "71-99", + "mean": 111, + "sd": 35, + "min": 76, + "max": 76, + "abnormalRate": 0.5 + } + } + }, + "10057^LACTATE^LAB": { + "obx": [ + "LACT^LACTATE" + ], + "values": { + "LACT^LACTATE": { + "kind": "numeric", + "units": "mmol/L", + "ref": "0.5-2.0", + "mean": 1.1, + "sd": 0, + "min": 1.1, + "max": 1.1, + "abnormalRate": 0 + } + } + }, + "8888^CBC/AUTOMATED DIFF^LLA": { + "obx": [ + "WBC^WBC COUNT", + "RBC^RBC COUNT", + "HGB^HEMOGLOBIN", + "HCT^HEMATOCRIT", + "MCV^MCV", + "MCH^MCH", + "MCHC^MCHC", + "RDW^RDW", + "PLT^PLATELET COUNT", + "MPV^MPV", + "SEG^SEG", + "LY^LYMPH", + "MONO^MONOCYTE", + "EOS^EOSINOPHIL", + "BASO^BASOPHIL", + "IG^IMMATURE GRANULOCYTES", + "SEGNO^Abs Seg Neutrophils", + "LYNO^Abs Lymphocytes", + "EONO^Abs Eosinophils", + "BASONO^Abs Basophils", + "MONNO^Abs Monocytes", + "IGNO^Abs Imm Granulocytes" + ], + "values": { + "WBC^WBC COUNT": { + "kind": "numeric", + "units": "10*3/uL", + "ref": "3.0-10.2", + "mean": 13.47, + "sd": 0, + "min": 13.47, + "max": 13.47, + "abnormalRate": 1 + }, + "RBC^RBC COUNT": { + "kind": "numeric", + "units": "10*6/uL", + "ref": "4.00-5.20", + "mean": 3.78, + "sd": 0, + "min": 3.78, + "max": 3.78, + "abnormalRate": 1 + }, + "HGB^HEMOGLOBIN": { + "kind": "numeric", + "units": "g/dl", + "ref": "11.7-15.8", + "mean": 8.8, + "sd": 0, + "min": 8.8, + "max": 8.8, + "abnormalRate": 1 + }, + "HCT^HEMATOCRIT": { + "kind": "numeric", + "units": "%", + "ref": "36.6-47.7", + "mean": 30.7, + "sd": 0, + "min": 30.7, + "max": 30.7, + "abnormalRate": 1 + }, + "MCV^MCV": { + "kind": "numeric", + "units": "fl", + "ref": "81.0-101.0", + "mean": 81.2, + "sd": 0, + "min": 81.2, + "max": 81.2, + "abnormalRate": 0 + }, + "MCH^MCH": { + "kind": "numeric", + "units": "pg", + "ref": "26.0-34.0", + "mean": 23.3, + "sd": 0, + "min": 23.3, + "max": 23.3, + "abnormalRate": 1 + }, + "MCHC^MCHC": { + "kind": "numeric", + "units": "g/dl", + "ref": "30.9-34.5", + "mean": 28.7, + "sd": 0, + "min": 28.7, + "max": 28.7, + "abnormalRate": 1 + }, + "RDW^RDW": { + "kind": "numeric", + "units": "%", + "ref": "11.5-15.5", + "mean": 17.7, + "sd": 0, + "min": 17.7, + "max": 17.7, + "abnormalRate": 1 + }, + "PLT^PLATELET COUNT": { + "kind": "numeric", + "units": "10*3/uL", + "ref": "130-400", + "mean": 271, + "sd": 0, + "min": 271, + "max": 271, + "abnormalRate": 0 + }, + "MPV^MPV": { + "kind": "numeric", + "units": "fl", + "ref": "9.0-12.5", + "mean": 10.6, + "sd": 0, + "min": 10.6, + "max": 10.6, + "abnormalRate": 0 + }, + "SEG^SEG": { + "kind": "numeric", + "units": "%", + "ref": "45-65", + "mean": 77, + "sd": 0, + "min": 77, + "max": 77, + "abnormalRate": 1 + }, + "LY^LYMPH": { + "kind": "numeric", + "units": "%", + "ref": "20-40", + "mean": 10, + "sd": 0, + "min": 10, + "max": 10, + "abnormalRate": 1 + }, + "MONO^MONOCYTE": { + "kind": "numeric", + "units": "%", + "ref": "3-9", + "mean": 12, + "sd": 0, + "min": 12, + "max": 12, + "abnormalRate": 1 + }, + "EOS^EOSINOPHIL": { + "kind": "numeric", + "units": "%", + "ref": "0-4", + "mean": 0, + "sd": 0, + "min": 0, + "max": 0, + "abnormalRate": 0 + }, + "BASO^BASOPHIL": { + "kind": "numeric", + "units": "%", + "ref": "0-1", + "mean": 0, + "sd": 0, + "min": 0, + "max": 0, + "abnormalRate": 0 + }, + "IG^IMMATURE GRANULOCYTES": { + "kind": "numeric", + "units": "%", + "ref": "0-0.4", + "mean": 0.7, + "sd": 0, + "min": 0.7, + "max": 0.7, + "abnormalRate": 1 + }, + "SEGNO^Abs Seg Neutrophils": { + "kind": "numeric", + "units": "10*3/uL", + "ref": "2.0-6.5", + "mean": 10.4, + "sd": 0, + "min": 10.4, + "max": 10.4, + "abnormalRate": 1 + }, + "LYNO^Abs Lymphocytes": { + "kind": "numeric", + "units": "10*3/uL", + "ref": "0.9-4.0", + "mean": 1.4, + "sd": 0, + "min": 1.4, + "max": 1.4, + "abnormalRate": 0 + }, + "EONO^Abs Eosinophils": { + "kind": "numeric", + "units": "10*3/uL", + "ref": "0.0-0.4", + "mean": 0, + "sd": 0, + "min": 0, + "max": 0, + "abnormalRate": 0 + }, + "BASONO^Abs Basophils": { + "kind": "numeric", + "units": "10*3/uL", + "ref": "0.0-0.1", + "mean": 0, + "sd": 0, + "min": 0, + "max": 0, + "abnormalRate": 0 + }, + "MONNO^Abs Monocytes": { + "kind": "numeric", + "units": "10*3/uL", + "ref": "0.1-0.9", + "mean": 1.6, + "sd": 0, + "min": 1.6, + "max": 1.6, + "abnormalRate": 1 + }, + "IGNO^Abs Imm Granulocytes": { + "kind": "numeric", + "units": "10*3/uL", + "ref": "0-0.03", + "mean": 0.09, + "sd": 0, + "min": 0.09, + "max": 0.09, + "abnormalRate": 1 + } + } + }, + "701^URINALYSIS, ROUTINE^LLA": { + "obx": [ + "UCLAR^URINE CLARITY", + "UCOL^URINE COLOR", + "UPH^URINE PH", + "UTP^URINE TOTAL PROTEIN", + "UGL^URINE GLUCOSE", + "UKET^URINE KETONES", + "UBIL^URINE BILIRUBIN", + "UHGB^URINE HEMOGLOBIN", + "UROB^URINE UROBILINOGEN", + "ULEU^URINE LEUKOCYTES", + "UNIT^URINE NITRITE", + "USPG^URINE SPEC GRAVITY", + "UWBC^URINE WBC'S", + "URBC^URINE RBC'S", + "EPI^SQUAMOUS EPITH. CELLS", + "BACT^BACTERIA", + "MUCUR^MUCOUS" + ], + "values": { + "UCLAR^URINE CLARITY": { + "kind": "coded", + "valueType": "TX", + "dist": [ + [ + "TURBID", + 1 + ] + ] + }, + "UCOL^URINE COLOR": { + "kind": "coded", + "valueType": "TX", + "dist": [ + [ + "YELLOW", + 1 + ] + ] + }, + "UPH^URINE PH": { + "kind": "numeric", + "units": "", + "ref": "5.0-8.0", + "mean": 7, + "sd": 0, + "min": 7, + "max": 7, + "abnormalRate": 0 + }, + "UTP^URINE TOTAL PROTEIN": { + "kind": "numeric", + "units": "mg/dL", + "ref": "NEG", + "mean": 30, + "sd": 0, + "min": 30, + "max": 30, + "abnormalRate": 1 + }, + "UGL^URINE GLUCOSE": { + "kind": "coded", + "valueType": "TX", + "dist": [ + [ + "NORMAL", + 1 + ] + ] + }, + "UKET^URINE KETONES": { + "kind": "coded", + "valueType": "TX", + "dist": [ + [ + "NEGATIVE", + 1 + ] + ] + }, + "UBIL^URINE BILIRUBIN": { + "kind": "coded", + "valueType": "TX", + "dist": [ + [ + "NEGATIVE", + 1 + ] + ] + }, + "UHGB^URINE HEMOGLOBIN": { + "kind": "coded", + "valueType": "TX", + "dist": [ + [ + "1+", + 1 + ] + ] + }, + "UROB^URINE UROBILINOGEN": { + "kind": "coded", + "valueType": "TX", + "dist": [ + [ + "NORMAL", + 1 + ] + ] + }, + "ULEU^URINE LEUKOCYTES": { + "kind": "coded", + "valueType": "TX", + "dist": [ + [ + "3+", + 1 + ] + ] + }, + "UNIT^URINE NITRITE": { + "kind": "coded", + "valueType": "TX", + "dist": [ + [ + "NEGATIVE", + 1 + ] + ] + }, + "USPG^URINE SPEC GRAVITY": { + "kind": "numeric", + "units": "", + "ref": "1.003-1.030", + "mean": 1.01, + "sd": 0, + "min": 1.015, + "max": 1.015, + "abnormalRate": 0 + }, + "UWBC^URINE WBC'S": { + "kind": "coded", + "valueType": "TX", + "dist": [ + [ + ">50", + 1 + ] + ] + }, + "URBC^URINE RBC'S": { + "kind": "coded", + "valueType": "TX", + "dist": [ + [ + "11-20", + 1 + ] + ] + }, + "EPI^SQUAMOUS EPITH. CELLS": { + "kind": "coded", + "valueType": "TX", + "dist": [ + [ + "NONE SEEN", + 1 + ] + ] + }, + "BACT^BACTERIA": { + "kind": "coded", + "valueType": "TX", + "dist": [ + [ + "SMALL", + 1 + ] + ] + }, + "MUCUR^MUCOUS": { + "kind": "coded", + "valueType": "TX", + "dist": [ + [ + "SMALL", + 1 + ] + ] + } + } + }, + "783^ALCOHOL, PLASMA^LAF": { + "obx": [ + "ALCO^ALCOHOL, PLASMA" + ], + "values": { + "ALCO^ALCOHOL, PLASMA": { + "kind": "numeric", + "units": "mg/dl", + "ref": "<10", + "mean": 353, + "sd": 0, + "min": 353, + "max": 353, + "abnormalRate": 1 + } + } + }, + "99498^VENOUS BLOOD GAS^LLA": { + "obx": [ + "PHV^pH", + "PCOV^pCO2", + "PO2V^pO2", + "HCOV^HCO3", + "SBEV^BASE EXCESS VEN", + "HBOV^O2 SAT", + "VHB^HB VENOUS", + "ABGTV^SPECIMEN SOURCE VEN" + ], + "values": { + "PHV^pH": { + "kind": "numeric", + "units": "", + "ref": "7.33-7.38", + "mean": 7.37, + "sd": 0, + "min": 7.37, + "max": 7.37, + "abnormalRate": 0 + }, + "PCOV^pCO2": { + "kind": "numeric", + "units": "mmHg", + "ref": "46.0-48.0", + "mean": 48, + "sd": 0, + "min": 48, + "max": 48, + "abnormalRate": 0 + }, + "PO2V^pO2": { + "kind": "numeric", + "units": "mmHg", + "ref": "35.0-45.0", + "mean": 200, + "sd": 0, + "min": 200, + "max": 200, + "abnormalRate": 1 + }, + "HCOV^HCO3": { + "kind": "numeric", + "units": "mEq/L", + "ref": "23.0-25.0", + "mean": 27.7, + "sd": 0, + "min": 27.7, + "max": 27.7, + "abnormalRate": 1 + }, + "SBEV^BASE EXCESS VEN": { + "kind": "numeric", + "units": "mmol/L", + "ref": "0-2.5", + "mean": 2, + "sd": 0, + "min": 2, + "max": 2, + "abnormalRate": 0 + }, + "HBOV^O2 SAT": { + "kind": "numeric", + "units": "%", + "ref": ">75.0", + "mean": 95.8, + "sd": 0, + "min": 95.8, + "max": 95.8, + "abnormalRate": 0 + }, + "VHB^HB VENOUS": { + "kind": "numeric", + "units": "gm%", + "ref": "12.0-15.0", + "mean": 9.5, + "sd": 0, + "min": 9.5, + "max": 9.5, + "abnormalRate": 1 + }, + "ABGTV^SPECIMEN SOURCE VEN": { + "kind": "coded", + "valueType": "TX", + "dist": [ + [ + "Venous", + 1 + ] + ] + } + } + }, + "10054^BASIC METABOLIC PANEL^LLA": { + "obx": [ + "NA^SODIUM", + "K^POTASSIUM", + "CL^CHLORIDE", + "CO2^CO2", + "AGAP^ANION GAP", + "GLUC^GLUCOSE", + "BUN^BLOOD UREA NITROGEN", + "CRET^CREATININE", + "CA^CALCIUM", + "GFRE^GLOMERULAR FILTRATION RATE" + ], + "values": { + "NA^SODIUM": { + "kind": "numeric", + "units": "mEq/L", + "ref": "136-145", + "mean": 139, + "sd": 0, + "min": 139, + "max": 139, + "abnormalRate": 0 + }, + "K^POTASSIUM": { + "kind": "numeric", + "units": "mEq/L", + "ref": "3.5-5.1", + "mean": 4.7, + "sd": 0, + "min": 4.7, + "max": 4.7, + "abnormalRate": 0 + }, + "CL^CHLORIDE": { + "kind": "numeric", + "units": "mEq/L", + "ref": "98-107", + "mean": 104, + "sd": 0, + "min": 104, + "max": 104, + "abnormalRate": 0 + }, + "CO2^CO2": { + "kind": "numeric", + "units": "mEq/L", + "ref": "21-31", + "mean": 29, + "sd": 0, + "min": 29, + "max": 29, + "abnormalRate": 0 + }, + "AGAP^ANION GAP": { + "kind": "numeric", + "units": "mEq/L", + "ref": "5-16", + "mean": 6, + "sd": 0, + "min": 6, + "max": 6, + "abnormalRate": 0 + }, + "GLUC^GLUCOSE": { + "kind": "numeric", + "units": "mg/dL", + "ref": "71-99", + "mean": 112, + "sd": 0, + "min": 112, + "max": 112, + "abnormalRate": 1 + }, + "BUN^BLOOD UREA NITROGEN": { + "kind": "numeric", + "units": "mg/dL", + "ref": "7-25", + "mean": 31, + "sd": 0, + "min": 31, + "max": 31, + "abnormalRate": 1 + }, + "CRET^CREATININE": { + "kind": "numeric", + "units": "mg/dL", + "ref": "0.6-1.2", + "mean": 1.2, + "sd": 0, + "min": 1.2, + "max": 1.2, + "abnormalRate": 0 + }, + "CA^CALCIUM": { + "kind": "numeric", + "units": "mg/dL", + "ref": "8.6-10.3", + "mean": 8.6, + "sd": 0, + "min": 8.6, + "max": 8.6, + "abnormalRate": 0 + }, + "GFRE^GLOMERULAR FILTRATION RATE": { + "kind": "numeric", + "units": "ml/min/1.73m2", + "ref": "", + "mean": 46, + "sd": 0, + "min": 46, + "max": 46, + "abnormalRate": 0 + } + } + }, + "443^VITAMIN B12^LAB": { + "obx": [ + "B12^VITAMIN B12" + ], + "values": { + "B12^VITAMIN B12": { + "kind": "numeric", + "units": "pg/mL", + "ref": "180-914", + "mean": 163, + "sd": 0, + "min": 163, + "max": 163, + "abnormalRate": 1 + } + } + }, + "1478^CRP C-REACTIVE PROTEIN^LAB": { + "obx": [ + "CRP^CRP C-REACTIVE PROTEIN" + ], + "values": { + "CRP^CRP C-REACTIVE PROTEIN": { + "kind": "numeric", + "units": "mg/dL", + "ref": "<1.0", + "mean": 0.6, + "sd": 0, + "min": 0.6, + "max": 0.6, + "abnormalRate": 0 + } + } + }, + "442^FOLATE^LAB": { + "obx": [ + "FOL^FOLATE" + ], + "values": { + "FOL^FOLATE": { + "kind": "numeric", + "units": "ng/mL", + "ref": ">3.0", + "mean": 15, + "sd": 0, + "min": 15, + "max": 15, + "abnormalRate": 0 + } + } + }, + "99730^DRUG SCREEN, URINE^LLA": { + "obx": [ + "AMPM^AMPHETAMINE", + "BARB^BARBITURATE", + "BENZ^BENZODIAZEPINES", + "THC^CANNABINOIDS", + "COCN^COCAINE", + "OP^OPIATES", + "PCP^PHENCYCLIDINE", + "OXY^OXYCODONE LEVEL", + "METD^METHADONE", + "FENTD^FENTANYL", + "DSPGR^SPECIFIC GRAVITY" + ], + "values": { + "AMPM^AMPHETAMINE": { + "kind": "coded", + "valueType": "TX", + "dist": [ + [ + "POSITIVE", + 1 + ] + ] + }, + "BARB^BARBITURATE": { + "kind": "coded", + "valueType": "TX", + "dist": [ + [ + "NEGATIVE", + 1 + ] + ] + }, + "BENZ^BENZODIAZEPINES": { + "kind": "coded", + "valueType": "TX", + "dist": [ + [ + "NEGATIVE", + 1 + ] + ] + }, + "THC^CANNABINOIDS": { + "kind": "coded", + "valueType": "TX", + "dist": [ + [ + "NEGATIVE", + 1 + ] + ] + }, + "COCN^COCAINE": { + "kind": "coded", + "valueType": "TX", + "dist": [ + [ + "NEGATIVE", + 1 + ] + ] + }, + "OP^OPIATES": { + "kind": "coded", + "valueType": "TX", + "dist": [ + [ + "NEGATIVE", + 1 + ] + ] + }, + "PCP^PHENCYCLIDINE": { + "kind": "coded", + "valueType": "TX", + "dist": [ + [ + "NEGATIVE", + 1 + ] + ] + }, + "OXY^OXYCODONE LEVEL": { + "kind": "coded", + "valueType": "TX", + "dist": [ + [ + "NEGATIVE", + 1 + ] + ] + }, + "METD^METHADONE": { + "kind": "coded", + "valueType": "TX", + "dist": [ + [ + "NEGATIVE", + 1 + ] + ] + }, + "FENTD^FENTANYL": { + "kind": "coded", + "valueType": "TX", + "dist": [ + [ + "NEGATIVE", + 1 + ] + ] + }, + "DSPGR^SPECIFIC GRAVITY": { + "kind": "numeric", + "units": "", + "ref": "1.003-1.030", + "mean": 1.02, + "sd": 0, + "min": 1.024, + "max": 1.024, + "abnormalRate": 0 + } + } + } + }, + "panelMix": [ + [ + "LC0O79H^HPV HIGH RISK DNA WITH HPV GENOTYPES 16 AND 18", + 0.025 + ], + [ + "LCP2RR^PSA, FREE AND TOTAL", + 0.008333 + ], + [ + "LCKDJ^Hematology Consult", + 0.008333 + ], + [ + "LCI0YT0^DEXAMETHASONE, SERUM", + 0.008333 + ], + [ + "LCURPVB^SHIGA TOXIN EIA", + 0.016667 + ], + [ + "LCEM83^CBC WITH DIFFERENTIAL", + 0.008333 + ], + [ + "LCMZJI^PT-INR", + 0.008333 + ], + [ + "LCP8KC^ALDOSTERONE^LCLAB1^^^", + 0.008333 + ], + [ + "LCCHQ^HEMOGLOBIN A1C^LCLAB1^^^", + 0.008333 + ], + [ + "LCBIOR4^MANUAL DIFFERENTIAL - CELLAVISION", + 0.008333 + ], + [ + "LCRFTC7^SYPHILIS (T. PALLIDUM) IGG/IGM", + 0.008333 + ], + [ + "LC5Q6^Extractable Nuclear Antibodies", + 0.008333 + ], + [ + "LCH47^pH Body Fluid", + 0.008333 + ], + [ + "LC8EZBZ^Antinuclear Abs w rflx Titer and Pattern^LCLAB3^29950-3^Nuclear IgG Ab [Presence] in Serum by Immunoassay^LN", + 0.008333 + ], + [ + "LCO8FDC^C. trachomatis / N. gonorrhoeae, DNA probe^LCLAB3^36902-5^Chlamydia trachomatis+Neisseria gonorrhoeae DNA [Presence] in Specimen by NAA with probe detection^LN", + 0.008333 + ], + [ + "LCZ17F3^Surgical^LCLAB5", + 0.008333 + ], + [ + "LCY3OHR^JAK 2 EXONS 12-15 MUTATION ANALYSIS (INCLUDES V617F)^LCLAB7^^^", + 0.008333 + ], + [ + "LCYXMZCWF8XO2^CBC WITH AUTO DIFFERENTIAL^^^^", + 0.008333 + ], + [ + "10054^BASIC METABOLIC PANEL^LAB", + 0.058333 + ], + [ + "10054^BASIC METABOLIC PANEL^LAF", + 0.041667 + ], + [ + "10052^LIVER (HEPATIC) FUNCTION PANEL^LAF", + 0.016667 + ], + [ + "99187^TYPE AND SCREEN (HOLD XM-CONVERTIBLE)^LAB", + 0.041667 + ], + [ + "8888^CBC/AUTOMATED DIFF^LAF", + 0.016667 + ], + [ + "89048^INFLUENZA A AND B POC PCR^LAF", + 0.016667 + ], + [ + "99684^GLUCOSE, POC^LAB", + 0.091667 + ], + [ + "10052^LIVER (HEPATIC) FUNCTION PANEL^LLA", + 0.008333 + ], + [ + "307^PHOSPHOROUS^LAB", + 0.008333 + ], + [ + "99145^TROPONIN I^LLA", + 0.025 + ], + [ + "10052^LIVER (HEPATIC) FUNCTION PANEL^LAB", + 0.008333 + ], + [ + "89048^INFLUENZA A AND B POC PCR^LAB", + 0.008333 + ], + [ + "99145^TROPONIN I^LAF", + 0.016667 + ], + [ + "2032^Acetylcholine Receptor Blocking Antibodies^LAB", + 0.008333 + ], + [ + "99698^Vitamin D, 25-Hydroxy, LC/MS/MS^LAB", + 0.008333 + ], + [ + "99730^DRUG SCREEN, URINE^LAB", + 0.016667 + ], + [ + "89096^PSA, Free and Total^LAB", + 0.008333 + ], + [ + "267^FERRITIN^LAB", + 0.008333 + ], + [ + "355^MAGNESIUM^LLA", + 0.008333 + ], + [ + "99145^TROPONIN I^LAB", + 0.05 + ], + [ + "327^LIPASE^LAF", + 0.016667 + ], + [ + "99684^GLUCOSE, POC^LAF", + 0.016667 + ], + [ + "355^MAGNESIUM^LAB", + 0.025 + ], + [ + "99271^HEPARIN TEST (ANTI XA)^LAB", + 0.025 + ], + [ + "99776^B-TYPE NATRIURETIC PEPTIDE^LAF", + 0.008333 + ], + [ + "8888^CBC/AUTOMATED DIFF^LAB", + 0.016667 + ], + [ + "439^CBC/NO DIFF^LAB", + 0.008333 + ], + [ + "1477^BETA-HCG^LAB", + 0.008333 + ], + [ + "226^SMEAR FOR MORPHOLOGY^LLA", + 0.008333 + ], + [ + "344^IRON^LAB", + 0.008333 + ], + [ + "9872^TSH^LAB", + 0.008333 + ], + [ + "701^URINALYSIS, ROUTINE^LAB", + 0.016667 + ], + [ + "89154^Tick Borne Disease Abs w RFLX^LAB", + 0.008333 + ], + [ + "99271^HEPARIN TEST (ANTI XA)^LLA", + 0.008333 + ], + [ + "1611^CORTISOL^LAB", + 0.008333 + ], + [ + "327^LIPASE^LAB", + 0.008333 + ], + [ + "427^PTT^LAB", + 0.016667 + ], + [ + "894^SERUM PREGNANCY TEST^LAF", + 0.008333 + ], + [ + "99947^CCMP and LFS^LAB", + 0.008333 + ], + [ + "701^URINALYSIS, ROUTINE^LAF", + 0.008333 + ], + [ + "2031^Acetylcholine Receptor Mod Ab^LAB", + 0.008333 + ], + [ + "1560^ANCA SCREEN W MPA PR3AB^LAB", + 0.008333 + ], + [ + "99684^GLUCOSE, POC^LLA", + 0.016667 + ], + [ + "10057^LACTATE^LAB", + 0.008333 + ], + [ + "8888^CBC/AUTOMATED DIFF^LLA", + 0.008333 + ], + [ + "701^URINALYSIS, ROUTINE^LLA", + 0.008333 + ], + [ + "783^ALCOHOL, PLASMA^LAF", + 0.008333 + ], + [ + "99498^VENOUS BLOOD GAS^LLA", + 0.008333 + ], + [ + "10054^BASIC METABOLIC PANEL^LLA", + 0.008333 + ], + [ + "443^VITAMIN B12^LAB", + 0.008333 + ], + [ + "1478^CRP C-REACTIVE PROTEIN^LAB", + 0.008333 + ], + [ + "442^FOLATE^LAB", + 0.008333 + ], + [ + "99730^DRUG SCREEN, URINE^LLA", + 0.008333 + ] + ], + "catalogs": { + "facility": [ + [ + "EDWINVILLE LABORATORY", + 0.002733 + ], + [ + "WEST JASMINECHESTER MEDICAL CENTER", + 0.000745 + ], + [ + "ANTIOCH DIAGNOSTICS", + 0.000497 + ], + [ + "CORYFIELD CLINIC", + 0.000497 + ], + [ + "LAKE ARMAND LABORATORY", + 0.000248 + ], + [ + "FORT VELMASTAD DIAGNOSTICS", + 0.000248 + ], + [ + "LAKE PETER CLINIC", + 0.000248 + ], + [ + "BRAKUSPORT CLINIC", + 0.000248 + ], + [ + "WEST MEGHAN MEDICAL CENTER", + 0.000248 + ], + [ + "SOUTH CASSANDRA HEALTH", + 0.327702 + ], + [ + "POWLOWSKIVIEW LABORATORY", + 0.15677 + ], + [ + "TREVORSTEAD CLINIC", + 0.043727 + ], + [ + "EAST CLARENCESHIRE MEDICAL CENTER", + 0.124224 + ], + [ + "MUELLERFORT MEDICAL CENTER", + 0.130932 + ], + [ + "FORT MADILYN DIAGNOSTICS", + 0.050186 + ], + [ + "FORT JANEBURGH CLINIC", + 0.082484 + ], + [ + "ERICHSTAD CLINIC", + 0.000497 + ], + [ + "FORT TODD DIAGNOSTICS", + 0.003975 + ], + [ + "CHESTERFIELD LABORATORY", + 0.073789 + ] + ], + "assigningAuthority": [ + [ + "YSBT_REG", + 0.005764 + ], + [ + "TXSD_REG", + 0.528571 + ], + [ + "AAAG_REG", + 0.199749 + ], + [ + "WOOV_REG", + 0.265915 + ] + ], + "lis": [ + [ + "DHPZOA", + 0.86087 + ], + [ + "UGUKWT", + 0.13913 + ] + ], + "provider": [ + [ + "6006^CARTWRIGHT^DENISE", + 0.000671 + ], + [ + "7416^BINS^DALE", + 0.000335 + ], + [ + "3270^MCCLURE^DAVONTE", + 0.000335 + ], + [ + "5443^CRONA^KARLA", + 0.000335 + ], + [ + "6456^YUNDT^JULIUS", + 0.000671 + ], + [ + "7708^BERGSTROM^CORENE", + 0.000335 + ], + [ + "7774^SCHNEIDER^JULES", + 0.001006 + ], + [ + "9323^RICE^TRACY", + 0.000335 + ], + [ + "1125^WHITE^THEODORE", + 0.000671 + ], + [ + "6774^MITCHELL^TEVIN", + 0.000335 + ], + [ + "4553^CASSIN^ALMA", + 0.000335 + ], + [ + "9531^DARE^RENEE", + 0.000335 + ], + [ + "4102^THIEL^JADYN", + 0.000335 + ], + [ + "5917^KUHN^LEO", + 0.000335 + ], + [ + "4903^TRANTOW^REX", + 0.000335 + ], + [ + "6508^PARISIAN^WILBERT", + 0.000335 + ], + [ + "4389^SIMONIS^EDISON", + 0.000335 + ], + [ + "6281^ROOB^ENA", + 0.000671 + ], + [ + "7037^JENKINS^MAE", + 0.000335 + ], + [ + "4872^DONNELLY^RACHEL", + 0.002348 + ], + [ + "8919^GLOVER^JUSTEN", + 0.001342 + ], + [ + "8219^LEANNON^JACK", + 0.000671 + ], + [ + "4084^BLOCK^ROGER", + 0.000671 + ], + [ + "8874^CROOKS^DENISE", + 0.001342 + ], + [ + "6332^VANDERVORT^CRISTINA", + 0.006709 + ], + [ + "5496^FRANEY^LORENZO", + 0.000335 + ], + [ + "3102^KREIGER^KARELLE", + 0.003355 + ], + [ + "6887^FADEL^AIMEE", + 0.003019 + ], + [ + "3919^LIND^REGAN", + 0.002684 + ], + [ + "8687^PROSACCO^ALEXANDRE", + 0.002348 + ], + [ + "7932^GERHOLD^RICKEY", + 0.012747 + ], + [ + "5015^GUTKOWSKI-CONROY^WESLEY", + 0.015767 + ], + [ + "6729^LEFFLER^AMANDA", + 0.000335 + ], + [ + "2443^HILLL^MARJOLAINE", + 0.000335 + ], + [ + "2697^ZBONCAK^ELEANOR", + 0.001677 + ], + [ + "7751^LANG^ELIANE", + 0.002348 + ], + [ + "1248^HOMENICK^JOSH", + 0.002013 + ], + [ + "9634^QUITZON^DAISHA", + 0.000335 + ], + [ + "3403^BREKKE^MARYJANE", + 0.000671 + ], + [ + "7895^KEEBLER^BROWN", + 0.001006 + ], + [ + "1705^KOCH^OLIVE", + 0.005032 + ], + [ + "4553^KIRLIN^RAQUEL", + 0.001342 + ], + [ + "4621^RYAN^ADELE", + 0.001342 + ], + [ + "5847^ULLRICH-SCHINNER^CHESTER", + 0.005367 + ], + [ + "2688^ZBONCAK^SYDNIE", + 0.001006 + ], + [ + "4607^CARTER^DONNIE", + 0.002684 + ], + [ + "8371^GLEICHNER-LESCH^FREEDA", + 0.001677 + ], + [ + "4902^BARTELL^ALBIN", + 0.000671 + ], + [ + "5698^MACGYVER^LUTHER", + 0.002013 + ], + [ + "3610^HAHN^HILBERT", + 0.001342 + ], + [ + "3226^WEHNER^DANIELA", + 0.001342 + ], + [ + "1787^DANIEL^ROLANDO", + 0.001342 + ], + [ + "2666^MARVIN^DIANA", + 0.001677 + ], + [ + "6976^DANIEL^RAHUL", + 0.000335 + ], + [ + "6349^MERTZ^APRIL", + 0.001006 + ], + [ + "5065^CUMMINGS-WIZA^LINWOOD", + 0.002348 + ], + [ + "7018^HERMAN^JAMEY", + 0.001006 + ], + [ + "5116^ROSENBAUM^DAGMAR", + 0.005367 + ], + [ + "4048^KOHLER^KAY", + 0.000671 + ], + [ + "7064^BAHRINGER^ADELLE", + 0.002348 + ], + [ + "7052^VOLKMAN^JEFFERY", + 0.001677 + ], + [ + "8733^PACOCHA^KEN", + 0.001677 + ], + [ + "7045^DIETRICH^RANDY", + 0.001342 + ], + [ + "4849^EMARD^OWEN", + 0.003355 + ], + [ + "7700^DOOLEY^AMANDA", + 0.000671 + ], + [ + "1998^BODE^TAMARA", + 0.005703 + ], + [ + "7401^SKILES^MAXIMO", + 0.013418 + ], + [ + "5302^CRONIN-DOUGLAS^CASSANDRA", + 0.000335 + ], + [ + "7563^ROMAGUERA^NAOMIE", + 0.002684 + ], + [ + "6743^REYNOLDS^RAYMOND", + 0.008722 + ], + [ + "1505^HUELS^SUE", + 0.002684 + ], + [ + "4331^COLE^KELLEY", + 0.002348 + ], + [ + "3353^SCHAEFER^CHAIM", + 0.000671 + ], + [ + "6151^DICKENS^CADE", + 0.002013 + ], + [ + "5959^REICHERT^AHMAD", + 0.003019 + ], + [ + "8742^ROSENBAUM^DRAKE", + 0.002684 + ], + [ + "1034^REMPEL^OLETA", + 0.000671 + ], + [ + "4373^MORAR^ABDIEL", + 0.000335 + ], + [ + "7974^CARTWRIGHT^SILVIA", + 0.000671 + ], + [ + "5888^GREENFELDER^ISAAC", + 0.000671 + ], + [ + "4997^HEGMANN^JACOB", + 0.000671 + ], + [ + "3832^KING^MARTIN", + 0.000335 + ], + [ + "2186^MOEN^JEROME", + 0.001006 + ], + [ + "8586^WEISSNAT^SALVATORE", + 0.002684 + ], + [ + "3264^WHITE^ADALINE", + 0.008722 + ], + [ + "9904^WEST^RYANN", + 0.003019 + ], + [ + "4958^KRIS^SINCERE", + 0.001006 + ], + [ + "5423^PAUCEK^CONSTANCE", + 0.003355 + ], + [ + "9684^CONSIDINE^KELLEN", + 0.003355 + ], + [ + "8151^PREDOVIC^MARIO", + 0.010399 + ], + [ + "2618^JAST^VIOLA", + 0.01476 + ], + [ + "3882^HIRTHE^DEMARCUS", + 0.009057 + ], + [ + "8135^HARRIS^RODOLFO", + 0.012412 + ], + [ + "3657^STEUBER^LARRY", + 0.012076 + ], + [ + "9083^GOTTLIEB^DANIELLA", + 0.003019 + ], + [ + "1123^HEATHCOTE^EVERETT", + 0.003355 + ], + [ + "2301^PFEFFER^DANIAL", + 0.004025 + ], + [ + "4821^BEER^LENNIE", + 0.002684 + ], + [ + "6834^LARKIN^ISRAEL", + 0.000671 + ], + [ + "4860^HEANEY^PAYTON", + 0.002684 + ], + [ + "4839^LANGOSH^MAKAYLA", + 0.007716 + ], + [ + "6701^YOST^CALE", + 0.003355 + ], + [ + "6752^KEEBLER^NAT", + 0.002013 + ], + [ + "6214^ZBONCAK^JEANETTE", + 0.006038 + ], + [ + "1967^GRAHAM^MAGDALENA", + 0.001342 + ], + [ + "4407^HAYES^JON", + 0.000671 + ], + [ + "1256^FAY^JUDITH", + 0.000335 + ], + [ + "9815^LUEILWITZ^DERRICK", + 0.001006 + ], + [ + "9199^LYNCH-HODKIEWICZ^JOEY", + 0.005032 + ], + [ + "1279^MURRAY^IZAIAH", + 0.001677 + ], + [ + "7376^LEGROS^CAROLYN", + 0.003355 + ], + [ + "7542^TORPHY^DOUGLAS", + 0.001342 + ], + [ + "1648^GREENFELDER^BARTHOLOME", + 0.002013 + ], + [ + "7057^CONROY^RACHAEL", + 0.001342 + ], + [ + "2725^EMMERICH^HENRIETTA", + 0.014089 + ], + [ + "6934^ARMSTRONG^GUNNER", + 0.000671 + ], + [ + "3889^SKILES-PRICE^AUBREY", + 0.002013 + ], + [ + "5266^KUHN^ISAIAH", + 0.000671 + ], + [ + "8893^HARBER^MAX", + 0.002348 + ], + [ + "2285^FERRY^KRISTIE", + 0.000335 + ], + [ + "1684^SCHULIST-GRAHAM^KARL", + 0.002684 + ], + [ + "9769^HANSEN^STANLEY", + 0.003019 + ], + [ + "7765^EBERT^ANDREA", + 0.000335 + ], + [ + "7244^RUNTE^CARL", + 0.001342 + ], + [ + "2740^MOEN^HORTENSE", + 0.004361 + ], + [ + "8629^SWANIAWSKI^VALENTINE", + 0.000335 + ], + [ + "1458^JONES^JAMES", + 0.000671 + ], + [ + "2129^BAUMBACH^TRICIA", + 0.001342 + ], + [ + "9495^HEGMANN-TREUTEL^TINA", + 0.009393 + ], + [ + "7157^PROHASKA^DEMETRIUS", + 0.003019 + ], + [ + "6404^CHRISTIANSEN^JUWAN", + 0.000671 + ], + [ + "3234^JONES^COLTON", + 0.002013 + ], + [ + "8436^MILLER^ROWENA", + 0.001006 + ], + [ + "3539^BUCKRIDGE^DAVON", + 0.000671 + ], + [ + "2182^MILLS^LEON", + 0.001006 + ], + [ + "6390^ROHAN^COREY", + 0.001342 + ], + [ + "7776^VONRUEDEN^LAVONNE", + 0.001677 + ], + [ + "8403^CASPER^BYRON", + 0.00369 + ], + [ + "6308^SCHAEFER^ALMA", + 0.000671 + ], + [ + "9977^WARD^LAWRENCE", + 0.001006 + ], + [ + "3377^FRAMI^ANNETTA", + 0.001342 + ], + [ + "9739^ROLFSON^JOAN", + 0.002013 + ], + [ + "7052^PARKER^CAITLYN", + 0.002013 + ], + [ + "7847^FEEST^FAYE", + 0.001677 + ], + [ + "8339^KEEBLER^HAZEL", + 0.001677 + ], + [ + "2744^PFEFFER^DIANNE", + 0.001677 + ], + [ + "5154^RENNER^RUSSELL", + 0.001677 + ], + [ + "4855^MCCLURE^STACEY", + 0.001006 + ], + [ + "3744^HARRIS^ANTWAN", + 0.001006 + ], + [ + "8907^ORN^MARGARET", + 0.000335 + ], + [ + "8630^WILL^HERBERT", + 0.001006 + ], + [ + "4030^ZEMLAK^BARRY", + 0.001342 + ], + [ + "4332^JERDE^BUSTER", + 0.001342 + ], + [ + "4553^SAUER^ELAINE", + 0.002684 + ], + [ + "9197^DICKENS^WENDELL", + 0.001006 + ], + [ + "2104^MCCULLOUGH^DESIREE", + 0.000671 + ], + [ + "2308^SWANIAWSKI^ROOSEVELT", + 0.002684 + ], + [ + "5462^WALSH^EDITH", + 0.001677 + ], + [ + "8964^RAYNOR^RUBY", + 0.000671 + ], + [ + "7697^MUELLER^ARTHUR", + 0.000671 + ], + [ + "8924^FUNK^GEOFFREY", + 0.001006 + ], + [ + "2140^GUTMANN^MAKENZIE", + 0.001342 + ], + [ + "1075^FEENEY^SALLY", + 0.002348 + ], + [ + "9843^SAUER^JAYDA", + 0.000335 + ], + [ + "6309^LIND^VERNICE", + 0.000335 + ], + [ + "4535^O'CONNELL^GUST", + 0.001006 + ], + [ + "2225^ERDMAN^LAUREL", + 0.002348 + ], + [ + "7719^WATERS^DONALD", + 0.000335 + ], + [ + "1644^OKUNEVA^KATRINA", + 0.000671 + ], + [ + "1944^HAYES^GERARDO", + 0.001677 + ], + [ + "7266^MACEJKOVIC^TREVA", + 0.001342 + ], + [ + "6371^RITCHIE^JOSHUAH", + 0.001342 + ], + [ + "1047^RUNTE^RAMONA", + 0.004025 + ], + [ + "6846^GORCZANY^LAURIANE", + 0.000671 + ], + [ + "8496^ZULAUF-CONNELLY^ALICE", + 0.000671 + ], + [ + "9746^PFANNERSTILL^GLENDA", + 0.004025 + ], + [ + "3284^BALISTRERI^AMBER", + 0.00369 + ], + [ + "8962^SCHINNER^TASHA", + 0.006038 + ], + [ + "9792^TOWNE-LEANNON^JACKLYN", + 0.001006 + ], + [ + "6446^HARVEY^KRISTIN", + 0.000335 + ], + [ + "4753^DAUGHERTY^VERONA", + 0.004361 + ], + [ + "1340^HAHN^GLORIA", + 0.002348 + ], + [ + "9583^KUVALIS^PRESTON", + 0.000335 + ], + [ + "8667^HOMENICK^DENISE", + 0.000335 + ], + [ + "9741^TOY^SHERMAN", + 0.000671 + ], + [ + "6687^CRONIN^CHERYL", + 0.000671 + ], + [ + "2774^SWIFT^ALEXANDRE", + 0.000335 + ], + [ + "5645^ALTENWERTH-FRITSCH^ALEXANDRA", + 0.001677 + ], + [ + "5411^NIKOLAUS^LORI", + 0.000671 + ], + [ + "4424^NADER^CARMEN", + 0.000671 + ], + [ + "9433^DENESIK^EDA", + 0.001677 + ], + [ + "3471^KIHN^JENIFER", + 0.000671 + ], + [ + "9675^WISOKY^MADELYN", + 0.000335 + ], + [ + "4904^KONOPELSKI^GILBERT", + 0.001006 + ], + [ + "3161^DARE-SCHUSTER^CAROLE", + 0.000335 + ], + [ + "9416^WHITE^JACOB", + 0.000335 + ], + [ + "6668^GLEASON^KATELYNN", + 0.000335 + ], + [ + "3894^FRAMI^BESSIE", + 0.000335 + ], + [ + "8829^CARTER^SUSIE", + 0.000335 + ], + [ + "8744^SCHMIDT^MAIA", + 0.000671 + ], + [ + "8270^LYNCH^MARIAH", + 0.000335 + ], + [ + "5682^SHANAHAN^VICKIE", + 0.000671 + ], + [ + "7677^WILKINSON^MABLE", + 0.000335 + ], + [ + "3020^ORN^MAGGIE", + 0.000671 + ], + [ + "4687^OSINSKI^URSULA", + 0.000335 + ], + [ + "9654^ROBERTS^LUZ", + 0.000335 + ], + [ + "6353^RUNOLFSSON^JASON", + 0.000335 + ], + [ + "6913^STEUBER^ALBINA", + 0.000335 + ], + [ + "1768^JOHNSON^ALEXANE", + 0.000335 + ], + [ + "3804^O'HARA^MADALINE", + 0.000335 + ], + [ + "6203^CREMIN-TREMBLAY^MELANIE", + 0.001342 + ], + [ + "3128^SCHILLER^SYLVESTER", + 0.000671 + ], + [ + "5317^BOGAN^MAXIMO", + 0.001677 + ], + [ + "9054^O'HARA^HECTOR", + 0.000335 + ], + [ + "3233^BERGSTROM-RATKE^DESHAUN", + 0.001677 + ], + [ + "6859^VEUM^CREOLA", + 0.002348 + ], + [ + "9454^EFFERTZ^LYLE", + 0.001006 + ], + [ + "7396^ROWE^ARYANNA", + 0.000335 + ], + [ + "2160^CASSIN^DENNIS", + 0.000671 + ], + [ + "8911^WOLF^ELWIN", + 0.000335 + ], + [ + "8190^KOCH^TOMAS", + 0.000335 + ], + [ + "6572^REINGER^DOMINICK", + 0.000335 + ], + [ + "3941^SCHMIDT^RAUL", + 0.000335 + ], + [ + "5802^JACOBS^TOMAS", + 0.001342 + ], + [ + "9377^MOSCISKI^DEMOND", + 0.000671 + ], + [ + "3617^LEUSCHKE^WOODROW", + 0.000671 + ], + [ + "4903^ZBONCAK^LORENZA", + 0.000671 + ], + [ + "7507^WALTER^EARLENE", + 0.001006 + ], + [ + "6419^GUTKOWSKI^ALVIN", + 0.000335 + ], + [ + "1444^WILKINSON^JUNIUS", + 0.000335 + ], + [ + "4179^BAUMBACH^ISABELL", + 0.000671 + ], + [ + "7470^STAMM^GUY", + 0.000335 + ], + [ + "5339^KRIS^URIEL", + 0.000671 + ], + [ + "6752^VONRUEDEN^SERGIO", + 0.000335 + ], + [ + "8648^LEUSCHKE^HENDERSON", + 0.000671 + ], + [ + "9800^WILKINSON^ADELINE", + 0.000335 + ], + [ + "4417^RITCHIE^FREDDIE", + 0.000335 + ], + [ + "6262^MORISSETTE^DELTA", + 0.000335 + ], + [ + "6901^REILLY^MAMIE", + 0.000335 + ], + [ + "3186^FEENEY^KARL", + 0.000335 + ], + [ + "1854^O'HARA^MARTY", + 0.000335 + ], + [ + "8319^KLEIN^MAVERICK", + 0.000335 + ], + [ + "2664^CORWIN^JEROMY", + 0.000335 + ], + [ + "1338^BAUMBACH^CLETA", + 0.000335 + ], + [ + "9829^MOSCISKI^KATHERINE", + 0.000671 + ], + [ + "7901^ORN^MARSHA", + 0.000335 + ], + [ + "2630^WUCKERT^STACEY", + 0.000335 + ], + [ + "6412^PAUCEK^RODNEY", + 0.000335 + ], + [ + "9725^CARROLL^ZACHARY", + 0.000335 + ], + [ + "4506^KIEHN^JAIME", + 0.001006 + ], + [ + "5613^DICKINSON^ANNA", + 0.001342 + ], + [ + "6028^BLANDA^HESTER", + 0.000335 + ], + [ + "8296^PROSACCO^ADOLF", + 0.000671 + ], + [ + "9842^SCHOEN^DARNELL", + 0.001006 + ], + [ + "4859^WEBER^WILLOW", + 0.001342 + ], + [ + "6791^HARVEY^BRYAN", + 0.000671 + ], + [ + "7014^COLE^LUCIE", + 0.001342 + ], + [ + "2844^LITTEL^SABRINA", + 0.000671 + ], + [ + "8830^MARQUARDT^LAVINIA", + 0.000335 + ], + [ + "3873^COLLIER^CELESTINO", + 0.001342 + ], + [ + "4070^WINTHEISER^JOHNNY", + 0.001677 + ], + [ + "9491^GRAHAM^DOMINGO", + 0.001342 + ], + [ + "1756^MACEJKOVIC^ISIDRO", + 0.000335 + ], + [ + "2657^WEST^LEATHA", + 0.000335 + ], + [ + "3264^WINDLER^GERALDINE", + 0.000335 + ], + [ + "7927^DOYLE^ELLA", + 0.000335 + ], + [ + "6389^WILLIAMSON^WINNIFRED", + 0.001342 + ], + [ + "4744^LANGWORTH^EMILY", + 0.000335 + ], + [ + "7919^LARSON^ELMIRA", + 0.001342 + ], + [ + "1265^KAUTZER^ANSEL", + 0.003019 + ], + [ + "2554^KUNZE^LAMONT", + 0.002348 + ], + [ + "3161^FERRY^DANIELLA", + 0.000335 + ], + [ + "2570^STARK^AMY", + 0.000671 + ], + [ + "7087^CONN^JUDY", + 0.000335 + ], + [ + "4805^COLLINS^ESTELLE", + 0.027172 + ], + [ + "7051^KOSS^DANDRE", + 0.000671 + ], + [ + "8604^FRITSCH^VIVIAN", + 0.00369 + ], + [ + "2196^ONDRICKA^JASMINE", + 0.010399 + ], + [ + "6425^PARKER^OLIN", + 0.013754 + ], + [ + "3766^CASSIN^ROOSEVELT", + 0.001342 + ], + [ + "4280^KSHLERIN^CHRISTIAN", + 0.018786 + ], + [ + "6840^KLEIN^MAYA", + 0.007045 + ], + [ + "6318^ABERNATHY^NATALIE", + 0.00369 + ], + [ + "1787^KASSULKE^JERAMIE", + 0.004361 + ], + [ + "6584^ABSHIRE^CASSANDRA", + 0.001342 + ], + [ + "6387^HAMMES^ABRAHAM", + 0.003355 + ], + [ + "7614^DENESIK^MICHELE", + 0.004696 + ], + [ + "1925^MRAZ^LUIS", + 0.001006 + ], + [ + "4486^LEGROS^KARLA", + 0.001342 + ], + [ + "2657^RYAN^JORDANE", + 0.000671 + ], + [ + "5309^HETTINGER^IRENE", + 0.000335 + ], + [ + "1269^LEGROS^ERIN", + 0.001006 + ], + [ + "6197^OLSON^DEANGELO", + 0.000335 + ], + [ + "9467^SCHUSTER^ILIANA", + 0.001006 + ], + [ + "7787^LEGROS^JUANITA", + 0.001006 + ], + [ + "9056^LOCKMAN^CEDRIC", + 0.000671 + ], + [ + "3555^STROSIN^LORENZO", + 0.000335 + ], + [ + "3719^SCHOWALTER^NICHOLE", + 0.000335 + ], + [ + "4068^DICKINSON^SERENITY", + 0.026501 + ], + [ + "9122^GRADY^JANICE", + 0.003355 + ], + [ + "2932^WINDLER^MYRTIE", + 0.004696 + ], + [ + "5119^GUTMANN^ELNA", + 0.002013 + ], + [ + "1112^WARD^PRESTON", + 0.000335 + ], + [ + "5742^WHITE^MYA", + 0.000671 + ], + [ + "7543^TURCOTTE^ROSEMARY", + 0.001342 + ], + [ + "5850^BARTOLETTI^MOSES", + 0.000335 + ], + [ + "6287^GORCZANY^KELSI", + 0.000671 + ], + [ + "6968^ROBERTS-HERMANN^SIMON", + 0.000671 + ], + [ + "3475^HOWELL^LUKE", + 0.002684 + ], + [ + "6611^RODRIGUEZ^HEIDI", + 0.00369 + ], + [ + "6972^ARMSTRONG^GERMAN", + 0.000335 + ], + [ + "2320^HAMILL^VICTOR", + 0.000671 + ], + [ + "7320^GLOVER^NORVAL", + 0.001006 + ], + [ + "6144^CORKERY^THEODORE", + 0.001006 + ], + [ + "2784^KING^TED", + 0.001006 + ], + [ + "4126^MAYER^JEFFREY", + 0.001342 + ], + [ + "6588^BRUEN^MAMIE", + 0.000335 + ], + [ + "1878^BUCKRIDGE^WANDA", + 0.000671 + ], + [ + "6504^HUDSON^DUDLEY", + 0.002013 + ], + [ + "6533^LAKIN^LEWIS", + 0.000335 + ], + [ + "1234^KSHLERIN^BARRY", + 0.000335 + ], + [ + "8752^WINDLER^CURTIS", + 0.000335 + ], + [ + "6300^MCGLYNN^OPAL", + 0.000671 + ], + [ + "2350^ARMSTRONG^GENEVA", + 0.000671 + ], + [ + "4259^HERZOG^SANTIAGO", + 0.001677 + ], + [ + "6810^RODRIGUEZ^SONJA", + 0.003019 + ], + [ + "2236^BRADTKE^JACKSON", + 0.000335 + ], + [ + "6879^LEBSACK^SETH", + 0.000335 + ], + [ + "6943^HINTZ^RYDER", + 0.001006 + ], + [ + "3354^LOCKMAN^MISTY", + 0.002013 + ], + [ + "1793^QUITZON^GRACIELA", + 0.000335 + ], + [ + "8177^HAMILL^LILLIE", + 0.001677 + ], + [ + "1375^JOHNS^HILDA", + 0.000671 + ], + [ + "2622^MERTZ^EMMA", + 0.000335 + ], + [ + "8476^MANTE^VELMA", + 0.001006 + ], + [ + "5980^O'CONNELL^ERVIN", + 0.000671 + ], + [ + "5143^FARRELL^DARRIN", + 0.000335 + ], + [ + "4673^DAVIS^LEONOR", + 0.000671 + ], + [ + "8912^VON^LAURIE", + 0.000335 + ], + [ + "4097^CROOKS^TOREY", + 0.001006 + ], + [ + "9109^BINS^KACEY", + 0.000335 + ], + [ + "8035^DIBBERT^LELA", + 0.000335 + ], + [ + "9736^BRAUN^KELLIE", + 0.000335 + ], + [ + "4134^STOLTENBERG^DEBORAH", + 0.005703 + ], + [ + "8814^HIRTHE^BETTY", + 0.000335 + ], + [ + "2068^DURGAN-ROGAHN^ALICIA", + 0.000671 + ], + [ + "2374^REMPEL^ROBERTA", + 0.000335 + ], + [ + "8545^BEAHAN^JAY", + 0.000335 + ], + [ + "2728^LEGROS^ADA", + 0.000335 + ], + [ + "3495^BOGAN^TYREE", + 0.000335 + ], + [ + "5475^CORMIER^MONSERRATE", + 0.000335 + ], + [ + "2614^CASSIN^LEOLA", + 0.00369 + ], + [ + "7310^STRACKE^TIARA", + 0.001677 + ], + [ + "6977^HERMAN^CARLEY", + 0.000671 + ], + [ + "5380^DICKENS^RODGER", + 0.001006 + ], + [ + "8450^ZULAUF^PENNY", + 0.000335 + ], + [ + "2099^DICKINSON^MARC", + 0.000335 + ], + [ + "7667^CARTER^GRAHAM", + 0.000335 + ], + [ + "6975^TURNER^LEONIE", + 0.000671 + ], + [ + "8110^HANSEN^DEBORAH", + 0.003355 + ], + [ + "1295^TERRY^MINNIE", + 0.00738 + ], + [ + "9273^WILLMS^JAMES", + 0.000335 + ], + [ + "6393^REINGER^TOMMY", + 0.000335 + ], + [ + "5311^SCHOWALTER^ROBYN", + 0.001677 + ], + [ + "7458^DAUGHERTY-FARRELL^LAVINA", + 0.000671 + ], + [ + "8895^KUNDE^MELANIE", + 0.005032 + ], + [ + "2983^HANE-BARTON^ANDY", + 0.001006 + ], + [ + "9012^JACOBSON^GINGER", + 0.000335 + ], + [ + "7756^SWIFT^ALEXANDER", + 0.000335 + ], + [ + "7352^RUNOLFSDOTTIR^ALFORD", + 0.000335 + ], + [ + "3382^PADBERG^STEPHON", + 0.000335 + ], + [ + "1780^RITCHIE^CRAIG", + 0.002684 + ], + [ + "4735^RUNOLFSDOTTIR^BOBBY", + 0.000335 + ], + [ + "3868^LITTLE^ELAINE", + 0.000671 + ], + [ + "3779^PARISIAN^NICOLE", + 0.017108 + ], + [ + "9485^FEEST^DASHAWN", + 0.001342 + ], + [ + "7823^REYNOLDS^ERICK", + 0.001342 + ], + [ + "9535^MERTZ^JOE", + 0.002013 + ], + [ + "7155^TORPHY^MARILYN", + 0.001677 + ], + [ + "3572^HACKETT^CAROLANNE", + 0.001677 + ], + [ + "7545^DENESIK^TERI", + 0.001342 + ], + [ + "8984^DANIEL^EDUARDO", + 0.001677 + ], + [ + "2538^LANGOSH^BRANSON", + 0.000671 + ], + [ + "7805^KOEPP^ALVIS", + 0.001342 + ], + [ + "9666^MARQUARDT^BILLY", + 0.001006 + ], + [ + "6235^YOST^RICKY", + 0.006038 + ], + [ + "8298^KUTCH^LAFAYETTE", + 0.013083 + ], + [ + "6867^RIPPIN^LYNETTE", + 0.000671 + ], + [ + "6914^HOEGER^KELLY", + 0.00369 + ], + [ + "4230^KLEIN^JARROD", + 0.000671 + ], + [ + "1794^BATZ^RAUL", + 0.001677 + ], + [ + "5393^BAUCH^ARJUN", + 0.001006 + ], + [ + "1977^POWLOWSKI^JANIE", + 0.000671 + ], + [ + "3034^WILDERMAN^LEIGH", + 0.000671 + ], + [ + "8667^KEMMER^EARL", + 0.000335 + ], + [ + "8000^GULGOWSKI^DEANNA", + 0.000335 + ], + [ + "6772^BODE^JORGE", + 0.000671 + ], + [ + "9669^BOGAN^ALEX", + 0.000335 + ], + [ + "7940^ROOB^EMERSON", + 0.001342 + ], + [ + "1990^OKUNEVA^BENNY", + 0.000335 + ], + [ + "3749^LEHNER^DEVYN", + 0.001006 + ], + [ + "6874^MANN^YESSENIA", + 0.002348 + ], + [ + "7997^HALEY^JOAN", + 0.000335 + ], + [ + "6495^BRUEN^KATE", + 0.000671 + ], + [ + "9960^WITTING^JODI", + 0.000671 + ], + [ + "9708^KESSLER^PRISCILLA", + 0.000671 + ], + [ + "1322^WILLMS-LYNCH^VAN", + 0.000335 + ], + [ + "7027^STEHR^MURL", + 0.001006 + ], + [ + "2409^QUIGLEY^JEDEDIAH", + 0.000335 + ], + [ + "2839^TORP^WALTON", + 0.000335 + ], + [ + "3740^KASSULKE^WINIFRED", + 0.000335 + ], + [ + "3301^WEBER^KRISTOPHER", + 0.000671 + ], + [ + "7061^HANE^BRADFORD", + 0.001006 + ], + [ + "7316^HINTZ^DORTHY", + 0.000671 + ], + [ + "8602^RENNER^MAURICIO", + 0.000671 + ], + [ + "6570^GERHOLD^CLARENCE", + 0.000335 + ], + [ + "6020^THIEL^RANDAL", + 0.000671 + ], + [ + "5452^BREKKE^EDISON", + 0.000335 + ], + [ + "6272^JOHNSTON^HOPE", + 0.000335 + ], + [ + "9288^HAMILL^KELLEN", + 0.000335 + ], + [ + "2958^FERRY-BREITENBERG^MAYE", + 0.000335 + ], + [ + "4941^JERDE^DOROTHEA", + 0.002348 + ], + [ + "6546^WINTHEISER^RANDOLPH", + 0.001006 + ], + [ + "9565^BALISTRERI^ALEXANDER", + 0.001342 + ], + [ + "8277^ABERNATHY^ETHYL", + 0.000671 + ], + [ + "8936^RAU-TERRY^SHELIA", + 0.000671 + ], + [ + "3731^MOEN^BROOKE", + 0.000335 + ], + [ + "5804^PADBERG^OLETA", + 0.000335 + ], + [ + "3050^HERMAN^PARKER", + 0.000335 + ], + [ + "1121^YOST^LEONARD", + 0.002684 + ], + [ + "2728^HODKIEWICZ^MARGIE", + 0.000671 + ], + [ + "2099^LEFFLER^RACHELLE", + 0.000671 + ], + [ + "3120^BOGISICH^VELMA", + 0.000335 + ], + [ + "7544^HERMAN^SHANE", + 0.000671 + ], + [ + "2984^BOYER^JAMAR", + 0.002013 + ], + [ + "8913^HAMMES^CLAY", + 0.000671 + ], + [ + "9455^BEDNAR^RITA", + 0.000335 + ], + [ + "1928^GOTTLIEB^VINCENZO", + 0.000671 + ], + [ + "6397^BAHRINGER^LISA", + 0.001677 + ], + [ + "3147^LAKIN^OLGA", + 0.000671 + ], + [ + "3969^TROMP^DALE", + 0.000335 + ], + [ + "6689^UPTON^LAURY", + 0.001342 + ], + [ + "2914^WALKER^DOMINICK", + 0.000671 + ], + [ + "4004^SCHULTZ^FERNANDO", + 0.000671 + ], + [ + "5839^MACEJKOVIC^BRET", + 0.000671 + ], + [ + "1275^BOTSFORD-BERGSTROM^RAPHAELLE", + 0.000671 + ], + [ + "5210^MCCULLOUGH^LURLINE", + 0.002013 + ], + [ + "6062^GRANT^NIGEL", + 0.000335 + ], + [ + "6378^RUNOLFSSON^DEVIN", + 0.000335 + ], + [ + "8130^LUETTGEN^JAKOB", + 0.000335 + ], + [ + "4643^BOSCO^MARILIE", + 0.000671 + ], + [ + "1028^LEGROS^GARRY", + 0.000671 + ], + [ + "8324^SCHMITT^JORDAN", + 0.000335 + ], + [ + "8321^ROLFSON^LILA", + 0.000335 + ], + [ + "3147^WINTHEISER^TOMMY", + 0.000335 + ], + [ + "8508^TILLMAN^LORENZO", + 0.000671 + ], + [ + "8599^POUROS^AARON", + 0.000335 + ], + [ + "3119^SIMONIS^OPAL", + 0.000335 + ], + [ + "3563^BOYLE^JOAN", + 0.001342 + ], + [ + "8327^KIHN^ANDREW", + 0.000671 + ], + [ + "1723^POUROS-GLEICHNER^ALIYA", + 0.000335 + ], + [ + "4253^TORP^BERNIECE", + 0.000335 + ], + [ + "6086^BOYER^LEO", + 0.000335 + ], + [ + "7478^MOHR^RAE", + 0.000335 + ], + [ + "4725^WARD^BETSY", + 0.000335 + ], + [ + "3861^KOZEY^CLAY", + 0.001006 + ], + [ + "4412^VOLKMAN^DEBORAH", + 0.000335 + ], + [ + "4347^GOTTLIEB^TAD", + 0.000671 + ], + [ + "2098^PAUCEK^COLEMAN", + 0.000671 + ], + [ + "8359^CORMIER^MARGARET", + 0.000335 + ], + [ + "7127^ROWE^MARK", + 0.000335 + ], + [ + "1809^RATH^KATHY", + 0.000335 + ], + [ + "9415^WYMAN^ELAINA", + 0.000335 + ], + [ + "8191^FEIL^KELVIN", + 0.000335 + ], + [ + "5448^SCHADEN^PEGGY", + 0.000335 + ], + [ + "4766^SCHUPPE^CHRIS", + 0.000335 + ], + [ + "7319^KONOPELSKI^JUAN", + 0.003355 + ], + [ + "1571^CORKERY^MELVIN", + 0.000335 + ], + [ + "3469^HARVEY^NADINE", + 0.000335 + ], + [ + "8264^GLOVER^IRIS", + 0.000335 + ], + [ + "1785^ROOB^GAYLE", + 0.000335 + ], + [ + "9935^RAU^DAMON", + 0.000335 + ], + [ + "9819^HAGENES^DEWAYNE", + 0.000671 + ], + [ + "2480^BATZ^JACQUES", + 0.000335 + ], + [ + "7064^BOGISICH^JAVIER", + 0.000671 + ], + [ + "3608^GOYETTE^LAURYN", + 0.000671 + ], + [ + "7574^BAUCH^ADOLF", + 0.000671 + ], + [ + "4967^DECKOW^ROOSEVELT", + 0.000335 + ], + [ + "2855^SCHOWALTER^BRANT", + 0.000335 + ], + [ + "5490^PACOCHA-DECKOW^RALPH", + 0.000335 + ], + [ + "8450^WEBER^EBONY", + 0.000335 + ], + [ + "5551^GREENHOLT^EMERY", + 0.000335 + ], + [ + "5432^JAST^KARI", + 0.000671 + ], + [ + "9915^ERNSER^GWENDOLYN", + 0.000335 + ], + [ + "3562^BREKKE^OLIVER", + 0.000335 + ], + [ + "6155^SWIFT^DARRELL", + 0.000335 + ], + [ + "9974^HINTZ^DERRICK", + 0.000335 + ], + [ + "8135^FEENEY^DAHLIA", + 0.001342 + ], + [ + "7175^BERNIER^MONSERRATE", + 0.000335 + ], + [ + "2510^SHANAHAN^HENRIETTA", + 0.000335 + ], + [ + "9211^BEDNAR^LORA", + 0.000335 + ], + [ + "7053^KUTCH^PERRY", + 0.000335 + ], + [ + "5514^LAKIN^CAROLYN", + 0.000335 + ], + [ + "2282^HEATHCOTE^VINCENZA", + 0.001006 + ], + [ + "7602^BERGSTROM^DEVANTE", + 0.000335 + ], + [ + "2151^HANSEN^VERNA", + 0.000335 + ], + [ + "6189^BEDNAR^KRISTA", + 0.001677 + ], + [ + "5841^BAILEY^MADELINE", + 0.000335 + ], + [ + "8737^WATSICA^ERNEST", + 0.000335 + ], + [ + "9441^YOST^DIANE", + 0.000335 + ], + [ + "9301^ROOB^VERDA", + 0.000335 + ], + [ + "8871^CONN^LILLIE", + 0.000335 + ], + [ + "6967^HETTINGER^FURMAN", + 0.000671 + ], + [ + "7044^LEGROS^STEPHANIE", + 0.000335 + ], + [ + "6865^KRIS^EFFIE", + 0.000335 + ], + [ + "4447^DOOLEY^MEGANE", + 0.000335 + ], + [ + "8062^PREDOVIC^LILLA", + 0.000335 + ], + [ + "4800^MAGGIO^KARL", + 0.000671 + ], + [ + "2544^SWIFT^SONIA", + 0.001342 + ], + [ + "8952^TURCOTTE^STAN", + 0.000335 + ], + [ + "8048^HUELS^GLADYS", + 0.000335 + ], + [ + "8790^HARBER-POUROS^SETH", + 0.001006 + ], + [ + "2926^BREITENBERG^HUGH", + 0.002013 + ], + [ + "8003^WINDLER^GUSTAVO", + 0.000335 + ], + [ + "7525^ROSENBAUM^JOSHUA", + 0.000335 + ], + [ + "5812^MILLS^HAYLEY", + 0.002684 + ], + [ + "6230^QUITZON^DALE", + 0.001342 + ], + [ + "4670^RATH^SHERRY", + 0.000671 + ], + [ + "6751^FRAMI^CARLEE", + 0.000671 + ], + [ + "8934^BAUMBACH^DANIEL", + 0.000671 + ], + [ + "9057^SIPES^MARTIN", + 0.000335 + ], + [ + "7910^MARVIN^DAWSON", + 0.000335 + ], + [ + "7815^SCHOWALTER^LOUIS", + 0.000335 + ], + [ + "7797^WOLF-MANN^D'ANGELO", + 0.000335 + ], + [ + "7897^MOHR^KAREEM", + 0.000335 + ], + [ + "5434^HETTINGER^MARGIE", + 0.000335 + ], + [ + "7910^ROHAN^ALEXANDER", + 0.002348 + ], + [ + "3688^WISOKY^LUCAS", + 0.000335 + ], + [ + "3079^ROOB^ARTHUR", + 0.000671 + ], + [ + "5040^BATZ^ALVIN", + 0.000335 + ], + [ + "9863^SCHAMBERGER^FERNANDO", + 0.000335 + ], + [ + "2800^BERNHARD^WILBER", + 0.000335 + ], + [ + "7873^BAHRINGER^CATHERINE", + 0.000335 + ], + [ + "7187^RENNER^BERT", + 0.000335 + ], + [ + "4347^ROMAGUERA^TAMIA", + 0.000671 + ], + [ + "5618^GOODWIN^LOWELL", + 0.000335 + ], + [ + "5455^BERNHARD^MAYMIE", + 0.001342 + ], + [ + "3933^REYNOLDS^KENDALL", + 0.000335 + ], + [ + "1306^HOEGER^IRA", + 0.000335 + ], + [ + "6758^ORTIZ^LARUE", + 0.000335 + ], + [ + "8572^GERLACH^MONIQUE", + 0.000335 + ], + [ + "1706^KILBACK^SHAYLEE", + 0.000335 + ], + [ + "1338^STROMAN^AVIS", + 0.000335 + ], + [ + "1509^BAUMBACH^OLIVIA", + 0.000335 + ], + [ + "9929^SCHINNER^CAMERON", + 0.001006 + ], + [ + "8170^BEER^ELLA", + 0.000335 + ], + [ + "5281^GREEN^THEODORE", + 0.000335 + ], + [ + "8770^CRONA^JERALD", + 0.000335 + ], + [ + "3028^KREIGER^JESSE", + 0.000335 + ], + [ + "8318^RATH^MUSTAFA", + 0.000671 + ], + [ + "3394^HOMENICK^AVIS", + 0.000335 + ], + [ + "9756^SAUER^DEMETRIS", + 0.000335 + ], + [ + "1416^LIND^ADDISON", + 0.000335 + ], + [ + "7713^MULLER^HERMAN", + 0.000335 + ], + [ + "7609^HIRTHE^CATHERINE", + 0.000335 + ], + [ + "1869^MANN^PETE", + 0.000335 + ], + [ + "3984^MARVIN^ETHEL", + 0.000335 + ], + [ + "6432^GRANT^ANN", + 0.000335 + ], + [ + "8869^ROBERTS^KYLEIGH", + 0.000335 + ], + [ + "2118^MITCHELL^MATHEW", + 0.000335 + ], + [ + "3307^HARRIS-HETTINGER^IRMA", + 0.000335 + ], + [ + "6570^NITZSCHE^ALVA", + 0.000335 + ], + [ + "9965^GRIMES^FORREST", + 0.000671 + ], + [ + "9762^KLOCKO^EDWIN", + 0.000335 + ], + [ + "5729^LANGOSH^YVETTE", + 0.000335 + ], + [ + "8404^FAHEY^MEGAN", + 0.001006 + ], + [ + "5956^KREIGER^BENNY", + 0.000335 + ], + [ + "5643^BATZ^CINDY", + 0.000671 + ], + [ + "6179^BERNIER-STROMAN^AMBROSE", + 0.000335 + ], + [ + "1892^MACGYVER^ROSALEE", + 0.000335 + ], + [ + "9965^JAKUBOWSKI^JENNIFER", + 0.000335 + ], + [ + "7160^EMMERICH^KEATON", + 0.000335 + ], + [ + "5191^KOHLER^GORDON", + 0.000335 + ], + [ + "5478^SWIFT^COLTEN", + 0.000335 + ], + [ + "8558^HAYES^AMYA", + 0.000335 + ], + [ + "9163^RATH^JACK", + 0.000335 + ], + [ + "6808^ROMAGUERA^MODESTA", + 0.000335 + ], + [ + "8395^HEANEY^MOISES", + 0.000335 + ], + [ + "5310^NITZSCHE^ERMA", + 0.000671 + ], + [ + "4482^GOYETTE^VIVIAN", + 0.001006 + ], + [ + "4606^MORISSETTE^JAZMIN", + 0.000335 + ], + [ + "3736^KOHLER^JACK", + 0.000335 + ], + [ + "7616^SIPES^TRINITY", + 0.000335 + ], + [ + "2163^HESSEL^GLENDA", + 0.000335 + ], + [ + "5887^MCCULLOUGH^EVA", + 0.000335 + ], + [ + "1670^JENKINS^CANDACE", + 0.000335 + ], + [ + "9042^BOSCO^ANN", + 0.000335 + ], + [ + "2306^KERLUKE^WESLEY", + 0.000335 + ], + [ + "2603^ZBONCAK^RUTHE", + 0.000335 + ], + [ + "6379^BEDNAR^RENE", + 0.000335 + ], + [ + "1251^MANN^LOMA", + 0.000335 + ], + [ + "2453^LEUSCHKE^LAWRENCE", + 0.000335 + ], + [ + "4650^KULAS^ROWENA", + 0.000671 + ], + [ + "4411^JACOBI^PERRY", + 0.000335 + ], + [ + "8173^MCLAUGHLIN^BREANNA", + 0.000335 + ], + [ + "3499^RAU^ESSIE", + 0.000335 + ], + [ + "8816^MACEJKOVIC-MACEJKOVIC^JEANNIE", + 0.000335 + ], + [ + "3044^BREITENBERG^JERALD", + 0.000335 + ], + [ + "2760^ERNSER^MAMIE", + 0.000335 + ], + [ + "3797^HILPERT^LAVERNE", + 0.000335 + ], + [ + "2388^DAUGHERTY^VIVIAN", + 0.000335 + ], + [ + "6924^BEAHAN^BRANDON", + 0.000335 + ], + [ + "2503^BOYER^ELLA", + 0.000335 + ], + [ + "5527^CRONIN^COLIN", + 0.000335 + ], + [ + "4689^KLOCKO^AVERY", + 0.000335 + ], + [ + "6463^O'REILLY^KARA", + 0.000335 + ], + [ + "6533^GOTTLIEB^RUDY", + 0.000335 + ], + [ + "7173^DENESIK^JOSEPH", + 0.000335 + ], + [ + "4023^ABSHIRE^EULALIA", + 0.000335 + ], + [ + "5228^BAHRINGER^SHANIA", + 0.000335 + ], + [ + "3926^DOUGLAS^ANNETTE", + 0.000335 + ], + [ + "4934^LOCKMAN^CAMILLE", + 0.000335 + ], + [ + "2936^AUER-KIHN^MABEL", + 0.000335 + ], + [ + "4563^BARTOLETTI^ASIA", + 0.000335 + ], + [ + "5962^GLEASON^KAYLEY", + 0.000335 + ], + [ + "3297^DENESIK^MAGNUS", + 0.000335 + ], + [ + "2833^CHRISTIANSEN^EDDIE", + 0.023482 + ], + [ + "6113^FEENEY^RUBY", + 0.000671 + ], + [ + "6802^HUDSON^SID", + 0.000335 + ], + [ + "1268^DARE^PABLO", + 0.000671 + ], + [ + "9612^KEEBLER^VERONA", + 0.001006 + ], + [ + "4029^SCHROEDER^ELDA", + 0.000335 + ], + [ + "4961^HEATHCOTE^FLOYD", + 0.000335 + ], + [ + "7214^HAYES^JULIE", + 0.000335 + ], + [ + "7573^BLICK^KRYSTAL", + 0.000671 + ], + [ + "4827^COLLINS^GWEN", + 0.000335 + ], + [ + "8277^DECKOW^ANGELITA", + 0.001006 + ], + [ + "3513^KOSS-TOWNE^FLOYD", + 0.000335 + ], + [ + "5470^HAUCK-SAUER^CRAIG", + 0.000335 + ], + [ + "6819^ROBERTS^ABNER", + 0.002348 + ], + [ + "6314^ROWE^ESSIE", + 0.001342 + ], + [ + "7669^YOST^DAVE", + 0.000335 + ], + [ + "4230^PROHASKA^IRVIN", + 0.000335 + ], + [ + "4848^WEIMANN^DEJA", + 0.001342 + ], + [ + "5935^SAUER^TED", + 0.001342 + ], + [ + "6892^ROWE^BRADLEY", + 0.000671 + ], + [ + "7205^ONDRICKA^KACI", + 0.001006 + ], + [ + "9053^REICHEL^HERBERT", + 0.001677 + ], + [ + "8734^JAKUBOWSKI^WESTLEY", + 0.001006 + ], + [ + "5401^PROHASKA^KAITLYN", + 0.000335 + ], + [ + "9819^VONRUEDEN-KASSULKE^JOANNA", + 0.000335 + ], + [ + "2794^OSINSKI^RODRIGO", + 0.000671 + ], + [ + "8494^JACOBS^JACQUELINE", + 0.000335 + ], + [ + "2813^WOLF^KRISTA", + 0.001006 + ], + [ + "4179^RENNER^PALMA", + 0.000335 + ], + [ + "8191^O'KEEFE^FRIDA", + 0.000335 + ], + [ + "7195^WEHNER^OSCAR", + 0.000335 + ], + [ + "4651^SCHAMBERGER^JORDAN", + 0.000335 + ], + [ + "7949^ZIEMANN^JOSEPH", + 0.005367 + ], + [ + "8559^KEMMER^JOSH", + 0.000335 + ], + [ + "3836^WEHNER^DAVION", + 0.000335 + ], + [ + "5729^JACOBS^HILTON", + 0.000335 + ], + [ + "7424^KING^RODOLFO", + 0.000335 + ], + [ + "3271^ABBOTT^WELDON", + 0.000335 + ], + [ + "6968^LESCH^KIANA", + 0.000335 + ], + [ + "2916^ORTIZ^JIMMY", + 0.000335 + ], + [ + "1933^NOLAN^JARRET", + 0.000335 + ], + [ + "8141^RUNOLFSDOTTIR^TOM", + 0.000671 + ], + [ + "6216^CRONIN^CURTIS", + 0.000335 + ], + [ + "8585^JACOBSON^NATASHA", + 0.000335 + ], + [ + "6095^RUNTE^CLYDE", + 0.000335 + ], + [ + "5899^CONNELLY^ANNALISE", + 0.000335 + ], + [ + "4323^HERZOG^BRADLY", + 0.000671 + ], + [ + "3290^HEGMANN^LINDSAY", + 0.000335 + ], + [ + "9916^QUIGLEY^YOLANDA", + 0.000335 + ], + [ + "6438^CONSIDINE^WM", + 0.001006 + ], + [ + "5460^MCLAUGHLIN^BRIDGETTE", + 0.000671 + ], + [ + "1072^MONAHAN^MICHAEL", + 0.000335 + ], + [ + "2797^DACH^TRISTON", + 0.000335 + ], + [ + "9175^FERRY^EVELYN", + 0.001006 + ], + [ + "5788^MARKS^SHAWN", + 0.001006 + ], + [ + "7604^JACOBI^DOREEN", + 0.000335 + ], + [ + "7191^HILPERT^MELYSSA", + 0.000335 + ], + [ + "7304^TILLMAN^GRADY", + 0.000335 + ], + [ + "1260^UPTON^SABINA", + 0.000335 + ], + [ + "8974^CARTWRIGHT^JERALD", + 0.000335 + ], + [ + "5382^VON^ENA", + 0.000671 + ], + [ + "8059^RUTHERFORD^IGNACIO", + 0.000335 + ], + [ + "3039^LITTLE-TURCOTTE^FRANCES", + 0.000335 + ], + [ + "9926^RUNOLFSDOTTIR^ASHA", + 0.001006 + ], + [ + "6526^BOTSFORD^LARON", + 0.001006 + ], + [ + "1130^WITTING^EARLENE", + 0.002013 + ], + [ + "3299^ALTENWERTH^LESTER", + 0.001677 + ], + [ + "5844^LINDGREN^ANTHONY", + 0.000335 + ], + [ + "6580^SPINKA^BLAISE", + 0.000335 + ], + [ + "3271^CRIST^DOMINICK", + 0.000671 + ], + [ + "7307^ROGAHN^FELICIA", + 0.000335 + ], + [ + "8954^BAHRINGER^JUSTINE", + 0.000671 + ], + [ + "7679^EFFERTZ^ALONZO", + 0.000335 + ], + [ + "7200^ARMSTRONG^WOODROW", + 0.000335 + ], + [ + "2209^CROOKS^DEDRICK", + 0.000335 + ], + [ + "8616^BOSCO^KAVON", + 0.001006 + ], + [ + "3861^AUER^BELINDA", + 0.001342 + ], + [ + "3525^POUROS^ROSELLA", + 0.001342 + ], + [ + "2363^PREDOVIC^DEMARCUS", + 0.000671 + ], + [ + "8081^GORCZANY^STELLA", + 0.000671 + ], + [ + "6770^LYNCH^NEOMA", + 0.001006 + ], + [ + "7300^WOLFF^LONNY", + 0.000335 + ], + [ + "3937^QUITZON^MELINDA", + 0.000671 + ], + [ + "5815^MOORE^KATRINA", + 0.000335 + ], + [ + "1846^CARTER^MILTON", + 0.000335 + ], + [ + "4399^VANDERVORT^MAYMIE", + 0.000335 + ], + [ + "9561^BAUMBACH^JESS", + 0.000335 + ], + [ + "6275^HOEGER^TRACEY", + 0.000335 + ], + [ + "4468^KUNZE^SHAWNA", + 0.000671 + ], + [ + "8389^ZIEME^ELVERA", + 0.000335 + ], + [ + "2435^HUDSON^LUCILE", + 0.001006 + ], + [ + "2865^GUTMANN^OVA", + 0.000335 + ], + [ + "5487^JACOBI^DOREEN", + 0.000335 + ], + [ + "9750^RIPPIN^DARLENE", + 0.000335 + ], + [ + "3288^STEHR^JARED", + 0.000335 + ], + [ + "8573^KIHN^JALEN", + 0.000671 + ], + [ + "5183^FEIL-FARRELL^NORMAN", + 0.000335 + ], + [ + "7601^ZULAUF^BONNIE", + 0.000335 + ], + [ + "6053^TURCOTTE^LILA", + 0.000335 + ], + [ + "3778^SPINKA^LUCIA", + 0.000335 + ], + [ + "6626^SCHROEDER^GLEN", + 0.000335 + ], + [ + "2025^CREMIN^BARRY", + 0.000335 + ], + [ + "6180^BINS^LORA", + 0.000335 + ], + [ + "1765^JENKINS^STEVEN", + 0.000335 + ], + [ + "4757^CARROLL^MARGARITA", + 0.000335 + ], + [ + "7161^PADBERG^MARK", + 0.000335 + ], + [ + "4636^KEELING^KYLE", + 0.000335 + ], + [ + "8770^LEANNON^CHERYL", + 0.000335 + ], + [ + "9211^BRAUN^KATIE", + 0.000335 + ], + [ + "6724^RUNTE^BERYL", + 0.000335 + ], + [ + "8346^KLOCKO^TYRESE", + 0.000335 + ], + [ + "7867^SCHNEIDER^DEBORAH", + 0.000671 + ], + [ + "5960^SCHMITT^EMILY", + 0.000335 + ] + ], + "app": [ + [ + "HKN_HUB", + 0.003057 + ], + [ + "ZDK_IF", + 0.000764 + ], + [ + "VCW_SYS", + 0.001019 + ], + [ + "WXD_SYS", + 0.000764 + ], + [ + "HHA_SYS", + 0.000255 + ], + [ + "AXG_GW", + 0.177325 + ], + [ + "QAC_GW", + 0.127389 + ], + [ + "QVK_HUB", + 0.025478 + ], + [ + "PAR_IF", + 0.025223 + ], + [ + "ATO_HUB", + 0.008153 + ], + [ + "CJH_SYS", + 0.012739 + ], + [ + "NUE_SYS", + 0.00051 + ], + [ + "QLO_HUB", + 0.219618 + ], + [ + "TJL_SYS", + 0.296561 + ], + [ + "CQC_IF", + 0.025478 + ], + [ + "RET_HUB", + 0.075669 + ] + ] + }, + "temporal": { + "sendYearRange": [ + 2025, + 2026 + ], + "collectToResultMins": { + "mean": 63, + "sd": 35 + } + }, + "idFormats": { + "mrn": "########", + "controlId": "GEN-##########", + "placer": "PL#########", + "filler": "FL#########", + "visit": "V#########" + } +} \ No newline at end of file diff --git a/utils/hl7v2-simulator/package.json b/utils/hl7v2-simulator/package.json new file mode 100644 index 0000000..f1858eb --- /dev/null +++ b/utils/hl7v2-simulator/package.json @@ -0,0 +1,24 @@ +{ + "name": "hl7v2-simulator", + "description": "Synthetic HL7v2 generator and multi-source upstream simulator for Interbox.", + "license": "MIT", + "private": true, + "type": "module", + "scripts": { + "test": "bun test", + "typecheck": "tsc --noEmit", + "gen": "bun run src/cli.ts", + "send": "bun run src/send-cli.ts", + "selftest": "bun run src/validate/selftest.ts", + "ui": "bun run ui/server.ts", + "ui:dev": "bun --hot ui/server.ts" + }, + "dependencies": { + "@atomic-ehr/hl7v2": "^0.0.1", + "@faker-js/faker": "^10.4.0" + }, + "devDependencies": { + "@types/bun": "^1.3.14", + "typescript": "^5.9.3" + } +} diff --git a/utils/hl7v2-simulator/src/cli.ts b/utils/hl7v2-simulator/src/cli.ts new file mode 100644 index 0000000..c35bc2b --- /dev/null +++ b/utils/hl7v2-simulator/src/cli.ts @@ -0,0 +1,122 @@ +import { join } from "node:path"; +import { mkdir, rm, readdir } from "node:fs/promises"; +import { Rng } from "./gen/rng.ts"; +import { fakerNames } from "./gen/names.ts"; +import { parseProfile } from "./profile/schema.ts"; +import { generateMessage } from "./gen/assemble.ts"; +import { FAULTS } from "./gen/faults.ts"; +import { classify } from "./validate/classify.ts"; +import { toRow, type MessageRow } from "./gen/row.ts"; +import { DEFAULT_PROFILE } from "./paths.ts"; + +// usage: bun run src/cli.ts +// [--profile f.json] (default fixtures/profile.json) +// [--output csv|jsonl] (write out.csv / out.jsonl) +// [--out-dir dir] (write one .hl7 file per message into dir — for folder/batch ingest) +// [--types A,B,C] (force an exact message-type mix, equal weights; default = profile mix) +// [--clean] (with --out-dir: delete existing files in dir first) +// [--locale en|de] +// +// To send generated traffic over MLLP, use the sender CLI: `bun run send` +// (src/send-cli.ts) — batch + live stream modes. +// +// Content comes ENTIRELY from the profile (aggregate distributions) + synthetic +// identity — no real data is read at generation time. +const args = process.argv.slice(2); +const count = Number(args[0] ?? 1000); +const faultRate = Number(args[1] ?? 0.1); +const seed = Number(args[2] ?? 42); +const flag = (n: string) => { const i = args.indexOf(n); return i >= 0 ? args[i + 1] : undefined; }; +const outFmt = flag("--output"); +const profilePath = flag("--profile") ?? DEFAULT_PROFILE; +const channel = flag("--channel") ?? "mllp-default"; +const outDir = flag("--out-dir"); +const doClean = args.includes("--clean"); +const typesArg = flag("--types"); +const localeArg = flag("--locale") ?? "en"; +if (localeArg !== "en" && localeArg !== "de") { + console.error(`--locale must be "en" or "de", got: ${localeArg}`); + process.exit(1); +} +const locale = localeArg as "en" | "de"; + +const profile = parseProfile(await Bun.file(profilePath).text()); +// Code-mapping demo knobs (override the profile): fraction of ORU using local +// codes, and of those the fraction emitted dual-coded (with the LOINC answer). +const lcr = flag("--local-code-rate"); if (lcr !== undefined) profile.localCodeRate = Number(lcr); +const mr = flag("--mapped-rate"); if (mr !== undefined) profile.mappedRate = Number(mr); +// --types: force a round-robin mix (validated against what the profile knows), so +// every listed type appears — evenly — regardless of the profile's own weights. +const forcedTypes = typesArg ? typesArg.split(",").map((s) => s.trim()).filter(Boolean) : null; +const knownTypes = new Set(profile.messageTypes.map(([t]) => t)); +if (forcedTypes) { + const bad = forcedTypes.filter((t) => !knownTypes.has(t)); + if (bad.length) { + console.error(`--types: unknown for this profile: ${bad.join(", ")}\navailable: ${[...knownTypes].join(", ")}`); + process.exit(1); + } +} +const rng = new Rng(seed); +const names = fakerNames(locale, seed); +const rows: MessageRow[] = []; +const outFiles: { msg: string; type: string }[] = []; + +for (let i = 0; i < count; i++) { + if (forcedTypes) profile.messageTypes = [[forcedTypes[i % forcedTypes.length]!, 1]]; + const gen = generateMessage(rng, profile, names, i); + let msg = gen.msg; + // Generated messages are valid + mappable by construction -> received. + // Only an injected fault can break a message; the engine's parser names the + // kind (a benign fault may still classify "ok" -> stays received). + if (rng.next() < faultRate) { + msg = rng.pick(FAULTS).apply(msg); + const c = classify(msg, knownTypes); + if (c.kind !== "ok") { + rows.push(toRow(msg, "error", channel, { errorKind: c.kind, errorMessage: c.detail })); + if (outDir) outFiles.push({ msg, type: gen.type }); + continue; + } + } + rows.push(toRow(msg, "received", channel)); + if (outDir) outFiles.push({ msg, type: gen.type }); +} + +const tally = (key: (r: MessageRow) => string) => { + const m = new Map(); + for (const r of rows) m.set(key(r), (m.get(key(r)) ?? 0) + 1); + return [...m.entries()].sort((a, b) => b[1] - a[1]); +}; +console.log(`generated ${rows.length} from ${profilePath} (faultRate=${faultRate}, seed=${seed})`); +console.log("by status:", tally((r) => r.status)); +console.log("by type: ", tally((r) => r.message_type ?? "?")); +console.log("by source:", tally((r) => r.source ?? "?")); +console.log("by error: ", tally((r) => r.error_kind ?? "-")); + +if (outFmt === "jsonl") { + await Bun.write("out.jsonl", rows.map((r) => JSON.stringify(r)).join("\n")); + console.log("wrote out.jsonl"); +} else if (outFmt === "csv") { + const cols = Object.keys(rows[0] ?? {}); + const esc = (v: unknown) => `"${String(v ?? "").replaceAll('"', '""')}"`; + const csv = [cols.join(","), ...rows.map((r) => cols.map((c) => esc((r as unknown as Record)[c])).join(","))].join("\n"); + await Bun.write("out.csv", csv); + console.log("wrote out.csv"); +} + +// Folder/batch output: one raw .hl7 per message (CR-separated segments, no MLLP +// framing), named msg--.hl7 — exactly what folderSource/hl7v2Parser drains. +if (outDir) { + await mkdir(outDir, { recursive: true }); + if (doClean) { + for (const f of await readdir(outDir)) await rm(join(outDir, f), { force: true }); + } + const width = String(outFiles.length).length; + let k = 0; + for (const { msg, type } of outFiles) { + k += 1; + const name = `msg-${String(k).padStart(width, "0")}-${type.replace(/\^/g, "_")}.hl7`; + await Bun.write(join(outDir, name), msg); + } + console.log(`wrote ${outFiles.length} .hl7 files to ${outDir}${doClean ? " (cleaned first)" : ""}`); +} + diff --git a/utils/hl7v2-simulator/src/gen/assemble.ts b/utils/hl7v2-simulator/src/gen/assemble.ts new file mode 100644 index 0000000..474faa3 --- /dev/null +++ b/utils/hl7v2-simulator/src/gen/assemble.ts @@ -0,0 +1,34 @@ +import type { Rng } from "./rng.ts"; +import type { NameProvider } from "./names.ts"; +import type { Profile } from "../profile/schema.ts"; +import { makeIdentity } from "./sample/identity.ts"; +import { BUILDERS } from "./grammar/message-types.ts"; + +export interface GeneratedMessage { + msg: string; + type: string; + event: string; +} + +export interface GenerateOpts { + /** Pin MSH-7 (and the derived collect time) to this instant instead of + * sampling the profile's year range. */ + now?: Date; +} + +/** + * Build one synthetic HL7v2 message by sampling the profile and filling a + * grammar skeleton — valid by construction. No real data is touched; everything + * comes from `profile` (aggregate distributions) + synthetic identity. + */ +export function generateMessage(rng: Rng, profile: Profile, names: NameProvider, index: number, opts?: GenerateOpts): GeneratedMessage { + const mt = rng.weighted(profile.messageTypes); + const caret = mt.indexOf("^"); + const type = caret >= 0 ? mt.slice(0, caret) : mt; + const event = caret >= 0 ? mt.slice(caret + 1) : ""; + const builder = BUILDERS[type]; + if (!builder) throw new Error(`no builder registered for message type "${type}" (from "${mt}")`); + const id = makeIdentity(rng, names, profile, index, opts?.now); + const segs = builder({ rng, profile, id, index }, event); + return { msg: segs.join("\r"), type, event }; +} diff --git a/utils/hl7v2-simulator/src/gen/faults.ts b/utils/hl7v2-simulator/src/gen/faults.ts new file mode 100644 index 0000000..9ee82f2 --- /dev/null +++ b/utils/hl7v2-simulator/src/gen/faults.ts @@ -0,0 +1,44 @@ +import { getComponent, getField, segments, setField } from "../hl7/message.ts"; + +export interface Fault { + id: string; + intendedKind: string; // hypothesis; VERIFIED against the real parser in Task 9 + apply: (msg: string) => string; +} + +const dropSegment = (msg: string, segId: string) => + segments(msg).filter((s) => !s.startsWith(segId + "|")).join("\r"); + +export const FAULTS: Fault[] = [ + { id: "no_msh", intendedKind: "parse_error", apply: (m) => dropSegment(m, "MSH") }, + { id: "bad_encoding_chars", intendedKind: "map_error", apply: (m) => m.replace("|^~\\&|", "|@#$%|") }, + { id: "truncated", intendedKind: "map_error", apply: (m) => m.slice(0, Math.floor(m.length / 2)) }, + { id: "bad_datetime", intendedKind: "ok", apply: (m) => setField(m, "MSH", 7, "20261399ZZ") }, + { id: "no_pid", intendedKind: "map_error", apply: (m) => dropSegment(m, "PID") }, + { id: "unknown_type", intendedKind: "map_error", apply: (m) => setField(m, "MSH", 9, "ZZZ^Z99") }, + { id: "pid_in_z_segment", intendedKind: "map_error", apply: (m) => { + const mrn = getField(m, "PID", 3); + return setField(m, "PID", 3, "") + `\rZPI|1|${mrn}`; + } }, + { id: "missing_patient_id", intendedKind: "map_error", apply: (m) => setField(m, "PID", 3, "") }, + { id: "future_dob", intendedKind: "ok", apply: (m) => setField(m, "PID", 7, "29991231") }, + { id: "pid_in_pid_2", intendedKind: "map_error", apply: (m) => { + const mrn = getField(m, "PID", 3); + return setField(setField(m, "PID", 3, ""), "PID", 2, mrn); + } }, + { id: "mrn_with_subcomponent", intendedKind: "ok", apply: (m) => { + const pid3 = getField(m, "PID", 3); + const mrn = getComponent(pid3, 1); + const rest = pid3.includes("^") ? pid3.slice(pid3.indexOf("^")) : ""; + return setField(m, "PID", 3, `${mrn}&XYZ&ISO${rest}`); + } }, + { id: "multiple_pid3_repetitions", intendedKind: "ok", apply: (m) => { + return setField(m, "PID", 3, `${getField(m, "PID", 3)}~M2^^^B^MR`); + } }, +]; + +export function applyFault(msg: string, id: string): string { + const f = FAULTS.find((x) => x.id === id); + if (!f) throw new Error(`unknown fault: ${id}`); + return f.apply(msg); +} diff --git a/utils/hl7v2-simulator/src/gen/grammar/message-types.ts b/utils/hl7v2-simulator/src/gen/grammar/message-types.ts new file mode 100644 index 0000000..935d336 --- /dev/null +++ b/utils/hl7v2-simulator/src/gen/grammar/message-types.ts @@ -0,0 +1,259 @@ +import type { Rng } from "../rng.ts"; +import type { CodeMapEntry, Profile } from "../../profile/schema.ts"; +import type { Identity } from "../sample/identity.ts"; +import { sampleCategorical, sampleResult, sampleWeighted } from "../sample/sampler.ts"; + +export interface GenContext { + rng: Rng; + profile: Profile; + id: Identity; + index: number; +} + +// Receiving side (MSH-5 / MSH-6) — the stand being fed, not something the +// corpus can tell us. Defaults to the Interbox engine; override when you point +// the simulator at an interface engine that routes on the receiver fields. +const RECEIVING_APP = process.env.RECEIVING_APP || "INTERBOX"; +const RECEIVING_FACILITY = process.env.RECEIVING_FACILITY || "INTERBOX"; + +// ── shared segment builders ──────────────────────────────────────────────── + +function buildMsh(ctx: GenContext, type: string, event: string): string { + const { rng, profile, id } = ctx; + const app = sampleWeighted(rng, profile.catalogs.app); + const fac = sampleWeighted(rng, profile.catalogs.facility); + return `MSH|^~\\&|${app}|${fac}|${RECEIVING_APP}|${RECEIVING_FACILITY}|${id.sendTime}||${type}^${event}|${id.controlId}|P|2.5.1`; +} + +function buildPid(ctx: GenContext): string { + const { rng, profile, id } = ctx; + const aa = sampleWeighted(rng, profile.catalogs.assigningAuthority); + // PID-3 = MRN^^^assigningAuthority^MR ; PID-5 = family^given ; PID-7 = DOB ; PID-8 = sex + return `PID|1||${id.mrn}^^^${aa}^MR||${id.family}^${id.given}||${id.dob}|${id.sex}`; +} + +function buildPv1(ctx: GenContext): string { + const { rng, profile, id } = ctx; + const cls = sampleCategorical(rng, profile.fields["PV1-2"], "I"); + const provider = sampleWeighted(rng, profile.catalogs.provider); + // PV1-2 patient class, PV1-7 attending provider, PV1-19 visit number. The + // v2-to-FHIR IG keys the Encounter on PV1-19; without it ADT/ORU visit + // conversion fails with missing_visit_number. + return `PV1|1|${cls}|||||${provider}||||||||||||${id.visit}`; +} + +// NTE — real vendor ORU carry note segments in the hundreds (methodology, specimen +// quality, critical-value callbacks); the original generator emitted none. Emitted +// AFTER an OBX so the mapper folds them into Observation.note (convertNTEsToAnnotation). +// English only (committed code). NTE-2 = "L" (filler/lab source). +const LAB_NOTES: readonly string[] = [ + "Result verified by repeat analysis.", + "Specimen slightly hemolyzed; result may be affected.", + "Reference range adjusted for patient age and sex.", + "Performed by high-complexity method.", + "Critical value phoned to ordering provider.", + "Fasting specimen received.", + "Test performed at reference laboratory.", + "Result confirmed on dilution.", +]; +const NTE_RATE = 0.3; // fraction of OBX that carry a note (approx. real ORU comment density) + +// ── per-type builders (segment list; event-parameterized) ─────────────────── + +function buildOru(ctx: GenContext, event: string): string[] { + const { rng, profile, id } = ctx; + const provider = sampleWeighted(rng, profile.catalogs.provider); + const segs = [buildMsh(ctx, "ORU", event), buildPid(ctx), buildPv1(ctx)]; + + // Code-mapping showcase: OBX carry LOCAL lab codes (the mapping input). With + // prob mappedRate the LOINC triplet is included (reference/answer present); + // otherwise it's local-only (unmapped — the AI feature must resolve it). + const codeMap = profile.codeMap; + if (codeMap && codeMap.length > 0 && rng.next() < (profile.localCodeRate ?? 0)) { + const mappedRate = profile.mappedRate ?? 0.5; + const pick = (): CodeMapEntry => sampleWeighted(rng, codeMap.map((e) => [e, e.weight] as [CodeMapEntry, number])); + const picks = Array.from({ length: 1 + rng.int(Math.min(5, codeMap.length)) }, pick); + const order = picks[0]!; + segs.push(`ORC|RE|${id.placer}|${id.filler}|||||||||${provider}`); + segs.push(`OBR|1|${id.placer}|${id.filler}|${order.local.code}^${order.local.text}^${order.local.system}|||${id.collectTime}|||||||${id.collectTime}||${provider}|||||${id.sendTime}|||F`); + segs.push(`TQ1|1||||||${id.collectTime}|${id.collectTime}|R`); + picks.forEach((e, i) => { + const r = sampleResult(rng, e.value); + const dual = e.loinc && rng.next() < mappedRate; + const code = dual + ? `${e.local.code}^${e.local.text}^${e.local.system}^${e.loinc!.code}^${e.loinc!.text}^${e.loinc!.system}` + : `${e.local.code}^${e.local.text}^${e.local.system}`; + segs.push(`OBX|${i + 1}|${r.valueType}|${code}||${r.value}|${r.units}|${r.ref}|${r.flag}|||F|||${id.sendTime}`); + if (rng.next() < NTE_RATE) {segs.push(`NTE|1|L|${rng.pick(LAB_NOTES)}`);} + }); + // Specimen from the order's learned code (never inferred); absent → no SPM. + if (order.specimen) segs.push(`SPM|1|||${order.specimen}`); + return segs; + } + + // Standard panel path. + const panelCode = sampleWeighted(rng, profile.panelMix); + const panel = profile.panels[panelCode]; + const lis = sampleWeighted(rng, profile.catalogs.lis); + segs.push(`ORC|RE|${id.placer}|${id.filler}|||||||||${provider}`); + segs.push( + `OBR|1|${id.placer}|${id.filler}|${panelCode}|||${id.collectTime}|||||||${id.collectTime}||${provider}|||||${id.sendTime}|||F|||||${lis}`, + ); + segs.push(`TQ1|1||||||${id.collectTime}|${id.collectTime}|R`); + if (panel) { + panel.obx.forEach((code, i) => { + const model = panel.values[code]; + if (!model) return; + // sampleResult handles both numeric and coded members — the learned OBX-2 + // decides; only the number varies, the qualitative fields are fixed. + const r = sampleResult(rng, model); + segs.push(`OBX|${i + 1}|${r.valueType}|${code}||${r.value}|${r.units}|${r.ref}|${r.flag}|||F|||${id.sendTime}`); + if (rng.next() < NTE_RATE) {segs.push(`NTE|1|L|${rng.pick(LAB_NOTES)}`);} + }); + } + // SPM only when the panel actually carried a specimen in the corpus (never guessed). + if (panel?.specimen) segs.push(`SPM|1|||${panel.specimen}`); + return segs; +} + +function buildAdt(ctx: GenContext, event: string): string[] { + const { id } = ctx; + return [ + buildMsh(ctx, "ADT", event), + `EVN|${event}|${id.sendTime}`, + buildPid(ctx), + buildPv1(ctx), + ]; +} + +// ── corpus-coverage builders ──────────────────────────────────────────────── +// Segment skeletons mirror real-world HL7v2 traffic; all values are synthetic. +// LOINC document-type codes are universal vocabulary, not PHI. + +// Synthetic medication pool for RXE/RXA/RXC (local-coded, corpus-style ^L). +const MEDS: readonly [code: string, dose: string, unit: string, route: string, form: string][] = [ + ["NIT^nitroglycerin 0.4 MG SL tablet^L", "0.4", "MG", "SUBLINGUAL^Sublingual", "TAB.SUBL"], + ["MET500^metFORMIN 500 MG oral tablet^L", "500", "MG", "PO^Oral", "TAB"], + ["SOLU40^SOLU-Medrol 40 MG IVPUSH^L", "40", "MG", "IVPUSH^IVPUSH^L", "VIAL"], + ["LISIN10^lisinopril 10 MG oral tablet^L", "10", "MG", "PO^Oral", "TAB"], + ["CEFTRI1^cefTRIaxone 1 G IVPB^L", "1", "G", "IVPB^IV Piggyback^L", "VIAL"], + ["AMLO5^amLODIPine 5 MG oral tablet^L", "5", "MG", "PO^Oral", "TAB"], +]; + +// LOINC document-type codes for TXA-2 (real vocabulary, synthetic content). +const DOC_TYPES: readonly [string, string][] = [ + ["34109-9", "Note"], + ["18842-5", "Discharge Summary"], + ["11506-3", "Progress Note"], + ["34117-2", "History and Physical"], + ["11488-4", "Consultation Note"], +]; + +const NOTE_LINES: readonly string[] = [ + "Patient seen and examined; findings discussed.", + "Vital signs stable; afebrile throughout the stay.", + "Imaging reviewed with radiology; no acute findings.", + "Medication list reconciled at discharge.", + "Follow-up with primary care in two weeks.", + "Labs trending toward baseline; continue current plan.", + "Patient tolerating oral intake without difficulty.", +]; + +// ORM^O01 — NEW lab order (the other half of the ORU pair: order out, result back). +function buildOrm(ctx: GenContext, event: string): string[] { + const { rng, profile, id } = ctx; + const provider = sampleWeighted(rng, profile.catalogs.provider); + const panel = sampleWeighted(rng, profile.panelMix); + return [ + buildMsh(ctx, "ORM", event), + buildPid(ctx), + buildPv1(ctx), + `ORC|NW|${id.placer}|${id.filler}|||||||||${provider}`, + `OBR|1|${id.placer}|${id.filler}|${panel}|||${id.collectTime}|||||||||${provider}`, + ]; +} + +// MDM^T02/T07/T11 — document notification (+ content as OBX ST lines). +function buildMdm(ctx: GenContext, event: string): string[] { + const { rng, profile, id } = ctx; + const provider = sampleWeighted(rng, profile.catalogs.provider); + const [docCode, docName] = DOC_TYPES[rng.int(DOC_TYPES.length)]!; + const segs = [ + buildMsh(ctx, "MDM", event), + `EVN|${event}|${id.sendTime}`, + buildPid(ctx), + buildPv1(ctx), + // TXA-12 = Unique Document Number (TXA-10 is the authenticator). The IG keys + // the DocumentReference id on TXA-12, so the doc number must live there for + // T11 cancels to upsert the original rather than mint a new resource. + `TXA|1|${docCode}^${docName}|TX|${id.sendTime}|${provider}|${id.sendTime}||||||${id.filler}||||AU`, + ]; + const lines = 2 + rng.int(4); + for (let i = 0; i < lines; i++) { + segs.push(`OBX|${i + 1}|ST|||${NOTE_LINES[rng.int(NOTE_LINES.length)]}`); + } + return segs; +} + +// RAS^O17 — pharmacy administration (given dose recorded). +function buildRas(ctx: GenContext, event: string): string[] { + const { rng, profile, id } = ctx; + const provider = sampleWeighted(rng, profile.catalogs.provider); + const [med, dose, unit, route, form] = MEDS[rng.int(MEDS.length)]!; + return [ + buildMsh(ctx, "RAS", event), + buildPid(ctx), + buildPv1(ctx), + `ORC|RE|${id.placer}||||N|${dose}&${unit}^Q8H^^${id.collectTime}^^R|||${provider}`, + `TQ1|1|${dose}^${unit}&${unit}&L|Q8H||||${id.collectTime}||R^Routine^L`, + `RXE||${med}|${dose}||${unit}^${unit}^L|${form}^${form}^L`, + `RXR|${route}`, + `RXC|B|${med}|${dose}|${unit}^${unit}^L`, + `RXA|0|1|${id.collectTime}|${id.collectTime}|${med}|${dose}|${unit}^${unit}^L||||||||||||CP`, + ]; +} + +// RDE^O01/O11 — pharmacy encoded order (O01 is the legacy event, PV2 present). +function buildRde(ctx: GenContext, event: string): string[] { + const { rng, profile, id } = ctx; + const provider = sampleWeighted(rng, profile.catalogs.provider); + const [med, dose, unit, route, form] = MEDS[rng.int(MEDS.length)]!; + const segs = [buildMsh(ctx, "RDE", event), buildPid(ctx), buildPv1(ctx)]; + if (event === "O01") segs.push(`PV2|||^Medication order`); + segs.push(`ORC|NW|${id.placer}|||||^Q8H^^${id.collectTime}^^R^1||${id.sendTime}|||${provider}`); + segs.push(`RXE|^Q8H^^${id.collectTime}^^R|${med}|${dose}||${unit}^${unit}^L|${form}^${form}^L`); + if (event !== "O01") { + segs.push(`TQ1|1||Q8H||||${id.collectTime}||R^Routine^L`); + segs.push(`RXR|${route}`); + } + return segs; +} + +function buildSiu(ctx: GenContext, event: string): string[] { + const { rng, profile, id } = ctx; + const provider = sampleWeighted(rng, profile.catalogs.provider); + // SCH-11 = ^^^^ (engine reads appointment start/end positionally) + return [ + buildMsh(ctx, "SIU", event), + `SCH|${id.placer}|${id.filler}|||||Routine||30|MIN|^^^${id.collectTime}^${id.sendTime}||||||${provider}`, + buildPid(ctx), + `RGS|1|A`, + `AIS|1|A|${id.placer}^Consult`, + ]; +} + +export type MessageBuilder = (ctx: GenContext, event: string) => string[]; + +/** Registry keyed by message TYPE (event is parameterized) — extend here. */ +export const BUILDERS: Record = { + ORU: buildOru, + ADT: buildAdt, + SIU: buildSiu, + ORM: buildOrm, + MDM: buildMdm, + RAS: buildRas, + RDE: buildRde, +}; + +/** Message types the generator can emit — the profiler restricts its mix to these. */ +export const SUPPORTED_TYPES: ReadonlySet = new Set(Object.keys(BUILDERS)); diff --git a/utils/hl7v2-simulator/src/gen/names.ts b/utils/hl7v2-simulator/src/gen/names.ts new file mode 100644 index 0000000..01dbe2f --- /dev/null +++ b/utils/hl7v2-simulator/src/gen/names.ts @@ -0,0 +1,16 @@ +import { Faker, base, en, de } from "@faker-js/faker"; + +export interface NameProvider { + firstName(): string; + lastName(): string; +} + +// Deterministic per (locale, seed): same inputs -> same name sequence. +export function fakerNames(locale: "en" | "de", seed: number): NameProvider { + const f = new Faker({ locale: [locale === "de" ? de : en, base] }); + f.seed(seed); + return { + firstName: () => f.person.firstName(), + lastName: () => f.person.lastName(), + }; +} diff --git a/utils/hl7v2-simulator/src/gen/rng.ts b/utils/hl7v2-simulator/src/gen/rng.ts new file mode 100644 index 0000000..96ca58d --- /dev/null +++ b/utils/hl7v2-simulator/src/gen/rng.ts @@ -0,0 +1,26 @@ +export class Rng { + private s: number; + constructor(seed: number) { this.s = seed >>> 0; } + next(): number { + this.s |= 0; this.s = (this.s + 0x6d2b79f5) | 0; + let t = Math.imul(this.s ^ (this.s >>> 15), 1 | this.s); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + } + int(maxExclusive: number): number { return Math.floor(this.next() * maxExclusive); } + pick(arr: readonly T[]): T { return arr[this.int(arr.length)]!; } + weighted(pairs: ReadonlyArray): T { + const total = pairs.reduce((a, [, w]) => a + w, 0); + let x = this.next() * total; + for (const [v, w] of pairs) { if ((x -= w) < 0) return v; } + return pairs[pairs.length - 1]![0]; + } + /** + * Inter-arrival time (seconds) for a Poisson process of mean `rate` events/sec. + * Exponentially distributed (mean 1/rate) — feed this between sends and the + * arrivals form a realistic feed cadence instead of a flat burst. + */ + exponential(rate: number): number { + return -Math.log(1 - this.next()) / rate; + } +} diff --git a/utils/hl7v2-simulator/src/gen/row.ts b/utils/hl7v2-simulator/src/gen/row.ts new file mode 100644 index 0000000..a837f5f --- /dev/null +++ b/utils/hl7v2-simulator/src/gen/row.ts @@ -0,0 +1,37 @@ +import { createHash } from "node:crypto"; +import { getComponent, getField } from "../hl7/message.ts"; + +/** One generated message flattened into columns — the shape `--output csv|jsonl` writes. */ +export interface MessageRow { + message_hash: string; status: string; channel: string; source: string | null; + message_type: string | null; event_type: string | null; + patient_id: string | null; patient_name: string | null; + message: string; error_kind: string | null; error_message: string | null; +} + +function messageHash(s: string): string { + return createHash("sha256").update(s).digest("hex"); +} + +export function toRow( + msg: string, status: string, channel: string, + err?: { errorKind?: string; errorMessage?: string }, +): MessageRow { + const pid5 = getField(msg, "PID", 5); + const name = [getComponent(pid5, 2), getComponent(pid5, 1)].filter(Boolean).join(" ").trim() || null; + return { + message_hash: messageHash(msg), + status, + // Protocol source these messages would arrive on. This tool emits HL7v2 bound + // for MLLP, so the default names the MLLP listener. + channel, + source: getComponent(getField(msg, "MSH", 4), 1) || null, + message_type: getComponent(getField(msg, "MSH", 9), 1) || null, + event_type: getComponent(getField(msg, "MSH", 9), 2) || null, + patient_id: getComponent(getField(msg, "PID", 3), 1) || null, + patient_name: name, + message: msg, + error_kind: err?.errorKind ?? null, + error_message: err?.errorMessage ?? null, + }; +} diff --git a/utils/hl7v2-simulator/src/gen/sample/identity.ts b/utils/hl7v2-simulator/src/gen/sample/identity.ts new file mode 100644 index 0000000..0872732 --- /dev/null +++ b/utils/hl7v2-simulator/src/gen/sample/identity.ts @@ -0,0 +1,58 @@ +import type { Rng } from "../rng.ts"; +import type { NameProvider } from "../names.ts"; +import type { Profile } from "../../profile/schema.ts"; +import { fillFormat, gaussian, sampleCategorical } from "./sampler.ts"; + +export interface Identity { + mrn: string; + controlId: string; + placer: string; + filler: string; + visit: string; + family: string; + given: string; + sex: string; // PID-8 + dob: string; // YYYYMMDD + sendTime: string; // YYYYMMDDHHMMSS (MSH-7, result time) + collectTime: string; // YYYYMMDDHHMMSS (specimen collection, earlier) +} + +const pad = (n: number, w: number) => String(n).padStart(w, "0"); +const fmtDtm = (d: Date) => + `${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1, 2)}${pad(d.getUTCDate(), 2)}` + + `${pad(d.getUTCHours(), 2)}${pad(d.getUTCMinutes(), 2)}${pad(d.getUTCSeconds(), 2)}`; + +/** + * All synthetic identity/time fields a message needs. `index` guarantees unique + * identifiers (so the engine's dedup never collapses generated messages). + * + * `sendOverride` pins MSH-7 to a caller-supplied instant instead of sampling the + * profile's year range — used by the sender CLI to spread MSH-7 over a recent + * window (batch) or stamp live wall-clock time (stream). When omitted, the + * year-range sampling path is byte-identical to before (same RNG draws). + */ +export function makeIdentity(rng: Rng, names: NameProvider, profile: Profile, index: number, sendOverride?: Date): Identity { + const [from, to] = profile.temporal.sendYearRange; + // `??` short-circuits: when sendOverride is set the RHS (and its 6 rng draws) + // is skipped entirely, so the override path is deterministic on its own seed. + const send = sendOverride ?? new Date( + Date.UTC(from + rng.int(to - from + 1), rng.int(12), 1 + rng.int(28), rng.int(24), rng.int(60), rng.int(60)), + ); + const offsetMin = Math.max(1, Math.round(gaussian(rng, profile.temporal.collectToResultMins.mean, profile.temporal.collectToResultMins.sd))); + const collect = new Date(send.getTime() - offsetMin * 60_000); + + const dobYear = 1940 + rng.int(70); + return { + mrn: fillFormat(rng, profile.idFormats.mrn, index), + controlId: fillFormat(rng, profile.idFormats.controlId, index), + placer: fillFormat(rng, profile.idFormats.placer, index), + filler: fillFormat(rng, profile.idFormats.filler, index), + visit: fillFormat(rng, profile.idFormats.visit, index), + family: names.lastName(), + given: names.firstName(), + sex: sampleCategorical(rng, profile.fields["PID-8"], "U"), + dob: `${dobYear}${pad(1 + rng.int(12), 2)}${pad(1 + rng.int(28), 2)}`, + sendTime: fmtDtm(send), + collectTime: fmtDtm(collect), + }; +} diff --git a/utils/hl7v2-simulator/src/gen/sample/sampler.ts b/utils/hl7v2-simulator/src/gen/sample/sampler.ts new file mode 100644 index 0000000..de9ad00 --- /dev/null +++ b/utils/hl7v2-simulator/src/gen/sample/sampler.ts @@ -0,0 +1,93 @@ +import type { Rng } from "../rng.ts"; +import type { CategoricalDist, CodedModel, NumericModel, Weighted } from "../../profile/schema.ts"; + +/** Pick a value from a weighted distribution. */ +export function sampleWeighted(rng: Rng, dist: Weighted): T { + return rng.weighted(dist); +} + +export function sampleCategorical(rng: Rng, field: CategoricalDist | undefined, fallback = ""): string { + if (!field || field.dist.length === 0) return fallback; + return rng.weighted(field.dist); +} + +/** Standard-normal via Box-Muller, built on the project Rng (seedable, deterministic). */ +export function gaussian(rng: Rng, mean: number, sd: number): number { + // u in (0,1] to avoid log(0) + const u1 = 1 - rng.next(); + const u2 = rng.next(); + const z = Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2); + return mean + sd * z; +} + +export interface SampledValue { + value: string; // formatted observation value + flag: "" | "H" | "L"; // abnormal flag, derived from value vs reference range +} + +function parseRef(ref: string): [number, number] | null { + const m = ref.match(/^([\d.]+)\s*-\s*([\d.]+)$/); + return m ? [Number(m[1]), Number(m[2])] : null; +} + +/** + * Draw a numeric observation: gaussian, clamped to [min,max], 1-dp. With prob + * `abnormalRate` the draw is pushed into a tail OUTSIDE the reference range so + * abnormal results occur — and the H/L flag is derived from the actual value vs + * the range, so flag and value never contradict. + */ +export function sampleNumeric(rng: Rng, m: NumericModel): SampledValue { + const range = parseRef(m.ref); + let raw: number; + if (range && rng.next() < m.abnormalRate) { + const [lo, hi] = range; + raw = rng.next() < 0.5 ? lo - Math.abs(gaussian(rng, 0, m.sd)) - 0.1 : hi + Math.abs(gaussian(rng, 0, m.sd)) + 0.1; + } else { + raw = gaussian(rng, m.mean, m.sd); + } + raw = Math.max(m.min, Math.min(m.max, raw)); + const value = (Math.round(raw * 10) / 10).toString(); + let flag: "" | "H" | "L" = ""; + if (range) { + const v = Number(value); + if (v < range[0]) flag = "L"; + else if (v > range[1]) flag = "H"; + } + return { value, flag }; +} + +export interface ResultValue { + valueType: string; // OBX-2 + value: string; // OBX-5 + units: string; // OBX-6 + ref: string; // OBX-7 + flag: "" | "H" | "L" | "A"; // OBX-8 +} + +/** Sample a result from a numeric OR coded model into the OBX fields. */ +export function sampleResult(rng: Rng, model: NumericModel | CodedModel): ResultValue { + if (model.kind === "coded") { + const value = rng.weighted(model.dist); + const flag = /detect|positive|abnormal|reactive/i.test(value) && !/not |non-?reactive|negative/i.test(value) ? "A" : ""; + return { valueType: model.valueType, value, units: "", ref: "", flag }; + } + const { value, flag } = sampleNumeric(rng, model); + return { valueType: "NM", value, units: model.units, ref: model.ref, flag }; +} + +/** + * Fill an id format: `#` -> digit, `@` -> uppercase letter, else literal. + * `index` seeds uniqueness so minted ids never collide (and never dedup-collapse). + */ +export function fillFormat(rng: Rng, pattern: string, index: number): string { + const hashCount = (pattern.match(/#/g) ?? []).length; + const digits = String(index % 10 ** hashCount).padStart(hashCount, "0"); + let di = 0; + let out = ""; + for (const ch of pattern) { + if (ch === "#") out += digits[di++]; + else if (ch === "@") out += String.fromCharCode(65 + rng.int(26)); + else out += ch; + } + return out; +} diff --git a/utils/hl7v2-simulator/src/hl7/message.ts b/utils/hl7v2-simulator/src/hl7/message.ts new file mode 100644 index 0000000..c713755 --- /dev/null +++ b/utils/hl7v2-simulator/src/hl7/message.ts @@ -0,0 +1,26 @@ +export function segments(msg: string): string[] { + return msg.split("\r").filter((s) => s.length > 0); +} +function fieldIndex(segId: string, n: number): number { + return segId === "MSH" ? n - 1 : n; +} +export function getField(msg: string, segId: string, n: number): string { + const seg = segments(msg).find((s) => s.startsWith(segId + "|")); + if (!seg) return ""; + return seg.split("|")[fieldIndex(segId, n)] ?? ""; +} +export function setField(msg: string, segId: string, n: number, value: string): string { + return segments(msg) + .map((seg) => { + if (!seg.startsWith(segId + "|")) return seg; + const parts = seg.split("|"); + const i = fieldIndex(segId, n); + while (parts.length <= i) parts.push(""); + parts[i] = value; + return parts.join("|"); + }) + .join("\r"); +} +export function getComponent(field: string, c: number): string { + return field.split("^")[c - 1] ?? ""; +} diff --git a/utils/hl7v2-simulator/src/paths.ts b/utils/hl7v2-simulator/src/paths.ts new file mode 100644 index 0000000..c6b8ad2 --- /dev/null +++ b/utils/hl7v2-simulator/src/paths.ts @@ -0,0 +1,27 @@ +// Default paths resolve against THIS package, not the caller's cwd. +// +// The simulator ships inside the workspace at utils/hl7v2-simulator/, so it is +// routinely started from the repo root (`bun --cwd utils/hl7v2-simulator run ui`, +// or the root `bun run simulator` script). Resolving `fixtures/profile.json` and +// friends relative to cwd would make those invocations fail with ENOENT — and, +// worse, scatter `data/sources.json` and `batch-out/` wherever the user happened +// to be standing. Anchoring on the package root keeps state in one place. +// +// A path the user passes explicitly (--profile, PROFILE_PATH, EXPORT_DIR, …) is +// still honoured as-is, so relative user paths keep resolving against their cwd. +import { join } from "node:path"; + +/** Absolute path to this package's root — the directory holding package.json. */ +export const PKG_ROOT = join(import.meta.dir, ".."); + +/** Resolve a package-relative path to an absolute one. */ +export const pkgPath = (...parts: string[]): string => join(PKG_ROOT, ...parts); + +/** The bundled synthetic generator profile. */ +export const DEFAULT_PROFILE = pkgPath("fixtures", "profile.json"); + +/** Where the UI persists its source definitions. */ +export const DEFAULT_SOURCES_PATH = pkgPath("data", "sources.json"); + +/** Where `/export` writes generated `.hl7` batches. */ +export const DEFAULT_EXPORT_DIR = pkgPath("batch-out"); diff --git a/utils/hl7v2-simulator/src/profile/schema.ts b/utils/hl7v2-simulator/src/profile/schema.ts new file mode 100644 index 0000000..c947c70 --- /dev/null +++ b/utils/hl7v2-simulator/src/profile/schema.ts @@ -0,0 +1,129 @@ +// The learned artifact. ONLY aggregate distributions — no raw records, no raw +// value lists, no free text. + +/** A weighted choice list; weights need not sum to 1 (Rng.weighted normalizes). */ +export type Weighted = Array<[T, number]>; + +/** Coded/categorical field: sampled by frequency. */ +export interface CategoricalDist { + kind: "categorical"; + dist: Weighted; +} + +/** Numeric observation value: summary stats, never a raw value list (§3 rule 2). */ +export interface NumericModel { + kind: "numeric"; + units: string; + ref: string; // reference range text, e.g. "136-145" + mean: number; + sd: number; + min: number; + max: number; + abnormalRate: number; // P(result flagged abnormal) +} + +/** A lab panel: which OBX codes ship together (| OBR code) + a value model each. + * The qualitative shape (specimen, per-code value type/units) is learned as a + * UNIT from the corpus and never recombined — the generator changes only the + * numbers. See the qualitative-panel-skeleton spec. */ +export interface PanelModel { + /** OBX-3 codes in order, e.g. "NA^SODIUM". */ + obx: string[]; + /** SPM-4 / OBR-15 specimen observed for this panel; absent when the corpus + * panel carried none — the generator then emits NO SPM (never a guess). */ + specimen?: string; + /** code -> value model (numeric OR coded — the learned OBX-2 decides). */ + values: Record; +} + +/** A qualitative (non-numeric) result: a distribution over observed values. */ +export interface CodedModel { + kind: "coded"; + valueType: string; // OBX-2, e.g. "ST" + dist: Weighted; // observed result values, e.g. [["Not Detected",..],["Detected",..]] +} + +/** + * One local-lab-code concept: a local proprietary code (obfuscated) and, when the + * source carried it, the standard reference code (LOINC). The generator emits OBX + * with the local triplet (the mapping *input*); `loinc` is the known answer. + * See spec 2026-06-10_local-code-mapping-fixtures-spec.md §4.1. + */ +export interface CodeMapEntry { + local: { code: string; system: string; text: string }; + loinc: { code: string; text: string; system: string } | null; + value: NumericModel | CodedModel; + /** Specimen observed on the message that carried this local code, if any — + * emitted as-is; never inferred. */ + specimen?: string; + weight: number; +} + +export interface ProfileCatalogs { + /** Facility names (MSH-4), frequency-weighted. */ + facility: Weighted; + /** PID-3 assigning authority codes. */ + assigningAuthority: Weighted; + /** OBR producing-lab / LIS labels. */ + lis: Weighted; + /** Provider entries as "id^family^given". */ + provider: Weighted; + /** Sending/receiving application names (MSH-3/5). */ + app: Weighted; +} + +export interface ProfileTemporal { + /** Inclusive [from, to] year range for MSH-7 send time. */ + sendYearRange: [number, number]; + /** Minutes from collection (OBR) to result (OBX), gaussian. */ + collectToResultMins: { mean: number; sd: number }; +} + +/** + * Format strings for synthetic identifiers. `#` -> a digit, `@` -> an uppercase + * letter; any other char is literal. IDs are never learned as values (§3 rule 4), + * only their shape — minted unique at generation so dedup never collapses them. + */ +export interface ProfileIdFormats { + mrn: string; + controlId: string; + placer: string; + filler: string; + visit: string; +} + +export interface Profile { + version: number; + minSupport: number; + /** "ORU^R01" -> weight. */ + messageTypes: Weighted; + /** type -> repetition-name -> distribution over counts. */ + segmentReps: Record>>; + /** "PID-8" -> categorical dist. */ + fields: Record; + /** OBR order-code -> panel; `panelMix` picks which order to emit. */ + panels: Record; + /** "10054^BASIC METABOLIC PANEL^LAB" -> weight. */ + panelMix: Weighted; + catalogs: ProfileCatalogs; + temporal: ProfileTemporal; + idFormats: ProfileIdFormats; + /** Local-lab-code concepts for the code-mapping showcase (optional). */ + codeMap?: CodeMapEntry[]; + /** Fraction of ORU that draw OBX from `codeMap` (local codes) vs standard panels. 0 = off. */ + localCodeRate?: number; + /** Of local-code ORU, fraction emitted dual-coded (local+LOINC reference) vs local-only (unmapped input). */ + mappedRate?: number; +} + +export const PROFILE_VERSION = 1; + +/** Parse + minimally validate a profile.json. Throws on shape/version mismatch. */ +export function parseProfile(text: string): Profile { + const p = JSON.parse(text) as Profile; + if (p.version !== PROFILE_VERSION) { + throw new Error(`profile version ${p.version} != supported ${PROFILE_VERSION}`); + } + if (!p.messageTypes?.length) throw new Error("profile has no messageTypes"); + return p; +} diff --git a/utils/hl7v2-simulator/src/send-cli.ts b/utils/hl7v2-simulator/src/send-cli.ts new file mode 100644 index 0000000..534c20a --- /dev/null +++ b/utils/hl7v2-simulator/src/send-cli.ts @@ -0,0 +1,346 @@ +// Send MLLP-framed HL7v2 messages to a listening engine. +// +// Usage: bun run send [flags] +// batch fire a fixed count as fast as the pool allows, then exit +// stream emit a continuous paced stream with live timestamps until stopped +// +// ` --help` lists that mode's flags. Run with no args for an overview. +// +// Messages come from the profile-driven generator (src/gen/*) — the same engine +// the UI and `bun run gen` use. Faults are injected at --errorRate from the +// FAULTS table and classified locally so the summary previews how the engine +// will bucket them (parse_error / map_error / data_quality / benign-ok). +import { Rng } from "./gen/rng.ts"; +import { fakerNames } from "./gen/names.ts"; +import { parseProfile, type Profile } from "./profile/schema.ts"; +import { generateMessage } from "./gen/assemble.ts"; +import { FAULTS } from "./gen/faults.ts"; +import { classify } from "./validate/classify.ts"; +import { sendOverMllp, sendOverMllpReliable, streamOverMllp } from "./send/mllp.ts"; +import { DEFAULT_PROFILE } from "./paths.ts"; + +// --- flag spec: one declarative source drives parsing, validation, and help --- +interface Flag { + name: string; + type: "number" | "string" | "boolean"; + /** Default when the flag is omitted; absent → the option is left undefined. */ + def?: number | string | boolean; + /** Short stand-in for `def` in --help, when the real value is too long to show. */ + defHelp?: string; + /** Value to use when a number/string flag is passed bare (e.g. `--jitter`). */ + bare?: number | string; + required?: boolean; + /** Placeholder shown in help, e.g. ``. */ + meta?: string; + help: string; +} + +const COMMON: Flag[] = [ + { name: "errorRate", type: "number", def: 0.1, meta: "", help: "fault fraction, 0..1" }, + { name: "seed", type: "number", def: 42, meta: "", help: "RNG seed (deterministic content)" }, + { name: "profile", type: "string", def: DEFAULT_PROFILE, defHelp: "the bundled fixtures/profile.json", meta: "", help: "generator profile" }, + { name: "host", type: "string", def: "127.0.0.1", meta: "", help: "target host" }, + { name: "port", type: "number", def: 2575, meta: "", help: "target port" }, +]; + +const MODES: Record = { + batch: { + summary: "fire a fixed count as fast as the pool allows, then exit", + flags: [ + { name: "count", type: "number", def: 1, meta: "", help: "total messages to send" }, + { + name: "months", + type: "number", + def: 1, + meta: "", + help: "spread send_time (MSH-7) uniformly over the last N months", + }, + { name: "reliable", type: "boolean", def: false, help: "wait for each ACK (AA) and retry on failure" }, + ...COMMON, + ], + examples: ["batch --count 500 --months 3", "batch --count 50 --errorRate 0.3 --reliable"], + }, + stream: { + summary: "emit a continuous paced stream with live timestamps until stopped", + flags: [ + { name: "rate", type: "number", required: true, meta: "", help: "target send rate" }, + { + name: "duration", + type: "number", + meta: "", + help: "stop after S seconds (default: run until Ctrl-C)", + }, + { + name: "jitter", + type: "number", + def: 0, + bare: 0.5, + meta: "[=frac]", + help: "randomize fixed inter-arrival by ±frac (bare --jitter = 0.5)", + }, + { name: "poisson", type: "boolean", def: false, help: "exponential inter-arrival (Poisson) instead of fixed; overrides --jitter" }, + ...COMMON, + ], + examples: ["stream --rate 5", "stream --rate 20 --duration 120 --poisson --errorRate 0.2"], + }, +}; + +const SELF = "bun run send"; + +function die(msg: string, mode?: string): never { + console.error(`error: ${msg}`); + console.error(mode ? `try \`${SELF} ${mode} --help\`` : `try \`${SELF} --help\``); + process.exit(1); +} + +function generalHelp(): void { + console.log("send — send MLLP-framed HL7v2 messages to a listening engine\n"); + console.log(`Usage: ${SELF} [flags]\n`); + console.log("Modes:"); + for (const [name, m] of Object.entries(MODES)) console.log(` ${name.padEnd(8)} ${m.summary}`); + console.log(`\nRun \`${SELF} --help\` for a mode's flags.\n`); + console.log("Examples:"); + for (const m of Object.values(MODES)) for (const ex of m.examples) console.log(` ${SELF} ${ex}`); +} + +function modeHelp(mode: string): void { + const m = MODES[mode]!; + console.log(`send ${mode} — ${m.summary}\n`); + console.log(`Usage: ${SELF} ${mode} [flags]\n`); + console.log("Flags:"); + for (const f of m.flags) { + const usage = `--${f.name}${f.meta ? ` ${f.meta}` : ""}`; + const note = f.required ? "(required)" : f.def !== undefined ? `(default ${f.defHelp ?? f.def})` : "(optional)"; + console.log(` ${usage.padEnd(20)} ${f.help} ${note}`); + } + console.log("\nExamples:"); + for (const ex of m.examples) console.log(` ${SELF} ${ex}`); +} + +type Opts = Record; + +/** Parse argv against a mode's flag spec; reject unknown/invalid/missing flags. */ +function parseMode(mode: string, argv: string[]): Opts { + const spec = MODES[mode]!; + const byName = new Map(spec.flags.map((f) => [f.name, f])); + const values: Opts = {}; + for (const f of spec.flags) if (f.def !== undefined) values[f.name] = f.def; + + for (let i = 0; i < argv.length; i++) { + const a = argv[i]!; + if (a === "--help" || a === "-h") { + modeHelp(mode); + process.exit(0); + } + if (!a.startsWith("--")) die(`unexpected argument '${a}' — flags start with '--'`, mode); + const eq = a.indexOf("="); + const key = eq >= 0 ? a.slice(2, eq) : a.slice(2); + const f = byName.get(key); + if (!f) { + die(`unknown flag '--${key}' for mode '${mode}' (valid: ${[...byName.keys()].join(", ")})`, mode); + } + if (f.type === "boolean") { + if (eq >= 0) die(`flag '--${key}' is a boolean and takes no value`, mode); + values[f.name] = true; + continue; + } + let raw: string | undefined = eq >= 0 ? a.slice(eq + 1) : undefined; + if (raw === undefined) { + const next = argv[i + 1]; + if (next !== undefined && !next.startsWith("--")) { + raw = next; + i++; + } else if (f.bare !== undefined) { + values[f.name] = f.bare; + continue; + } else { + die(`flag '--${key}' needs a value`, mode); + } + } + if (f.type === "string") { + values[f.name] = raw; + } else { + const n = Number(raw); + if (!Number.isFinite(n)) die(`--${key} expects a number, got '${raw}'`, mode); + values[f.name] = n; + } + } + for (const f of spec.flags) { + if (f.required && values[f.name] === undefined) die(`mode '${mode}' requires --${f.name}`, mode); + } + return values; +} + +// --- shared generation: profile-driven content + fault injection + tally --- +interface Generator { + /** Build the next wire message; `now` pins MSH-7 (batch spread / live stream). */ + gen: (now?: Date) => string; + summary: () => { injected: number; byKind: Map }; +} + +async function buildGenerator(profilePath: string, seed: number, errorRate: number): Promise { + let profile: Profile; + try { + profile = parseProfile(await Bun.file(profilePath).text()); + } catch (e) { + die(`could not read profile '${profilePath}': ${(e as Error)?.message ?? e}`); + } + const knownTypes = new Set(profile.messageTypes.map(([t]) => t)); + const rng = new Rng(seed); + const names = fakerNames("en", seed); + let index = 0; + let injected = 0; + const byKind = new Map(); + + const gen = (now?: Date): string => { + let msg = generateMessage(rng, profile, names, index++, { now }).msg; + // Roll a fault after the message is built so the RNG stream (and thus the + // content) is identical whether or not a fault lands — only the corruption + // differs. Mirrors src/cli.ts. We send the wire bytes regardless; the engine + // does the authoritative classification, this tally is just a preview. + if (errorRate > 0 && rng.next() < errorRate) { + msg = rng.pick(FAULTS).apply(msg); + injected++; + const kind = classify(msg, knownTypes).kind; + byKind.set(kind, (byKind.get(kind) ?? 0) + 1); + } + return msg; + }; + return { gen, summary: () => ({ injected, byKind }) }; +} + +function printFaultSummary(g: Generator, total: number): void { + const { injected, byKind } = g.summary(); + if (injected === 0) return; + const breakdown = [...byKind.entries()] + .sort((a, b) => b[1] - a[1]) + .map(([k, n]) => `${k}=${n}`) + .join(" "); + const pct = total > 0 ? ((injected / total) * 100).toFixed(1) : "0.0"; + // "ok" here = a fault that classifies benign (e.g. future DOB) — engine keeps it. + console.log(`injected ${injected} faults (${pct}%): ${breakdown}`); +} + +// --- batch mode: fixed count, MSH-7 spread over [now - months, now], conn pool --- +async function runBatch(opts: Opts): Promise { + const host = opts.host as string; + const port = opts.port as number; + const count = opts.count as number; + const months = opts.months as number; + const reliable = opts.reliable as boolean; + if (!(count > 0)) die(`--count must be > 0, got ${count}`, "batch"); + + const g = await buildGenerator(opts.profile as string, opts.seed as number, opts.errorRate as number); + + // send_time (MSH-7) window: [now - N months, now], messages evenly spaced so + // the distribution is uniform across the span — each calendar month gets + // ~count/N. Deterministic: message i lands at the center of its slot. + const windowEnd = Date.now(); + const windowStart = (() => { + const d = new Date(windowEnd); + d.setMonth(d.getMonth() - months); + return d.getTime(); + })(); + const slotMs = (windowEnd - windowStart) / count; + const messages = Array.from({ length: count }, (_, i) => g.gen(new Date(windowStart + (i + 0.5) * slotMs))); + + console.log( + `spreading ${count} msgs over ${months} month(s): ` + + `${new Date(windowStart).toISOString()} .. ${new Date(windowEnd).toISOString()} ` + + `(${(slotMs / 1000).toFixed(2)}s/msg)`, + ); + + const t0 = Date.now(); + if (reliable) { + const r = await sendOverMllpReliable(messages, { host, port }); + const dt = (Date.now() - t0) / 1000; + console.log( + `done: acked ${r.acked}, failed ${r.failed}, retries ${r.retries} in ${dt.toFixed(2)}s ` + + `(${(r.acked / dt).toFixed(0)} msg/s) to ${host}:${port}`, + ); + } else { + const sent = await sendOverMllp(messages, { host, port }); + const dt = (Date.now() - t0) / 1000; + console.log(`done: sent ${sent} msgs in ${dt.toFixed(2)}s (${(sent / dt).toFixed(0)} msg/s) to ${host}:${port}`); + } + printFaultSummary(g, count); +} + +// --- stream mode: paced, live-timestamped, runs until Ctrl-C or --duration --- +async function runStream(opts: Opts): Promise { + const host = opts.host as string; + const port = opts.port as number; + const rate = opts.rate as number; + if (!(rate > 0)) die(`--rate must be > 0, got ${rate}`, "stream"); + const duration = opts.duration as number | undefined; + const jitter = opts.jitter as number; + const poisson = opts.poisson as boolean; + + const g = await buildGenerator(opts.profile as string, opts.seed as number, opts.errorRate as number); + + const intervalMs = 1000 / rate; + // Inter-arrival gap model: Poisson (exponential) is the realistic feed cadence; + // otherwise a fixed interval, optionally jittered ±frac for organic pacing. + const gapMs = poisson + ? (): number => (-Math.log(1 - Math.random()) / rate) * 1000 + : jitter + ? (): number => intervalMs * (1 - jitter + 2 * jitter * Math.random()) + : (): number => intervalMs; + + const ac = new AbortController(); + const stop = (): void => ac.abort(); + process.on("SIGINT", stop); + process.on("SIGTERM", stop); + const durTimer = duration ? setTimeout(stop, duration * 1000) : undefined; + + const pacing = poisson ? "Poisson" : jitter ? `jitter ±${Math.round(jitter * 100)}%` : "even"; + console.log( + `streaming ~${rate} msg/s` + + (duration ? ` for ${duration}s` : " (Ctrl-C to stop)") + + `, ${pacing} → ${host}:${port} (errorRate ${opts.errorRate})`, + ); + + const t0 = Date.now(); + let lastLog = t0; + const sent = await streamOverMllp({ + host, + port, + reconnect: true, // survive an engine restart mid-demo + signal: ac.signal, + next: () => g.gen(new Date()), // live MSH-7 = wall-clock send time + gapMs, + onSent: (n) => { + const now = Date.now(); + if (now - lastLog >= 5000) { + const secs = (now - t0) / 1000; + console.log(`streamed ${n} msgs in ${secs.toFixed(0)}s (~${(n / secs).toFixed(1)} msg/s)`); + lastLog = now; + } + }, + }); + + if (durTimer) clearTimeout(durTimer); + process.off("SIGINT", stop); + process.off("SIGTERM", stop); + const dt = (Date.now() - t0) / 1000; + console.log(`\nstopped: streamed ${sent} msgs in ${dt.toFixed(1)}s (~${(sent / dt).toFixed(1)} msg/s)`); + printFaultSummary(g, sent); +} + +// --- dispatch --- +const [mode, ...rest] = process.argv.slice(2); + +if (mode === undefined || mode === "help" || mode === "--help" || mode === "-h") { + generalHelp(); + process.exit(0); +} +if (!(mode in MODES)) { + const hint = /^\d/.test(mode) ? ` (positional args are gone — did you mean \`batch --count ${mode}\`?)` : ""; + console.error(`error: unknown mode '${mode}'${hint}\n`); + generalHelp(); + process.exit(1); +} + +const opts = parseMode(mode, rest); +if (mode === "batch") await runBatch(opts); +else await runStream(opts); diff --git a/utils/hl7v2-simulator/src/send/mllp.ts b/utils/hl7v2-simulator/src/send/mllp.ts new file mode 100644 index 0000000..bd44671 --- /dev/null +++ b/utils/hl7v2-simulator/src/send/mllp.ts @@ -0,0 +1,347 @@ +import * as net from "node:net"; + +// MLLP minimal lower layer: SB + UTF-8 payload + EB CR. +const SB = 0x0b; +/** Cap on unframed response bytes held per socket. An ACK is a few hundred. */ +const ACK_BUF_MAX = 64 * 1024; +/** Grace before closing a leg, so ACKs already on the wire still land. */ +const ACK_LINGER_MS = 300; +const EB = 0x1c; +const CR = 0x0d; + +/** Wrap a raw HL7v2 message in an MLLP frame. */ +export function mllpFrame(payload: string): Buffer { + return Buffer.concat([ + Buffer.from([SB]), + Buffer.from(payload, "utf8"), + Buffer.from([EB, CR]), + ]); +} + +export interface MllpOpts { + host?: string; + port?: number; + concurrency?: number; +} + +/** + * Open an MLLP socket. `onAck` receives each ACK's MSA-1 code (AA / AE / AR) as + * it arrives; without it the responses are drained and dropped. + * + * Reading is not waiting. The sender never blocks on a response — it keeps + * writing at the requested rate while replies are framed and classified on the + * data event. A generator that discards ACKs cannot tell "delivered" from + * "refused", which is the one thing the receiver is telling it. + */ +function open(host: string, port: number, onAck?: (code: string | undefined) => void): Promise { + return new Promise((resolve, reject) => { + const sock = net.createConnection({ host, port }, () => { + sock.removeListener("error", reject); + resolve(sock); + }); + sock.setNoDelay(true); + sock.once("error", reject); + if (!onAck) { + sock.on("data", () => {}); // drain, don't block + return; + } + // Responses arrive framed and may split or coalesce across chunks: buffer, + // then take every complete block that has landed. + let buf = Buffer.alloc(0); + sock.on("data", (chunk: Buffer) => { + buf = Buffer.concat([buf, chunk]); + for (;;) { + const eb = buf.indexOf(EB); + if (eb < 0) break; + const sb = buf.indexOf(SB); + if (sb >= 0 && sb < eb) onAck(ackCode(buf.subarray(sb + 1, eb).toString("utf8"))); + buf = buf.subarray(eb + 1); + } + // A peer that never sends an end-block would grow this without limit — + // reachable by ordinary misconfiguration, e.g. a source aimed at an HTTP + // port, where every message draws a reply containing no MLLP framing. + // Report the garbage rather than swallowing it, then drop what cannot be + // a frame: keep from the last start-block, or nothing if there is none. + if (buf.length > ACK_BUF_MAX) { + onAck(undefined); + const sb = buf.lastIndexOf(SB); + buf = sb >= 0 ? buf.subarray(sb) : Buffer.alloc(0); + } + }); + }); +} + +/** + * Send messages over MLLP via a small pool of persistent connections. + * Returns how many were written. The engine assigns status itself. + */ +export async function sendOverMllp(messages: string[], opts: MllpOpts = {}): Promise { + const host = opts.host ?? "127.0.0.1"; + const port = opts.port ?? 2575; + const concurrency = Math.max(1, Math.min(opts.concurrency ?? 8, messages.length || 1)); + const socks = await Promise.all( + Array.from({ length: concurrency }, () => open(host, port)), + ); + let sent = 0; + await Promise.all( + socks.map((sock, k) => + new Promise((resolve, reject) => { + sock.once("error", reject); + let i = k; + const writeNext = (): void => { + if (i >= messages.length) { + sock.end(); + resolve(); + return; + } + const buf = mllpFrame(messages[i]!); + i += concurrency; + sent++; + if (sock.write(buf)) process.nextTick(writeNext); + else sock.once("drain", writeNext); + }; + writeNext(); + }), + ), + ); + return sent; +} + +/** Parse the ACK code (MSA-1: AA/AE/AR) from an HL7 ACK message. */ +export function ackCode(ack: string): string | undefined { + const msa = ack.split("\r").find((s) => s.startsWith("MSA|")); + return msa ? msa.split("|")[1] : undefined; +} + +/** Send one message and await its MLLP ACK. Resolves the MSA-1 code, or + * undefined when nothing came back — silence is not refusal. */ +function deliverOne(host: string, port: number, msg: string, ackTimeoutMs: number): Promise { + return new Promise((resolve) => { + const sock = net.createConnection({ host, port }); + let buf = Buffer.alloc(0); + let done = false; + const finish = (code: string | undefined): void => { + if (done) return; + done = true; + clearTimeout(timer); + sock.destroy(); + resolve(code); + }; + const timer = setTimeout(() => finish(undefined), ackTimeoutMs); + sock.setNoDelay(true); + sock.on("connect", () => sock.write(mllpFrame(msg))); + sock.on("data", (chunk: Buffer) => { + buf = Buffer.concat([buf, chunk]); + const eb = buf.indexOf(EB); + if (eb >= 0) { + const sb = buf.indexOf(SB); + finish(ackCode(buf.subarray(sb + 1, eb).toString("utf8"))); + } + }); + sock.on("error", () => finish(undefined)); + }); +} + +export interface ReliableOpts extends MllpOpts { + ackTimeoutMs?: number; + maxRetries?: number; + backoffMs?: number; +} + +export interface ReliableResult { + acked: number; + /** Refused with an explicit AE/AR — the engine saw it and said no. */ + refused: number; + /** No answer at all: timeout, connection error, unreachable. */ + silent: number; + /** refused + silent — kept for callers that only need "not delivered". */ + failed: number; + retries: number; +} + +/** + * At-least-once MLLP delivery: wait for each message's ACK (AA) and RETRY any + * that time out / aren't accepted, in backoff passes. So if the engine dies + * mid-receive (never ACKs), those messages are re-sent — and land once it's + * back up. End-to-end durability that fire-and-forget `sendOverMllp` can't give. + */ +export async function sendOverMllpReliable(messages: string[], opts: ReliableOpts = {}): Promise { + const host = opts.host ?? "127.0.0.1"; + const port = opts.port ?? 2575; + const concurrency = Math.max(1, opts.concurrency ?? 8); + const ackTimeoutMs = opts.ackTimeoutMs ?? 3000; + const maxRetries = opts.maxRetries ?? 5; + const backoffMs = opts.backoffMs ?? 1000; + + let pending = messages.slice(); + let retries = 0; + // Last response code per still-failing message: present = refused, absent = silent. + let lastCodes = new Map(); + for (let attempt = 0; attempt <= maxRetries && pending.length > 0; attempt++) { + if (attempt > 0) { + retries += pending.length; + await new Promise((r) => setTimeout(r, backoffMs)); + } + const batch = pending; + const failed: string[] = []; + let i = 0; + lastCodes = new Map(); + const worker = async (): Promise => { + while (i < batch.length) { + const msg = batch[i++]!; + const code = await deliverOne(host, port, msg, ackTimeoutMs); + if (code !== "AA") { failed.push(msg); lastCodes.set(msg, code); } + } + }; + await Promise.all(Array.from({ length: Math.min(concurrency, batch.length) }, worker)); + pending = failed; + } + let refused = 0; + for (const m of pending) if (lastCodes.get(m) !== undefined) refused += 1; + return { acked: messages.length - pending.length, refused, silent: pending.length - refused, failed: pending.length, retries }; +} + +const sleep = (ms: number): Promise => new Promise((r) => setTimeout(r, ms)); + +/** Write one frame, awaiting backpressure drain; rejects if the socket errors. */ +function writeFrame(sock: net.Socket, frame: Buffer): Promise { + return new Promise((resolve, reject) => { + const onErr = (e: Error): void => reject(e); + sock.once("error", onErr); + const done = (): void => { sock.off("error", onErr); resolve(); }; + if (sock.write(frame)) process.nextTick(done); + else sock.once("drain", done); + }); +} + +export interface LiveStreamOpts extends MllpOpts { + /** Pull the next message to send; return null/undefined to end the stream. + * Called lazily right before each send, so a caller can stamp live time. */ + next: () => string | null | undefined; + /** Gap (ms) to wait before the NEXT send. Supply an exponential draw + * (`Rng.exponential`) and the arrivals form a Poisson process. */ + gapMs: () => number; + onSent?: (n: number, msg: string) => void; + /** Each ACK's MSA-1 code (AA / AE / AR) as it arrives, or undefined when the + * response carries none. Classification only — the stream never waits on it. */ + onAck?: (code: string | undefined) => void; + /** External stop. When aborted the loop exits after the current send/wait. */ + signal?: AbortSignal; + /** Reconnect (with backoff) on a dropped socket instead of throwing — lets a + * long-running stream survive an engine restart mid-demo. Default false: + * a connect/write failure throws, so finite callers fail fast. */ + reconnect?: boolean; +} + +// After a stall longer than this (GC pause, reconnect backoff), resync the +// schedule to "now" instead of firing the whole accumulated backlog as a burst. +const MAX_LAG_MS = 250; + +/** + * Stream messages over a single MLLP connection, pulling each lazily from + * `next()`, with `gapMs()` the intended gap before the next send. Fed + * exponential gaps the arrivals follow a Poisson process — a realistic feed + * cadence (organic ebb/flow on the operator dashboard) instead of a flat burst. + * + * Pacing is scheduled by ABSOLUTE deadline, not a raw sleep per message: we + * sleep only when ahead of schedule. So when sub-tick gaps make the OS timer + * (≈15.5ms granularity on Windows) overshoot, the loop falls behind and skips + * the sleep — sending the backlog within a tick — instead of capping at the + * ~64 msg/s timer floor. High `--rate` therefore actually delivers. + * + * With `reconnect`, a dropped/refused socket is retried (the in-flight message + * is resent once the link is back) until `signal` aborts — so the stream + * outlives an engine restart. Returns the count actually sent. + */ +export async function streamOverMllp(opts: LiveStreamOpts): Promise { + const host = opts.host ?? "127.0.0.1"; + const port = opts.port ?? 2575; + const reconnect = opts.reconnect ?? false; + const aborted = (): boolean => opts.signal?.aborted ?? false; + + let broken = false; + const connect = async (): Promise => { + const s = await open(host, port, opts.onAck); + broken = false; + s.on("error", () => { broken = true; }); + s.on("close", () => { broken = true; }); + return s; + }; + // Return a live socket, reusing `current` unless it's broken. With reconnect, + // a refused connect is retried (backoff) until the signal aborts. + const ensureSock = async (current: net.Socket | null): Promise => { + if (current && !broken) return current; + if (current) { try { current.end(); } catch { /* gone */ } } + for (;;) { + try { return await connect(); } + catch (e) { if (!reconnect || aborted()) throw e; await sleep(500); } + } + }; + + let sock: net.Socket | null = null; + let sent = 0; + let pending: string | null = null; + let nextAt = performance.now(); // absolute deadline for the next send + try { + while (!aborted()) { + if (pending == null) { + const m = opts.next(); + if (m == null) break; + pending = m; + } + const s = (sock = await ensureSock(sock)); + if (!s) break; // aborted while reconnecting + try { + await writeFrame(s, mllpFrame(pending)); + } catch (e) { + broken = true; + if (!reconnect) throw e; + continue; // reconnect on next pass and resend the same `pending` + } + sent++; + opts.onSent?.(sent, pending); + pending = null; + + // Advance the deadline by the intended gap, then sleep only the time that + // remains. When behind (sub-tick gaps, GC, reconnect) the wait is ≤0 and + // we send the next message immediately — draining the backlog rather than + // paying a full timer tick per message. + nextAt += Math.max(0, opts.gapMs()); + const now = performance.now(); + if (nextAt < now - MAX_LAG_MS) nextAt = now; // long stall — resync, don't burst + const wait = nextAt - now; + if (wait > 0 && !aborted()) await sleep(wait); + } + } finally { + // Responses lag the writes, so closing the moment the last message goes out + // discards the ACKs still on the wire. Without this the tail of every run — + // and every target switch, which restarts the leg — silently inflates + // `unanswered`. Linger briefly when someone is listening for them. + if (sock && opts.onAck) await sleep(ACK_LINGER_MS); + if (sock) { try { sock.end(); } catch { /* gone */ } } + } + return sent; +} + +export interface StreamOpts extends MllpOpts { + /** Gap (ms) to wait before the NEXT send. Supply an exponential draw + * (`Rng.exponential`) and the arrivals form a Poisson process. */ + gapMs: () => number; + onSent?: (n: number) => void; +} + +/** + * Stream a fixed array of messages over one MLLP connection, pausing `gapMs()` + * between each. Thin wrapper over `streamOverMllp` (index-pull, no reconnect → + * fails fast if the engine is down). + */ +export async function sendOverMllpStream(messages: string[], opts: StreamOpts): Promise { + let i = 0; + return streamOverMllp({ + host: opts.host, + port: opts.port, + next: () => (i < messages.length ? messages[i++]! : null), + gapMs: opts.gapMs, + onSent: opts.onSent ? (n) => opts.onSent!(n) : undefined, + }); +} diff --git a/utils/hl7v2-simulator/src/validate/classify.ts b/utils/hl7v2-simulator/src/validate/classify.ts new file mode 100644 index 0000000..abc73b6 --- /dev/null +++ b/utils/hl7v2-simulator/src/validate/classify.ts @@ -0,0 +1,21 @@ +import { parseMessage } from "@atomic-ehr/hl7v2/src/hl7v2/parse"; +import { findSegment, getComponent } from "@atomic-ehr/hl7v2/src/hl7v2/types"; + +export type Kind = "ok" | "parse_error" | "map_error" | "data_quality"; +export interface Classification { kind: Kind; detail?: string; } + +export function classify(msg: string, knownTypes: ReadonlySet): Classification { + let parsed; + try { parsed = parseMessage(msg); } + catch (e) { return { kind: "parse_error", detail: String(e) }; } + if (!parsed || parsed.length === 0) return { kind: "parse_error", detail: "empty parse" }; + const msh = findSegment(parsed, "MSH"); + if (!msh) return { kind: "parse_error", detail: "no MSH" }; + const type = getComponent(msh.fields[9], 1); + const event = getComponent(msh.fields[9], 2); + if (!knownTypes.has(`${type}^${event}`)) return { kind: "map_error", detail: "no mapper" }; + const pid = findSegment(parsed, "PID"); + const patientId = pid ? getComponent(pid.fields[3], 1) : ""; + if (!patientId) return { kind: "map_error", detail: "no patient id" }; + return { kind: "ok" }; +} diff --git a/utils/hl7v2-simulator/src/validate/selftest.ts b/utils/hl7v2-simulator/src/validate/selftest.ts new file mode 100644 index 0000000..2d25a92 --- /dev/null +++ b/utils/hl7v2-simulator/src/validate/selftest.ts @@ -0,0 +1,65 @@ +// Foundation self-test: generate from the synthetic profile and prove every +// message is valid per the REAL parser (classify), with correct field extraction +// and no duplicates. No real data involved. +// bun run src/validate/selftest.ts [count] [seed] +import { parseProfile } from "../profile/schema.ts"; +import { generateMessage } from "../gen/assemble.ts"; +import { Rng } from "../gen/rng.ts"; +import { fakerNames } from "../gen/names.ts"; +import { classify, type Kind } from "./classify.ts"; +import { toRow } from "../gen/row.ts"; +import { DEFAULT_PROFILE } from "../paths.ts"; + +const N = Number(process.argv[2] ?? 1000); +const seed = Number(process.argv[3] ?? 42); +const profilePath = process.argv[4] ?? DEFAULT_PROFILE; + +const profile = parseProfile(await Bun.file(profilePath).text()); +const rng = new Rng(seed); +const names = fakerNames("en", seed); +const knownTypes = new Set(profile.messageTypes.map(([t]) => t)); + +let ok = 0; +const failKinds = new Map(); +const failSample = new Map(); +const byType = new Map(); +const sources = new Set(); +const hashes = new Set(); +let missingPatient = 0; +const samples = new Map(); + +for (let i = 0; i < N; i++) { + const { msg, type, event } = generateMessage(rng, profile, names, i); + const mt = `${type}^${event}`; + byType.set(mt, (byType.get(mt) ?? 0) + 1); + if (!samples.has(mt)) samples.set(mt, msg); + + const c = classify(msg, knownTypes); + if (c.kind === "ok") ok++; + else { + failKinds.set(c.kind, (failKinds.get(c.kind) ?? 0) + 1); + if (!failSample.has(c.kind)) failSample.set(c.kind, `${c.detail} :: ${msg.replace(/\r/g, " / ").slice(0, 160)}`); + } + + const row = toRow(msg, "received", "mllp-default"); + if (!row.patient_id || !row.patient_name) missingPatient++; + if (row.source) sources.add(row.source); + hashes.add(row.message_hash); +} + +console.log(`\n=== self-test: ${N} messages, seed ${seed} ===`); +console.log(`valid (classify ok): ${ok}/${N} ${ok === N ? "✓" : "✗ FAIL"}`); +console.log(`unique (no dup hash): ${hashes.size}/${N} ${hashes.size === N ? "✓" : "✗ FAIL"}`); +console.log(`patient_id+name set: ${N - missingPatient}/${N} ${missingPatient === 0 ? "✓" : "✗ FAIL"}`); +console.log(`distinct sources: ${[...sources].join(", ")}`); +console.log(`by type:`, Object.fromEntries(byType)); +if (failKinds.size) { + console.log(`\nFAILURES:`); + for (const [k, n] of failKinds) console.log(` ${k}: ${n} e.g. ${failSample.get(k)}`); +} +console.log(`\n--- one sample per type ---`); +for (const [mt, msg] of samples) console.log(`\n[${mt}]\n${msg.replace(/\r/g, "\n")}`); + +const pass = ok === N && hashes.size === N && missingPatient === 0; +console.log(`\n${pass ? "PASS ✓" : "FAIL ✗"}`); +process.exit(pass ? 0 : 1); diff --git a/utils/hl7v2-simulator/test/classify.test.ts b/utils/hl7v2-simulator/test/classify.test.ts new file mode 100644 index 0000000..8b3bccb --- /dev/null +++ b/utils/hl7v2-simulator/test/classify.test.ts @@ -0,0 +1,34 @@ +import { expect, test } from "bun:test"; +import { classify } from "../src/validate/classify.ts"; +import { applyFault, FAULTS } from "../src/gen/faults.ts"; + +const KNOWN = new Set(["ADT^A01", "ADT^A03"]); +const VALID = "MSH|^~\\&|APP|LAB1|RCV|RFAC|20250101||ADT^A01|C1|P|2.5\rPID|1||M1^^^O^MR||LEE^AMY||19900101|F"; + +test("valid message classifies as ok", () => { + expect(classify(VALID, KNOWN).kind).toBe("ok"); +}); +test("structural faults -> parse_error", () => { + // Only no_msh truly throws with the real parser; truncated and bad_encoding_chars + // are lenient-parsed and produce map_error (reality wins). + for (const id of ["no_msh"]) { + expect(classify(applyFault(VALID, id), KNOWN).kind).toBe("parse_error"); + } +}); +test("truncated -> map_error (parser is lenient)", () => { + expect(classify(applyFault(VALID, "truncated"), KNOWN).kind).toBe("map_error"); +}); +test("bad_encoding_chars -> map_error (parser is lenient)", () => { + expect(classify(applyFault(VALID, "bad_encoding_chars"), KNOWN).kind).toBe("map_error"); +}); +test("semantic faults -> map_error", () => { + for (const id of ["unknown_type", "missing_patient_id"]) { + expect(classify(applyFault(VALID, id), KNOWN).kind).toBe("map_error"); + } +}); +test("every fault's intendedKind matches classify output", () => { + for (const f of FAULTS) { + const result = classify(applyFault(VALID, f.id), KNOWN); + expect(result.kind as string, `fault ${f.id}: intendedKind=${f.intendedKind} actual=${result.kind}`).toBe(f.intendedKind); + } +}); diff --git a/utils/hl7v2-simulator/test/corpus-builders.test.ts b/utils/hl7v2-simulator/test/corpus-builders.test.ts new file mode 100644 index 0000000..d4f702a --- /dev/null +++ b/utils/hl7v2-simulator/test/corpus-builders.test.ts @@ -0,0 +1,49 @@ +import { expect, test } from "bun:test"; +import { generateMessage } from "../src/gen/assemble.ts"; +import { Rng } from "../src/gen/rng.ts"; +import { fakerNames } from "../src/gen/names.ts"; +import type { Profile } from "../src/profile/schema.ts"; +import prof from "../fixtures/profile.json"; +import { SUPPORTED_TYPES } from "../src/gen/grammar/message-types.ts"; + +const base = prof as unknown as Profile; + +function build(mt: string, seed = 7): string[] { + const p: Profile = { ...base, messageTypes: [[mt, 1]] }; + return generateMessage(new Rng(seed), p, fakerNames("en", 1), 0).msg.split("\r"); +} +const skeleton = (segs: string[]): string[] => segs.map((s) => s.split("|")[0]!); + +// Skeletons below cover every supported message type. +test("ORM^O01: MSH PID PV1 ORC OBR — new order, no results", () => { + const segs = build("ORM^O01"); + expect(skeleton(segs)).toEqual(["MSH", "PID", "PV1", "ORC", "OBR"]); + expect(segs.find((s) => s.startsWith("ORC|"))!.split("|")[1]).toBe("NW"); + expect(segs[0]).toContain("|ORM^O01|"); +}); + +test("MDM^T02: MSH EVN PID PV1 TXA OBX+ — document with ST content lines", () => { + const segs = build("MDM^T02"); + const sk = skeleton(segs); + expect(sk.slice(0, 5)).toEqual(["MSH", "EVN", "PID", "PV1", "TXA"]); + expect(sk.slice(5).every((x) => x === "OBX")).toBe(true); + expect(sk.filter((x) => x === "OBX").length).toBeGreaterThanOrEqual(2); + const txa = segs.find((s) => s.startsWith("TXA|"))!; + expect(txa.split("|")[2]).toMatch(/^\d{5}-\d\^/); // LOINC doc-type code +}); + +test("RAS^O17: pharmacy administration skeleton", () => { + const segs = build("RAS^O17"); + expect(skeleton(segs)).toEqual(["MSH", "PID", "PV1", "ORC", "TQ1", "RXE", "RXR", "RXC", "RXA"]); +}); + +test("RDE^O11 vs RDE^O01 differ per corpus (O01 carries PV2, no TQ1/RXR)", () => { + expect(skeleton(build("RDE^O11"))).toEqual(["MSH", "PID", "PV1", "ORC", "RXE", "TQ1", "RXR"]); + expect(skeleton(build("RDE^O01"))).toEqual(["MSH", "PID", "PV1", "PV2", "ORC", "RXE"]); +}); + +test("registry covers every family the corpora contain", () => { + for (const t of ["ORU", "ORM", "ADT", "SIU", "MDM", "RAS", "RDE"]) { + expect(SUPPORTED_TYPES.has(t)).toBe(true); + } +}); diff --git a/utils/hl7v2-simulator/test/faults.test.ts b/utils/hl7v2-simulator/test/faults.test.ts new file mode 100644 index 0000000..bdc20d2 --- /dev/null +++ b/utils/hl7v2-simulator/test/faults.test.ts @@ -0,0 +1,17 @@ +import { expect, test } from "bun:test"; +import { FAULTS, applyFault } from "../src/gen/faults.ts"; + +const VALID = "MSH|^~\\&|APP|LAB1|RCV|RFAC|20250101||ADT^A01|C1|P|2.5\rPID|1||M1^^^O^MR||LEE^AMY||19900101|F"; + +test("each fault is registered with a kind and mutates the message", () => { + expect(FAULTS.length).toBeGreaterThanOrEqual(8); + for (const f of FAULTS) { + const out = applyFault(VALID, f.id); + expect(out).not.toBe(VALID); + expect(typeof f.intendedKind).toBe("string"); + } +}); +test("dropSegment(PID) removes PID; unknownType rewrites MSH-9", () => { + expect(applyFault(VALID, "no_pid").includes("\rPID|")).toBe(false); + expect(applyFault(VALID, "unknown_type").includes("ZZZ^Z99")).toBe(true); +}); diff --git a/utils/hl7v2-simulator/test/generation.test.ts b/utils/hl7v2-simulator/test/generation.test.ts new file mode 100644 index 0000000..e6f0723 --- /dev/null +++ b/utils/hl7v2-simulator/test/generation.test.ts @@ -0,0 +1,63 @@ +import { expect, test } from "bun:test"; +import { parseProfile } from "../src/profile/schema.ts"; +import { generateMessage } from "../src/gen/assemble.ts"; +import { Rng } from "../src/gen/rng.ts"; +import { fakerNames } from "../src/gen/names.ts"; +import { classify } from "../src/validate/classify.ts"; +import { fillFormat, sampleNumeric } from "../src/gen/sample/sampler.ts"; +import { getField } from "../src/hl7/message.ts"; +import type { NumericModel } from "../src/profile/schema.ts"; + +const profile = parseProfile(await Bun.file(`${import.meta.dir}/../fixtures/profile.example.json`).text()); +const knownTypes = new Set(profile.messageTypes.map(([t]) => t)); + +test("every generated message is parser-valid + unique (example profile)", () => { + const rng = new Rng(99); + const names = fakerNames("en", 99); + const seen = new Set(); + for (let i = 0; i < 300; i++) { + const { msg } = generateMessage(rng, profile, names, i); + expect(classify(msg, knownTypes).kind).toBe("ok"); + seen.add(msg); + } + expect(seen.size).toBe(300); +}); + +test("generation is deterministic per seed", () => { + const a = generateMessage(new Rng(5), profile, fakerNames("en", 5), 0).msg; + const b = generateMessage(new Rng(5), profile, fakerNames("en", 5), 0).msg; + expect(a).toBe(b); +}); + +test("opts.now pins MSH-7 to the given instant (UTC YYYYMMDDHHMMSS)", () => { + const now = new Date(Date.UTC(2026, 0, 15, 13, 45, 30)); // 2026-01-15T13:45:30Z + const { msg } = generateMessage(new Rng(7), profile, fakerNames("en", 7), 0, { now }); + expect(getField(msg, "MSH", 7)).toBe("20260115134530"); + // still parser-valid with the injected timestamp + expect(classify(msg, knownTypes).kind).toBe("ok"); +}); + +test("opts.now=undefined keeps the profile-sampled timestamp path unchanged", () => { + // Byte-identical to a plain call: the override's short-circuit must not perturb + // the RNG stream when `now` is absent. + const a = generateMessage(new Rng(11), profile, fakerNames("en", 11), 0).msg; + const b = generateMessage(new Rng(11), profile, fakerNames("en", 11), 0, {}).msg; + expect(b).toBe(a); +}); + +test("numeric abnormal flag never contradicts the reference range", () => { + const rng = new Rng(3); + const model: NumericModel = { kind: "numeric", units: "mmol/L", ref: "136-145", mean: 140, sd: 2.5, min: 120, max: 160, abnormalRate: 0.5 }; + for (let i = 0; i < 500; i++) { + const { value, flag } = sampleNumeric(rng, model); + const v = Number(value); + expect(flag).toBe(v < 136 ? "L" : v > 145 ? "H" : ""); + } +}); + +test("fillFormat fills # with the index and stays unique per index", () => { + expect(fillFormat(new Rng(1), "GEN-####", 7)).toBe("GEN-0007"); + const seen = new Set(); + for (let i = 0; i < 1000; i++) seen.add(fillFormat(new Rng(1), "########", i)); + expect(seen.size).toBe(1000); +}); diff --git a/utils/hl7v2-simulator/test/message.test.ts b/utils/hl7v2-simulator/test/message.test.ts new file mode 100644 index 0000000..1a15d35 --- /dev/null +++ b/utils/hl7v2-simulator/test/message.test.ts @@ -0,0 +1,21 @@ +import { expect, test } from "bun:test"; +import { getField, setField, getComponent, segments } from "../src/hl7/message.ts"; + +const MSG = "MSH|^~\\&|APP|FAC|RCV|RFAC|20260322010925||ADT^A01|CTRL123|P|2.5\rPID|1||MRN42^^^OCC^MR||DOE^JOHN||19800101|M"; + +test("segments split on CR", () => { + expect(segments(MSG).map((s) => s.slice(0, 3))).toEqual(["MSH", "PID"]); +}); +test("MSH field is off-by-one (MSH-9 = message type)", () => { + expect(getField(MSG, "MSH", 9)).toBe("ADT^A01"); + expect(getField(MSG, "MSH", 10)).toBe("CTRL123"); +}); +test("non-MSH field indexing (PID-3, PID-5)", () => { + expect(getField(MSG, "PID", 3)).toBe("MRN42^^^OCC^MR"); + expect(getComponent(getField(MSG, "PID", 5), 1)).toBe("DOE"); +}); +test("setField round-trips and is consistent", () => { + const out = setField(MSG, "MSH", 10, "NEWCTRL"); + expect(getField(out, "MSH", 10)).toBe("NEWCTRL"); + expect(getField(out, "MSH", 9)).toBe("ADT^A01"); +}); diff --git a/utils/hl7v2-simulator/test/mllp.test.ts b/utils/hl7v2-simulator/test/mllp.test.ts new file mode 100644 index 0000000..588a116 --- /dev/null +++ b/utils/hl7v2-simulator/test/mllp.test.ts @@ -0,0 +1,184 @@ +import { expect, test } from "bun:test"; +import * as net from "node:net"; +import { ackCode, mllpFrame, streamOverMllp } from "../src/send/mllp.ts"; + +const SB = 0x0b; +const EB = 0x1c; + +/** Spin a loopback MLLP server that unframes payloads into `onFrame`. */ +function listen( + onFrame: (payload: string, sock: net.Socket) => void, +): Promise<{ port: number; close: () => void }> { + const srv = net.createServer((sock) => { + let buf = Buffer.alloc(0); + sock.on("data", (c: Buffer) => { + buf = Buffer.concat([buf, c]); + let eb: number; + while ((eb = buf.indexOf(EB)) >= 0) { + const sb = buf.indexOf(SB); + onFrame(buf.subarray(sb + 1, eb).toString("utf8"), sock); + buf = buf.subarray(eb + 2); // drop EB + CR + } + }); + sock.on("error", () => {}); // client drops are expected in the reconnect test + }); + return new Promise((resolve) => { + srv.listen(0, "127.0.0.1", () => { + resolve({ port: (srv.address() as net.AddressInfo).port, close: () => srv.close() }); + }); + }); +} + +const waitFor = async (cond: () => boolean, ms = 3000): Promise => { + const t0 = Date.now(); + while (!cond()) { + if (Date.now() - t0 > ms) throw new Error("waitFor timed out"); + await new Promise((r) => setTimeout(r, 5)); + } +}; + +test("mllp frame wraps payload in SB ... EB CR", () => { + const f = mllpFrame("MSH|x"); + expect(f[0]).toBe(0x0b); // SB + expect(f[f.length - 2]).toBe(0x1c); // EB + expect(f[f.length - 1]).toBe(0x0d); // CR + expect(f.subarray(1, f.length - 2).toString("utf8")).toBe("MSH|x"); +}); + +test("ackCode reads MSA-1 (AA/AE), undefined when no MSA", () => { + expect(ackCode("MSH|^~\\&|A|B\rMSA|AA|CTRL1")).toBe("AA"); + expect(ackCode("MSH|^~\\&|A|B\rMSA|AE|CTRL1|err")).toBe("AE"); + expect(ackCode("MSH|^~\\&|A|B")).toBeUndefined(); +}); + +test("streamOverMllp pulls until next() returns null; framing intact, in order", async () => { + const got: string[] = []; + const srv = await listen((p) => got.push(p)); + const msgs = ["MSH|a", "MSH|b", "MSH|c"]; + let i = 0; + const sent = await streamOverMllp({ + port: srv.port, + next: () => (i < msgs.length ? msgs[i++]! : null), + gapMs: () => 0, + }); + await waitFor(() => got.length === msgs.length); + srv.close(); + expect(sent).toBe(3); + expect(got).toEqual(msgs); +}); + +test("streamOverMllp stops promptly when the signal aborts", async () => { + const got: string[] = []; + const srv = await listen((p) => got.push(p)); + const ac = new AbortController(); + const sent = await streamOverMllp({ + port: srv.port, + next: () => "MSH|x", // infinite source — only the signal ends it + gapMs: () => 5, + signal: ac.signal, + onSent: (n) => { if (n >= 3) ac.abort(); }, + }); + srv.close(); + expect(sent).toBe(3); +}); + +test("streamOverMllp reconnects after a dropped connection and delivers all", async () => { + const got: string[] = []; + let dropped = false; + const srv = await listen((p, sock) => { + got.push(p); + if (!dropped) { dropped = true; sock.destroy(); } // drop once, after the first frame + }); + const msgs = ["MSH|1", "MSH|2", "MSH|3", "MSH|4"]; + let i = 0; + const sent = await streamOverMllp({ + port: srv.port, + reconnect: true, + next: () => (i < msgs.length ? msgs[i++]! : null), + gapMs: () => 10, + }); + await waitFor(() => new Set(got).size === msgs.length); + srv.close(); + expect(new Set(got)).toEqual(new Set(msgs)); + expect(sent).toBe(4); +}); + +// ── ACK classification (streamOverMllp onAck) ──────────────────────────────── +// +// The generator used to discard responses, so it could not tell "delivered" +// from "refused". These pin the framing that makes the difference readable. + +const CR = 0x0d; +const ack = (code: string): Buffer => + Buffer.concat([ + Buffer.from([SB]), + Buffer.from(`MSH|^~\\&|SINK|T|||20260730||ACK|1|P|2.5\rMSA|${code}|1\r`, "utf8"), + Buffer.from([EB, CR]), + ]); + +/** Drive one message through the stream and collect whatever onAck reports. */ +async function codesFor(reply: (sock: net.Socket) => void, sends = 1): Promise<(string | undefined)[]> { + const codes: (string | undefined)[] = []; + const srv = await listen((_p, sock) => reply(sock)); + const ac = new AbortController(); + let left = sends; + await streamOverMllp({ + port: srv.port, + signal: ac.signal, + next: () => (left-- > 0 ? "MSH|^~\\&|GEN|T|||20260730||ADT^A01|1|P|2.5\r" : null), + gapMs: () => 1, + onAck: (c) => codes.push(c), + }); + await waitFor(() => codes.length > 0, 2000).catch(() => {}); + srv.close(); + return codes; +} + +test("an ACK split across two TCP chunks is classified exactly once", async () => { + const codes = await codesFor((sock) => { + const full = ack("AA"); + sock.write(full.subarray(0, 12)); + setTimeout(() => sock.write(full.subarray(12)), 30); + }); + expect(codes).toEqual(["AA"]); +}); + +test("three ACKs coalesced into one chunk are classified in order", async () => { + const codes = await codesFor((sock) => { + sock.write(Buffer.concat([ack("AA"), ack("AR"), ack("AA")])); + }); + await waitFor(() => codes.length >= 3, 2000).catch(() => {}); + expect(codes).toEqual(["AA", "AR", "AA"]); +}); + +test("a response carrying no MSA-1 classifies as undefined, not as acceptance", async () => { + const codes = await codesFor((sock) => { + sock.write(Buffer.concat([ + Buffer.from([SB]), + Buffer.from("MSH|^~\\&|SINK|T|||20260730||ACK|1|P|2.5\r", "utf8"), + Buffer.from([EB, CR]), + ])); + }); + expect(codes).toEqual([undefined]); +}); + +test("without onAck the socket still drains — old callers are unaffected", async () => { + const srv = await listen((_p, sock) => sock.write(ack("AA"))); + const ac = new AbortController(); + let left = 2; + const sent = await streamOverMllp({ + port: srv.port, + signal: ac.signal, + next: () => (left-- > 0 ? "MSH|^~\\&|GEN|T|||20260730||ADT^A01|1|P|2.5\r" : null), + gapMs: () => 1, + }); + srv.close(); + expect(sent).toBe(2); +}); + +test("ackCode reads AA, AE and AR, and returns undefined when MSA is absent", () => { + expect(ackCode("MSH|x\rMSA|AA|1\r")).toBe("AA"); + expect(ackCode("MSH|x\rMSA|AE|1\r")).toBe("AE"); + expect(ackCode("MSH|x\rMSA|AR|1\r")).toBe("AR"); + expect(ackCode("MSH|x\r")).toBeUndefined(); +}); diff --git a/utils/hl7v2-simulator/test/names.test.ts b/utils/hl7v2-simulator/test/names.test.ts new file mode 100644 index 0000000..34382e4 --- /dev/null +++ b/utils/hl7v2-simulator/test/names.test.ts @@ -0,0 +1,12 @@ +import { expect, test } from "bun:test"; +import { fakerNames } from "../src/gen/names.ts"; + +test("deterministic per seed: same seed -> same first name", () => { + expect(fakerNames("en", 7).firstName()).toBe(fakerNames("en", 7).firstName()); +}); +test("produces non-empty names for en and de", () => { + const en = fakerNames("en", 1); + expect(en.firstName().length).toBeGreaterThan(0); + expect(en.lastName().length).toBeGreaterThan(0); + expect(typeof fakerNames("de", 1).firstName()).toBe("string"); +}); diff --git a/utils/hl7v2-simulator/test/nte.test.ts b/utils/hl7v2-simulator/test/nte.test.ts new file mode 100644 index 0000000..19423df --- /dev/null +++ b/utils/hl7v2-simulator/test/nte.test.ts @@ -0,0 +1,46 @@ +import { expect, test } from "bun:test"; +import { generateMessage } from "../src/gen/assemble.ts"; +import { Rng } from "../src/gen/rng.ts"; +import { fakerNames } from "../src/gen/names.ts"; +import type { Profile } from "../src/profile/schema.ts"; +import prof from "../fixtures/profile.json"; + +const profile = prof as unknown as Profile; + +// Collect ORU messages (the only type that carries OBX/NTE) from a spread of seeds. +function oruMessages(n: number): string[] { + const out: string[] = []; + for (let i = 0; i < n; i++) { + const { msg } = generateMessage(new Rng(100 + i), profile, fakerNames("en", 1), i); + if (msg.split("\r").some((s) => s.startsWith("OBR|"))) out.push(msg); + } + return out; +} + +test("some ORU OBX carry NTE notes (real vendor ORU have them; generator used to emit none)", () => { + const orus = oruMessages(120); + expect(orus.length).toBeGreaterThan(0); + const withNte = orus.filter((m) => m.split("\r").some((s) => s.startsWith("NTE|"))); + expect(withNte.length).toBeGreaterThan(0); +}); + +test("every NTE immediately follows an OBX and is well-formed NTE|1|L|", () => { + for (const msg of oruMessages(120)) { + const segs = msg.split("\r"); + segs.forEach((s, i) => { + if (!s.startsWith("NTE|")) return; + // The mapper folds an NTE into the note of its preceding OBX — so it must follow one. + expect(segs[i - 1]?.startsWith("OBX|")).toBe(true); + const f = s.split("|"); + expect(f[1]).toBe("1"); // NTE-1 set id + expect(f[2]).toBe("L"); // NTE-2 source (filler/lab) + expect((f[3] ?? "").length).toBeGreaterThan(0); // NTE-3 comment present + }); + } +}); + +test("NTE emission is deterministic per seed", () => { + const a = generateMessage(new Rng(7), profile, fakerNames("en", 1), 0).msg; + const b = generateMessage(new Rng(7), profile, fakerNames("en", 1), 0).msg; + expect(a).toBe(b); +}); diff --git a/utils/hl7v2-simulator/test/rng.test.ts b/utils/hl7v2-simulator/test/rng.test.ts new file mode 100644 index 0000000..7891665 --- /dev/null +++ b/utils/hl7v2-simulator/test/rng.test.ts @@ -0,0 +1,23 @@ +import { expect, test } from "bun:test"; +import { Rng } from "../src/gen/rng.ts"; + +test("same seed -> same sequence (deterministic)", () => { + const a = new Rng(42), b = new Rng(42); + expect([a.int(1000), a.int(1000), a.int(1000)]).toEqual([b.int(1000), b.int(1000), b.int(1000)]); +}); +test("pick returns an element; weighted favours heavy option", () => { + const r = new Rng(7); + expect(["x", "y"]).toContain(r.pick(["x", "y"])); + let heavy = 0; + for (let i = 0; i < 1000; i++) if (r.weighted([["A", 9], ["B", 1]]) === "A") heavy++; + expect(heavy).toBeGreaterThan(800); +}); +test("exponential inter-arrivals: positive, deterministic, mean ~ 1/rate", () => { + expect(new Rng(7).exponential(5)).toBe(new Rng(7).exponential(5)); + const r = new Rng(123); + let sum = 0; + for (let i = 0; i < 20000; i++) sum += r.exponential(10); + const mean = sum / 20000; // expect ~0.1s for rate=10/s + expect(mean).toBeGreaterThan(0.095); + expect(mean).toBeLessThan(0.105); +}); diff --git a/utils/hl7v2-simulator/test/sources.test.ts b/utils/hl7v2-simulator/test/sources.test.ts new file mode 100644 index 0000000..c188a17 --- /dev/null +++ b/utils/hl7v2-simulator/test/sources.test.ts @@ -0,0 +1,133 @@ +import { expect, test } from "bun:test"; +import { generateMessage } from "../src/gen/assemble.ts"; +import { Rng } from "../src/gen/rng.ts"; +import { fakerNames } from "../src/gen/names.ts"; +import type { Profile } from "../src/profile/schema.ts"; +import prof from "../fixtures/profile.json"; +import { profileFor, normalizeName, slugOf, SourceRegistry, type SourceDef } from "../ui/sources.ts"; + +const base = prof as unknown as Profile; + +const LAB: SourceDef = { id: "sunrise-lab", name: "SUNRISE LAB", type: "lab", rate: 2, faultRate: 0 }; +const CLINIC: SourceDef = { id: "cedarview-clinic", name: "CEDARVIEW CLINIC", type: "clinic", rate: 1, faultRate: 0 }; + +function sample(def: SourceDef, n: number): string[] { + const p = profileFor(base, def); + const out: string[] = []; + for (let i = 0; i < n; i++) out.push(generateMessage(new Rng(500 + i), p, fakerNames("en", 1), i).msg); + return out; +} + +test("name normalization + slug", () => { + expect(normalizeName(" Sunrise Lab ")).toBe("SUNRISE LAB"); + expect(slugOf("Sunrise Lab")).toBe("sunrise-lab"); +}); + +test("identity injection: MSH-3/MSH-4 come from the source, not the corpus catalogs", () => { + for (const msg of sample(LAB, 30)) { + const msh = msg.split("\r")[0]!.split("|"); + expect(msh[2]).toBe("LAB_IF"); // MSH-3 from type preset + expect(msh[3]).toBe("SUNRISE LAB"); // MSH-4 from source name + } +}); + +test("identity injection: own MRN pool (assigning authority + MRN prefix)", () => { + for (const msg of sample(LAB, 30)) { + const pid = msg.split("\r").find((s) => s.startsWith("PID|")); + if (!pid) continue; // SIU has PID too, but guard anyway + const cx = pid.split("|")[3]!; // PID-3 = MRN^^^AA^MR + expect(cx.split("^")[0]!.startsWith("SL")).toBe(true); + expect(cx).toContain("^SUNRISE_LAB_MRN^"); + } +}); + +test("type drives the message mix: lab is ORU-heavy, clinic sends no ORU", () => { + const oruShare = (msgs: string[]): number => + msgs.filter((m) => m.split("\r")[0]!.includes("|ORU^")).length / msgs.length; + expect(oruShare(sample(LAB, 200))).toBeGreaterThanOrEqual(0.7); + expect(oruShare(sample(CLINIC, 200))).toBe(0); +}); + +test("registry: create / update / delete round-trip + persistence across instances", async () => { + const path = `/tmp/sources-test-${Math.floor(Math.random() * 1e9)}.json`; + const target = () => ({ host: "127.0.0.1", port: 2510, mock: true }); + + const reg = new SourceRegistry(path, target); + await reg.init(); // seeds 3 defaults + expect(reg.list().length).toBe(3); + + const def = await reg.create({ name: "Sunrise Lab", type: "lab", rate: 3, targetPort: 2520 }); + expect(def.id).toBe("sunrise-lab"); + expect(reg.targetOf("sunrise-lab")).toEqual({ host: "127.0.0.1", port: 2520 }); + + await reg.update("sunrise-lab", { rate: 5, clearTargetPort: true }); + expect(reg.get("sunrise-lab")!.def.rate).toBe(5); + expect(reg.targetOf("sunrise-lab")).toEqual(target()); // override cleared → global + + // A fresh instance loads the same config from disk. + const reg2 = new SourceRegistry(path, target); + await reg2.init(); + expect(reg2.list().map((s) => s.id)).toContain("sunrise-lab"); + expect(reg2.get("sunrise-lab")!.def.rate).toBe(5); + + await reg2.remove("sunrise-lab"); + const reg3 = new SourceRegistry(path, target); + await reg3.init(); + expect(reg3.list().map((s) => s.id)).not.toContain("sunrise-lab"); + + expect(() => reg3.list().length).not.toThrow(); +}); + +test("registry rejects duplicates and bad input", async () => { + const path = `/tmp/sources-test-${Math.floor(Math.random() * 1e9)}.json`; + const reg = new SourceRegistry(path, () => ({ host: "127.0.0.1", port: 2510, mock: true })); + await reg.init(); + await reg.create({ name: "Twin Lab", type: "lab" }); + expect(reg.create({ name: "TWIN LAB", type: "lab" })).rejects.toThrow(/already exists/); + expect(reg.create({ name: "", type: "lab" })).rejects.toThrow(/name/); + expect(reg.create({ name: "P Lab", type: "lab", targetPort: 99999 })).rejects.toThrow(/invalid port/); +}); + +test("actors are independent: stopping one stream does not affect another", async () => { + const path = `/tmp/sources-test-${Math.floor(Math.random() * 1e9)}.json`; + // Mock target: the actor loop simulates dispatch without any network. + const reg = new SourceRegistry(path, () => ({ host: "mock", port: 0, mock: true })); + await reg.init(); + const a = reg.get("memorial-lab")!.actor; + const b = reg.get("cedarview-clinic")!.actor; + + a.start(30, 0); // high rates so counters move within ~300ms + b.start(30, 0); + await new Promise((r) => setTimeout(r, 300)); + expect(a.snapshot().counters.sent).toBeGreaterThan(0); + expect(b.snapshot().counters.sent).toBeGreaterThan(0); + + a.stop(); + await new Promise((r) => setTimeout(r, 100)); // let the loop wind down + const aFrozen = a.snapshot().counters.sent; + const bBefore = b.snapshot().counters.sent; + await new Promise((r) => setTimeout(r, 300)); + expect(a.snapshot().counters.sent).toBe(aFrozen); // stopped → frozen + expect(b.snapshot().counters.sent).toBeGreaterThan(bBefore); // other one still flows + b.stop(); +}); + +test("hand-picked msgTypes override the preset mix (equal shares, live via update)", async () => { + const def: SourceDef = { id: "pick-lab", name: "PICK LAB", type: "lab", rate: 1, faultRate: 0, msgTypes: ["SIU^S12"] }; + const p = profileFor(base, def); + const types = new Set(); + for (let i = 0; i < 60; i++) { + const msh = generateMessage(new Rng(900 + i), p, fakerNames("en", 1), i).msg.split("\r")[0]!; + types.add(msh.split("|")[8]!); + } + expect([...types]).toEqual(["SIU^S12"]); // a lab forced to scheduling-only + + const path = `/tmp/sources-test-${Math.floor(Math.random() * 1e9)}.json`; + const reg = new SourceRegistry(path, () => ({ host: "mock", port: 0, mock: true })); + await reg.init(); + await reg.update("memorial-lab", { msgTypes: ["ADT^A01", "ORU^R01"] }); + expect(reg.get("memorial-lab")!.def.msgTypes).toEqual(["ADT^A01", "ORU^R01"]); + await reg.update("memorial-lab", { msgTypes: [] }); // empty = back to preset + expect(reg.get("memorial-lab")!.def.msgTypes).toBeUndefined(); + expect(reg.update("memorial-lab", { msgTypes: ["FOO^X01"] })).rejects.toThrow(/no valid message types/); +}); diff --git a/utils/hl7v2-simulator/tsconfig.json b/utils/hl7v2-simulator/tsconfig.json new file mode 100644 index 0000000..ea183aa --- /dev/null +++ b/utils/hl7v2-simulator/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "lib": ["ESNext"], + "module": "ESNext", + "target": "ESNext", + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "strict": true, + "noUncheckedIndexedAccess": true, + "noEmit": true, + "types": ["bun"] + } +} diff --git a/utils/hl7v2-simulator/ui/actor.ts b/utils/hl7v2-simulator/ui/actor.ts new file mode 100644 index 0000000..f735529 --- /dev/null +++ b/utils/hl7v2-simulator/ui/actor.ts @@ -0,0 +1,249 @@ +/** + * Source UI — SourceActor: one independent upstream sender. + * + * The former stream.ts singleton (one state + one Poisson loop + one MLLP + * connection) generalized into a class, so N actors — one per simulated + * sender — run concurrently in this process, each with its own pacing loop, + * its own connection (error isolation), its own Rng (per-source determinism) + * and its own counters. A stream is 99.9% waiting, so the event loop hosts + * many of them the way a web server hosts many sockets. + */ + +import { Rng } from "../src/gen/rng.ts"; +import { fakerNames } from "../src/gen/names.ts"; +import { generateMessage } from "../src/gen/assemble.ts"; +import { FAULTS } from "../src/gen/faults.ts"; +import { streamOverMllp } from "../src/send/mllp.ts"; +import type { Profile } from "../src/profile/schema.ts"; +import { publish, type ActorStateSnapshot, type ActorCounters } from "./bus.ts"; + +export interface ActorTarget { host: string; port: number; mock?: boolean } + +interface StreamMessage { msg: string; type: string; injected: boolean } + +// Exponential inter-arrival delay for a Poisson process with the given rate. +function poissonDelayMs(rate: number): number { + return (-Math.log(1 - Math.random()) / rate) * 1000; +} + +const sleep = (ms: number): Promise => new Promise((r) => setTimeout(r, ms)); + +export class SourceActor { + readonly id: string; + /** Lazy so construction stays sync; resolved once on first use. */ + private readonly profileFn: () => Promise; + /** Read per leg — a live target/port change applies on the next leg. */ + private readonly targetFn: () => ActorTarget; + + private running = false; + private rate = 1.0; + private faultRate = 0; + // Two independent axes, deliberately not mixed. + // + // delivery sent = accepted + rejected + unanswered + // content malformed — a fault we injected, orthogonal to the above + // + // `unanswered` is derived on read rather than stored, so the delivery identity + // needs no reconciliation. A message still in flight counts as unanswered, + // which is accurate: nothing has come back for it yet. + // + // The clamp in view() is a guard, not a proof. An engine using enhanced + // acknowledgement answers twice per message — a commit ACK and an application + // ACK — and nothing here correlates MSA-2 back to the message that caused it, + // so accepted + rejected can exceed sent. That shows up as unanswered pinned + // at zero rather than as a negative number. + private readonly counters = { sent: 0, accepted: 0, rejected: 0, malformed: 0 }; + + private runAc: AbortController | null = null; // whole stream + private legAc: AbortController | null = null; // current target leg + private gen: ((faultRate: number) => StreamMessage) | null = null; + + constructor(id: string, profileFn: () => Promise, targetFn: () => ActorTarget) { + this.id = id; + this.profileFn = profileFn; + this.targetFn = targetFn; + } + + /** Counters as published: `unanswered` derived, so `sent` always balances. */ + private view(): ActorCounters { + const c = this.counters; + return { ...c, unanswered: Math.max(0, c.sent - c.accepted - c.rejected) }; + } + + snapshot(): ActorStateSnapshot { + return { running: this.running, rate: this.rate, faultRate: this.faultRate, counters: this.view() }; + } + + /** One synthetic message per call — own Rng/names, live MSH-7 timestamp. */ + private async makeGen(): Promise<(faultRate: number) => StreamMessage> { + const profile = await this.profileFn(); + const seed = Math.floor(Math.random() * 1e9); + const rng = new Rng(seed); + const names = fakerNames("en", seed); + let i = 0; + return (faultRate: number): StreamMessage => { + const m = generateMessage(rng, profile, names, i++, { now: new Date() }); + let msg = m.msg; + let injected = false; + if (faultRate > 0 && Math.random() < faultRate) { + msg = FAULTS[Math.floor(Math.random() * FAULTS.length)]!.apply(msg); + injected = true; + } + return { msg, type: m.type, injected }; + }; + } + + private lastTickAt = 0; + private onMessage(m: StreamMessage): void { + this.counters.sent += 1; + if (m.injected) this.counters.malformed += 1; + // High-rate protection: at 500+ msg/s a tick per message would flood SSE + // clients. Cap published ticks to ~20/s per source; counters ride on each + // tick, so nothing is lost — the UI just animates a sample. Injected-fault + // ticks always go through (the red pulse must not be sampled away). + const now = performance.now(); + if (!m.injected && now - this.lastTickAt < 50) return; + this.lastTickAt = now; + publish({ type: "tick", sourceId: this.id, malformed: m.injected, msgType: m.type, counters: this.view() }); + } + + private lastAckPublishAt = 0; + private ackFlush: ReturnType | null = null; + + /** + * One ACK came back. Classification only — the stream never waited for it. + * + * Publishing is sampled to ~20/s for the same reason ticks are: at 600 msg/s + * a publish per ACK is a publish per message, and every subscriber answers it. + * A trailing flush fires after the window so the last ACKs of a burst are never + * left unpublished — sampling may delay the counters, never strand them. + */ + private onAck(code: string | undefined): void { + if (code === "AA") this.counters.accepted += 1; + else this.counters.rejected += 1; // AE, AR, or a response carrying no MSA-1 + + const now = performance.now(); + if (now - this.lastAckPublishAt >= 50) { + this.lastAckPublishAt = now; + if (this.ackFlush) { clearTimeout(this.ackFlush); this.ackFlush = null; } + publish({ type: "state", sourceId: this.id, state: this.snapshot() }); + return; + } + if (!this.ackFlush) { + this.ackFlush = setTimeout(() => { + this.ackFlush = null; + this.lastAckPublishAt = performance.now(); + publish({ type: "state", sourceId: this.id, state: this.snapshot() }); + }, 60); + } + } + + // Real MLLP leg: persistent connection, absolute-deadline pacing. + private async runReal(signal: AbortSignal, t: ActorTarget): Promise { + const gen = this.gen ?? (this.gen = await this.makeGen()); + let last: StreamMessage = { msg: "", type: "", injected: false }; + await streamOverMllp({ + host: t.host, + port: t.port, + reconnect: true, // survive an engine restart mid-demo + signal, + next: () => { if (signal.aborted) return null; last = gen(this.faultRate); return last.msg; }, + gapMs: () => poissonDelayMs(this.rate), // live rate + onSent: () => this.onMessage(last), + onAck: (code) => this.onAck(code), + }); + } + + // Mock leg: no network — same pacing, simulated dispatch. + private async runMock(signal: AbortSignal): Promise { + const gen = this.gen ?? (this.gen = await this.makeGen()); + let nextAt = performance.now(); + while (!signal.aborted) { + this.onMessage(gen(this.faultRate)); + this.onAck("AA"); // a mock target's contract is to simulate acceptance + nextAt += poissonDelayMs(this.rate); + const now = performance.now(); + if (nextAt < now - 250) nextAt = now; // long stall — resync, don't burst + const wait = nextAt - now; + if (wait > 0 && !signal.aborted) await sleep(wait); + } + } + + // Supervisor: runs the active leg; restarts it on retarget; exits on stop. + private async supervise(signal: AbortSignal): Promise { + while (!signal.aborted) { + const leg = new AbortController(); + this.legAc = leg; + const onAbort = (): void => leg.abort(); + signal.addEventListener("abort", onAbort); + const t = this.targetFn(); + try { + if (t.mock) await this.runMock(leg.signal); + else await this.runReal(leg.signal, t); + } catch (e) { + if (!leg.signal.aborted) { + publish({ type: "error", sourceId: this.id, error: (e as Error)?.message ?? String(e) }); + if (!signal.aborted) await sleep(500); // don't hot-spin on a hard failure + } + } finally { + signal.removeEventListener("abort", onAbort); + } + } + this.running = false; + publish({ type: "state", sourceId: this.id, state: this.snapshot() }); + } + + start(rate?: number, faultRate?: number): void { + if (typeof rate === "number") this.rate = Math.max(0.1, rate); + if (typeof faultRate === "number") this.faultRate = Math.max(0, Math.min(1, faultRate)); + if (this.running) return; // idempotent + this.running = true; + this.runAc = new AbortController(); + publish({ type: "state", sourceId: this.id, state: this.snapshot() }); + void this.supervise(this.runAc.signal); + } + + stop(): void { + if (!this.running) return; + // Flip synchronously so the API response / UI reflect the stop immediately; + // the supervisor's own exit publish is then an idempotent no-op. Without + // this the state stayed running=true until the loop unwound, which made + // the Stop button look unresponsive. + this.running = false; + this.runAc?.abort(); + publish({ type: "state", sourceId: this.id, state: this.snapshot() }); + } + + update(p: { rate?: number; faultRate?: number }): void { + if (typeof p.rate === "number") this.rate = Math.max(0.1, p.rate); + if (typeof p.faultRate === "number") this.faultRate = Math.max(0, Math.min(1, p.faultRate)); + publish({ type: "state", sourceId: this.id, state: this.snapshot() }); + } + + /** Restart the current leg (e.g. after a target/port change). */ + retarget(): void { + if (this.running) this.legAc?.abort(); + } + + /** Profile changed (e.g. hand-picked message types) — rebuild the generator + * on the next leg and restart the current one so the change applies live. */ + regen(): void { + this.gen = null; + this.retarget(); + } + + /** Fold externally-produced traffic (single/burst sends) into the counters. + * `sent` is passed explicitly rather than inferred, so a send that was never + * answered stays visible as unanswered instead of being forced to zero. */ + bump(sent: number, accepted: number, rejected: number, malformed: number): void { + this.counters.sent += sent; + this.counters.accepted += accepted; + this.counters.rejected += rejected; + this.counters.malformed += malformed; + publish({ type: "state", sourceId: this.id, state: this.snapshot() }); + } + + get isRunning(): boolean { + return this.running; + } +} diff --git a/utils/hl7v2-simulator/ui/bus.ts b/utils/hl7v2-simulator/ui/bus.ts new file mode 100644 index 0000000..7d9b97e --- /dev/null +++ b/utils/hl7v2-simulator/ui/bus.ts @@ -0,0 +1,56 @@ +/** + * Source UI — shared SSE event bus. + * + * One pub/sub channel for everything the UI streams: the classic single-stream + * events (shape unchanged — `sourceId` is additive, old clients ignore it) and + * per-source actor events for the topology view. Extracted from stream.ts so + * N actors and the classic adapter publish to the same /events pipe. + */ + +/** + * Two axes, deliberately not mixed. + * + * delivery sent = accepted + rejected + unanswered + * content malformed — messages we deliberately corrupted + * + * `sent` counts messages the sender accounted for — on the retrying path a + * message is counted once even when it was written more than once. The receiver + * then said yes (AA), said no (AE/AR), or has not said anything yet. + * `malformed` is orthogonal to all three: a corrupted message is already inside + * `sent`, and can be accepted or refused like any other. + */ +export interface ActorCounters { + sent: number; + accepted: number; + rejected: number; + unanswered: number; + malformed: number; +} + +export interface ActorStateSnapshot { + running: boolean; + rate: number; + faultRate: number; + counters: ActorCounters; +} + +export type BusEvent = + | { type: "state"; sourceId?: string; state: ActorStateSnapshot } + | { type: "tick"; sourceId?: string; malformed: boolean; msgType: string; counters: ActorCounters } + | { type: "error"; sourceId?: string; error: string } + | { type: "sources"; sources: unknown[] }; // registry snapshot for the topology view + +type Subscriber = (event: BusEvent) => void; +const subscribers = new Set(); + +export function publish(event: BusEvent): void { + for (const sub of subscribers) { + try { sub(event); } catch { /* dead connection — ignore */ } + } +} + +/** Subscribe; `hello` events (initial snapshots) are the caller's concern. */ +export function subscribeBus(fn: Subscriber): () => void { + subscribers.add(fn); + return () => subscribers.delete(fn); +} diff --git a/utils/hl7v2-simulator/ui/generator.ts b/utils/hl7v2-simulator/ui/generator.ts new file mode 100644 index 0000000..4566589 --- /dev/null +++ b/utils/hl7v2-simulator/ui/generator.ts @@ -0,0 +1,305 @@ +/** + * Source UI — generator wrapper (v0.2 / step 2) + * + * Thin adapter around the existing src/gen/ library so the UI server can + * generate + MLLP-send HL7 messages from a single `/send` route. No subprocess + * — everything runs in the same Bun process. + * + * Profile is loaded once, cached. RNG seed is bumped per call so successive + * sends look different. Fault injection runs at the requested rate using the + * existing FAULTS table from src/gen/faults.ts. + */ + +import { mkdir, rm, readdir } from "node:fs/promises"; +import { join } from "node:path"; +import { Rng } from "../src/gen/rng.ts"; +import { fakerNames } from "../src/gen/names.ts"; +import { parseProfile } from "../src/profile/schema.ts"; +import { generateMessage } from "../src/gen/assemble.ts"; +import { FAULTS } from "../src/gen/faults.ts"; +import { sendOverMllpReliable } from "../src/send/mllp.ts"; +import type { Profile } from "../src/profile/schema.ts"; +import { DEFAULT_PROFILE } from "../src/paths.ts"; + +const PROFILE_PATH = process.env.PROFILE_PATH ?? DEFAULT_PROFILE; +let cachedProfile: Profile | null = null; +let seedCounter = Math.floor(Math.random() * 1e9); + +async function getProfile(): Promise { + if (!cachedProfile) { + cachedProfile = parseProfile(await Bun.file(PROFILE_PATH).text()); + } + return cachedProfile; +} + +/** The corpus-learned base profile — sources specialize it (see sources.ts). */ +export async function getBaseProfile(): Promise { + return getProfile(); +} + +export interface SendParams { + count: number; + faultRate: number; // 0..1 + forceType?: string; // 'ADT^A01' to override profile distribution + target: { host: string; port: number }; + mock?: boolean; // if true: simulate ACK locally, no MLLP + profile?: Profile; // per-source specialized profile (identity + mix) +} + +export interface SendResult { + generated: number; + sent: number; // written to the socket = accepted + rejected + unanswered + accepted: number; // ACK AA — the engine confirmed it + rejected: number; // ACK AE/AR — the engine saw it and refused + unanswered: number; // no answer: timeout, connection error, unreachable + failed: number; // rejected + unanswered, for callers that need only "not delivered" + injectedFaults: number; // faults we intentionally injected — a subset of sent + retries: number; // total redelivery attempts + durationMs: number; + types: Record; + ok: boolean; + error?: string; +} + +export async function generateAndSend(p: SendParams): Promise { + const t0 = performance.now(); + const baseProfile = p.profile ?? (await getProfile()); + const profile: Profile = p.forceType + ? { ...baseProfile, messageTypes: [[p.forceType, 1]] } + : baseProfile; + + const seed = ++seedCounter; + const rng = new Rng(seed); + const names = fakerNames("en", seed); + + const messages: string[] = []; + const types: Record = {}; + let injectedFaults = 0; + + for (let i = 0; i < p.count; i++) { + const m = generateMessage(rng, profile, names, i); + let msg = m.msg; + if (p.faultRate > 0 && Math.random() < p.faultRate) { + const fault = FAULTS[Math.floor(Math.random() * FAULTS.length)]!; + msg = fault.apply(msg); + injectedFaults++; + } + messages.push(msg); + types[m.type] = (types[m.type] ?? 0) + 1; + } + + // Mock targets: simulate a small latency + AA ACK without touching the network + if (p.mock) { + // realistic-ish: 1–4ms per message, capped at 80ms total + const simMs = Math.min(80, 1 + messages.length * 0.3); + await new Promise((r) => setTimeout(r, simMs)); + return { + generated: messages.length, + sent: messages.length, + accepted: messages.length, // a mock target's contract is to simulate acceptance + rejected: 0, + unanswered: 0, + failed: 0, + retries: 0, + injectedFaults, + durationMs: Math.round(performance.now() - t0), + types, + ok: true, + }; + } + try { + const result = await sendOverMllpReliable(messages, { + host: p.target.host, + port: p.target.port, + concurrency: Math.min(16, Math.max(2, Math.ceil(p.count / 32))), + ackTimeoutMs: 3000, + // For single we want to surface failure immediately; for burst/stream + // we still want at-least-once delivery so keep modest retries. + maxRetries: p.count === 1 ? 1 : 3, + backoffMs: 500, + }); + return { + generated: messages.length, + sent: messages.length, + accepted: result.acked, + rejected: result.refused, + unanswered: result.silent, + failed: result.failed, + retries: result.retries, + injectedFaults, + durationMs: Math.round(performance.now() - t0), + types, + ok: result.failed === 0, + error: result.failed > 0 + ? `${result.failed}/${messages.length} not delivered after ${result.retries} retries ` + + `(${result.refused} refused, ${result.silent} unanswered)` + : undefined, + }; + } catch (e) { + return { + generated: messages.length, + // The send never got off the ground, so nothing was written and nothing + // was refused — the engine never saw these. Silence, not rejection. + sent: messages.length, + accepted: 0, + rejected: 0, + unanswered: messages.length, + failed: messages.length, + retries: 0, + injectedFaults, + durationMs: Math.round(performance.now() - t0), + types, + ok: false, + error: (e as Error)?.message ?? String(e), + }; + } +} + +export interface StreamMessage { + msg: string; + type: string; + injected: boolean; // a fault was deliberately injected into this message +} + +/** + * A per-call message source for STREAM mode. Each call generates one synthetic + * message (live MSH-7 timestamp) and injects a fault with probability + * `faultRate`. Profile is cached; rng/names are seeded fresh per source so + * successive streams differ. Unlike `generateAndSend` this does NO network I/O — + * the stream loop owns transport (a persistent fire-and-forget MLLP connection), + * which is what lets it pace far past the per-message-ACK ceiling. + */ +export async function streamGenerator(): Promise<(faultRate: number) => StreamMessage> { + const profile = await getProfile(); + const seed = ++seedCounter; + const rng = new Rng(seed); + const names = fakerNames("en", seed); + let i = 0; + return (faultRate: number): StreamMessage => { + const m = generateMessage(rng, profile, names, i++, { now: new Date() }); + let msg = m.msg; + let injected = false; + if (faultRate > 0 && Math.random() < faultRate) { + msg = FAULTS[Math.floor(Math.random() * FAULTS.length)]!.apply(msg); + injected = true; + } + return { msg, type: m.type, injected }; + }; +} + +// ── Folder / batch output (files, not MLLP) ───────────────────────────────── +// The batch counterpart of generateAndSend: write raw .hl7 files a folderSource +// can drain (one message per file, CR-separated segments, no MLLP framing). + +export interface FolderParams { + dir: string; + count: number; + faultRate: number; // 0..1 + types?: string[]; // round-robin forced mix; empty = the profile's own mix + clean?: boolean; // wipe existing files first (folderSource reads ALL files) + profile?: Profile; +} +export interface FolderResult { + written: number; + dir: string; + types: Record; + injectedFaults: number; + ok: boolean; + error?: string; +} + +function badTypes(base: Profile, types?: string[]): string | null { + if (!types?.length) return null; + const avail = new Set(base.messageTypes.map(([t]) => t)); + const bad = types.filter((t) => !avail.has(t)); + return bad.length ? `unknown message types: ${bad.join(", ")}` : null; +} + +// Round-robin generator over the (optional) forced type list, so every listed +// type appears — evenly — regardless of the profile's own weights. +function forcedGen(base: Profile, types: string[] | null): (faultRate: number) => StreamMessage { + const seed = ++seedCounter; + const rng = new Rng(seed); + const names = fakerNames("en", seed); + let i = 0; + return (faultRate: number): StreamMessage => { + const profile: Profile = types ? { ...base, messageTypes: [[types[i % types.length]!, 1]] } : base; + const m = generateMessage(rng, profile, names, i++, { now: new Date() }); + let msg = m.msg; + let injected = false; + if (faultRate > 0 && Math.random() < faultRate) { + msg = FAULTS[Math.floor(Math.random() * FAULTS.length)]!.apply(msg); + injected = true; + } + return { msg, type: m.type, injected }; + }; +} + +export async function generateToFolder(p: FolderParams): Promise { + const base = p.profile ?? (await getProfile()); + const bad = badTypes(base, p.types); + if (bad) return { written: 0, dir: p.dir, types: {}, injectedFaults: 0, ok: false, error: bad }; + const next = forcedGen(base, p.types?.length ? p.types : null); + const types: Record = {}; + let injectedFaults = 0; + const width = String(Math.max(1, p.count)).length; + const files: { name: string; msg: string }[] = []; + for (let i = 0; i < p.count; i++) { + const m = next(p.faultRate); + if (m.injected) injectedFaults += 1; + types[m.type] = (types[m.type] ?? 0) + 1; + files.push({ name: `msg-${String(i + 1).padStart(width, "0")}-${m.type.replace(/\^/g, "_")}.hl7`, msg: m.msg }); + } + try { + await mkdir(p.dir, { recursive: true }); + if (p.clean) for (const f of await readdir(p.dir)) await rm(join(p.dir, f), { force: true }); + for (const f of files) await Bun.write(join(p.dir, f.name), f.msg); + return { written: files.length, dir: p.dir, types, injectedFaults, ok: true }; + } catch (e) { + return { written: 0, dir: p.dir, types, injectedFaults, ok: false, error: (e as Error)?.message ?? String(e) }; + } +} + +/** Streaming folder writer: trickle one .hl7 per tick at a fixed rate until stopped. */ +class FolderStreamer { + private running = false; + private timer: ReturnType | null = null; + private next: ((faultRate: number) => StreamMessage) | null = null; + private seq = 0; + written = 0; + dir = ""; + rate = 2; + faultRate = 0; + + async start(o: { dir: string; rate: number; faultRate: number; types?: string[]; profile?: Profile }): Promise<{ ok: boolean; error?: string }> { + if (this.running) return { ok: true }; + const base = o.profile ?? (await getProfile()); + const bad = badTypes(base, o.types); + if (bad) return { ok: false, error: bad }; + await mkdir(o.dir, { recursive: true }); + this.dir = o.dir; + this.rate = Math.max(0.1, o.rate); + this.faultRate = Math.max(0, Math.min(1, o.faultRate)); + this.written = 0; + this.next = forcedGen(base, o.types?.length ? o.types : null); + this.running = true; + const tick = async (): Promise => { + if (!this.running || !this.next) return; + const m = this.next(this.faultRate); + const name = `stream-${String(++this.seq).padStart(6, "0")}-${m.type.replace(/\^/g, "_")}.hl7`; + try { await Bun.write(join(this.dir, name), m.msg); this.written += 1; } catch { /* keep going */ } + if (this.running) this.timer = setTimeout(() => void tick(), Math.max(20, 1000 / this.rate)); + }; + void tick(); + return { ok: true }; + } + stop(): void { + this.running = false; + if (this.timer) clearTimeout(this.timer); + this.timer = null; + } + state(): { running: boolean; written: number; dir: string; rate: number } { + return { running: this.running, written: this.written, dir: this.dir, rate: this.rate }; + } +} +export const folderStreamer = new FolderStreamer(); diff --git a/utils/hl7v2-simulator/ui/page.ts b/utils/hl7v2-simulator/ui/page.ts new file mode 100644 index 0000000..6ece7c5 --- /dev/null +++ b/utils/hl7v2-simulator/ui/page.ts @@ -0,0 +1,1169 @@ +/** + * Source UI — page renderer (v0.1) + * + * Returns an HTML string for the H-skin pocket widget with mode-aware controls. + * Single-page, Alpine.js for interactivity, inline CSS for portability. + * + * The UI shows the same three modes (Single / Burst / Stream); the control + * area between mode-pills and the faults slider reshapes based on the selected + * mode. Send / Start are local stubs in v0.1 — they bump counters so the + * visual rhythm of the demo is observable without wiring the generator yet. + */ + +export interface Target { + id: string; + label: string; + host: string; + port: number; +} + +export interface PageProps { + engineTarget: string; + profile: string; + targets: Target[]; + activeTargetId: string; +} + +export function renderPage({ engineTarget, profile, targets, activeTargetId }: PageProps): string { + return ` + + + +Source · interbox upstream simulator + + + + + + + + +
+
+
+
+ + + +
+
Sourceupstream simulator
+
+
+ + +
+
+ +
+
+
+
+
+ + +
+
+
+
+
+
+
Real · MLLP listeners
+ +
Demo · mocked ACK in-process
+ +
+
+ +
+
+ Live channel · HL7 + msg/s +
+
+
+ + + + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
Single
+
Burst
+
Stream
+
+ + +
+ + +
+
Pick a message type
+
+ +
+
+ +
+
+ + +
+
How many at once
+
+ + + + +
+
+ +
+
+ + +
+
+ Rate + msg/s +
+
+
+
+
+
+ 0.1 + 1 + 10 + 100 +
+
+ +
+
+ +
+ + +
+ error +
+ + +
+
+ Faults + % +
+
+
+
+
+
+ 0% + 10% + 25% + 40% + 50% +
+
+ + +
+
ADT 58%
+
ORU 31%
+
SIU 11%
+
+ +
+ v0.1 · + +
+
+ + + +`; +} diff --git a/utils/hl7v2-simulator/ui/server.ts b/utils/hl7v2-simulator/ui/server.ts new file mode 100644 index 0000000..aaa4a46 --- /dev/null +++ b/utils/hl7v2-simulator/ui/server.ts @@ -0,0 +1,387 @@ +/** + * Source UI — Bun server (v0.3 / step 3) + * + * Adds /stream/start, /stream/stop, /stream (PATCH) and /events (SSE). + * Single + Burst from /send route also feed the shared counters so the UI + * sees a unified throughput regardless of which mode produced traffic. + * + * Run: bun run ui/server.ts → http://localhost:4003 + */ + +import { renderPage } from "./page.ts"; +import { renderTopologyPage } from "./topology.ts"; +import { generateAndSend, generateToFolder, folderStreamer } from "./generator.ts"; +import { + startStream, stopStream, updateStream, subscribe, + setTarget, getTarget, bumpExternalCounters, +} from "./stream.ts"; +import { ALLOWED_MSG_TYPES, SourceRegistry, type SourceType } from "./sources.ts"; +import { DEFAULT_EXPORT_DIR, DEFAULT_SOURCES_PATH } from "../src/paths.ts"; + +const PORT = Number(process.env.PORT ?? 4003); +// Loopback by default. This server is a developer tool with no authentication: +// /export writes and (with clean:true) deletes files at a path taken straight +// from the request body, and /probe will TCP-scan from wherever it runs. Bun +// would otherwise bind 0.0.0.0 and hand all of that to anyone on the network. +// Set HOST=0.0.0.0 only on a network you control. +const HOST = process.env.HOST ?? "127.0.0.1"; +const PROFILE = process.env.PROFILE_NAME ?? "default"; +const SOURCES_PATH = process.env.SOURCES_PATH ?? DEFAULT_SOURCES_PATH; +const EXPORT_DIR = process.env.EXPORT_DIR ?? DEFAULT_EXPORT_DIR; + +// Available MLLP targets — switchable from the UI. +// Override via TARGETS env: "label1:host1:port1,label2:host2:port2". +// Mock targets simulate ACK in-process — no real MLLP, no DB writes. +interface Target { id: string; label: string; host: string; port: number; mock?: boolean; } +const TARGETS: Target[] = parseTargets(process.env.TARGETS) ?? [ + // Real MLLP listeners on local ports. First entry → the default selected target, + // matching the MLLP ingest port the reference docker-compose publishes. + { id: "demo", label: "Engine", host: "127.0.0.1", port: 2575 }, + { id: "alt", label: "Engine (alt)", host: "127.0.0.1", port: 2576 }, + // Mocked destination — generate + simulate AA, no MLLP traffic, no listener needed. + { id: "mock", label: "Mock target — no network", host: "mock", port: 0, mock: true }, +]; + +function parseTargets(spec: string | undefined): Target[] | undefined { + if (!spec) return undefined; + const out: Target[] = []; + for (const chunk of spec.split(",")) { + const [label, host, portStr] = chunk.trim().split(":"); + if (!label || !host || !portStr) continue; + out.push({ + id: label.toLowerCase().replace(/[^a-z0-9]+/g, "-"), + label, host, port: Number(portStr), + }); + } + return out.length ? out : undefined; +} + +// Pick a sensible default +let activeTargetId = TARGETS[0]?.id ?? "demo"; +const initial = TARGETS.find((t) => t.id === activeTargetId)!; +setTarget(initial.host, initial.port, initial.label, initial.mock ?? false); + +function activeTarget(): Target { + return TARGETS.find((t) => t.id === activeTargetId) ?? TARGETS[0]!; +} + +// Simulated upstream senders (multi-source mode) — each an independent actor. +// They default to the GLOBAL active target; a source may override its port. +const registry = new SourceRegistry(SOURCES_PATH, () => { + const t = activeTarget(); + return { host: t.host, port: t.port, mock: t.mock ?? false }; +}); +await registry.init(); + +// Quick TCP probe for the add-source form: is anything listening on the port? +// Bun.connect resolves on open and rejects on connect failure; race a timeout. +async function probePort(port: number, host = "127.0.0.1"): Promise { + try { + const sock = await Promise.race([ + Bun.connect({ hostname: host, port, socket: { data() {}, error() {}, close() {} } }), + new Promise((_, rej) => setTimeout(() => rej(new Error("probe timeout")), 1500)), + ]); + sock.end(); + return true; + } catch { + return false; + } +} + +interface SendBody { + mode: "single" | "burst"; + type?: string; + count?: number; + faultRate?: number; +} + +interface StreamStartBody { rate?: number; faultRate?: number; } +interface StreamPatchBody { rate?: number; faultRate?: number; } + +Bun.serve({ + port: PORT, + hostname: HOST, + // SSE /events is long-lived; Bun's default 10s idleTimeout would close it + // between ticks (heartbeat is 15s, too late to save it). 0 = no timeout. + idleTimeout: 0, + routes: { + // Topology (multi-source map) is the default view; the classic + // single-stream page stays fully functional at /classic. + "/": () => + new Response( + renderTopologyPage({ targets: TARGETS, activeTargetId, profile: PROFILE, exportDir: EXPORT_DIR }), + { headers: { "content-type": "text/html; charset=utf-8" } }, + ), + "/classic": () => { + const t = activeTarget(); + return new Response( + renderPage({ + engineTarget: `${t.host}:${t.port}`, + profile: PROFILE, + targets: TARGETS, + activeTargetId, + }), + { headers: { "content-type": "text/html; charset=utf-8" } }, + ); + }, + "/health": () => + new Response(JSON.stringify({ ok: true, port: PORT, target: getTarget() }), { + headers: { "content-type": "application/json" }, + }), + "/targets": { + GET: () => Response.json({ targets: TARGETS, activeId: activeTargetId }), + POST: async (req) => { + let body: { id?: string } = {}; + try { body = await req.json() as { id?: string }; } catch {} + const t = TARGETS.find((x) => x.id === body.id); + if (!t) return Response.json({ ok: false, error: "unknown target id" }, { status: 400 }); + activeTargetId = t.id; + setTarget(t.host, t.port, t.label, t.mock ?? false); + registry.retargetAll(); // sources without a port override follow the global target + return Response.json({ ok: true, active: t }); + }, + }, + + // ── Multi-source mode: simulated upstream senders (see sources.ts) ── + "/sources": { + GET: () => Response.json({ sources: registry.list() }), + POST: async (req) => { + let body: { name?: string; type?: SourceType; rate?: number; faultRate?: number; targetPort?: number; msgTypes?: string[] } = {}; + try { body = await req.json() as typeof body; } catch {} + try { + const def = await registry.create({ + name: body.name ?? "", + type: body.type ?? "lab", + rate: body.rate, + faultRate: body.faultRate, + targetPort: body.targetPort, + msgTypes: body.msgTypes, + }); + return Response.json({ ok: true, source: def }); + } catch (e) { + return Response.json({ ok: false, error: (e as Error).message }, { status: 400 }); + } + }, + }, + + "/sources/:id": { + PATCH: async (req) => { + let body: { rate?: number; faultRate?: number; targetPort?: number; clearTargetPort?: boolean; msgTypes?: string[] } = {}; + try { body = await req.json() as typeof body; } catch {} + try { + const def = await registry.update(req.params.id, body); + return Response.json({ ok: true, source: def }); + } catch (e) { + return Response.json({ ok: false, error: (e as Error).message }, { status: 400 }); + } + }, + DELETE: async (req) => { + try { + await registry.remove(req.params.id); + return Response.json({ ok: true }); + } catch (e) { + return Response.json({ ok: false, error: (e as Error).message }, { status: 400 }); + } + }, + }, + + // Single/burst from ONE source: its identity + mix, ACK-confirmed counts. + "/sources/:id/send": { + POST: async (req) => { + const entry = registry.get(req.params.id); + if (!entry) return Response.json({ ok: false, error: "unknown source" }, { status: 404 }); + let body: SendBody = { mode: "burst" }; + try { body = await req.json() as SendBody; } catch {} + const count = body.mode === "single" ? 1 : Math.max(1, Math.min(10000, body.count ?? 1)); + const t = registry.targetOf(req.params.id)!; + const result = await generateAndSend({ + count, + faultRate: Math.max(0, Math.min(1, body.faultRate ?? entry.def.faultRate)), + forceType: body.mode === "single" ? body.type : undefined, + target: { host: t.host, port: t.port }, + mock: t.mock ?? false, + profile: await registry.profileOf(req.params.id)!, + }); + entry.actor.bump(result.sent, result.accepted, result.rejected, result.injectedFaults); + return Response.json(result, { status: result.ok ? 200 : 502 }); + }, + }, + + "/sources/:id/stream": { + POST: async (req) => { + const entry = registry.get(req.params.id); + if (!entry) return Response.json({ ok: false, error: "unknown source" }, { status: 404 }); + let body: { action?: "start" | "stop"; rate?: number; faultRate?: number } = {}; + try { body = await req.json() as typeof body; } catch {} + if (body.action === "stop") { + entry.actor.stop(); + } else { + // A rate/fault passed with start becomes the source's setting (persisted), + // so the node card and the actual pacing never disagree. + if (typeof body.rate === "number" || typeof body.faultRate === "number") { + await registry.update(req.params.id, { rate: body.rate, faultRate: body.faultRate }); + } + entry.actor.start(entry.def.rate, entry.def.faultRate); + } + registry.publishSources(); + return Response.json({ ok: true, state: entry.actor.snapshot() }); + }, + }, + + // Start or stop every source at once (each at its own rate). + "/sources/stream-all": { + POST: async (req) => { + let body: { action?: "start" | "stop" } = {}; + try { body = await req.json() as typeof body; } catch {} + registry.streamAll(body.action === "stop" ? "stop" : "start"); + return Response.json({ ok: true, sources: registry.list() }); + }, + }, + + // ── File export: write .hl7 files to a folder (batch) or trickle (stream) ── + "/export": { + GET: () => Response.json({ stream: folderStreamer.state() }), + POST: async (req) => { + let body: { dir?: string; count?: number; faultRate?: number; types?: string[]; clean?: boolean } = {}; + try { body = await req.json() as typeof body; } catch {} + if (!body.dir) return Response.json({ ok: false, error: "dir is required" }, { status: 400 }); + const result = await generateToFolder({ + dir: body.dir, + count: Math.max(1, Math.min(100000, body.count ?? 10)), + faultRate: Math.max(0, Math.min(1, body.faultRate ?? 0)), + types: body.types, + clean: !!body.clean, + }); + return Response.json(result, { status: result.ok ? 200 : 400 }); + }, + }, + "/export/stream": { + POST: async (req) => { + let body: { action?: "start" | "stop"; dir?: string; rate?: number; faultRate?: number; types?: string[] } = {}; + try { body = await req.json() as typeof body; } catch {} + if (body.action === "stop") { + folderStreamer.stop(); + return Response.json({ ok: true, stream: folderStreamer.state() }); + } + if (!body.dir) return Response.json({ ok: false, error: "dir is required" }, { status: 400 }); + const r = await folderStreamer.start({ + dir: body.dir, + rate: Math.max(0.1, Math.min(200, body.rate ?? 2)), + faultRate: Math.max(0, Math.min(1, body.faultRate ?? 0)), + types: body.types, + }); + return Response.json({ ...r, stream: folderStreamer.state() }, { status: r.ok ? 200 : 400 }); + }, + }, + + // Buildable message types (registry-derived) — the UI chips come from here, + // so new builders appear in the picker automatically. + "/msg-types": () => Response.json({ types: ALLOWED_MSG_TYPES }), + + // TCP probe for the add-source form: ✓ listening / ✗ refused. + "/probe": async (req) => { + const url = new URL(req.url); + const port = Number(url.searchParams.get("port")); + if (!Number.isInteger(port) || port < 1 || port > 65535) { + return Response.json({ ok: false, error: "invalid port" }, { status: 400 }); + } + return Response.json({ port, listening: await probePort(port) }); + }, + + "/send": { + POST: async (req) => { + let body: SendBody; + try { body = await req.json() as SendBody; } + catch { return Response.json({ ok: false, error: "invalid json" }, { status: 400 }); } + const faultRate = Math.max(0, Math.min(1, body.faultRate ?? 0)); + const count = body.mode === "single" ? 1 : Math.max(1, Math.min(10000, body.count ?? 1)); + const forceType = body.mode === "single" ? body.type : undefined; + const t = activeTarget(); + const result = await generateAndSend({ + count, faultRate, forceType, + target: { host: t.host, port: t.port }, + mock: t.mock ?? false, + }); + // feed shared counters — sent = written, accepted = AA, rejected = AE/AR, unanswered = silence + bumpExternalCounters(result.sent, result.accepted, result.rejected, result.injectedFaults); + return Response.json(result, { status: result.ok ? 200 : 502 }); + }, + }, + + "/stream/start": { + POST: async (req) => { + let body: StreamStartBody = {}; + try { body = await req.json() as StreamStartBody; } catch {} + startStream(body.rate ?? 3.0, body.faultRate ?? 0); + return Response.json({ ok: true }); + }, + }, + + "/stream/stop": { + POST: () => { + stopStream(); + return Response.json({ ok: true }); + }, + }, + + "/stream": { + PATCH: async (req) => { + let body: StreamPatchBody = {}; + try { body = await req.json() as StreamPatchBody; } catch {} + updateStream(body); + return Response.json({ ok: true }); + }, + }, + + "/events": (req) => { + // SSE: client connects once on page-load, receives state + tick events + const enc = new TextEncoder(); + let unsubscribe = () => {}; + let heartbeat: ReturnType | null = null; + const stream = new ReadableStream({ + start(controller) { + const send = (data: unknown) => { + try { controller.enqueue(enc.encode(`data: ${JSON.stringify(data)}\n\n`)); } + catch { /* connection closed mid-flight */ } + }; + unsubscribe = subscribe(send); + heartbeat = setInterval(() => { + try { controller.enqueue(enc.encode(`: heartbeat\n\n`)); } + catch {} + }, 15000); + // disconnect when client navigates away + req.signal.addEventListener("abort", () => { + try { unsubscribe(); } catch {} + if (heartbeat) clearInterval(heartbeat); + try { controller.close(); } catch {} + }); + }, + cancel() { + try { unsubscribe(); } catch {} + if (heartbeat) clearInterval(heartbeat); + }, + }); + return new Response(stream, { + headers: { + "content-type": "text/event-stream", + "cache-control": "no-cache, no-transform", + "connection": "keep-alive", + }, + }); + }, + }, + error(err) { + console.error("[source-ui]", err); + return Response.json( + { ok: false, error: (err as Error)?.message ?? String(err) }, + { status: 500 }, + ); + }, +}); + +console.log( + `[source-ui] listening on http://localhost:${PORT} · profile ${PROFILE}`, +); +console.log( + `[source-ui] targets: ${TARGETS.map((t) => `${t.label}@${t.host}:${t.port}`).join(" · ")} (active: ${activeTarget().label})`, +); diff --git a/utils/hl7v2-simulator/ui/sources.ts b/utils/hl7v2-simulator/ui/sources.ts new file mode 100644 index 0000000..c788165 --- /dev/null +++ b/utils/hl7v2-simulator/ui/sources.ts @@ -0,0 +1,264 @@ +/** + * Source UI — SourceRegistry: the simulated upstream senders. + * + * A Source is "who": a sender identity (name → MSH-4, type → MSH-3 + message + * mix + its own MRN pool) plus behavior (stream rate, fault rate, optional own + * target port). Identity is injected by SPECIALIZING the corpus profile — the + * grammar samples MSH-3/4 and the assigning authority from profile catalogs, + * so a clone with single-entry catalogs gives a source its identity with zero + * changes to the generation core. + * + * The registry persists to a local JSON file (a prepared demo setup must not + * evaporate on restart) and owns one SourceActor per source. + */ + +import type { Profile } from "../src/profile/schema.ts"; +import { SourceActor, type ActorTarget } from "./actor.ts"; +import type { ActorCounters } from "./bus.ts"; +import { getBaseProfile } from "./generator.ts"; +import { publish } from "./bus.ts"; + +export type SourceType = "lab" | "clinic" | "hospital" | "pharmacy"; + +export interface SourceDef { + id: string; // slug of name — stable key + name: string; // display + MSH-4 (CAPS ASCII) + type: SourceType; + rate: number; // stream msg/s + faultRate: number; // 0..1 + targetPort?: number; // per-source override; unset → global target + // Hand-picked message types (equal weights). Unset → the type preset's mix. + msgTypes?: string[]; +} + +/** Message types the grammar can build (events the profile/builders support). */ +export const ALLOWED_MSG_TYPES = [ + "ORU^R01", "ORM^O01", + "ADT^A01", "ADT^A03", "ADT^A08", + "SIU^S12", + "MDM^T02", "MDM^T07", "MDM^T11", + "RDE^O01", "RDE^O11", "RAS^O17", +] as const; + +// MSH-3 (sending application) + message mix per source type. Labs push results; +// clinics push visits + scheduling; hospitals are ADT-heavy with some results. +const TYPE_PRESETS: Record = { + lab: { app: "LAB_IF", mix: [["ORU^R01", 0.75], ["ORM^O01", 0.1], ["ADT^A08", 0.15]] }, + clinic: { app: "CLINIC_EHR", mix: [["ADT^A08", 0.55], ["SIU^S12", 0.45]] }, + hospital: { app: "HOSP_ADT", mix: [["ADT^A01", 0.3], ["ADT^A03", 0.2], ["ADT^A08", 0.2], ["ORU^R01", 0.2], ["MDM^T02", 0.1]] }, + pharmacy: { app: "PHARM_SYS", mix: [["RDE^O11", 0.5], ["RAS^O17", 0.35], ["ADT^A08", 0.15]] }, +}; + +/** HL7-style sender name: CAPS, ASCII, single spaces. */ +export function normalizeName(raw: string): string { + return raw + .normalize("NFKD") + .replace(/[^\x20-\x7E]/g, "") + .replace(/\s+/g, " ") + .trim() + .toUpperCase(); +} + +export function slugOf(name: string): string { + return normalizeName(name).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, ""); +} + +// Initials for the per-source MRN prefix: "SUNRISE LAB" → "SL". +function initialsOf(name: string): string { + const parts = normalizeName(name).split(" ").filter(Boolean); + return (parts.map((p) => p[0]).join("") || "SRC").slice(0, 3); +} + +/** + * Identity injection: specialize the corpus profile for one source. Single-entry + * catalogs pin MSH-3/MSH-4/assigning authority; the mix comes from the type + * preset; the MRN format gets a per-source prefix (own patient-id pool). + */ +export function profileFor(base: Profile, def: SourceDef): Profile { + const preset = TYPE_PRESETS[def.type]; + const facility = normalizeName(def.name); + const aa = `${slugOf(def.name).toUpperCase().replace(/-/g, "_")}_MRN`; + // Hand-picked types win over the preset mix (equal weights across picks). + const mix: [string, number][] = def.msgTypes?.length + ? def.msgTypes.map((t) => [t, 1 / def.msgTypes!.length]) + : preset.mix; + return { + ...base, + messageTypes: mix, + catalogs: { + ...base.catalogs, + app: [[preset.app, 1]], + facility: [[facility, 1]], + assigningAuthority: [[aa, 1]], + }, + idFormats: { ...base.idFormats, mrn: `${initialsOf(def.name)}########` }, + }; +} + +export interface SourceView extends SourceDef { + running: boolean; + counters: ActorCounters; +} + +export class SourceRegistry { + private readonly path: string; + private readonly globalTarget: () => ActorTarget; + private readonly defs = new Map(); + private readonly actors = new Map(); + + constructor(path: string, globalTarget: () => ActorTarget) { + this.path = path; + this.globalTarget = globalTarget; + } + + /** Load persisted sources, or seed the three defaults on first boot. */ + async init(): Promise { + let defs: SourceDef[] | null = null; + try { + const raw = await Bun.file(this.path).json() as SourceDef[]; + if (Array.isArray(raw) && raw.every((d) => d && typeof d.id === "string")) defs = raw; + } catch { /* no file yet — seed below */ } + if (!defs) { + defs = [ + { id: "memorial-lab", name: "MEMORIAL LAB", type: "lab", rate: 2.0, faultRate: 0.05 }, + { id: "cedarview-clinic", name: "CEDARVIEW CLINIC", type: "clinic", rate: 0.5, faultRate: 0.05 }, + { id: "st-marys-hospital", name: "ST MARYS HOSPITAL", type: "hospital", rate: 1.0, faultRate: 0.05 }, + ]; + await this.saveDefs(defs); + } + for (const def of defs) this.mount(def); + } + + private async saveDefs(defs?: SourceDef[]): Promise { + const list = defs ?? [...this.defs.values()]; + await Bun.write(this.path, JSON.stringify(list, null, 2)); + } + + private mount(def: SourceDef): void { + this.defs.set(def.id, def); + const actor = new SourceActor( + def.id, + async () => profileFor(await getBaseProfile(), this.defs.get(def.id) ?? def), + // Per-leg resolution: a port override (or a live global-target switch) + // applies on the next leg without recreating the actor. + () => { + const d = this.defs.get(def.id); + const g = this.globalTarget(); + return d?.targetPort ? { host: "127.0.0.1", port: d.targetPort } : g; + }, + ); + this.actors.set(def.id, actor); + } + + list(): SourceView[] { + return [...this.defs.values()].map((d) => { + const a = this.actors.get(d.id)!; + const s = a.snapshot(); + return { ...d, running: s.running, counters: s.counters }; + }); + } + + get(id: string): { def: SourceDef; actor: SourceActor } | undefined { + const def = this.defs.get(id); + const actor = this.actors.get(id); + return def && actor ? { def, actor } : undefined; + } + + profileOf(id: string): Promise | undefined { + const def = this.defs.get(id); + return def ? getBaseProfile().then((b) => profileFor(b, def)) : undefined; + } + + targetOf(id: string): ActorTarget | undefined { + const def = this.defs.get(id); + if (!def) return undefined; + return def.targetPort ? { host: "127.0.0.1", port: def.targetPort } : this.globalTarget(); + } + + async create(input: { name: string; type: SourceType; rate?: number; faultRate?: number; targetPort?: number; msgTypes?: string[] }): Promise { + const name = normalizeName(input.name); + if (!name) throw new Error("name is required"); + if (!TYPE_PRESETS[input.type]) throw new Error(`unknown type "${input.type}" (${Object.keys(TYPE_PRESETS).join(" | ")})`); + const id = slugOf(name); + if (this.defs.has(id)) throw new Error(`source "${id}" already exists`); + const def: SourceDef = { + id, + name, + type: input.type, + rate: clampRate(input.rate ?? 1.0), + faultRate: clamp01(input.faultRate ?? 0), + ...(input.targetPort ? { targetPort: validPort(input.targetPort) } : {}), + ...(input.msgTypes ? { msgTypes: validMsgTypes(input.msgTypes) } : {}), + }; + this.mount(def); + await this.saveDefs(); + this.publishSources(); + return def; + } + + async update(id: string, patch: Partial> & { clearTargetPort?: boolean }): Promise { + const entry = this.get(id); + if (!entry) throw new Error(`unknown source "${id}"`); + const def = entry.def; + if (typeof patch.rate === "number") def.rate = clampRate(patch.rate); + if (typeof patch.faultRate === "number") def.faultRate = clamp01(patch.faultRate); + if (typeof patch.targetPort === "number") def.targetPort = validPort(patch.targetPort); + if (patch.clearTargetPort) delete def.targetPort; + if (Array.isArray(patch.msgTypes)) { + // Empty selection = back to the type preset. The actor's generator is + // profile-backed lazily per leg, so restart the leg to pick up the mix. + if (patch.msgTypes.length === 0) delete def.msgTypes; + else def.msgTypes = validMsgTypes(patch.msgTypes); + entry.actor.regen(); + } + entry.actor.update({ rate: def.rate, faultRate: def.faultRate }); // live-applies + if (typeof patch.targetPort === "number" || patch.clearTargetPort) entry.actor.retarget(); + await this.saveDefs(); + this.publishSources(); + return def; + } + + async remove(id: string): Promise { + const entry = this.get(id); + if (!entry) throw new Error(`unknown source "${id}"`); + entry.actor.stop(); + this.defs.delete(id); + this.actors.delete(id); + await this.saveDefs(); + this.publishSources(); + } + + /** Every actor restarts its leg — used when the GLOBAL target switches. */ + retargetAll(): void { + for (const a of this.actors.values()) a.retarget(); + } + + /** Start (at each source's own rate) or stop every source at once. */ + streamAll(action: "start" | "stop"): void { + for (const [id, actor] of this.actors) { + if (action === "stop") actor.stop(); + else { const d = this.defs.get(id); actor.start(d?.rate, d?.faultRate); } + } + this.publishSources(); + } + + publishSources(): void { + publish({ type: "sources", sources: this.list() }); + } +} + +function validMsgTypes(list: string[]): string[] { + const ok = list.filter((t): t is (typeof ALLOWED_MSG_TYPES)[number] => (ALLOWED_MSG_TYPES as readonly string[]).includes(t)); + if (ok.length === 0) throw new Error(`no valid message types in [${list.join(", ")}]`); + return [...new Set(ok)]; +} + +const clamp01 = (n: number): number => Math.max(0, Math.min(1, n)); +// Ceiling is a safety clamp, not the measured limit — see the load section in +// the simulator spec. Overridable for load rigs via MAX_STREAM_RATE. +const MAX_RATE = Number(process.env.MAX_STREAM_RATE ?? 1000); +const clampRate = (n: number): number => Math.max(0.1, Math.min(MAX_RATE, n)); +function validPort(n: number): number { + if (!Number.isInteger(n) || n < 1 || n > 65535) throw new Error(`invalid port ${n}`); + return n; +} diff --git a/utils/hl7v2-simulator/ui/stream.ts b/utils/hl7v2-simulator/ui/stream.ts new file mode 100644 index 0000000..0a7e91c --- /dev/null +++ b/utils/hl7v2-simulator/ui/stream.ts @@ -0,0 +1,54 @@ +/** + * Source UI — classic single-stream adapter (v0.5) + * + * The old singleton stream now delegates to one SourceActor (see actor.ts) so + * the Classic page keeps its exact API and event shapes while the same actor + * machinery also powers N independent per-source streams (sources.ts). The + * classic actor publishes without a distinguishing identity of its own — + * its events carry sourceId "classic", which old clients simply ignore. + */ + +import { SourceActor } from "./actor.ts"; +import { getBaseProfile } from "./generator.ts"; +import { publish, subscribeBus, type BusEvent } from "./bus.ts"; + +let target = { host: "127.0.0.1", port: 2575, label: "Engine A", mock: false }; + +const classic = new SourceActor("classic", getBaseProfile, () => target); + +export function setTarget(host: string, port: number, label = "", mock = false): void { + target = { host, port, label: label || `${host}:${port}`, mock }; + classic.retarget(); // restart the current leg against the new target +} + +export function getTarget(): { host: string; port: number; label: string; mock: boolean } { + return { ...target }; +} + +export function startStream(rate: number, faultRate: number): void { + classic.start(rate, faultRate); +} + +export function stopStream(): void { + classic.stop(); +} + +export function updateStream(p: { rate?: number; faultRate?: number }): void { + classic.update(p); +} + +export function bumpExternalCounters(sent: number, accepted: number, rejected: number, malformed: number): void { + classic.bump(sent, accepted, rejected, malformed); +} + +export function subscribe(fn: (event: BusEvent) => void): () => void { + const unsub = subscribeBus(fn); + fn({ type: "state", sourceId: "classic", state: classic.snapshot() }); // initial snapshot + return unsub; +} + +export function getState(): ReturnType { + return classic.snapshot(); +} + +export { publish }; diff --git a/utils/hl7v2-simulator/ui/topology.ts b/utils/hl7v2-simulator/ui/topology.ts new file mode 100644 index 0000000..e222cc7 --- /dev/null +++ b/utils/hl7v2-simulator/ui/topology.ts @@ -0,0 +1,743 @@ +/** + * Source UI — topology view (M2): the multi-source simulator as a live map. + * + * Visual language from mockup concept-p-topology: source nodes around a central + * engine hub, colored dots flying along bezier curves INTO the hub. Everything + * here is live: nodes come from /sources, a dot is spawned per real SSE tick + * (no fake looping animation), counters/status update from the same events. + * The classic single-stream page stays at /classic. + */ + +interface TargetOpt { id: string; label: string; host: string; port: number; mock?: boolean } + +export interface TopologyProps { + targets: TargetOpt[]; + activeTargetId: string; + profile: string; + exportDir: string; +} + +const esc = (s: string): string => s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); + +export function renderTopologyPage({ targets, activeTargetId, profile, exportDir }: TopologyProps): string { + const targetOpts = targets + .filter((t) => !t.mock) + .map((t) => ``) + .join(""); + return ` + + + +Interbox Source — Topology + + + +
+
+
S Source · upstream simulator
+
+ + +
+ Topology + Classic +
+ + +
+
+
+
+ +
+
+ INTEGRATION ENGINE 0/0 + idle +
+
+
+
+
Interbox
+
+
0.0msg/s in
+
+
accepted0
+
rejected0
+
no answer0
+
+
+
+
+
Lab
+
Clinic
+
Hospital
+
Pharmacy
+
+
+
Click a source node to inspect and control it.
+
+
+ + + + + + + +`; +} From 44bbd1fe42f9acbd825cec1aa49e3edef5db39d4 Mon Sep 17 00:00:00 2001 From: Ilia Pasechnikov Date: Thu, 6 Aug 2026 00:54:29 +0900 Subject: [PATCH 2/4] refactor(utils): name the root script hl7v2:simulator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The workspace ingests more than HL7v2 — the engine also has CSV and folder sources — so a bare `simulator` quietly claims the unqualified name and would need a breaking rename the day a second one lands. The `hl7v2:` prefix is free today, groups any future HL7v2 tooling, and echoes the SDK's existing interbox-hl7v2-* skill vocabulary. Domain prefix rather than a location one (`utils:`), which would encode a directory the caller doesn't care about. --- README.md | 2 +- package.json | 2 +- utils/hl7v2-simulator/README.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index e01d3e7..a8bbd65 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ MRN pool, so routing and per-source error handling get exercised rather than a single uniform stream. With the stack up: ```bash -bun run simulator +bun run hl7v2:simulator ``` That installs the simulator and opens its UI on http://localhost:4003, already diff --git a/package.json b/package.json index 4b7d7d6..822aef0 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "typecheck": "tsc --noEmit", "test": "bun test", "interbox-cli": "interbox", - "simulator": "bun install --cwd utils/hl7v2-simulator && bun run --cwd utils/hl7v2-simulator ui" + "hl7v2:simulator": "bun install --cwd utils/hl7v2-simulator && bun run --cwd utils/hl7v2-simulator ui" }, "dependencies": { "@health-samurai/interbox": "^1.0.0" diff --git a/utils/hl7v2-simulator/README.md b/utils/hl7v2-simulator/README.md index c6c1560..68c9877 100644 --- a/utils/hl7v2-simulator/README.md +++ b/utils/hl7v2-simulator/README.md @@ -23,7 +23,7 @@ You need [Bun](https://bun.sh) (`curl -fsSL https://bun.sh/install | bash`). From the workspace root, with the dev stack already up (`docker compose up`): ```bash -bun run simulator +bun run hl7v2:simulator ``` That installs the simulator's dependencies and starts its UI on From ebc1661f1973448014f0dde8fb05d18d12952e98 Mon Sep 17 00:00:00 2001 From: Ilia Pasechnikov Date: Thu, 6 Aug 2026 01:26:13 +0900 Subject: [PATCH 3/4] fix(simulator): close the browser-reachable attack surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Binding to loopback keeps the network out but does nothing about the developer's own browser, and the README claimed otherwise. Bun's req.json() ignores Content-Type, so every mutating route was reachable from any website the developer visited: a plain
POSTing a JSON-shaped field name is a CORS simple request — no preflight, no origin check, and the attacker never needs to read the response to have caused the effect. Verified end to end; POST /export with clean:true deleted a target directory's contents cross-origin. - Guard every route: Host must be one we expect (this is what stops DNS rebinding, which loopback binding cannot), and POST must carry application/json. A form cannot send that content-type, and fetch() with it preflights, which this server answers with no CORS headers. The check is applied by wrapping the route table, so a route added later cannot forget it. POST only: PATCH and DELETE are never simple requests, and demanding a body content-type on those would break legitimate bodiless calls. - HOST used `??`, so an empty-but-set HOST (docker run -e HOST, a Kubernetes `value: ""`) reached Bun as hostname:"" and bound every interface — while Bun still reported "localhost", so the log looked fine. Use `||`, log the real bind address, and warn loudly on a wildcard. - Confine /export and /export/stream under EXPORT_ROOT. Resolving and then testing containment is the only check correct on both platforms; rejecting ".." textually misses Windows drive-relative and UNC forms. - Scope `clean` to the .hl7 files we wrote. The blanket rm destroyed unrelated files and, lacking `recursive`, threw on the first subdirectory — deleting whatever sorted ahead of it and then reporting written: 0. - Strip HL7 delimiters from source names and bound the length. A name became MSH-4 by raw interpolation, so `|` shifted every later field along and let a caller forge MSH-9 (what receivers route on) and MSH-10 (what they dedupe on) in traffic aimed at a real engine. - Add ceilings: MAX_SOURCES, MAX_SSE_CLIENTS, MAX_STREAM_FILES, and a 1000-message burst cap. Drop SSE frames when a client stops draining rather than queueing without limit, and release the subscriber slot on disconnect. - Validate persisted sources on load, escape `type` in attribute context, and escape `<` in JSON embedded in ` or `/g, "--\\u003e"); + export function renderPage({ engineTarget, profile, targets, activeTargetId }: PageProps): string { return ` @@ -720,10 +730,10 @@ export function renderPage({ engineTarget, profile, targets, activeTargetId }: P function sourceUi() { return { // env - engineTarget: ${JSON.stringify(engineTarget)}, - profile: ${JSON.stringify(profile)}, - targets: ${JSON.stringify(targets)}, - activeTargetId: ${JSON.stringify(activeTargetId)}, + engineTarget: ${safeJson(engineTarget)}, + profile: ${safeJson(profile)}, + targets: ${safeJson(targets)}, + activeTargetId: ${safeJson(activeTargetId)}, targetMenuOpen: false, get activeTarget() { @@ -1035,7 +1045,7 @@ export function renderPage({ engineTarget, profile, targets, activeTargetId }: P // for technical viewers during the demo. cliCommand() { const fr = (this.faultRate / 100).toFixed(2); - const target = ${JSON.stringify(engineTarget)}; + const target = ${safeJson(engineTarget)}; const [host, port] = target.split(':'); const conn = ' --host ' + host + ' --port ' + port + ''; if (this.mode === 'single') { @@ -1127,7 +1137,7 @@ export function renderPage({ engineTarget, profile, targets, activeTargetId }: P async toggleStream() { if (this.status === 'streaming') { try { - await fetch('/stream/stop', { method: 'POST' }); + await fetch('/stream/stop', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' }); this.lastError = null; } catch (e) { this.lastError = e.message || String(e); diff --git a/utils/hl7v2-simulator/ui/server.ts b/utils/hl7v2-simulator/ui/server.ts index aaa4a46..9f1a09c 100644 --- a/utils/hl7v2-simulator/ui/server.ts +++ b/utils/hl7v2-simulator/ui/server.ts @@ -16,18 +16,29 @@ import { setTarget, getTarget, bumpExternalCounters, } from "./stream.ts"; import { ALLOWED_MSG_TYPES, SourceRegistry, type SourceType } from "./sources.ts"; -import { DEFAULT_EXPORT_DIR, DEFAULT_SOURCES_PATH } from "../src/paths.ts"; +import { DEFAULT_EXPORT_DIR, DEFAULT_SOURCES_PATH, EXPORT_ROOT } from "../src/paths.ts"; const PORT = Number(process.env.PORT ?? 4003); // Loopback by default. This server is a developer tool with no authentication: -// /export writes and (with clean:true) deletes files at a path taken straight -// from the request body, and /probe will TCP-scan from wherever it runs. Bun -// would otherwise bind 0.0.0.0 and hand all of that to anyone on the network. -// Set HOST=0.0.0.0 only on a network you control. -const HOST = process.env.HOST ?? "127.0.0.1"; +// /export writes files at a path taken from the request body, and /probe will +// TCP-connect from wherever it runs. Bun would otherwise bind 0.0.0.0 and hand +// all of that to anyone on the network. Set HOST=0.0.0.0 only on a network you +// control — and see the guard below, because binding loopback is NOT on its own +// a defence against a browser. +// +// `||`, not `??`: an empty-but-set HOST (`docker run -e HOST`, a Kubernetes +// `value: ""`) reaches Bun as hostname:"" and binds every interface — while Bun +// still reports the hostname as "localhost", so the log looks reassuring. +const HOST = process.env.HOST || "127.0.0.1"; +const WILDCARD_HOST = HOST === "0.0.0.0" || HOST === "::"; const PROFILE = process.env.PROFILE_NAME ?? "default"; const SOURCES_PATH = process.env.SOURCES_PATH ?? DEFAULT_SOURCES_PATH; const EXPORT_DIR = process.env.EXPORT_DIR ?? DEFAULT_EXPORT_DIR; +// Ceilings. Every one of these guards a route that a single request can drive +// without bound; the defaults are far above any real demo. +const MAX_SOURCES = Number(process.env.MAX_SOURCES ?? 64); +const MAX_SSE_CLIENTS = Number(process.env.MAX_SSE_CLIENTS ?? 32); +let sseClients = 0; // Available MLLP targets — switchable from the UI. // Override via TARGETS env: "label1:host1:port1,label2:host2:port2". @@ -76,15 +87,27 @@ await registry.init(); // Quick TCP probe for the add-source form: is anything listening on the port? // Bun.connect resolves on open and rejects on connect failure; race a timeout. async function probePort(port: number, host = "127.0.0.1"): Promise { + // Keep a handle on the connect promise: when the timeout wins the race, the + // connection may still succeed afterwards, and whatever it yields has to be + // closed or it leaks a descriptor on every probe the UI makes. + const connecting = Bun.connect({ + hostname: host, port, socket: { data() {}, error() {}, close() {} }, + }); + let timer: ReturnType | undefined; try { const sock = await Promise.race([ - Bun.connect({ hostname: host, port, socket: { data() {}, error() {}, close() {} } }), - new Promise((_, rej) => setTimeout(() => rej(new Error("probe timeout")), 1500)), + connecting, + new Promise((_, rej) => { + timer = setTimeout(() => rej(new Error("probe timeout")), 1500); + }), ]); sock.end(); return true; } catch { + void connecting.then((s) => s.end()).catch(() => {}); return false; + } finally { + if (timer) clearTimeout(timer); } } @@ -98,13 +121,90 @@ interface SendBody { interface StreamStartBody { rate?: number; faultRate?: number; } interface StreamPatchBody { rate?: number; faultRate?: number; } +// ── Browser guard ─────────────────────────────────────────────────────────── +// +// Binding to loopback keeps the network out; it does nothing about the +// developer's own browser. Bun's `req.json()` ignores Content-Type, so without +// this every mutating route is reachable from any website the developer visits: +// a plain `` POSTing a JSON-shaped field name is a +// CORS *simple request* — no preflight, no origin check, and the attacker never +// needs to read the response to have caused the effect. +// +// Two checks close it: +// 1. content-type must be application/json. A cannot send that, and +// fetch() with it triggers a preflight this server answers with no CORS +// headers, so the browser blocks the real request. +// 2. the Host header must be one we expect. This is what stops DNS rebinding, +// where an attacker-controlled name re-resolves to 127.0.0.1 and thereby +// becomes same-origin — able to READ responses, which check 1 can't help +// with. Skipped when bound to a wildcard, since we can't know the legit +// names then and the operator has explicitly opted into exposure. +const ALLOWED_HOSTS = new Set([ + `localhost:${PORT}`, `127.0.0.1:${PORT}`, `[::1]:${PORT}`, `${HOST}:${PORT}`, +]); + +function guard(req: Request): Response | null { + if (!WILDCARD_HOST && !ALLOWED_HOSTS.has(req.headers.get("host") ?? "")) { + return Response.json({ ok: false, error: "unrecognised Host" }, { status: 421 }); + } + if (req.method === "GET" || req.method === "HEAD") return null; + // POST is the only mutating method a can emit, so it is the only one + // that can arrive without a preflight. PATCH and DELETE are never simple + // requests — the browser preflights them, and this server answers with no CORS + // headers, so they are already unreachable cross-origin. Demanding a JSON + // content-type on those too would only break legitimate bodiless calls. + if (req.method === "POST" + && !(req.headers.get("content-type") ?? "").toLowerCase().startsWith("application/json")) { + return Response.json( + { ok: false, error: "content-type: application/json required" }, + { status: 415 }, + ); + } + const origin = req.headers.get("origin"); + if (origin && origin !== `http://${req.headers.get("host")}`) { + return Response.json({ ok: false, error: "cross-origin request denied" }, { status: 403 }); + } + return null; +} + +/** + * Apply {@link guard} to every handler in a Bun route table. + * + * Wrapping the table rather than calling guard() inside each handler means a + * route added later cannot forget it — there is no per-route opt-in to omit. + * The signature is written in terms of Bun's own `Routes` type so each handler + * still gets its `BunRequest` (and therefore typed `req.params`); the + * casts below are confined to the untyped walk over the table. + */ +function guarded(routes: Bun.Serve.Routes): Bun.Serve.Routes { + type AnyHandler = (req: Request, ...rest: unknown[]) => Response | Promise; + const wrap = (h: AnyHandler): AnyHandler => + (req, ...rest) => guard(req) ?? h(req, ...rest); + const out: Record = {}; + for (const [path, def] of Object.entries(routes as Record)) { + if (typeof def === "function") { + out[path] = wrap(def as AnyHandler); + } else if (def && typeof def === "object") { + out[path] = Object.fromEntries( + Object.entries(def as Record).map(([m, h]) => [ + m, + typeof h === "function" ? wrap(h as AnyHandler) : h, + ]), + ); + } else { + out[path] = def; + } + } + return out as Bun.Serve.Routes; +} + Bun.serve({ port: PORT, hostname: HOST, // SSE /events is long-lived; Bun's default 10s idleTimeout would close it // between ticks (heartbeat is 15s, too late to save it). 0 = no timeout. idleTimeout: 0, - routes: { + routes: guarded({ // Topology (multi-source map) is the default view; the classic // single-stream page stays fully functional at /classic. "/": () => @@ -148,6 +248,12 @@ Bun.serve({ POST: async (req) => { let body: { name?: string; type?: SourceType; rate?: number; faultRate?: number; targetPort?: number; msgTypes?: string[] } = {}; try { body = await req.json() as typeof body; } catch {} + if (registry.list().length >= MAX_SOURCES) { + return Response.json( + { ok: false, error: `at the ${MAX_SOURCES}-source limit (raise MAX_SOURCES)` }, + { status: 429 }, + ); + } try { const def = await registry.create({ name: body.name ?? "", @@ -192,7 +298,9 @@ Bun.serve({ if (!entry) return Response.json({ ok: false, error: "unknown source" }, { status: 404 }); let body: SendBody = { mode: "burst" }; try { body = await req.json() as SendBody; } catch {} - const count = body.mode === "single" ? 1 : Math.max(1, Math.min(10000, body.count ?? 1)); + // 1000, not 10000: the reliable path waits for an ACK per message, so a + // silent target turns a big burst into a request that hangs for hours. + const count = body.mode === "single" ? 1 : Math.max(1, Math.min(1000, body.count ?? 1)); const t = registry.targetOf(req.params.id)!; const result = await generateAndSend({ count, @@ -294,7 +402,9 @@ Bun.serve({ try { body = await req.json() as SendBody; } catch { return Response.json({ ok: false, error: "invalid json" }, { status: 400 }); } const faultRate = Math.max(0, Math.min(1, body.faultRate ?? 0)); - const count = body.mode === "single" ? 1 : Math.max(1, Math.min(10000, body.count ?? 1)); + // 1000, not 10000: the reliable path waits for an ACK per message, so a + // silent target turns a big burst into a request that hangs for hours. + const count = body.mode === "single" ? 1 : Math.max(1, Math.min(1000, body.count ?? 1)); const forceType = body.mode === "single" ? body.type : undefined; const t = activeTarget(); const result = await generateAndSend({ @@ -335,12 +445,29 @@ Bun.serve({ "/events": (req) => { // SSE: client connects once on page-load, receives state + tick events + if (sseClients >= MAX_SSE_CLIENTS) { + return Response.json({ ok: false, error: "too many event subscribers" }, { status: 503 }); + } + sseClients += 1; const enc = new TextEncoder(); let unsubscribe = () => {}; let heartbeat: ReturnType | null = null; + let closed = false; + const release = () => { + if (closed) return; + closed = true; + sseClients -= 1; + try { unsubscribe(); } catch {} + if (heartbeat) clearInterval(heartbeat); + }; const stream = new ReadableStream({ start(controller) { const send = (data: unknown) => { + // Drop rather than buffer when the client isn't draining. These are + // periodic state snapshots, not a log: the next tick carries the + // full counters, so a skipped frame costs nothing — whereas an + // unbounded queue against a backgrounded tab is a memory leak. + if ((controller.desiredSize ?? 0) <= 0) return; try { controller.enqueue(enc.encode(`data: ${JSON.stringify(data)}\n\n`)); } catch { /* connection closed mid-flight */ } }; @@ -351,14 +478,12 @@ Bun.serve({ }, 15000); // disconnect when client navigates away req.signal.addEventListener("abort", () => { - try { unsubscribe(); } catch {} - if (heartbeat) clearInterval(heartbeat); + release(); try { controller.close(); } catch {} }); }, cancel() { - try { unsubscribe(); } catch {} - if (heartbeat) clearInterval(heartbeat); + release(); }, }); return new Response(stream, { @@ -369,19 +494,25 @@ Bun.serve({ }, }); }, - }, + }), error(err) { + // Log the detail, return a generic message: these carry absolute paths + // (ENOENT/EACCES), which is free reconnaissance for anything that can read + // a response. console.error("[source-ui]", err); - return Response.json( - { ok: false, error: (err as Error)?.message ?? String(err) }, - { status: 500 }, - ); + return Response.json({ ok: false, error: "internal error" }, { status: 500 }); }, }); console.log( - `[source-ui] listening on http://localhost:${PORT} · profile ${PROFILE}`, + `[source-ui] listening on http://${HOST}:${PORT} · profile ${PROFILE}`, ); +if (WILDCARD_HOST) { + console.warn( + `[source-ui] WARNING: bound to ${HOST} — this server has no authentication. ` + + `Anyone who can reach port ${PORT} can generate traffic and write files under ${EXPORT_ROOT}.`, + ); +} console.log( `[source-ui] targets: ${TARGETS.map((t) => `${t.label}@${t.host}:${t.port}`).join(" · ")} (active: ${activeTarget().label})`, ); diff --git a/utils/hl7v2-simulator/ui/sources.ts b/utils/hl7v2-simulator/ui/sources.ts index c788165..800cbb3 100644 --- a/utils/hl7v2-simulator/ui/sources.ts +++ b/utils/hl7v2-simulator/ui/sources.ts @@ -50,13 +50,23 @@ const TYPE_PRESETS: Record }; /** HL7-style sender name: CAPS, ASCII, single spaces. */ +/** Longest source name we accept — MSH-4 has no business being longer. */ +export const MAX_NAME_LEN = 40; + export function normalizeName(raw: string): string { return raw .normalize("NFKD") .replace(/[^\x20-\x7E]/g, "") + // HL7v2 delimiters must never survive into a segment. The name becomes MSH-4 + // by raw interpolation, so a name containing `|` shifts every later field + // along — letting the caller forge MSH-9 (what receivers route on) and + // MSH-10 (what they dedupe on) on traffic aimed at a real engine. Stripping + // non-printables above already removes CR/LF, so this closes the rest. + .replace(/[|^~\\&]/g, " ") .replace(/\s+/g, " ") .trim() - .toUpperCase(); + .toUpperCase() + .slice(0, MAX_NAME_LEN); } export function slugOf(name: string): string { @@ -91,7 +101,19 @@ export function profileFor(base: Profile, def: SourceDef): Profile { facility: [[facility, 1]], assigningAuthority: [[aa, 1]], }, - idFormats: { ...base.idFormats, mrn: `${initialsOf(def.name)}########` }, + // Every identifier gets the source prefix, not just the MRN. Leaving the + // rest on the shared base format made concurrent sources emit identical + // control IDs, placer/filler numbers and visit numbers in lockstep — which + // defeats the point of a multi-source simulator, since the engine's dedup + // sees one system's traffic replayed rather than several systems. + idFormats: { + ...base.idFormats, + mrn: `${initialsOf(def.name)}########`, + controlId: `${initialsOf(def.name)}-##########`, + placer: `${initialsOf(def.name)}P#########`, + filler: `${initialsOf(def.name)}F#########`, + visit: `${initialsOf(def.name)}V#########`, + }, }; } @@ -100,6 +122,19 @@ export interface SourceView extends SourceDef { counters: ActorCounters; } +/** Shape check for a persisted definition — see SourceRegistry.init. */ +function isValidDef(d: unknown): d is SourceDef { + if (!d || typeof d !== "object") return false; + const c = d as Record; + return typeof c.id === "string" && c.id.length > 0 + && typeof c.name === "string" && c.name.length > 0 && c.name.length <= MAX_NAME_LEN + && typeof c.type === "string" && Object.hasOwn(TYPE_PRESETS, c.type) + && typeof c.rate === "number" && Number.isFinite(c.rate) + && typeof c.faultRate === "number" && Number.isFinite(c.faultRate) + && (c.targetPort === undefined || (Number.isInteger(c.targetPort) && (c.targetPort as number) > 0 && (c.targetPort as number) <= 65535)) + && (c.msgTypes === undefined || (Array.isArray(c.msgTypes) && c.msgTypes.every((t) => (ALLOWED_MSG_TYPES as readonly string[]).includes(t as string)))); +} + export class SourceRegistry { private readonly path: string; private readonly globalTarget: () => ActorTarget; @@ -115,8 +150,18 @@ export class SourceRegistry { async init(): Promise { let defs: SourceDef[] | null = null; try { - const raw = await Bun.file(this.path).json() as SourceDef[]; - if (Array.isArray(raw) && raw.every((d) => d && typeof d.id === "string")) defs = raw; + const raw = await Bun.file(this.path).json() as unknown; + // Re-validate on load, not just on create. This file is on disk: it can be + // hand-edited or restored from elsewhere, and an unchecked `type` flows + // into a class attribute in the topology view. Drop bad entries rather + // than refusing to boot — a corrupt row shouldn't cost you the others. + if (Array.isArray(raw)) { + const kept = raw.filter(isValidDef); + if (kept.length !== raw.length) { + console.warn(`[sources] ignored ${raw.length - kept.length} invalid definition(s) in ${this.path}`); + } + if (kept.length) defs = kept; + } } catch { /* no file yet — seed below */ } if (!defs) { defs = [ @@ -200,15 +245,27 @@ export class SourceRegistry { const entry = this.get(id); if (!entry) throw new Error(`unknown source "${id}"`); const def = entry.def; - if (typeof patch.rate === "number") def.rate = clampRate(patch.rate); - if (typeof patch.faultRate === "number") def.faultRate = clamp01(patch.faultRate); - if (typeof patch.targetPort === "number") def.targetPort = validPort(patch.targetPort); + // Validate everything BEFORE touching the live def. `def` is the object in + // the registry map, so mutating as we went meant a patch that failed + // validation halfway left memory, disk and the actor's pacing disagreeing — + // the UI showing a value that was rejected, never persisted, and silently + // reverted on the next restart. + const nextRate = typeof patch.rate === "number" ? clampRate(patch.rate) : undefined; + const nextFaultRate = typeof patch.faultRate === "number" ? clamp01(patch.faultRate) : undefined; + const nextPort = typeof patch.targetPort === "number" ? validPort(patch.targetPort) : undefined; + const nextMsgTypes = Array.isArray(patch.msgTypes) && patch.msgTypes.length > 0 + ? validMsgTypes(patch.msgTypes) + : undefined; + + if (nextRate !== undefined) def.rate = nextRate; + if (nextFaultRate !== undefined) def.faultRate = nextFaultRate; + if (nextPort !== undefined) def.targetPort = nextPort; if (patch.clearTargetPort) delete def.targetPort; if (Array.isArray(patch.msgTypes)) { // Empty selection = back to the type preset. The actor's generator is // profile-backed lazily per leg, so restart the leg to pick up the mix. - if (patch.msgTypes.length === 0) delete def.msgTypes; - else def.msgTypes = validMsgTypes(patch.msgTypes); + if (nextMsgTypes === undefined) delete def.msgTypes; + else def.msgTypes = nextMsgTypes; entry.actor.regen(); } entry.actor.update({ rate: def.rate, faultRate: def.faultRate }); // live-applies diff --git a/utils/hl7v2-simulator/ui/topology.ts b/utils/hl7v2-simulator/ui/topology.ts index e222cc7..f7f62fa 100644 --- a/utils/hl7v2-simulator/ui/topology.ts +++ b/utils/hl7v2-simulator/ui/topology.ts @@ -358,9 +358,9 @@ function render() { ? '
' + fmtRate(s.rate) + ' msg/s · ' + s.counters.sent + ' sent
' : '
Idle · ' + s.counters.sent + ' sent
'; el.innerHTML = - '
' + escapeHtml(strip) + '' + + '
' + escapeHtml(strip) + '' + '
' + - '
' + + '
' + '
' + escapeHtml(title(s.name)) + '
' + '
' + escapeHtml(typesLine(s)) + '
' + stat + '
'; stage.appendChild(el); @@ -381,6 +381,9 @@ function updateAllBtn() { function title(name) { return name.toLowerCase().replace(/\\b[a-z]/g, (c) => c.toUpperCase()); } function escapeHtml(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; } +// escapeHtml goes through textContent, which does NOT escape quotes — safe in +// element context, not inside an attribute. Use this one there. +function escapeAttr(s) { return String(s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); } // Log-scale rate slider: position 0..100 -> 0.1..1000 msg/s (0.1 @0, 1 @25, // 10 @50, 100 @75, 1000 @100) — fine control at demo rates, load rates reachable. @@ -472,7 +475,7 @@ function renderInspector() { insSig = sig; inspector.innerHTML = '
Selected source
' + - '
' + escapeHtml(title(s.name)) + '' + escapeHtml(s.id) + ' · ' + s.type + (s.targetPort ? ' · port ' + s.targetPort : '') + '
' + + '
' + escapeHtml(title(s.name)) + '' + escapeHtml(s.id) + ' · ' + escapeHtml(s.type) + (s.targetPort ? ' · port ' + s.targetPort : '') + '
' + '
' + '
Rate' + fmtRate(s.rate) + '/s
' + '
' + @@ -480,7 +483,7 @@ function renderInspector() { '
' + '
Message types
' + ALL_MSG_TYPES.map((t) => '').join('') + - '
' + (s.msgTypes && s.msgTypes.length ? 'hand-picked, equal shares' : 'preset mix for ' + s.type + ' — click to hand-pick') + '
' + + '
' + (s.msgTypes && s.msgTypes.length ? 'hand-picked, equal shares' : 'preset mix for ' + escapeHtml(s.type) + ' — click to hand-pick') + '
' + '
Counters
' + '
Sent
' + s.counters.sent + '
' + '
Accepted
' + s.counters.accepted + '
' + From 6f97556446374459dd8295f5be23931066fb1e09 Mon Sep 17 00:00:00 2001 From: Ilia Pasechnikov Date: Thu, 6 Aug 2026 01:39:46 +0900 Subject: [PATCH 4/4] fix(simulator): correct state-machine bugs, unify the generator, cover the gaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the security pass: the correctness and duplication findings from the same review. Bugs, each reproduced before fixing: - A source could become permanently unstoppable. stop() flips `running` synchronously, but the loop can sit in an uncancellable sleep for a whole inter-arrival gap — seconds at low rates. A start() in that window began a second run; when the first unwound, its tail clobbered the live run's state and stop() then early-returned forever. Runs now carry a token and only the current one may write the tail. Reachable by clicking Stop then Start. - A rejected PATCH left memory, disk and the actor's pacing disagreeing: update() mutated the live definition field-by-field and validated afterwards. Validate everything first, then assign. - Every source emitted identical control IDs, placer/filler numbers and visit numbers, because profileFor specialized only the MRN. A simulator built to exercise routing and dedup was handing the receiver one system replayed. - sendOverMllp abandoned connected sockets when any one failed, and `open` removes its own error listener once connected — so a later error on a leaked socket was an unhandled 'error' event, which is fatal. allSettled plus an explicit teardown. - The reliable path keyed its response map by message TEXT, so two identical bodies shared one entry and the last outcome overwrote the first, losing a refusal it had been told about. Key by index. - writeFrame leaked a `drain` listener per frame on the error path; probePort leaked a socket whenever the timeout won its race; Rng.pick/weighted returned undefined on an empty distribution, which reached the wire as the literal string "undefined". Generator unification: The block "draw a message, roll faultRate, pick a fault, apply it" existed in five places and they had drifted — src/ drew fault decisions from the seeded Rng, ui/ used Math.random(). So "its own seeded RNG" held for `bun run gen` and silently did not for anything the UI drove. One makeGenerator now serves all five, so the seed governs content and faults alike, and /export reports the seed it used so a batch can be reproduced. Also removed genuinely dead code: sendOverMllpStream, streamGenerator, getState. Tests: 49 -> 75. The additions are the ones that earn their place — a golden vector for the PRNG (change its constants and every other test still passes while every generated corpus silently becomes irreproducible), prove-it tests for the clean-deletes-user-files and rejected-patch bugs, the refused-vs-silent split that had no coverage at all, MSH injection, and toRow's column contract. Test hygiene: scratch files go to the OS temp dir and are removed, four un-awaited rejects assertions now await, and listeners close even when a test throws. --- utils/hl7v2-simulator/README.md | 58 +++++++-- utils/hl7v2-simulator/src/cli.ts | 36 +++--- utils/hl7v2-simulator/src/gen/rng.ts | 11 +- utils/hl7v2-simulator/src/gen/stream.ts | 70 +++++++++++ utils/hl7v2-simulator/src/send-cli.ts | 24 ++-- utils/hl7v2-simulator/src/send/mllp.ts | 114 +++++++++--------- .../hl7v2-simulator/test/determinism.test.ts | 76 ++++++++++++ utils/hl7v2-simulator/test/export.test.ts | 55 +++++++++ utils/hl7v2-simulator/test/mllp.test.ts | 9 +- utils/hl7v2-simulator/test/reliable.test.ts | 108 +++++++++++++++++ utils/hl7v2-simulator/test/row.test.ts | 39 ++++++ utils/hl7v2-simulator/test/sources.test.ts | 94 +++++++++++++-- utils/hl7v2-simulator/ui/actor.ts | 38 +++--- utils/hl7v2-simulator/ui/generator.ts | 98 ++++----------- 14 files changed, 625 insertions(+), 205 deletions(-) create mode 100644 utils/hl7v2-simulator/src/gen/stream.ts create mode 100644 utils/hl7v2-simulator/test/determinism.test.ts create mode 100644 utils/hl7v2-simulator/test/export.test.ts create mode 100644 utils/hl7v2-simulator/test/reliable.test.ts create mode 100644 utils/hl7v2-simulator/test/row.test.ts diff --git a/utils/hl7v2-simulator/README.md b/utils/hl7v2-simulator/README.md index 68c9877..a92ee54 100644 --- a/utils/hl7v2-simulator/README.md +++ b/utils/hl7v2-simulator/README.md @@ -76,21 +76,43 @@ Other environment knobs: | `PROFILE_PATH` | this package's `fixtures/profile.json` | generator profile | | `PROFILE_NAME` | `default` | profile label shown in the UI | | `SOURCES_PATH` | this package's `data/sources.json` | where source definitions persist | +| `EXPORT_ROOT` | this package's `batch-out/` | **every** export directory must resolve inside this | | `EXPORT_DIR` | this package's `batch-out/` | directory prefilled in the export form | | `MAX_STREAM_RATE` | `1000` | per-source msg/s ceiling | +| `MAX_SOURCES` | `64` | how many sources may exist at once | +| `MAX_SSE_CLIENTS` | `32` | concurrent `/events` subscribers | +| `MAX_STREAM_FILES` | `100000` | files the folder-stream writes before stopping itself | | `RECEIVING_APP` / `RECEIVING_FACILITY` | `INTERBOX` | MSH-5 / MSH-6 on generated messages | Path defaults resolve inside this package, so the simulator behaves the same whether you start it from here or from the workspace root. A path you pass explicitly is used as given — a relative one resolves against your shell's -working directory, as usual. +working directory, as usual. The one exception is the export directory, which is +always resolved inside `EXPORT_ROOT` (see below). -> **Bind address.** The simulator listens on loopback only, because it has no -> authentication of any kind: `/export` writes — and with `clean: true`, deletes -> — files at a path taken straight from the request body, and `/probe` opens TCP -> connections on request. Setting `HOST=0.0.0.0` hands those to anyone who can -> reach the port. Do it only on a network you control, and never on a shared or -> internet-facing host. +## Security + +The simulator has **no authentication**. It is a developer tool, and the design +assumes only you can reach it. Three things enforce that: + +- **It binds loopback** (`127.0.0.1`) unless you set `HOST`. +- **It requires `application/json` on POST.** Loopback binding alone is not + enough: a form on any website you visit can POST to `localhost` as a CORS + simple request — no preflight, no origin check — and the attacker does not + need to read the response to have caused the effect. A form cannot send a JSON + content-type, and `fetch()` with one preflights, which this server declines. +- **It checks the `Host` header.** That is what stops DNS rebinding, where an + attacker's domain re-resolves to `127.0.0.1` and thereby becomes same-origin — + able to read responses, which the content-type rule cannot prevent. + +Export directories are confined under `EXPORT_ROOT`, and `clean` removes only the +`.hl7` files the simulator itself wrote, so a mistargeted export cannot delete +your data. + +> **Setting `HOST=0.0.0.0` gives up the first and third of those**, and hands +> traffic generation and file writes under `EXPORT_ROOT` to anyone who can reach +> the port. Do it only on a network you control, never on a shared or +> internet-facing host. The server prints a warning at startup when you do. ## Choose source types @@ -138,6 +160,10 @@ assigning authority. own seeded RNG, so streams interleave on the wire and one source failing does not disturb the others. Fault injection is per-source via `faultRate`. +**Distinct identifiers.** Control IDs, placer/filler numbers and visit numbers +all carry the source's own prefix, as MRNs do — so a receiver's deduplication +sees genuinely separate systems rather than one system replayed. + ## Faults `faultRate` is the fraction of messages deliberately broken before sending — @@ -147,6 +173,19 @@ bucket them (`parse_error` / `map_error` / `data_quality`, or benign-but-valid). Set it to `0` for a clean stream, or crank it to see the dashboard's error views populate. +## Reproducibility + +The seed governs everything: message content, identities, **and** which messages +get corrupted. Same seed, same corpus, down to the faults. + +```bash +bun run gen 1000 0.05 42 --out-dir ./corpus # rerun with 42 to get it back +``` + +`POST /export` reports the `seed` it used; pass that seed back to regenerate the +same batch. Live streaming deliberately seeds itself fresh per run, so successive +demos differ. + ## The two views | Route | View | @@ -167,7 +206,7 @@ bun run gen 1000 0.05 42 # Write a corpus: out.jsonl / out.csv, or one .hl7 file per message bun run gen 1000 0.05 42 --output jsonl -bun run gen 500 0 42 --out-dir ./corpus --clean +bun run gen 500 0 42 --out-dir ./corpus --clean # --clean removes only .hl7 files bun run gen 200 0 42 --types ADT^A01,ORU^R01 # force an even mix # Send over MLLP to a running engine @@ -216,7 +255,8 @@ folder source ingests, for testing that path without a socket. | `src/cli.ts` | CLI generator (`bun run gen`) | | `src/send-cli.ts` | MLLP sender CLI, `batch` / `stream` (`bun run send`) | | `src/gen/` | Message synthesis (profile-driven, no real data) | +| `src/gen/stream.ts` | `makeGenerator` — the one seeded generate-and-maybe-corrupt used by every caller | | `src/send/mllp.ts` | MLLP transport — fire-and-forget, reliable (ACK-aware), live stream | -| `src/paths.ts` | Package-relative defaults for profile / state / export paths | +| `src/paths.ts` | Package-relative path defaults, and the export-directory confinement | | `fixtures/` | `profile.json` (the shipped generator profile) and `profile.example.json` (a smaller one used by the tests) | | `test/` | Unit tests, incl. `sources.test.ts` for the registry | diff --git a/utils/hl7v2-simulator/src/cli.ts b/utils/hl7v2-simulator/src/cli.ts index c35bc2b..7f5144e 100644 --- a/utils/hl7v2-simulator/src/cli.ts +++ b/utils/hl7v2-simulator/src/cli.ts @@ -1,13 +1,10 @@ import { join } from "node:path"; -import { mkdir, rm, readdir } from "node:fs/promises"; -import { Rng } from "./gen/rng.ts"; -import { fakerNames } from "./gen/names.ts"; +import { mkdir } from "node:fs/promises"; import { parseProfile } from "./profile/schema.ts"; -import { generateMessage } from "./gen/assemble.ts"; -import { FAULTS } from "./gen/faults.ts"; +import { makeGenerator } from "./gen/stream.ts"; import { classify } from "./validate/classify.ts"; import { toRow, type MessageRow } from "./gen/row.ts"; -import { DEFAULT_PROFILE } from "./paths.ts"; +import { cleanExports, DEFAULT_PROFILE } from "./paths.ts"; // usage: bun run src/cli.ts // [--profile f.json] (default fixtures/profile.json) @@ -56,29 +53,25 @@ if (forcedTypes) { process.exit(1); } } -const rng = new Rng(seed); -const names = fakerNames(locale, seed); +const next = makeGenerator({ profile, seed, types: forcedTypes, locale }); const rows: MessageRow[] = []; const outFiles: { msg: string; type: string }[] = []; for (let i = 0; i < count; i++) { - if (forcedTypes) profile.messageTypes = [[forcedTypes[i % forcedTypes.length]!, 1]]; - const gen = generateMessage(rng, profile, names, i); - let msg = gen.msg; + const gen = next(faultRate); // Generated messages are valid + mappable by construction -> received. // Only an injected fault can break a message; the engine's parser names the // kind (a benign fault may still classify "ok" -> stays received). - if (rng.next() < faultRate) { - msg = rng.pick(FAULTS).apply(msg); - const c = classify(msg, knownTypes); + if (gen.injected) { + const c = classify(gen.msg, knownTypes); if (c.kind !== "ok") { - rows.push(toRow(msg, "error", channel, { errorKind: c.kind, errorMessage: c.detail })); - if (outDir) outFiles.push({ msg, type: gen.type }); + rows.push(toRow(gen.msg, "error", channel, { errorKind: c.kind, errorMessage: c.detail })); + if (outDir) outFiles.push({ msg: gen.msg, type: gen.type }); continue; } } - rows.push(toRow(msg, "received", channel)); - if (outDir) outFiles.push({ msg, type: gen.type }); + rows.push(toRow(gen.msg, "received", channel)); + if (outDir) outFiles.push({ msg: gen.msg, type: gen.type }); } const tally = (key: (r: MessageRow) => string) => { @@ -107,9 +100,10 @@ if (outFmt === "jsonl") { // framing), named msg--.hl7 — exactly what folderSource/hl7v2Parser drains. if (outDir) { await mkdir(outDir, { recursive: true }); - if (doClean) { - for (const f of await readdir(outDir)) await rm(join(outDir, f), { force: true }); - } + // Only our own .hl7 files: a blanket delete took out whatever else lived in + // the directory, and without `recursive` threw on the first subdirectory — + // after having already removed everything sorted ahead of it. + if (doClean) await cleanExports(outDir); const width = String(outFiles.length).length; let k = 0; for (const { msg, type } of outFiles) { diff --git a/utils/hl7v2-simulator/src/gen/rng.ts b/utils/hl7v2-simulator/src/gen/rng.ts index 96ca58d..40f865d 100644 --- a/utils/hl7v2-simulator/src/gen/rng.ts +++ b/utils/hl7v2-simulator/src/gen/rng.ts @@ -8,8 +8,17 @@ export class Rng { return ((t ^ (t >>> 14)) >>> 0) / 4294967296; } int(maxExclusive: number): number { return Math.floor(this.next() * maxExclusive); } - pick(arr: readonly T[]): T { return arr[this.int(arr.length)]!; } + // Both of these used to end in a non-null assertion, so an empty distribution + // returned `undefined` at runtime and got interpolated into a segment as the + // literal string "undefined" — a profile with an empty catalog produced + // plausible-looking messages that were silently wrong. Fail where the mistake + // is instead. + pick(arr: readonly T[]): T { + if (arr.length === 0) throw new Error("Rng.pick: empty array"); + return arr[this.int(arr.length)]!; + } weighted(pairs: ReadonlyArray): T { + if (pairs.length === 0) throw new Error("Rng.weighted: empty distribution"); const total = pairs.reduce((a, [, w]) => a + w, 0); let x = this.next() * total; for (const [v, w] of pairs) { if ((x -= w) < 0) return v; } diff --git a/utils/hl7v2-simulator/src/gen/stream.ts b/utils/hl7v2-simulator/src/gen/stream.ts new file mode 100644 index 0000000..9907e7e --- /dev/null +++ b/utils/hl7v2-simulator/src/gen/stream.ts @@ -0,0 +1,70 @@ +// One generator, used by every caller that wants "a message, maybe broken". +// +// This block — draw a message, roll faultRate, pick a fault, apply it — used to +// exist in five places (the two CLIs, the UI's folder export, the UI's classic +// stream, and each SourceActor). They had drifted: the `src/` copies drew fault +// decisions from the seeded Rng, the `ui/` copies used Math.random(). So the +// documented "own seeded RNG" guarantee held for `bun run gen` and not for +// anything the UI drove — same seed, different faults, silently. +// +// Everything goes through here now, so the seed governs content AND faults. +import { generateMessage } from "./assemble.ts"; +import { FAULTS } from "./faults.ts"; +import { fakerNames } from "./names.ts"; +import { Rng } from "./rng.ts"; +import type { Profile } from "../profile/schema.ts"; + +export interface StreamMessage { + msg: string; + type: string; + /** True when a fault was deliberately injected into this message. */ + injected: boolean; +} + +export interface GeneratorOpts { + profile: Profile; + /** Seed for content, identities and fault selection alike. */ + seed: number; + /** + * Force an exact message-type mix, round-robin so every listed type appears + * evenly regardless of the profile's own weights. Empty/omitted = profile mix. + */ + types?: string[] | null; + /** + * Stamp MSH-7 with wall-clock time instead of sampling the profile's year + * range. Live streams want "now"; corpus generation wants the profile. + */ + liveTime?: boolean; + /** Index to start identifiers at — lets a restart avoid replaying IDs. */ + startIndex?: number; + locale?: "en" | "de"; +} + +/** + * Build a generator: call the result with a fault rate to get one message. + * + * The optional `now` pins MSH-7 for that message — the sender CLI uses it to + * spread a batch over a past window. Omitted, it falls back to wall-clock when + * `liveTime` is set, and otherwise to the profile's own year range. + * + * The fault is rolled AFTER the message is built, so the RNG stream — and + * therefore the content — is identical whether or not a fault lands. Only the + * corruption differs. + */ +export function makeGenerator(o: GeneratorOpts): (faultRate: number, now?: Date) => StreamMessage { + const rng = new Rng(o.seed); + const names = fakerNames(o.locale ?? "en", o.seed); + const forced = o.types?.length ? o.types : null; + let i = o.startIndex ?? 0; + return (faultRate: number, now?: Date): StreamMessage => { + const profile: Profile = forced + ? { ...o.profile, messageTypes: [[forced[i % forced.length]!, 1]] } + : o.profile; + const at = now ?? (o.liveTime ? new Date() : undefined); + const m = generateMessage(rng, profile, names, i++, at ? { now: at } : undefined); + if (faultRate > 0 && rng.next() < faultRate) { + return { msg: rng.pick(FAULTS).apply(m.msg), type: m.type, injected: true }; + } + return { msg: m.msg, type: m.type, injected: false }; + }; +} diff --git a/utils/hl7v2-simulator/src/send-cli.ts b/utils/hl7v2-simulator/src/send-cli.ts index 534c20a..07b7c9d 100644 --- a/utils/hl7v2-simulator/src/send-cli.ts +++ b/utils/hl7v2-simulator/src/send-cli.ts @@ -10,11 +10,8 @@ // the UI and `bun run gen` use. Faults are injected at --errorRate from the // FAULTS table and classified locally so the summary previews how the engine // will bucket them (parse_error / map_error / data_quality / benign-ok). -import { Rng } from "./gen/rng.ts"; -import { fakerNames } from "./gen/names.ts"; import { parseProfile, type Profile } from "./profile/schema.ts"; -import { generateMessage } from "./gen/assemble.ts"; -import { FAULTS } from "./gen/faults.ts"; +import { makeGenerator } from "./gen/stream.ts"; import { classify } from "./validate/classify.ts"; import { sendOverMllp, sendOverMllpReliable, streamOverMllp } from "./send/mllp.ts"; import { DEFAULT_PROFILE } from "./paths.ts"; @@ -186,25 +183,20 @@ async function buildGenerator(profilePath: string, seed: number, errorRate: numb die(`could not read profile '${profilePath}': ${(e as Error)?.message ?? e}`); } const knownTypes = new Set(profile.messageTypes.map(([t]) => t)); - const rng = new Rng(seed); - const names = fakerNames("en", seed); - let index = 0; + const next = makeGenerator({ profile, seed }); let injected = 0; const byKind = new Map(); const gen = (now?: Date): string => { - let msg = generateMessage(rng, profile, names, index++, { now }).msg; - // Roll a fault after the message is built so the RNG stream (and thus the - // content) is identical whether or not a fault lands — only the corruption - // differs. Mirrors src/cli.ts. We send the wire bytes regardless; the engine - // does the authoritative classification, this tally is just a preview. - if (errorRate > 0 && rng.next() < errorRate) { - msg = rng.pick(FAULTS).apply(msg); + // We send the wire bytes regardless; the engine does the authoritative + // classification, this tally is just a preview of how it will bucket them. + const m = next(errorRate, now); + if (m.injected) { injected++; - const kind = classify(msg, knownTypes).kind; + const kind = classify(m.msg, knownTypes).kind; byKind.set(kind, (byKind.get(kind) ?? 0) + 1); } - return msg; + return m.msg; }; return { gen, summary: () => ({ injected, byKind }) }; } diff --git a/utils/hl7v2-simulator/src/send/mllp.ts b/utils/hl7v2-simulator/src/send/mllp.ts index bd44671..5c955b5 100644 --- a/utils/hl7v2-simulator/src/send/mllp.ts +++ b/utils/hl7v2-simulator/src/send/mllp.ts @@ -79,32 +79,53 @@ export async function sendOverMllp(messages: string[], opts: MllpOpts = {}): Pro const host = opts.host ?? "127.0.0.1"; const port = opts.port ?? 2575; const concurrency = Math.max(1, Math.min(opts.concurrency ?? 8, messages.length || 1)); - const socks = await Promise.all( + // allSettled + an explicit teardown: with Promise.all, one failed connect (or + // one socket erroring mid-write) rejected the aggregate and left every sibling + // socket open and unreferenced. Worse, `open` removes its own error listener + // once connected, so a later error on a leaked socket was an unhandled 'error' + // event — which takes the process down. + const opened = await Promise.allSettled( Array.from({ length: concurrency }, () => open(host, port)), ); + const socks = opened.flatMap((r) => (r.status === "fulfilled" ? [r.value] : [])); + if (socks.length === 0) { + const why = opened.find((r) => r.status === "rejected"); + throw why?.status === "rejected" ? why.reason : new Error(`could not connect to ${host}:${port}`); + } let sent = 0; - await Promise.all( - socks.map((sock, k) => - new Promise((resolve, reject) => { - sock.once("error", reject); - let i = k; - const writeNext = (): void => { - if (i >= messages.length) { - sock.end(); - resolve(); - return; - } - const buf = mllpFrame(messages[i]!); - i += concurrency; - sent++; - if (sock.write(buf)) process.nextTick(writeNext); - else sock.once("drain", writeNext); - }; - writeNext(); - }), - ), - ); - return sent; + try { + const results = await Promise.allSettled( + socks.map((sock, k) => + new Promise((resolve, reject) => { + sock.once("error", reject); + let i = k; + const writeNext = (): void => { + if (i >= messages.length) { + sock.end(); + resolve(); + return; + } + const buf = mllpFrame(messages[i]!); + i += socks.length; + sent++; + if (sock.write(buf)) process.nextTick(writeNext); + else sock.once("drain", writeNext); + }; + writeNext(); + }), + ), + ); + const failed = results.find((r) => r.status === "rejected"); + if (failed?.status === "rejected") throw failed.reason; + return sent; + } finally { + // Keep a listener attached: destroying a socket can still surface an error, + // and an unhandled 'error' event is fatal. + for (const sock of socks) { + sock.on("error", () => {}); + sock.destroy(); + } + } } /** Parse the ACK code (MSA-1: AA/AE/AR) from an HL7 ACK message. */ @@ -173,31 +194,34 @@ export async function sendOverMllpReliable(messages: string[], opts: ReliableOpt const maxRetries = opts.maxRetries ?? 5; const backoffMs = opts.backoffMs ?? 1000; - let pending = messages.slice(); + // Track INDICES, not message text. Keying the response map by the message + // body collapsed duplicates, so a batch containing the same message twice + // reported the refused/silent split wrong. + let pending = messages.map((_, i) => i); let retries = 0; // Last response code per still-failing message: present = refused, absent = silent. - let lastCodes = new Map(); + let lastCodes = new Map(); for (let attempt = 0; attempt <= maxRetries && pending.length > 0; attempt++) { if (attempt > 0) { retries += pending.length; await new Promise((r) => setTimeout(r, backoffMs)); } const batch = pending; - const failed: string[] = []; + const failed: number[] = []; let i = 0; lastCodes = new Map(); const worker = async (): Promise => { while (i < batch.length) { - const msg = batch[i++]!; - const code = await deliverOne(host, port, msg, ackTimeoutMs); - if (code !== "AA") { failed.push(msg); lastCodes.set(msg, code); } + const idx = batch[i++]!; + const code = await deliverOne(host, port, messages[idx]!, ackTimeoutMs); + if (code !== "AA") { failed.push(idx); lastCodes.set(idx, code); } } }; await Promise.all(Array.from({ length: Math.min(concurrency, batch.length) }, worker)); pending = failed; } let refused = 0; - for (const m of pending) if (lastCodes.get(m) !== undefined) refused += 1; + for (const idx of pending) if (lastCodes.get(idx) !== undefined) refused += 1; return { acked: messages.length - pending.length, refused, silent: pending.length - refused, failed: pending.length, retries }; } @@ -206,9 +230,12 @@ const sleep = (ms: number): Promise => new Promise((r) => setTimeout(r, ms /** Write one frame, awaiting backpressure drain; rejects if the socket errors. */ function writeFrame(sock: net.Socket, frame: Buffer): Promise { return new Promise((resolve, reject) => { - const onErr = (e: Error): void => reject(e); + // Both listeners come off on either outcome. Leaving the pending `drain` + // attached when the socket errored accumulated one listener per frame on a + // long back-pressured stream. + const done = (): void => { sock.off("error", onErr); sock.off("drain", done); resolve(); }; + const onErr = (e: Error): void => { sock.off("drain", done); reject(e); }; sock.once("error", onErr); - const done = (): void => { sock.off("error", onErr); resolve(); }; if (sock.write(frame)) process.nextTick(done); else sock.once("drain", done); }); @@ -322,26 +349,3 @@ export async function streamOverMllp(opts: LiveStreamOpts): Promise { } return sent; } - -export interface StreamOpts extends MllpOpts { - /** Gap (ms) to wait before the NEXT send. Supply an exponential draw - * (`Rng.exponential`) and the arrivals form a Poisson process. */ - gapMs: () => number; - onSent?: (n: number) => void; -} - -/** - * Stream a fixed array of messages over one MLLP connection, pausing `gapMs()` - * between each. Thin wrapper over `streamOverMllp` (index-pull, no reconnect → - * fails fast if the engine is down). - */ -export async function sendOverMllpStream(messages: string[], opts: StreamOpts): Promise { - let i = 0; - return streamOverMllp({ - host: opts.host, - port: opts.port, - next: () => (i < messages.length ? messages[i++]! : null), - gapMs: opts.gapMs, - onSent: opts.onSent ? (n) => opts.onSent!(n) : undefined, - }); -} diff --git a/utils/hl7v2-simulator/test/determinism.test.ts b/utils/hl7v2-simulator/test/determinism.test.ts new file mode 100644 index 0000000..b97ff9c --- /dev/null +++ b/utils/hl7v2-simulator/test/determinism.test.ts @@ -0,0 +1,76 @@ +// Determinism is what this tool sells: a seed must reproduce a corpus. +// +// Every other determinism check in the suite compares two runs in the SAME +// process at the SAME commit, which cannot catch a change to the PRNG itself. +// The golden vector below can: change the mulberry32 constants in src/gen/rng.ts +// and this fails, instead of every previously-generated corpus silently becoming +// irreproducible. +import { expect, test } from "bun:test"; +import { Rng } from "../src/gen/rng.ts"; +import { makeGenerator } from "../src/gen/stream.ts"; +import { parseProfile } from "../src/profile/schema.ts"; +import { DEFAULT_PROFILE } from "../src/paths.ts"; + +const profile = parseProfile(await Bun.file(DEFAULT_PROFILE).text()); + +test("Rng golden vector — seed 42 draws a fixed sequence", () => { + const r = new Rng(42); + expect([r.int(1000), r.int(1000), r.int(1000), r.int(1000), r.int(1000)]) + .toEqual([601, 448, 852, 669, 174]); +}); + +test("Rng.next golden vector", () => { + const r = new Rng(7); + const first = [r.next(), r.next(), r.next()].map((n) => Number(n.toFixed(12))); + expect(first).toEqual([0.011704753153, 0.061958257575, 0.976907632779]); +}); + +test("pick and weighted reject empty distributions instead of yielding undefined", () => { + const r = new Rng(1); + expect(() => r.pick([])).toThrow(/empty/); + expect(() => r.weighted([])).toThrow(/empty/); +}); + +test("same seed reproduces a whole message sequence, not just one message", () => { + const run = (): string[] => { + const next = makeGenerator({ profile, seed: 99 }); + return Array.from({ length: 50 }, () => next(0).msg); + }; + expect(run()).toEqual(run()); +}); + +test("faults are seeded too — same seed, same corruptions", () => { + const run = (): { msg: string; injected: boolean }[] => { + const next = makeGenerator({ profile, seed: 5 }); + return Array.from({ length: 40 }, () => { + const m = next(0.5); + return { msg: m.msg, injected: m.injected }; + }); + }; + const a = run(); + const b = run(); + expect(a).toEqual(b); + // Guard against the assertion passing vacuously: at 0.5 over 40 messages, + // some faults must have landed. + expect(a.some((m) => m.injected)).toBe(true); +}); + +test("a different seed produces different traffic", () => { + const first = makeGenerator({ profile, seed: 1 })(0).msg; + const second = makeGenerator({ profile, seed: 2 })(0).msg; + expect(first).not.toBe(second); +}); + +test("forced types round-robin evenly regardless of profile weights", () => { + const next = makeGenerator({ profile, seed: 3, types: ["ADT^A01", "ORU^R01"] }); + const types = Array.from({ length: 10 }, () => next(0).type); + expect(types.filter((t) => t === "ADT").length).toBe(5); + expect(types.filter((t) => t === "ORU").length).toBe(5); +}); + +test("startIndex shifts the identifier sequence so a restart does not replay IDs", () => { + const fresh = makeGenerator({ profile, seed: 11 }); + const firstTwo = [fresh(0).msg, fresh(0).msg]; + const resumed = makeGenerator({ profile, seed: 11, startIndex: 2 }); + expect(firstTwo).not.toContain(resumed(0).msg); +}); diff --git a/utils/hl7v2-simulator/test/export.test.ts b/utils/hl7v2-simulator/test/export.test.ts new file mode 100644 index 0000000..0bb4c90 --- /dev/null +++ b/utils/hl7v2-simulator/test/export.test.ts @@ -0,0 +1,55 @@ +// Export-directory safety: what may be written, and what may be deleted. +// +// `POST /export` takes its directory from a request body, so these are the +// checks standing between a stray request and the user's filesystem. +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { mkdir, mkdtemp, readdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { cleanExports, EXPORT_ROOT, safeExportDir } from "../src/paths.ts"; + +let scratch = ""; +beforeEach(async () => { scratch = await mkdtemp(join(tmpdir(), "hl7v2-export-")); }); +afterEach(async () => { await rm(scratch, { recursive: true, force: true }); }); + +test("safeExportDir keeps relative paths inside the export root", () => { + expect(safeExportDir("batch")).toBe(join(EXPORT_ROOT, "batch")); + expect(safeExportDir("nested/deeper")).toBe(join(EXPORT_ROOT, "nested", "deeper")); + expect(safeExportDir("")).toBe(EXPORT_ROOT); +}); + +test("safeExportDir rejects traversal and absolute escapes", () => { + expect(() => safeExportDir("../../etc")).toThrow(/must stay inside/); + expect(() => safeExportDir("ok/../../..")).toThrow(/must stay inside/); + // Absolute paths escape at resolve() and must be caught by the containment + // check — this is the shape a CSRF request would use. + expect(() => safeExportDir(process.platform === "win32" ? "C:\\Windows" : "/etc")).toThrow(/must stay inside/); +}); + +test("safeExportDir allows an absolute path that IS inside the root", () => { + const inside = join(EXPORT_ROOT, "inside"); + expect(safeExportDir(inside)).toBe(inside); +}); + +// Prove-It: the old `clean` deleted every entry with a non-recursive rm, so it +// removed unrelated files and then threw on the first subdirectory — losing +// data AND failing. Names are chosen so the subdirectory sorts between them. +test("clean removes only our .hl7 files, and a subdirectory does not abort it", async () => { + await writeFile(join(scratch, "aaa-precious.txt"), "do not delete me"); + await writeFile(join(scratch, "msg-1-ADT.hl7"), "MSH|..."); + await mkdir(join(scratch, "mmm-subdir"), { recursive: true }); + await writeFile(join(scratch, "mmm-subdir", "keep.txt"), "nested"); + await writeFile(join(scratch, "zzz-msg-2-ORU.hl7"), "MSH|..."); + + const removed = await cleanExports(scratch); + + expect(removed).toBe(2); + const left = (await readdir(scratch)).sort(); + expect(left).toEqual(["aaa-precious.txt", "mmm-subdir"]); +}); + +test("clean on a directory with nothing of ours is a no-op", async () => { + await writeFile(join(scratch, "notes.md"), "mine"); + expect(await cleanExports(scratch)).toBe(0); + expect(await readdir(scratch)).toEqual(["notes.md"]); +}); diff --git a/utils/hl7v2-simulator/test/mllp.test.ts b/utils/hl7v2-simulator/test/mllp.test.ts index 588a116..b59451a 100644 --- a/utils/hl7v2-simulator/test/mllp.test.ts +++ b/utils/hl7v2-simulator/test/mllp.test.ts @@ -1,10 +1,16 @@ -import { expect, test } from "bun:test"; +import { afterEach, expect, test } from "bun:test"; import * as net from "node:net"; import { ackCode, mllpFrame, streamOverMllp } from "../src/send/mllp.ts"; const SB = 0x0b; const EB = 0x1c; +// Servers are closed in afterEach as well as inline: without this, an assertion +// that throws mid-test leaks its listener, and the cascade of follow-on errors +// buries the real failure. +const openServers: net.Server[] = []; +afterEach(() => { for (const s of openServers.splice(0)) s.close(); }); + /** Spin a loopback MLLP server that unframes payloads into `onFrame`. */ function listen( onFrame: (payload: string, sock: net.Socket) => void, @@ -22,6 +28,7 @@ function listen( }); sock.on("error", () => {}); // client drops are expected in the reconnect test }); + openServers.push(srv); return new Promise((resolve) => { srv.listen(0, "127.0.0.1", () => { resolve({ port: (srv.address() as net.AddressInfo).port, close: () => srv.close() }); diff --git a/utils/hl7v2-simulator/test/reliable.test.ts b/utils/hl7v2-simulator/test/reliable.test.ts new file mode 100644 index 0000000..905e24a --- /dev/null +++ b/utils/hl7v2-simulator/test/reliable.test.ts @@ -0,0 +1,108 @@ +// sendOverMllpReliable is the delivery path behind every UI send button and +// `send batch --reliable`, and it owns the refused-vs-silent split the whole +// counter model rests on ("two axes, deliberately not mixed" — see ui/actor.ts). +// It had no coverage at all: mllp.test.ts exercises framing and the live stream, +// not this. +import { afterEach, expect, test } from "bun:test"; +import * as net from "node:net"; +import { mllpFrame, sendOverMllpReliable } from "../src/send/mllp.ts"; + +const SB = 0x0b; +const EB = 0x1c; + +const servers: net.Server[] = []; +afterEach(() => { + for (const s of servers.splice(0)) s.close(); +}); + +/** Loopback MLLP server; `reply` returns the MSA-1 code, or null to stay silent. */ +function listen(reply: (payload: string) => string | null): Promise { + const srv = net.createServer((sock) => { + let buf = Buffer.alloc(0); + sock.on("data", (c: Buffer) => { + buf = Buffer.concat([buf, c]); + let eb: number; + while ((eb = buf.indexOf(EB)) >= 0) { + const sb = buf.indexOf(SB); + const payload = buf.subarray(sb + 1, eb).toString("utf8"); + buf = buf.subarray(eb + 2); + const code = reply(payload); + if (code === null) continue; // accept the TCP connection, never answer + const ctrl = payload.split("\r")[0]!.split("|")[9] ?? "1"; + sock.write(mllpFrame(`MSH|^~\\&|T|T|S|S|20260101||ACK|${ctrl}|P|2.5.1\rMSA|${code}|${ctrl}`)); + } + }); + sock.on("error", () => {}); + }); + servers.push(srv); + return new Promise((resolve) => { + srv.listen(0, "127.0.0.1", () => resolve((srv.address() as net.AddressInfo).port)); + }); +} + +const MSGS = ["MSH|^~\\&|A|B|C|D|1||ADT^A01|ID-1|P|2.5.1", "MSH|^~\\&|A|B|C|D|2||ADT^A01|ID-2|P|2.5.1", "MSH|^~\\&|A|B|C|D|3||ADT^A01|ID-3|P|2.5.1"]; +const FAST = { ackTimeoutMs: 200, maxRetries: 1, backoffMs: 10 }; + +test("all accepted: acked counts them, nothing refused or silent", async () => { + const port = await listen(() => "AA"); + const r = await sendOverMllpReliable(MSGS, { port, ...FAST }); + expect(r).toMatchObject({ acked: 3, refused: 0, silent: 0, failed: 0, retries: 0 }); +}); + +test("explicit AE is refused, not silent", async () => { + const port = await listen(() => "AE"); + const r = await sendOverMllpReliable(MSGS, { port, ...FAST }); + expect(r.acked).toBe(0); + expect(r.refused).toBe(3); + expect(r.silent).toBe(0); + expect(r.retries).toBe(3); // one retry pass over all three +}); + +test("a listener that never answers is silent, not refused", async () => { + const port = await listen(() => null); + const r = await sendOverMllpReliable(MSGS, { port, ...FAST }); + expect(r.acked).toBe(0); + expect(r.refused).toBe(0); + expect(r.silent).toBe(3); +}); + +test("a closed port is silent, not refused", async () => { + // Bind then immediately release, so the port is almost certainly free. + const port = await listen(() => "AA"); + for (const s of servers.splice(0)) s.close(); + await new Promise((r) => setTimeout(r, 50)); + const r = await sendOverMllpReliable(MSGS, { port, ...FAST }); + expect(r.acked).toBe(0); + expect(r.refused).toBe(0); + expect(r.silent).toBe(3); +}); + +test("a message accepted on retry is counted as acked", async () => { + const seen = new Set(); + const port = await listen((payload) => { + const id = payload.split("|")[9]!; + if (!seen.has(id)) { seen.add(id); return null; } // ignore the first attempt + return "AA"; + }); + const r = await sendOverMllpReliable(MSGS, { port, ...FAST, maxRetries: 2 }); + expect(r.acked).toBe(3); + expect(r.failed).toBe(0); + expect(r.retries).toBeGreaterThan(0); +}); + +// Prove-It: the response map used to be keyed by message TEXT, so two identical +// bodies shared one entry and the LAST outcome overwrote the first. Answer the +// first copy with an explicit refusal and stay silent on the second: the old +// code recorded `undefined` for both and reported refused=0 / silent=2, losing +// the refusal it was actually told about. +test("identical bodies with different outcomes are accounted separately", async () => { + let seen = 0; + const port = await listen(() => (++seen === 1 ? "AR" : null)); + const r = await sendOverMllpReliable([MSGS[0]!, MSGS[0]!], { + port, concurrency: 1, ackTimeoutMs: 200, maxRetries: 0, backoffMs: 10, + }); + expect(r.acked).toBe(0); + expect(r.failed).toBe(2); + expect(r.refused).toBe(1); + expect(r.silent).toBe(1); +}); diff --git a/utils/hl7v2-simulator/test/row.test.ts b/utils/hl7v2-simulator/test/row.test.ts new file mode 100644 index 0000000..fe68f27 --- /dev/null +++ b/utils/hl7v2-simulator/test/row.test.ts @@ -0,0 +1,39 @@ +// toRow defines the CSV/JSONL output contract. A silent column shift here is +// invisible downstream — whatever consumes the corpus just reads the wrong +// field — so pin the extraction against a message with known components. +import { expect, test } from "bun:test"; +import { toRow } from "../src/gen/row.ts"; + +const MSG = [ + "MSH|^~\\&|LAB_IF|SUNRISE LAB|INTERBOX|INTERBOX|20260115093000||ORU^R01|SL-0000000042|P|2.5.1", + "PID|1||SL00012345^^^SUNRISE_LAB_MRN^MR||DOE^JANE||19800215|F", + "OBR|1|PL000000042|FL000000042|CBC^COMPLETE BLOOD COUNT^LN", +].join("\r"); + +test("toRow extracts the MSH/PID components the corpus format promises", () => { + const r = toRow(MSG, "received", "mllp-default"); + expect(r.status).toBe("received"); + expect(r.channel).toBe("mllp-default"); + expect(r.message_type).toBe("ORU"); + expect(r.source).toBe("SUNRISE LAB"); // MSH-4, the sending facility + // patient_name is "given family" — the join order is the easy thing to invert. + expect(r.patient_name).toBe("JANE DOE"); +}); + +test("toRow carries error detail only when the status is error", () => { + const ok = toRow(MSG, "received", "mllp-default"); + expect(ok.error_kind ?? null).toBeNull(); + + const bad = toRow(MSG, "error", "mllp-default", { + errorKind: "parse_error", + errorMessage: "unterminated segment", + }); + expect(bad.status).toBe("error"); + expect(bad.error_kind).toBe("parse_error"); + expect(bad.error_message).toBe("unterminated segment"); +}); + +test("toRow does not throw on a message missing PID", () => { + const mshOnly = "MSH|^~\\&|A|B|C|D|20260101000000||ADT^A01|X-1|P|2.5.1"; + expect(() => toRow(mshOnly, "received", "mllp-default")).not.toThrow(); +}); diff --git a/utils/hl7v2-simulator/test/sources.test.ts b/utils/hl7v2-simulator/test/sources.test.ts index c188a17..ef4eff5 100644 --- a/utils/hl7v2-simulator/test/sources.test.ts +++ b/utils/hl7v2-simulator/test/sources.test.ts @@ -1,4 +1,7 @@ -import { expect, test } from "bun:test"; +import { afterAll, expect, test } from "bun:test"; +import { rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { generateMessage } from "../src/gen/assemble.ts"; import { Rng } from "../src/gen/rng.ts"; import { fakerNames } from "../src/gen/names.ts"; @@ -8,6 +11,19 @@ import { profileFor, normalizeName, slugOf, SourceRegistry, type SourceDef } fro const base = prof as unknown as Profile; +// Scratch files go to the OS temp dir and are removed afterwards. The literal +// "/tmp" used before resolves to C: mp under Bun on Windows, so every run +// littered a directory outside the package. +const scratch: string[] = []; +function tmpSourcesPath(): string { + const p = join(tmpdir(), `sources-test-${Math.floor(Math.random() * 1e9)}.json`); + scratch.push(p); + return p; +} +afterAll(async () => { + for (const p of scratch) await rm(p, { force: true }); +}); + const LAB: SourceDef = { id: "sunrise-lab", name: "SUNRISE LAB", type: "lab", rate: 2, faultRate: 0 }; const CLINIC: SourceDef = { id: "cedarview-clinic", name: "CEDARVIEW CLINIC", type: "clinic", rate: 1, faultRate: 0 }; @@ -49,7 +65,7 @@ test("type drives the message mix: lab is ORU-heavy, clinic sends no ORU", () => }); test("registry: create / update / delete round-trip + persistence across instances", async () => { - const path = `/tmp/sources-test-${Math.floor(Math.random() * 1e9)}.json`; + const path = tmpSourcesPath(); const target = () => ({ host: "127.0.0.1", port: 2510, mock: true }); const reg = new SourceRegistry(path, target); @@ -79,17 +95,17 @@ test("registry: create / update / delete round-trip + persistence across instanc }); test("registry rejects duplicates and bad input", async () => { - const path = `/tmp/sources-test-${Math.floor(Math.random() * 1e9)}.json`; + const path = tmpSourcesPath(); const reg = new SourceRegistry(path, () => ({ host: "127.0.0.1", port: 2510, mock: true })); await reg.init(); await reg.create({ name: "Twin Lab", type: "lab" }); - expect(reg.create({ name: "TWIN LAB", type: "lab" })).rejects.toThrow(/already exists/); - expect(reg.create({ name: "", type: "lab" })).rejects.toThrow(/name/); - expect(reg.create({ name: "P Lab", type: "lab", targetPort: 99999 })).rejects.toThrow(/invalid port/); + await expect(reg.create({ name: "TWIN LAB", type: "lab" })).rejects.toThrow(/already exists/); + await expect(reg.create({ name: "", type: "lab" })).rejects.toThrow(/name/); + await expect(reg.create({ name: "P Lab", type: "lab", targetPort: 99999 })).rejects.toThrow(/invalid port/); }); test("actors are independent: stopping one stream does not affect another", async () => { - const path = `/tmp/sources-test-${Math.floor(Math.random() * 1e9)}.json`; + const path = tmpSourcesPath(); // Mock target: the actor loop simulates dispatch without any network. const reg = new SourceRegistry(path, () => ({ host: "mock", port: 0, mock: true })); await reg.init(); @@ -122,12 +138,72 @@ test("hand-picked msgTypes override the preset mix (equal shares, live via updat } expect([...types]).toEqual(["SIU^S12"]); // a lab forced to scheduling-only - const path = `/tmp/sources-test-${Math.floor(Math.random() * 1e9)}.json`; + const path = tmpSourcesPath(); const reg = new SourceRegistry(path, () => ({ host: "mock", port: 0, mock: true })); await reg.init(); await reg.update("memorial-lab", { msgTypes: ["ADT^A01", "ORU^R01"] }); expect(reg.get("memorial-lab")!.def.msgTypes).toEqual(["ADT^A01", "ORU^R01"]); await reg.update("memorial-lab", { msgTypes: [] }); // empty = back to preset expect(reg.get("memorial-lab")!.def.msgTypes).toBeUndefined(); - expect(reg.update("memorial-lab", { msgTypes: ["FOO^X01"] })).rejects.toThrow(/no valid message types/); + await expect(reg.update("memorial-lab", { msgTypes: ["FOO^X01"] })).rejects.toThrow(/no valid message types/); +}); + +// Prove-It: update() used to mutate the live def field-by-field and only then +// validate, so a rejected PATCH left memory, disk and the actor's pacing +// disagreeing — and the bad value silently reverted on the next restart. +test("a rejected update leaves the definition untouched", async () => { + const path = tmpSourcesPath(); + const reg = new SourceRegistry(path, () => ({ host: "mock", port: 0, mock: true })); + await reg.init(); + + const before = { ...reg.get("memorial-lab")!.def }; + await expect( + reg.update("memorial-lab", { rate: 99, targetPort: 4242, msgTypes: ["TOTALLY^BOGUS"] }), + ).rejects.toThrow(); + + const after = reg.get("memorial-lab")!.def; + expect(after.rate).toBe(before.rate); + expect(after.targetPort).toBeUndefined(); + // The actor must still route to the global target, not the port we rejected. + expect(reg.targetOf("memorial-lab")).toEqual({ host: "mock", port: 0, mock: true }); +}); + +test("an invalid port is rejected before anything else in the patch lands", async () => { + const path = tmpSourcesPath(); + const reg = new SourceRegistry(path, () => ({ host: "mock", port: 0, mock: true })); + await reg.init(); + + const rateBefore = reg.get("memorial-lab")!.def.rate; + await expect(reg.update("memorial-lab", { rate: 7, targetPort: 99999 })).rejects.toThrow(/invalid port/); + expect(reg.get("memorial-lab")!.def.rate).toBe(rateBefore); +}); + +// Distinct sources must not emit colliding identifiers: the whole point of a +// multi-source simulator is that a receiver's dedup sees several systems, not +// one system replayed. profileFor used to specialize only the MRN. +test("sources get distinct control/placer/filler/visit IDs, not just MRNs", () => { + const idsOf = (def: SourceDef): string[] => + sample(def, 5).map((m) => m.split("\r")[0]!.split("|")[9]!); + const lab = idsOf(LAB); + const clinic = idsOf(CLINIC); + expect(lab).not.toEqual(clinic); + expect(lab.some((id) => clinic.includes(id))).toBe(false); + // And they carry the source's own prefix. + expect(lab.every((id) => id.startsWith("SL-"))).toBe(true); +}); + +test("HL7 delimiters in a source name cannot escape into extra MSH fields", () => { + const evil: SourceDef = { + id: "evil", type: "lab", rate: 1, faultRate: 0, + name: normalizeName("Evil|ATTACKER_APP|ATTACKER_FAC|20990101||ADT^A01|FORGED|D|2.3"), + }; + for (const msg of sample(evil, 5)) { + // split[n] is MSH-(n+1): the field separator itself counts as MSH-1. + const msh = msg.split("\r")[0]!.split("|"); + expect(msh[4]).toBe("INTERBOX"); // MSH-5 still ours + expect(msh[5]).toBe("INTERBOX"); // MSH-6 still ours + expect(msh[10]).toBe("P"); // MSH-11 not shifted to "D" + expect(msh[11]).toBe("2.5.1"); // MSH-12 not shifted to "2.3" + expect(msh[3]).not.toContain("|"); + } }); diff --git a/utils/hl7v2-simulator/ui/actor.ts b/utils/hl7v2-simulator/ui/actor.ts index 692e809..f3fff7e 100644 --- a/utils/hl7v2-simulator/ui/actor.ts +++ b/utils/hl7v2-simulator/ui/actor.ts @@ -9,18 +9,13 @@ * many of them the way a web server hosts many sockets. */ -import { Rng } from "../src/gen/rng.ts"; -import { fakerNames } from "../src/gen/names.ts"; -import { generateMessage } from "../src/gen/assemble.ts"; -import { FAULTS } from "../src/gen/faults.ts"; +import { makeGenerator, type StreamMessage } from "../src/gen/stream.ts"; import { streamOverMllp } from "../src/send/mllp.ts"; import type { Profile } from "../src/profile/schema.ts"; import { publish, type ActorStateSnapshot, type ActorCounters } from "./bus.ts"; export interface ActorTarget { host: string; port: number; mock?: boolean } -interface StreamMessage { msg: string; type: string; injected: boolean } - // Exponential inter-arrival delay for a Poisson process with the given rate. function poissonDelayMs(rate: number): number { return (-Math.log(1 - Math.random()) / rate) * 1000; @@ -57,6 +52,7 @@ export class SourceActor { private runAc: AbortController | null = null; // whole stream private legAc: AbortController | null = null; // current target leg private runToken: object | null = null; // identifies the live run (see supervise) + private nextIndex = 0; // identifier index, carried across regenerations private gen: ((faultRate: number) => StreamMessage) | null = null; constructor(id: string, profileFn: () => Promise, targetFn: () => ActorTarget) { @@ -75,28 +71,28 @@ export class SourceActor { return { running: this.running, rate: this.rate, faultRate: this.faultRate, counters: this.view() }; } - /** One synthetic message per call — own Rng/names, live MSH-7 timestamp. */ + /** + * One synthetic message per call — own seeded Rng/names, live MSH-7 timestamp. + * + * `startIndex` advances across regenerations rather than resetting to 0. + * Identifiers are index-derived, so restarting a source used to replay the + * same control/placer/visit numbers it had already sent — which is exactly + * what a receiver's dedup is meant to catch. + */ private async makeGen(): Promise<(faultRate: number) => StreamMessage> { const profile = await this.profileFn(); - const seed = Math.floor(Math.random() * 1e9); - const rng = new Rng(seed); - const names = fakerNames("en", seed); - let i = 0; - return (faultRate: number): StreamMessage => { - const m = generateMessage(rng, profile, names, i++, { now: new Date() }); - let msg = m.msg; - let injected = false; - if (faultRate > 0 && Math.random() < faultRate) { - msg = FAULTS[Math.floor(Math.random() * FAULTS.length)]!.apply(msg); - injected = true; - } - return { msg, type: m.type, injected }; - }; + return makeGenerator({ + profile, + seed: Math.floor(Math.random() * 1e9), + liveTime: true, + startIndex: this.nextIndex, + }); } private lastTickAt = 0; private onMessage(m: StreamMessage): void { this.counters.sent += 1; + this.nextIndex += 1; if (m.injected) this.counters.malformed += 1; // High-rate protection: at 500+ msg/s a tick per message would flood SSE // clients. Cap published ticks to ~20/s per source; counters ride on each diff --git a/utils/hl7v2-simulator/ui/generator.ts b/utils/hl7v2-simulator/ui/generator.ts index a42d109..5204e82 100644 --- a/utils/hl7v2-simulator/ui/generator.ts +++ b/utils/hl7v2-simulator/ui/generator.ts @@ -10,15 +10,14 @@ * existing FAULTS table from src/gen/faults.ts. */ -import { mkdir, rm, readdir } from "node:fs/promises"; +import { mkdir } from "node:fs/promises"; import { join } from "node:path"; -import { Rng } from "../src/gen/rng.ts"; -import { fakerNames } from "../src/gen/names.ts"; import { parseProfile } from "../src/profile/schema.ts"; -import { generateMessage } from "../src/gen/assemble.ts"; -import { FAULTS } from "../src/gen/faults.ts"; +import { makeGenerator, type StreamMessage } from "../src/gen/stream.ts"; import { sendOverMllpReliable } from "../src/send/mllp.ts"; import type { Profile } from "../src/profile/schema.ts"; + +export type { StreamMessage }; import { cleanExports, DEFAULT_PROFILE, safeExportDir } from "../src/paths.ts"; const PROFILE_PATH = process.env.PROFILE_PATH ?? DEFAULT_PROFILE; @@ -71,23 +70,16 @@ export async function generateAndSend(p: SendParams): Promise { ? { ...baseProfile, messageTypes: [[p.forceType, 1]] } : baseProfile; - const seed = ++seedCounter; - const rng = new Rng(seed); - const names = fakerNames("en", seed); + const next = makeGenerator({ profile, seed: ++seedCounter }); const messages: string[] = []; const types: Record = {}; let injectedFaults = 0; for (let i = 0; i < p.count; i++) { - const m = generateMessage(rng, profile, names, i); - let msg = m.msg; - if (p.faultRate > 0 && Math.random() < p.faultRate) { - const fault = FAULTS[Math.floor(Math.random() * FAULTS.length)]!; - msg = fault.apply(msg); - injectedFaults++; - } - messages.push(msg); + const m = next(p.faultRate); + if (m.injected) injectedFaults++; + messages.push(m.msg); types[m.type] = (types[m.type] ?? 0) + 1; } @@ -158,38 +150,6 @@ export async function generateAndSend(p: SendParams): Promise { } } -export interface StreamMessage { - msg: string; - type: string; - injected: boolean; // a fault was deliberately injected into this message -} - -/** - * A per-call message source for STREAM mode. Each call generates one synthetic - * message (live MSH-7 timestamp) and injects a fault with probability - * `faultRate`. Profile is cached; rng/names are seeded fresh per source so - * successive streams differ. Unlike `generateAndSend` this does NO network I/O — - * the stream loop owns transport (a persistent fire-and-forget MLLP connection), - * which is what lets it pace far past the per-message-ACK ceiling. - */ -export async function streamGenerator(): Promise<(faultRate: number) => StreamMessage> { - const profile = await getProfile(); - const seed = ++seedCounter; - const rng = new Rng(seed); - const names = fakerNames("en", seed); - let i = 0; - return (faultRate: number): StreamMessage => { - const m = generateMessage(rng, profile, names, i++, { now: new Date() }); - let msg = m.msg; - let injected = false; - if (faultRate > 0 && Math.random() < faultRate) { - msg = FAULTS[Math.floor(Math.random() * FAULTS.length)]!.apply(msg); - injected = true; - } - return { msg, type: m.type, injected }; - }; -} - // ── Folder / batch output (files, not MLLP) ───────────────────────────────── // The batch counterpart of generateAndSend: write raw .hl7 files a folderSource // can drain (one message per file, CR-separated segments, no MLLP framing). @@ -199,14 +159,17 @@ export interface FolderParams { count: number; faultRate: number; // 0..1 types?: string[]; // round-robin forced mix; empty = the profile's own mix - clean?: boolean; // wipe existing files first (folderSource reads ALL files) + clean?: boolean; // remove our own .hl7 files first (folderSource reads ALL files) profile?: Profile; + seed?: number; // reproduce a previous export; omitted = a fresh one } export interface FolderResult { written: number; dir: string; types: Record; injectedFaults: number; + /** The seed actually used — pass it back to regenerate this exact batch. */ + seed: number; ok: boolean; error?: string; } @@ -220,35 +183,26 @@ function badTypes(base: Profile, types?: string[]): string | null { // Round-robin generator over the (optional) forced type list, so every listed // type appears — evenly — regardless of the profile's own weights. -function forcedGen(base: Profile, types: string[] | null): (faultRate: number) => StreamMessage { - const seed = ++seedCounter; - const rng = new Rng(seed); - const names = fakerNames("en", seed); - let i = 0; - return (faultRate: number): StreamMessage => { - const profile: Profile = types ? { ...base, messageTypes: [[types[i % types.length]!, 1]] } : base; - const m = generateMessage(rng, profile, names, i++, { now: new Date() }); - let msg = m.msg; - let injected = false; - if (faultRate > 0 && Math.random() < faultRate) { - msg = FAULTS[Math.floor(Math.random() * FAULTS.length)]!.apply(msg); - injected = true; - } - return { msg, type: m.type, injected }; - }; +// +// `seed` is accepted so an export can be reproduced; without one it advances a +// process counter, which keeps successive exports different but still puts the +// seed on the record (the result reports it back). +function forcedGen(base: Profile, types: string[] | null, seed: number): (faultRate: number) => StreamMessage { + return makeGenerator({ profile: base, seed, types, liveTime: true }); } export async function generateToFolder(p: FolderParams): Promise { const base = p.profile ?? (await getProfile()); const bad = badTypes(base, p.types); - if (bad) return { written: 0, dir: p.dir, types: {}, injectedFaults: 0, ok: false, error: bad }; + if (bad) return { written: 0, dir: p.dir, types: {}, injectedFaults: 0, seed: 0, ok: false, error: bad }; let dir: string; try { dir = safeExportDir(p.dir); } catch (e) { - return { written: 0, dir: p.dir, types: {}, injectedFaults: 0, ok: false, error: (e as Error).message }; + return { written: 0, dir: p.dir, types: {}, injectedFaults: 0, seed: 0, ok: false, error: (e as Error).message }; } - const next = forcedGen(base, p.types?.length ? p.types : null); + const seed = p.seed ?? ++seedCounter; + const next = forcedGen(base, p.types?.length ? p.types : null, seed); const types: Record = {}; let injectedFaults = 0; const width = String(Math.max(1, p.count)).length; @@ -267,9 +221,9 @@ export async function generateToFolder(p: FolderParams): Promise { // of both: destructive AND failed. if (p.clean) await cleanExports(dir); for (const f of files) await Bun.write(join(dir, f.name), f.msg); - return { written: files.length, dir, types, injectedFaults, ok: true }; + return { written: files.length, dir, types, injectedFaults, seed, ok: true }; } catch (e) { - return { written: 0, dir, types, injectedFaults, ok: false, error: (e as Error)?.message ?? String(e) }; + return { written: 0, dir, types, injectedFaults, seed, ok: false, error: (e as Error)?.message ?? String(e) }; } } @@ -290,7 +244,7 @@ class FolderStreamer { rate = 2; faultRate = 0; - async start(o: { dir: string; rate: number; faultRate: number; types?: string[]; profile?: Profile }): Promise<{ ok: boolean; error?: string }> { + async start(o: { dir: string; rate: number; faultRate: number; types?: string[]; profile?: Profile; seed?: number }): Promise<{ ok: boolean; error?: string }> { if (this.running) return { ok: false, error: `already streaming to ${this.dir}` }; const base = o.profile ?? (await getProfile()); const bad = badTypes(base, o.types); @@ -307,7 +261,7 @@ class FolderStreamer { this.faultRate = Math.max(0, Math.min(1, o.faultRate)); this.written = 0; this.lastError = null; - this.next = forcedGen(base, o.types?.length ? o.types : null); + this.next = forcedGen(base, o.types?.length ? o.types : null, o.seed ?? ++seedCounter); this.running = true; const mine = ++this.generation; const tick = async (): Promise => {