Skip to content

Commit 52bfa28

Browse files
authored
Merge pull request #7 from NHSDigital/feat/modality-worklist
Implement modality worklist
2 parents 882bf35 + b538cb6 commit 52bfa28

20 files changed

Lines changed: 1723 additions & 87 deletions

.gitleaksignore

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,14 @@ tests/integration/test_c_store_saves_metadata.py:ipv4:21
1212
tests/integration/test_c_store_saves_metadata.py:ipv4:41
1313
tests/integration/test_c_store_saves_metadata.py:ipv4:52
1414
tests/integration/test_c_store_saves_metadata.py:ipv4:65
15+
tests/services/dicom/test_c_store.py:ipv4:15
16+
tests/services/dicom/test_c_store.py:ipv4:48
1517
tests/services/test_storage.py:ipv4:37
1618
tests/services/test_storage.py:ipv4:40
1719
tests/services/test_storage.py:ipv4:52
1820
tests/services/test_storage.py:ipv4:55
1921
tests/services/test_storage.py:ipv4:88
2022
tests/services/test_storage.py:ipv4:107
21-
tests/services/dicom/test_c_store.py:ipv4:15
22-
tests/services/dicom/test_c_store.py:ipv4:48
23+
tests/services/test_storage.py:ipv4:136
24+
tests/services/test_storage.py:ipv4:176
25+

compose.yml

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ services:
44
context: .
55
dockerfile: Dockerfile
66
container_name: pacs-server
7+
command: ["uv", "run", "python", "-m", "pacs_main"]
78
ports:
89
- "4244:4244"
910
volumes:
@@ -22,6 +23,28 @@ services:
2223
timeout: 5s
2324
retries: 3
2425

26+
mwl:
27+
build:
28+
context: .
29+
dockerfile: Dockerfile
30+
container_name: mwl-server
31+
command: ["uv", "run", "python", "-m", "mwl_main"]
32+
ports:
33+
- "4243:4243"
34+
volumes:
35+
- pacs-db:/var/lib/pacs
36+
environment:
37+
- MWL_AET=MWL_SCP
38+
- MWL_PORT=4243
39+
- MWL_DB_PATH=/var/lib/pacs/worklist.db
40+
- LOG_LEVEL=INFO
41+
restart: unless-stopped
42+
healthcheck:
43+
test: ["CMD", "sqlite3", "/var/lib/pacs/worklist.db", "SELECT 1"]
44+
interval: 30s
45+
timeout: 5s
46+
retries: 3
47+
2548
volumes:
2649
pacs-storage:
2750
driver: local
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
# ADR-003: Separate containers for PACS and MWL
2+
3+
Date: 2026-01-08
4+
5+
Status: Accepted
6+
7+
## Context
8+
9+
The Gateway needs to provide two DICOM services:
10+
11+
1. **PACS Server** - C-STORE operations for receiving medical images (port 4244)
12+
2. **MWL Server** - C-FIND operations for modality worklist queries (port 4243)
13+
14+
These are distinct DICOM services with different protocols, different databases, and different responsibilities. However, they are both part of the same Gateway system and need to run together in production.
15+
16+
## Options Considered
17+
18+
### 1. Separate Containers
19+
Each service runs in its own Docker container with dedicated entry points.
20+
21+
**Pros:**
22+
- Independent scaling/deployment - Can scale/deploy PACS and MWL
23+
- Operational flexibility - Can restart, debug or maintain services independently
24+
- Better alignment with container best practices - One process per container
25+
26+
**Cons:**
27+
- Slightly more resource overhead - Two separate container processes instead of one
28+
- Additional configuration complexity - Need to manage two containers in orchestration
29+
30+
### 2. Separate Threads (Single Container)
31+
Both services run in the same container using Python threading.
32+
33+
**Pros:**
34+
- Operational simplicity - Single container to deploy, monitor and manage
35+
- Shared resources - Both services share volume mounts and environment configuration
36+
- Lower overhead - Threads have less overhead than separate processes
37+
38+
**Cons:**
39+
- No independent scaling/deployment - Cannot scale/deploy PACS and MWL separately
40+
- Shared failure domain - Issue with one service could affect the other
41+
42+
### 3. Separate Processes (Single Container)
43+
Both services run in the same container using Python multiprocessing or a process manager.
44+
45+
**Pros:**
46+
- True process isolation within a single container
47+
- Can restart individual processes without container restart
48+
- Better fault isolation than threads
49+
50+
**Cons:**
51+
- More complex process management required
52+
- Still cannot scale services independently
53+
- More resource overhead than threads
54+
- Need inter-processes communication mechanism if services need to communicate
55+
56+
### 4. Async Single Process
57+
Both services run in the same async event loop.
58+
59+
**Pros:**
60+
- Most efficient resource usage
61+
- Single process to manage
62+
63+
**Cons:**
64+
- More complex error handling - one service crash could bring down both
65+
- Harder to debug
66+
- Relatively more difficult to understand
67+
68+
## Decision
69+
70+
Run PACS and MWL servers in **separate Docker containers** using dedicated entry points.
71+
72+
**Key factors in this decision:**
73+
74+
1. **Independent scaling** - PACS may receive more load than MWL (or vice versa) during different times of day
75+
2. **Independent deployment** - Ability to update one service without affecting the other
76+
3. **Operational flexibility** - Ability to restart, debug or maintain one service independently
77+
4. **Container best practices** - One process per container is the standard pattern
78+
5. **Minimal trade-offs** - The resource overhead is minimal
79+
80+
## Consequences
81+
82+
### Positive Consequences
83+
84+
- **Independent scaling** - Can scale PACS and MWL based on their individual load patterns
85+
- **Independent deployment** - Update one service without touching the other
86+
- **Better observability** - Separate log streams and health checks for each service
87+
- **Operational flexibility** - Can restart, debug or maintain services independently
88+
89+
### Negative Consequences
90+
91+
- **Slightly more resource usage** - Two separate processes instead of one (minimal overhead)
92+
- **Multiple containers** - Requires separate containers and an orchestration strategy to manage both

