See AGENTS.md for repository guidance.
This project simulates laboratory analyzers for integration and acceptance testing. It emits deterministic analyzer traffic through the same transport that a real instrument uses.
The supported result path is:
Analyzer mock -> Analyzer Bridge -> normalized result contract -> OpenELIS
The mock never posts analyzer results directly to OpenELIS and never creates or updates OpenELIS analyzer records. Analyzer Bridge owns protocol listeners, connection identity, parsing, FILE watching, and normalized transport. OpenELIS owns lab-facing setup, local catalog bindings, audit, activation, held-result review, and operational quality control.
| Protocol | Mock behavior | Bridge entry point |
|---|---|---|
| ASTM LIS2-A2 | Opens an ASTM TCP session and sends framed records | Saved ASTM listener |
| HL7 v2 | Opens an MLLP session and sends an ORU message | Saved MLLP listener |
| FILE | Writes a result file into the configured watch directory | Saved FILE connection |
The HTTP API is a test control surface. It tells the mock to emit analyzer traffic; it is not a result transport to OpenELIS.
A mock template may contain an exact Bridge profileRef:
{
"profileRef": {
"profileId": "example-profile",
"revision": 1
}
}When profileRef is present, set ANALYZER_BRIDGE_PROFILES_DIR to the
Bridge profile catalog. The mock resolves exactly that profile revision and
derives profile-owned analyzer identity, protocol, test codes, result types,
units, value vocabularies, and FILE layout. It fails closed when the revision is
missing, duplicated, or invalid.
The mock template retains only simulation concerns such as deterministic sample values and transport fixtures. It cannot override profile-owned fields.
GeneXpert ASTM and Hain FluoroCycler are the priority cross-process fixtures. Their messages are tested against exact Bridge profile revisions.
Requirements:
- Python 3
uvfor the reproducible test environment- Docker for dynamic analyzer networks and cross-process tests
Start the control API:
export ANALYZER_BRIDGE_PROFILES_DIR=/path/to/openelis-analyzer-bridge/src/main/resources/analyzer-profiles
python3 server.py --simulate-api-port 8081This also starts a listener for every port in config/port_templates.json. To
start one listener instead, see ACCESS.md.
Check health:
curl http://localhost:8081/healthGenerate a message without sending it:
curl http://localhost:8081/simulate/file/hain_fluorocyclerThe GeneXpert sends only the messages Cepheid documents, kept under
fixtures/genexpert/<assay>/<outcome>.astm; genexpert_astm replays them and
never generates one:
curl -X POST http://localhost:8081/simulate/fixture/genexpert_astm/hivvl/quantified \
-H 'Content-Type: application/json' \
-d '{
"destination": "tcp://127.0.0.1:12001",
"sample_id": "LAB-2026-00001"
}'The destination must use tcp://. HTTP destinations are rejected.
For GeneXpert, fixtures/genexpert/<assay>/<outcome>.astm holds the ASTM messages Cepheid prints
in its LIS guidance (303-0251 section 2.1.1 and 302-7279 section 6), unchanged except for
placeholders. Push one as an instrument would send it:
curl -s -X POST http://localhost:8085/simulate/fixture/genexpert_astm/hivvl/below-40 \
-H 'Content-Type: application/json' \
-d '{"destination": "tcp://bridge:12001", "sample_id": "ACC-1", "sender_id": "GX-1",
"patient": {"id": "MRN-9", "name": "Roe^Jane"},
"instrument_codes": {"HIVVL": "HIVU"}}'destination and sample_id are required. {patient_id} and {patient_name} fill the P
record (empty by default, as in Cepheid's examples), and instrument_codes maps a profile test
code to the code this instrument sends for it ({instrument_code:HIVVL} in a fixture).
test_fixtures_match_profile.py keeps the fixtures and the Bridge's baseline profile in step: every
record and value in a fixture is declared by the profile, and every declared one is in a fixture.
Assays today: hivvl, cov-flu-rsv-plus, cov-flu-plus, cov-plus.
For the Mindray BC-5380, fixtures/mindray-bc5380/cbc/ holds messages built on the two Mindray
prints in the BC-5380 Operator's Manual, appendix C.3.3. analysis-result is that CBC as a routine
run sends it: the automated count with its alarms, histograms and scattergram. manual-count is the
same CBC with the microscope exam a technician enters on the analyzer (OBR-4 00002 Manual Count),
counted to 100 white cells; Mindray's print sends that exam with every percentage at 0.0.
xr-qc is the X-R QC run as printed. {sample_id} fills OBR-3, {patient_id} and
{patient_name} PID-3 and PID-5, and sender_id replaces MSH-3. The template
mindray_bc5380_hl7 replays them over MLLP:
curl -s -X POST http://localhost:8085/simulate/fixture/mindray_bc5380_hl7/cbc/analysis-result \
-H 'Content-Type: application/json' \
-d '{"destination": "mllp://bridge:2575", "sample_id": "ACC-1", "sender_id": "BC-5380"}'curl -X POST http://localhost:8081/simulate/hl7/abbott_architect_hl7 \
-H 'Content-Type: application/json' \
-d '{
"destination": "mllp://127.0.0.1:2575",
"sample_id": "LAB-2026-00001"
}'The destination must use mllp://. HTTP destinations are rejected.
Two optional body fields make one template stand in for distinct instruments, on both the ASTM and HL7 endpoints:
source_ip: the local address the push leaves from, so the LIS sees a different peer. The address must be configured on the mock container.sender_id: the instrument's own name, as a GeneXpert PC sends the System Name from its configuration. It replaces component 1 of ASTM H.5 and HL7 MSH-3 and leaves the rest of the message unchanged. Without it the template's token is sent as before.
curl -X POST http://localhost:8081/simulate/fixture/genexpert_astm/hivvl/quantified \
-H 'Content-Type: application/json' \
-d '{
"destination": "tcp://127.0.0.1:12001",
"sample_id": "LAB-2026-00001",
"sender_id": "GX-LAB-A"
}'A real instrument reports when it ran a test, often long before the result
reaches the LIS. By default the mock takes both times from the clock. To check
that a receiver keeps the instrument's time, send completed_at (ASTM
YYYYMMDDHHMMSS) to /simulate/astm: it becomes the completion time (R.13 in
a template with astm_config) and the start time (R.12) is set 90 minutes
earlier. An unreadable value is rejected with 400. Templates without
astm_config carry the time in R.10 instead, and QC messages ("qc": true)
ignore completed_at. A replayed fixture keeps the instrument's own times.
curl -X POST http://localhost:8081/simulate/astm/stago_start4 \
-H 'Content-Type: application/json' \
-d '{
"destination": "tcp://127.0.0.1:12001",
"completed_at": "20251021161230"
}'curl -X POST http://localhost:8081/simulate/file/hain_fluorocycler \
-H 'Content-Type: application/json' \
-d '{
"target_dir": "/data/analyzer-imports/fluorocycler",
"filename": "result.xlsx"
}'target_dir must be the directory watched by the saved Bridge FILE
connection. Bridge discovers and transports the file through that watched
directory.
For a captured XLSX fixture, the optional sample_ids array replaces the
patient rows' sample IDs, in file order. Control rows, the ones a template lists
in fixture.controlRows, keep their names unless control_ids renames them,
also in file order. The response reports the actual IDs and values written. This
lets repeated tests use newly created orders while retaining the analyzer's
file layout, measured values and control wells.
variant replays another workbook a template lists under fixture.variants:
| Template | Variant | Workbook |
|---|---|---|
quantstudio7, quantstudio5 |
control-failing |
the same plate with its NTC well amplifying |
hain_fluorocycler |
with-controls |
the patient rows plus a positive (PC) and a negative (NC) control |
hain_fluorocycler |
control-failing |
the same, with the negative control reading Positive |
The QuantStudio workbooks follow the real exports described in the openelis-work
QuantStudio spec (sections 3 to 8): a metadata block above the Well header row,
upper-case tasks, and two rows per well (the VIH-1 target and the IC internal
control) under one sample name. The plate carries standards (STD1_E7, STD3_E5,
STD5_E3, Task STANDARD), NTC wells named NC and NTC (Task NTC), and a
positive control PC (Task UNKNOWN). The FluoroCycler XT workbooks keep the
Madagascar site file's columns; no file we have shows the Type value a control
row carries, so control rows use the values the Bridge profile recognises.
test_fixtures_match_profile.py checks that each template's controlRows are
exactly the rows its pinned profile revision recognises as controls.
The control API can provision isolated mock analyzer network identities:
curl -X POST http://localhost:8081/analyzers \
-H 'Content-Type: application/json' \
-d '{"name":"genexpert-lab-1","template":"genexpert_astm"}'
curl http://localhost:8081/analyzersUse the instance name in a /simulate/astm/{name} or /simulate/hl7/{name}
request when a test must send from that instance's source address; FILE
requests take a template name. Network provisioning is test infrastructure
only; Bridge remains the durable connection authority.
Set ANALYZER_NETWORK_NAMESPACE and ANALYZER_SUBNET_PREFIX (default 10.42)
to keep one stack's analyzer networks apart from another's on the same Docker
host. MOCK_CONTAINER_NAME (default: the container hostname) and
BRIDGE_CONTAINER_NAME name the containers joined to each analyzer network.
python3 server.py --serial-port /dev/pts/3 --serial-analyzer mindray_ba88a--serial-port sends ASTM over a serial device, for example one end of a
socat virtual pair; --serial-analyzer names the template to send.
Run the complete mock suite:
uv run --with-requirements requirements.txt python -m pytestThe suite tests the mock on its own: the file templates generate from the copies
of their Bridge profiles in test_profiles. Tests that need the Bridge as well
are marked needs_bridge and skipped here; OpenELIS runs them, and the Bridge's
cross-process result-traffic harness, against its Bridge and mock submodules,
which record the versions that go together. To run them locally, set
ANALYZER_BRIDGE_PROFILES_DIR to the Bridge's src/main/resources/analyzer-profiles.
Focused tests should assert observable protocol behavior. Do not add filesystem scans or source-text guards for code that should simply be removed.
api.py: HTTP test-control APIserver.py: analyzer listener and command-line entry pointpush.py: ASTM TCP and HL7 MLLP sendersprotocols/: protocol message generation and framingtemplates/: deterministic simulator inputsprofile_adapter.py: exact Bridge profile revision adapterfixtures/: representative analyzer output filestest_*.py: behavior and contract tests
- ASTM E1381/E1394 and LIS2-A2 where implemented by analyzer fixtures
- HL7 v2 ORU over MLLP
- Analyzer-specific FILE formats represented by curated fixtures
This repository simulates analyzer behavior. Bridge profile and runtime contracts remain authoritative.