pylantir 0.0.9__py3-none-any.whl → 0.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pylantir/__init__.py +1 -1
- pylantir/cli/run.py +4 -1
- pylantir/config/mwl_config.json +12 -2
- pylantir/redcap_to_db.py +88 -18
- {pylantir-0.0.9.dist-info → pylantir-0.1.1.dist-info}/METADATA +16 -6
- pylantir-0.1.1.dist-info/RECORD +14 -0
- pylantir/.env +0 -2
- pylantir-0.0.9.dist-info/RECORD +0 -15
- {pylantir-0.0.9.dist-info → pylantir-0.1.1.dist-info}/WHEEL +0 -0
- {pylantir-0.0.9.dist-info → pylantir-0.1.1.dist-info}/entry_points.txt +0 -0
- {pylantir-0.0.9.dist-info → pylantir-0.1.1.dist-info}/licenses/LICENSE +0 -0
pylantir/__init__.py
CHANGED
pylantir/cli/run.py
CHANGED
|
@@ -209,6 +209,9 @@ def main() -> None:
|
|
|
209
209
|
# Extract the database update interval (default to 60 seconds if missing)
|
|
210
210
|
db_update_interval = config.get("db_update_interval", 60)
|
|
211
211
|
|
|
212
|
+
# Extract the operation interval (default from 00:00 to 23:59 hours if missing)
|
|
213
|
+
operation_interval = config.get("operation_interval", {"start_time": [0,0], "end_time": [23,59]})
|
|
214
|
+
|
|
212
215
|
# Extract allowed AE Titles (default to empty list if missing)
|
|
213
216
|
allowed_aet = config.get("allowed_aet", [])
|
|
214
217
|
|
|
@@ -219,7 +222,6 @@ def main() -> None:
|
|
|
219
222
|
redcap2wl = config.get("redcap2wl", {})
|
|
220
223
|
|
|
221
224
|
# EXtract protocol mapping
|
|
222
|
-
|
|
223
225
|
protocol = config.get("protocol", {})
|
|
224
226
|
|
|
225
227
|
# Create and update the MWL database
|
|
@@ -230,6 +232,7 @@ def main() -> None:
|
|
|
230
232
|
protocol=protocol,
|
|
231
233
|
redcap2wl=redcap2wl,
|
|
232
234
|
interval=db_update_interval,
|
|
235
|
+
operation_interval=operation_interval,
|
|
233
236
|
)
|
|
234
237
|
|
|
235
238
|
# sync_redcap_to_db(
|
pylantir/config/mwl_config.json
CHANGED
|
@@ -3,6 +3,16 @@
|
|
|
3
3
|
"db_echo": "0",
|
|
4
4
|
"db_update_interval": 60,
|
|
5
5
|
"allowed_aet": [],
|
|
6
|
+
"operation_interval": {
|
|
7
|
+
"start_time": [
|
|
8
|
+
0,
|
|
9
|
+
0
|
|
10
|
+
],
|
|
11
|
+
"end_time": [
|
|
12
|
+
23,
|
|
13
|
+
59
|
|
14
|
+
]
|
|
15
|
+
},
|
|
6
16
|
"mri_visit_session_mapping": {
|
|
7
17
|
"t1_arm_1": "1",
|
|
8
18
|
"t2_arm_1": "2",
|
|
@@ -10,7 +20,7 @@
|
|
|
10
20
|
},
|
|
11
21
|
"site": "792",
|
|
12
22
|
"redcap2wl": {
|
|
13
|
-
"study_id"
|
|
23
|
+
"study_id": "study_id",
|
|
14
24
|
"family_id": "family_id",
|
|
15
25
|
"youth_dob_y": "youth_dob_y",
|
|
16
26
|
"t1_date": "t1_date",
|
|
@@ -22,7 +32,7 @@
|
|
|
22
32
|
"performing_physician": "performing_physician",
|
|
23
33
|
"station_name": "station_name",
|
|
24
34
|
"status": "performed_procedure_step_status"
|
|
25
|
-
|
|
35
|
+
},
|
|
26
36
|
"protocol": {
|
|
27
37
|
"792": "BRAIN_MRI_3T"
|
|
28
38
|
}
|
pylantir/redcap_to_db.py
CHANGED
|
@@ -8,6 +8,7 @@ from .db_setup import engine
|
|
|
8
8
|
from .models import WorklistItem
|
|
9
9
|
import time
|
|
10
10
|
import threading
|
|
11
|
+
from datetime import datetime, time, date, timedelta
|
|
11
12
|
|
|
12
13
|
lgr = logging.getLogger(__name__)
|
|
13
14
|
|
|
@@ -22,7 +23,7 @@ Session = sessionmaker(bind=engine)
|
|
|
22
23
|
|
|
23
24
|
|
|
24
25
|
|
|
25
|
-
def fetch_redcap_entries(redcap_fields: list) -> list:
|
|
26
|
+
def fetch_redcap_entries(redcap_fields: list, interval: float) -> list:
|
|
26
27
|
"""Fetch REDCap entries using PyCap and return a list of filtered dicts."""
|
|
27
28
|
project = Project(REDCAP_API_URL, REDCAP_API_TOKEN)
|
|
28
29
|
|
|
@@ -41,7 +42,9 @@ def fetch_redcap_entries(redcap_fields: list) -> list:
|
|
|
41
42
|
lgr.info(f"Fetching REDCap data for fields: {redcap_fields}")
|
|
42
43
|
|
|
43
44
|
# Export data
|
|
44
|
-
|
|
45
|
+
datetime_now = datetime.now()
|
|
46
|
+
datetime_interval = datetime_now - timedelta(seconds=interval)
|
|
47
|
+
records = project.export_records(fields=redcap_fields, date_begin=datetime_interval, date_end=datetime_now, format_type="df")
|
|
45
48
|
|
|
46
49
|
if records.empty:
|
|
47
50
|
lgr.warning("No records retrieved from REDCap.")
|
|
@@ -60,7 +63,9 @@ def fetch_redcap_entries(redcap_fields: list) -> list:
|
|
|
60
63
|
mri_rows = group[
|
|
61
64
|
(group["redcap_repeat_instrument"] == "mri") &
|
|
62
65
|
(group.get("mri_instance").notna()) &
|
|
63
|
-
(group.get("mri_instance") != "")
|
|
66
|
+
(group.get("mri_instance") != "" ) &
|
|
67
|
+
(group.get("mri_date").notna()) &
|
|
68
|
+
(group.get("mri_time").notna())
|
|
64
69
|
]
|
|
65
70
|
|
|
66
71
|
for _, mri_row in mri_rows.iterrows():
|
|
@@ -102,6 +107,7 @@ def sync_redcap_to_db(
|
|
|
102
107
|
site_id: str,
|
|
103
108
|
protocol: dict,
|
|
104
109
|
redcap2wl: dict,
|
|
110
|
+
interval: float = 60.0,
|
|
105
111
|
) -> None:
|
|
106
112
|
"""Sync REDCap patient data with the worklist database."""
|
|
107
113
|
|
|
@@ -121,7 +127,7 @@ def sync_redcap_to_db(
|
|
|
121
127
|
if i not in redcap_fields:
|
|
122
128
|
redcap_fields.append(i)
|
|
123
129
|
|
|
124
|
-
redcap_entries = fetch_redcap_entries(redcap_fields)
|
|
130
|
+
redcap_entries = fetch_redcap_entries(redcap_fields, interval)
|
|
125
131
|
|
|
126
132
|
for record in redcap_entries:
|
|
127
133
|
study_id = record.get("study_id")
|
|
@@ -217,38 +223,102 @@ def sync_redcap_to_db_repeatedly(
|
|
|
217
223
|
site_id=None,
|
|
218
224
|
protocol=None,
|
|
219
225
|
redcap2wl=None,
|
|
220
|
-
interval=60
|
|
226
|
+
interval=60,
|
|
227
|
+
operation_interval={"start_time": [00,00], "end_time": [23,59]},
|
|
221
228
|
):
|
|
222
229
|
"""
|
|
223
|
-
Keep syncing with REDCap in a loop every `interval` seconds
|
|
230
|
+
Keep syncing with REDCap in a loop every `interval` seconds,
|
|
231
|
+
but only between operation_interval[start_time] and operation_interval[end_time].
|
|
224
232
|
Exit cleanly when STOP_EVENT is set.
|
|
225
233
|
"""
|
|
234
|
+
if operation_interval is None:
|
|
235
|
+
operation_interval = {"start_time": [0, 0], "end_time": [23, 59]}
|
|
236
|
+
|
|
237
|
+
start_h, start_m = operation_interval.get("start_time", [0, 0])
|
|
238
|
+
end_h, end_m = operation_interval.get("end_time", [23, 59])
|
|
239
|
+
start_time = time(start_h, start_m)
|
|
240
|
+
end_time = time(end_h, end_m)
|
|
241
|
+
|
|
242
|
+
# last_sync_date = datetime.now().date()
|
|
243
|
+
last_sync_date = datetime.now().date() - timedelta(days=1)
|
|
244
|
+
interval_sync = interval + 600 # add 10 minutes to the interval to overlap with the previous sync and avoid missing data
|
|
245
|
+
|
|
226
246
|
while not STOP_EVENT.is_set():
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
247
|
+
# === 1) BASELINE: set defaults for flags and wait-time each iteration ===
|
|
248
|
+
is_first_run = False
|
|
249
|
+
extended_interval = interval
|
|
250
|
+
|
|
251
|
+
# === 2) FIGURE OUT "NOW" in hours/minutes (zero out seconds) ===
|
|
252
|
+
now_dt = datetime.now().replace(second=0, microsecond=0)
|
|
253
|
+
now_time = now_dt.time()
|
|
254
|
+
today_date = now_dt.date()
|
|
255
|
+
|
|
256
|
+
# === 3) ONLY SYNC IF WE'RE WITHIN [start_time, end_time] ===
|
|
257
|
+
if start_time <= now_time <= end_time:
|
|
258
|
+
# Check if we haven't synced today yet
|
|
259
|
+
is_first_run = (last_sync_date != today_date)
|
|
260
|
+
|
|
261
|
+
# If it really *is* the first sync of this new day (and it's not the very first run ever)
|
|
262
|
+
if is_first_run and (last_sync_date is not None):
|
|
263
|
+
logging.info(f"First sync of the day for site {site_id} at {now_time}.")
|
|
264
|
+
# Calculate how many seconds from "end_time of yesterday" until "start_time of today"
|
|
265
|
+
yesterday = last_sync_date
|
|
266
|
+
dt_end_yesterday = datetime.combine(yesterday, end_time)
|
|
267
|
+
dt_start_today = datetime.combine(today_date, start_time)
|
|
268
|
+
delta = dt_start_today - dt_end_yesterday
|
|
269
|
+
# guaranteed to be positive if yesterday < today
|
|
270
|
+
extended_interval = delta.total_seconds()
|
|
271
|
+
logging.info(f"Using extended interval: {extended_interval}, {interval} seconds until next sync.")
|
|
272
|
+
else:
|
|
273
|
+
# Either not first run, or last_sync_date is None (this is first-ever run)
|
|
274
|
+
logging.info("Using default interval {interval} seconds.")
|
|
275
|
+
|
|
276
|
+
# --- CALL THE SYNC FUNCTION INSIDE A TRY/EXCEPT ---
|
|
277
|
+
logging.debug(f"Syncing REDCap to DB for site {site_id} at {now_time}.")
|
|
278
|
+
logging.debug(f"First run {is_first_run}")
|
|
279
|
+
try:
|
|
280
|
+
logging.debug(f"last_sync_date was: {last_sync_date}")
|
|
281
|
+
if is_first_run and (last_sync_date is not None):
|
|
282
|
+
sync_redcap_to_db(
|
|
283
|
+
site_id=site_id,
|
|
284
|
+
protocol=protocol,
|
|
285
|
+
redcap2wl=redcap2wl,
|
|
286
|
+
interval=extended_interval,
|
|
287
|
+
)
|
|
288
|
+
else:
|
|
289
|
+
sync_redcap_to_db(
|
|
290
|
+
site_id=site_id,
|
|
291
|
+
protocol=protocol,
|
|
292
|
+
redcap2wl=redcap2wl,
|
|
293
|
+
interval=interval_sync,
|
|
294
|
+
)
|
|
295
|
+
last_sync_date = today_date
|
|
296
|
+
logging.debug(f"REDCap sync completed at {now_time}. Next sync atempt in {interval} seconds.")
|
|
297
|
+
except Exception as exc:
|
|
298
|
+
logging.error(f"Error in REDCap sync: {exc}")
|
|
299
|
+
else:
|
|
300
|
+
# We're outside of operation hours. Just log once and sleep a bit.
|
|
301
|
+
logging.debug(
|
|
302
|
+
f"Current time {now_time} is outside operation window "
|
|
303
|
+
f"({start_time}–{end_time}). Sleeping for {interval} seconds."
|
|
232
304
|
)
|
|
233
|
-
except Exception as exc:
|
|
234
|
-
logging.error(f"Error in REDCap sync: {exc}")
|
|
235
305
|
|
|
236
|
-
#
|
|
306
|
+
# === 4) WAIT before the next iteration. We already set extended_interval above. ===
|
|
307
|
+
logging.debug(f"Sleeping for {interval} seconds before next check...")
|
|
237
308
|
STOP_EVENT.wait(interval)
|
|
238
309
|
|
|
239
310
|
logging.info("Exiting sync_redcap_to_db_repeatedly because STOP_EVENT was set.")
|
|
240
311
|
|
|
241
312
|
|
|
242
313
|
if __name__ == "__main__":
|
|
243
|
-
# This block is just a demo usage. In practice, you might set STOP_EVENT
|
|
244
|
-
# from a signal handler or from another part of your code.
|
|
245
314
|
try:
|
|
246
315
|
sync_redcap_to_db_repeatedly(
|
|
247
316
|
site_id=None,
|
|
248
317
|
protocol=None,
|
|
249
318
|
redcap2wl=None,
|
|
250
|
-
interval=60
|
|
319
|
+
interval=60,
|
|
320
|
+
operation_interval={"start_time": [0, 0], "end_time": [23, 59]},
|
|
251
321
|
)
|
|
252
322
|
except KeyboardInterrupt:
|
|
253
323
|
logging.info("KeyboardInterrupt received. Stopping background sync...")
|
|
254
|
-
STOP_EVENT.set()
|
|
324
|
+
STOP_EVENT.set()
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pylantir
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.1.1
|
|
4
4
|
Summary: Python - DICOM Modality WorkList
|
|
5
5
|
Author-email: Milton Camacho <miltoncamachoicc@gmail.com>
|
|
6
6
|
Requires-Python: >=3.11.1
|
|
@@ -82,12 +82,19 @@ pylantir start --ip 127.0.0.1 --port 4242 --AEtitle MWL_SERVER --pylantir_config
|
|
|
82
82
|
|
|
83
83
|
## Tests
|
|
84
84
|
|
|
85
|
-
|
|
85
|
+
If you want to run the tests make sure to clone the repository and run them from there.
|
|
86
|
+
|
|
87
|
+
Git clone the repository:
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
git clone https://github.com/miltoncamacho/pylantir
|
|
91
|
+
cd pylantir/tests
|
|
92
|
+
```
|
|
86
93
|
|
|
87
94
|
Query the worklist database to check that you have some entries using:
|
|
88
95
|
|
|
89
96
|
```bash
|
|
90
|
-
|
|
97
|
+
python query-db.py
|
|
91
98
|
```
|
|
92
99
|
|
|
93
100
|
Then, you can get a StudyUID from one of the entries to test the MPPS workflow. For example: 1.2.840.10008.3.1.2.3.4.55635351412689303463019139483773956632
|
|
@@ -95,19 +102,19 @@ Then, you can get a StudyUID from one of the entries to test the MPPS workflow.
|
|
|
95
102
|
Take this and run a create action to mark the worklist Procedure Step Status as IN_PROGRESS
|
|
96
103
|
|
|
97
104
|
```bash
|
|
98
|
-
|
|
105
|
+
python test-mpps.py --AEtitle MWL_SERVER --mpps_action create --callingAEtitle MWL_TESTER --ip 127.0.0.1 --port 4242 --study_uid 1.2.840.10008.3.1.2.3.4.55635351412689303463019139483773956632
|
|
99
106
|
```
|
|
100
107
|
|
|
101
108
|
You can verify that this in fact modified your database re-running:
|
|
102
109
|
|
|
103
110
|
```bash
|
|
104
|
-
|
|
111
|
+
python query-db.py
|
|
105
112
|
```
|
|
106
113
|
|
|
107
114
|
Finally, you can also simulate the pocedure completion efectively updating the Procedure Step Status to COMPLETED or DISCONTINUED:
|
|
108
115
|
|
|
109
116
|
```bash
|
|
110
|
-
|
|
117
|
+
python test-mpps.py --AEtitle MWL_SERVER --mpps_action set --mpps_status COMPLETED --callingAEtitle MWL_TESTER --ip 127.0.0.1 --port 4242 --study_uid 1.2.840.10008.3.1.2.3.4.55635351412689303463019139483773956632 --sop_uid 1.2.840.10008.3.1.2.3.4.187176383255263644225774937658729238426
|
|
111
118
|
```
|
|
112
119
|
|
|
113
120
|
## Usage
|
|
@@ -137,6 +144,8 @@ usage: pylantir [-h] [--AEtitle AETITLE] [--ip IP] [--port PORT] [--pylantir_con
|
|
|
137
144
|
- **site**: Site ID:string
|
|
138
145
|
- **protocol**: `{"site": "protocol_name", "mapping": "HIS/RIS mapping"}`
|
|
139
146
|
- **redcap2wl**: Dictionary of REDCap fields to worklist fields mapping e.g., `{"redcap_field": "worklist_field"}`
|
|
147
|
+
- **db_update_interval**: How often to reload the database e
|
|
148
|
+
- **operation_interval**: What is the time range in a day in which the database will be updated e.g., `{"start_time":[hours,minutes],"end_time":[hours,minutes]}`
|
|
140
149
|
- **--mpps_action {create,set}**: Action to perform for MPPS either create or set
|
|
141
150
|
- **--mpps_status {COMPLETED,DISCONTINUED}**: Status to set for MPPS either COMPLETED or DISCONTINUED
|
|
142
151
|
- **--callingAEtitle CALLINGAETITLE**: Calling AE Title for MPPS, it helps when the MWL is limited to only accept certain AE titles
|
|
@@ -152,6 +161,7 @@ As a default pylantir will try to read a JSON structured file with the following
|
|
|
152
161
|
"db_path": "/path/to/worklist.db",
|
|
153
162
|
"db_echo": "False",
|
|
154
163
|
"db_update_interval": 60,
|
|
164
|
+
"operation_interval": {"start_time": [0,0],"end_time": [23,59]},
|
|
155
165
|
"allowed_aet": [],
|
|
156
166
|
"site": "792",
|
|
157
167
|
"redcap2wl": {
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
pylantir/__init__.py,sha256=eed6MscqD3GeVPx1JwDo0xYYOwQaOjVpFwvlb7NJTSA,374
|
|
2
|
+
pylantir/db_setup.py,sha256=KTILsRrH7V5EaPqbCfOYYECM9mUB-AvOdjqjMM2H1n0,1333
|
|
3
|
+
pylantir/models.py,sha256=bKgI0EN1VSYanPTOvEhEY2Zzqa0gDYLpVnE_KNQ6PEc,1780
|
|
4
|
+
pylantir/mwl_server.py,sha256=GMJDcK0u_KM3oa6UqQ87NxMVye2pvG2cdkcI9k_iExg,10338
|
|
5
|
+
pylantir/populate_db.py,sha256=KIbkVA-EAuTlDArXMFOHkjMmVfjlsTApj7S1wpUu1bM,2207
|
|
6
|
+
pylantir/redcap_to_db.py,sha256=uGjKAbvZr7DcWTQyBVWxwiIkW3RTB-u54q17EWAh9ck,13442
|
|
7
|
+
pylantir/cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
pylantir/cli/run.py,sha256=ZE-CIBTn3vp4APqs0U7wKW70RFM4ph-AuKkOrGbruu8,10321
|
|
9
|
+
pylantir/config/mwl_config.json,sha256=v14HXu1ft1mwFyjsowHe3H1LXZGD6sAoYuGb9_4w2kA,1008
|
|
10
|
+
pylantir-0.1.1.dist-info/entry_points.txt,sha256=vxaxvfGppLqRt9_4sqNDdP6b2jlgpcHIwP7UQfrM1T0,50
|
|
11
|
+
pylantir-0.1.1.dist-info/licenses/LICENSE,sha256=ws_MuBL-SCEBqPBFl9_FqZkaaydIJmxHrJG2parhU4M,1141
|
|
12
|
+
pylantir-0.1.1.dist-info/WHEEL,sha256=G2gURzTEtmeR8nrdXUJfNiB3VYVxigPQ-bEQujpNiNs,82
|
|
13
|
+
pylantir-0.1.1.dist-info/METADATA,sha256=FLD6OFnJBDnSRSo2PJVB6Z5A_Laj5tCsmfw1at6LHb0,7585
|
|
14
|
+
pylantir-0.1.1.dist-info/RECORD,,
|
pylantir/.env
DELETED
pylantir-0.0.9.dist-info/RECORD
DELETED
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
pylantir/.env,sha256=qU4xxA3iOy2DQGT78CQG05ljTsFwKzgF2wXdnBpg8xQ,56
|
|
2
|
-
pylantir/__init__.py,sha256=kl2Et644PvUIvziU4BTxiTD1W4_g7E0xBYCHgPE6RZc,363
|
|
3
|
-
pylantir/db_setup.py,sha256=KTILsRrH7V5EaPqbCfOYYECM9mUB-AvOdjqjMM2H1n0,1333
|
|
4
|
-
pylantir/models.py,sha256=bKgI0EN1VSYanPTOvEhEY2Zzqa0gDYLpVnE_KNQ6PEc,1780
|
|
5
|
-
pylantir/mwl_server.py,sha256=GMJDcK0u_KM3oa6UqQ87NxMVye2pvG2cdkcI9k_iExg,10338
|
|
6
|
-
pylantir/populate_db.py,sha256=KIbkVA-EAuTlDArXMFOHkjMmVfjlsTApj7S1wpUu1bM,2207
|
|
7
|
-
pylantir/redcap_to_db.py,sha256=cZam71uUOhkiO8z20pMEE9ExEOZEdisytYbVVlQhIZY,9589
|
|
8
|
-
pylantir/cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
|
-
pylantir/cli/run.py,sha256=vqb7kbKsf39tI8-xjDceS4j5V-YJSaC_k0Lu6vlajmo,10072
|
|
10
|
-
pylantir/config/mwl_config.json,sha256=1Ma2guYAEAXQh1z7959aZadAn3ORjBqnDDibSLcwv_g,851
|
|
11
|
-
pylantir-0.0.9.dist-info/entry_points.txt,sha256=vxaxvfGppLqRt9_4sqNDdP6b2jlgpcHIwP7UQfrM1T0,50
|
|
12
|
-
pylantir-0.0.9.dist-info/licenses/LICENSE,sha256=ws_MuBL-SCEBqPBFl9_FqZkaaydIJmxHrJG2parhU4M,1141
|
|
13
|
-
pylantir-0.0.9.dist-info/WHEEL,sha256=G2gURzTEtmeR8nrdXUJfNiB3VYVxigPQ-bEQujpNiNs,82
|
|
14
|
-
pylantir-0.0.9.dist-info/METADATA,sha256=70OdtHOBFhTtJNiA7rqQiDIud4BCfzccSNlIyqVLa8c,7174
|
|
15
|
-
pylantir-0.0.9.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|