docs/mwl/README.md

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
# MWL Server
2+
3+
DICOM [Modality Worklist (MWL)](https://dicom.nema.org/medical/dicom/current/output/html/part04.html#chapter_K) server for managing scheduled breast screening appointments and providing worklist information to imaging modalities.
4+
5+
## Overview
6+
7+
The MWL server is a lightweight, production-ready DICOM worklist solution that:
8+
9+
- Provides scheduled procedure information via [DICOM C-FIND](https://dicom.nema.org/medical/dicom/current/output/html/part04.html#chapter_C) protocol
10+
- Stores worklist items in SQLite database
11+
- Supports filtering by modality, date, and patient ID
12+
- Runs in a separate container alongside the [PACS Server](../pacs/README.md)
13+
14+
## Architecture
15+
16+
### Components
17+
18+
```
19+
┌─────────────────────────────────────────────────────────────┐
20+
│ MWL Server (Port 4243) │
21+
├─────────────────────────────────────────────────────────────┤
22+
│ │
23+
│ ┌──────────────┐ ┌──────────────┐ ┌──────────┐ │
24+
│ │ C-FIND │─────▶│ Storage │─────▶│ SQLite │ │
25+
│ │ Handler │ │ Layer │ │ Database │ │
26+
│ └──────────────┘ └──────────────┘ └──────────┘ │
27+
│ │ ▲ │
28+
│ │ │ │
29+
│ └──────────────────────┘ │
30+
│ Query & Response │
31+
└─────────────────────────────────────────────────────────────┘
32+
▲ ▲
33+
│ │
34+
┌──────┴──────┐ ┌───────┴────────┐
35+
│ Modality │ │ Relay Listener │
36+
│ (SCU) │ │ (Populates DB) │
37+
└─────────────┘ └────────────────┘
38+
```
39+
40+
### Workflow
41+
42+
1. **Worklist Creation**: Relay listener receives appointments from web app and creates worklist items (NB not yet implemented; worklist items must be created programmatically via `scripts/add_worklist_item.py`)
43+
2. **Worklist Query**: Modality sends C-FIND request to MWL server
44+
3. **Filtering**: MWL server filters by modality, date, patient ID, status
45+
4. **Response**: Server returns matching worklist items to modality
46+
5. **Status Updates**: [MPPS (Modality Performed Procedure Step)](https://dicom.nema.org/medical/dicom/current/output/html/part04.html#chapter_F) updates procedure status (NB not yet implemented)
47+
48+
## Running the MWL Server
49+
50+
The MWL server runs in a separate container:
51+
52+
```bash
53+
# Start both PACS and MWL servers
54+
docker compose up -d
55+
56+
# Start only MWL server
57+
docker compose up -d mwl
58+
59+
# View logs
60+
docker compose logs -f mwl
61+
62+
# Stop servers
63+
docker compose down
64+
65+
# Reset databases
66+
docker compose down -v
67+
```
68+
69+
## Configuration
70+
71+
Environment variables:
72+
73+
| Variable | Default | Description |
74+
|----------|---------|-------------|
75+
| `MWL_AET` | `MWL_SCP` | Application Entity Title |
76+
| `MWL_PORT` | `4243` | DICOM service port |
77+
| `MWL_DB_PATH` | `/var/lib/pacs/worklist.db` | SQLite database path |
78+
| `LOG_LEVEL` | `INFO` | Logging level |
79+
80+
## Example query
81+
82+
```python
83+
from pynetdicom import AE, QueryRetrievePresentationContexts
84+
from pydicom import Dataset
85+
86+
ae = AE()
87+
ae.requested_contexts = QueryRetrievePresentationContexts
88+
89+
# Create query dataset
90+
ds = Dataset()
91+
ds.PatientID = '9876543210'
92+
ds.PatientName = ''
93+
ds.AccessionNumber = ''
94+
95+
# Scheduled procedure step query
96+
sps = Dataset()
97+
sps.Modality = 'MG'
98+
sps.ScheduledProcedureStepStartDate = '20260108'
99+
ds.ScheduledProcedureStepSequence = [sps]
100+
101+
# Send C-FIND with Worklist Information Model ('W')
102+
assoc = ae.associate('localhost', 4243, ae_title='MWL_SCP')
103+
responses = assoc.send_c_find(ds, query_model='W')
104+
for (status, identifier) in responses:
105+
if status.Status in (0xFF00, 0xFF01):
106+
print(f"Found: {identifier.PatientName}")
107+
assoc.release()
108+
```
109+
110+
## Verification
111+
112+
Check worklist items:
113+
114+
```bash
115+
docker compose exec gateway sqlite3 /var/lib/pacs/worklist.db \
116+
"SELECT accession_number, patient_name, scheduled_date, status FROM worklist_items;"
117+
```
118+
119+
Add test worklist item:
120+
121+
```bash
122+
docker compose exec gateway sqlite3 /var/lib/pacs/worklist.db <<EOF
123+
INSERT INTO worklist_items (
124+
accession_number, patient_id, patient_name, patient_birth_date,
125+
scheduled_date, scheduled_time, modality, study_description
126+
) VALUES (
127+
'ACC001', '9876543210', 'TEST^PATIENT', '19800101',
128+
'20260108', '100000', 'MG', 'Bilateral Screening Mammogram'
129+
);
130+
EOF
131+
```
132+
133+
## Integration testing
134+
135+
**Running integration tests:**
136+
137+
```bash
138+
uv run pytest tests/integration/test_c_find_returns_worklist_items.py -v
139+
uv run pytest tests/integration/test_request_cfind_on_worklist.py -v
140+
```
141+
142+
## Multi-container architecture
143+
144+
The PACS and MWL servers run in separate containers. See [ADR-003: Separate containers for PACS and MWL](../adr/ADR-003_Separate_containers_for_PACS_and_MWL.md) for the architectural decision and trade-offs.
145+
146+
**Docker Compose services:**
147+
148+
```yaml
149+
services:
150+
pacs:
151+
container_name: pacs-server
152+
command: ["uv", "run", "python", "-m", "pacs_main"]
153+
ports:
154+
- "4244:4244"
155+
156+
mwl:
157+
container_name: mwl-server
158+
command: ["uv", "run", "python", "-m", "mwl_main"]
159+
ports:
160+
- "4243:4243"
161+
```
162+
163+
Each server:
164+
165+
- Runs in its own container
166+
- Has its own Application Entity (AE)
167+
- Uses a separate SQLite database
168+
- Can be scaled and deployed independently
169+
- Handles different DICOM operations (C-STORE vs C-FIND)

docs/pacs/README.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ The PACS server is a lightweight, production-ready DICOM storage solution that:
88
- Receives medical images via DICOM C-STORE protocol
99
- Stores the images using hash-based directory structure
1010
- Indexes metadata in SQLite database
11-
- Runs as a Docker container
11+
- Runs in a separate container alongside the [MWL Server](../mwl/README.md) (see [ADR-003](../adr/ADR-003_Separate_containers_for_PACS_and_MWL.md))
1212

1313
## Architecture
1414

@@ -74,13 +74,16 @@ CREATE TABLE stored_instances (
7474
## Running the PACS Server
7575

7676
```bash
77-
# Start the server
77+
# Start both PACS and MWL servers
7878
docker compose up -d
7979

80+
# Start only PACS server
81+
docker compose up -d pacs
82+
8083
# View logs
8184
docker compose logs -f pacs
8285

83-
# Stop the server
86+
# Stop servers
8487
docker compose down
8588

8689
# Reset database and storage

src/mwl_main.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
"""Entry point for MWL server."""
2+
3+
import logging
4+
import os
5+
6+
from server import MWLServer
7+
8+
9+
def main():
10+
"""Main entry point for MWL server."""
11+
logging.basicConfig(
12+
level=os.getenv("LOG_LEVEL", "INFO").upper(),
13+
format=os.getenv("LOG_FORMAT", "%(asctime)s - %(name)s - %(levelname)s - %(message)s"),
14+
)
15+
16+
mwl_aet = os.getenv("MWL_AET", "MWL_SCP")
17+
mwl_port = int(os.getenv("MWL_PORT", "4243"))
18+
mwl_db_path = os.getenv("MWL_DB_PATH", "/var/lib/pacs/worklist.db")
19+
20+
mwl_server = MWLServer(mwl_aet, mwl_port, mwl_db_path, block=True)
21+
22+
try:
23+
mwl_server.start()
24+
except KeyboardInterrupt:
25+
logging.info("Received shutdown signal")
26+
mwl_server.stop()
27+
28+
29+
if __name__ == "__main__":
30+
main()

0 commit comments

Comments
 (0)