clabtoolkit 0.3.3__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.
@@ -0,0 +1,1187 @@
1
+ import os
2
+ import shutil
3
+ import pandas as pd
4
+ import time
5
+ import queue
6
+ import threading
7
+ import numpy as np
8
+
9
+ from typing import Union, Dict, List, Optional
10
+
11
+ import re
12
+ import json
13
+ from glob import glob
14
+
15
+ from rich.progress import (
16
+ Progress,
17
+ BarColumn,
18
+ TimeRemainingColumn,
19
+ TextColumn,
20
+ MofNCompleteColumn,
21
+ SpinnerColumn,
22
+ )
23
+ from rich.console import Console
24
+ from rich.panel import Panel
25
+
26
+ # Importing the clabtoolkit modules
27
+ from . import misctools as cltmisc
28
+ from . import bidstools as cltbids
29
+
30
+
31
+ ####################################################################################################
32
+ ####################################################################################################
33
+ ############ ############
34
+ ############ ############
35
+ ############ Section 1: Utility methods ############
36
+ ############ ############
37
+ ############ ############
38
+ ####################################################################################################
39
+ ####################################################################################################
40
+ def get_ids2process(
41
+ ids: Union[str, List[str], None] = None, in_dir: str = None
42
+ ) -> List[str]:
43
+ """
44
+ Get list of subject IDs to process from various input sources.
45
+
46
+ Parameters
47
+ ----------
48
+ ids : str, list of str, or None, optional
49
+ Subject IDs specification. Can be:
50
+ - None: discover all subjects in `in_dir` (default)
51
+ - list: list of subject ID strings
52
+ - str: comma-separated IDs, single ID, or path to text file
53
+
54
+ in_dir : str, optional
55
+ Directory path to scan for subjects when `ids` is None.
56
+ Only used when `ids` is None.
57
+
58
+ Returns
59
+ -------
60
+ list of str
61
+ List of subject ID strings, with empty entries filtered out.
62
+
63
+ Raises
64
+ ------
65
+ ValueError
66
+ If `ids` is not None/list/str, or if `in_dir` is invalid when `ids` is None.
67
+ FileNotFoundError
68
+ If specified file path in `ids` does not exist.
69
+ IOError
70
+ If file cannot be read due to permissions or other IO issues.
71
+
72
+ Examples
73
+ --------
74
+ >>> # Discover subjects from directory
75
+ >>> get_ids2process(ids=None, in_dir='/data/subjects')
76
+ ['sub-001', 'sub-002', 'sub-003']
77
+
78
+ >>> # From list
79
+ >>> get_ids2process(['sub-001', 'sub-002'])
80
+ ['sub-001', 'sub-002']
81
+
82
+ >>> # From comma-separated string
83
+ >>> get_ids2process('sub-001, sub-002, sub-003')
84
+ ['sub-001', 'sub-002', 'sub-003']
85
+
86
+ >>> # Single subject ID
87
+ >>> get_ids2process('sub-001')
88
+ ['sub-001']
89
+
90
+ >>> # From text file
91
+ >>> get_ids2process('/path/to/subjects.txt')
92
+ ['sub-001', 'sub-002', 'sub-003']
93
+
94
+ Notes
95
+ -----
96
+ When scanning directories (ids=None), only directories starting with 'sub-'
97
+ are considered valid subject directories.
98
+
99
+ Text files should contain one subject ID per line. Empty lines and
100
+ whitespace are automatically filtered out.
101
+ """
102
+ # Handle None case - discover from directory
103
+ if ids is None:
104
+ if not in_dir or not os.path.isdir(in_dir):
105
+ raise ValueError(f"Valid in_dir required when ids is None. Got: {in_dir}")
106
+ return [d for d in os.listdir(in_dir) if d.startswith("sub-")]
107
+
108
+ # Handle list case
109
+ if isinstance(ids, list):
110
+ return [str(id_).strip() for id_ in ids if str(id_).strip()]
111
+
112
+ # Handle string case
113
+ if isinstance(ids, str):
114
+ ids = ids.strip()
115
+ if not ids:
116
+ return []
117
+
118
+ # File path
119
+ if os.path.isfile(ids):
120
+ with open(ids, "r", encoding="utf-8") as f:
121
+ return [line.strip() for line in f if line.strip()]
122
+
123
+ # Comma-separated or single ID
124
+ return [id_.strip() for id_ in ids.split(",") if id_.strip()]
125
+
126
+ raise ValueError(f"ids must be None, list, or string, got {type(ids)}")
127
+
128
+
129
+ ####################################################################################################
130
+ ####################################################################################################
131
+ ############ ############
132
+ ############ ############
133
+ ############ Section 2: Methods for assessing the processing status of the pipelines ############
134
+ ############ ############
135
+ ############ ############
136
+ ####################################################################################################
137
+ ####################################################################################################
138
+ def create_processing_status_table(
139
+ deriv_dir: str,
140
+ subj_ids: Union[list, str],
141
+ output_table: str = None,
142
+ n_jobs: int = -1,
143
+ ):
144
+ """
145
+ This method creates a table with the processing status of the subjects in the BIDs derivatives directory.
146
+ Uses parallel processing for improved performance with rich progress visualization.
147
+
148
+ Parameters
149
+ ----------
150
+ deriv_dir : str
151
+ Path to the derivatives directory.
152
+
153
+ subj_ids : list or str
154
+ List of subject IDs or a text file containing the subject IDs.
155
+
156
+ output_table : str, optional
157
+ Path to save the resulting table. If None, the table is not saved.
158
+
159
+ n_jobs : int, optional
160
+ Number of parallel jobs to run. Default is -1 which uses all available cores.
161
+
162
+
163
+ Returns
164
+ -------
165
+ pd.DataFrame
166
+ DataFrame containing the processing status of the subjects.
167
+
168
+ str
169
+ Path to the saved table if output_table is provided, otherwise None.
170
+
171
+ Raises
172
+ ------
173
+ FileNotFoundError
174
+ If the derivatives directory or the subject IDs file does not exist.
175
+ ValueError
176
+ If no derivatives folders are found or if the subject IDs list is empty.
177
+
178
+ TypeError
179
+ If subj_ids is not a list or a string path to a file.
180
+
181
+ Examples
182
+ --------
183
+ >>> deriv_dir = "/path/to/derivatives"
184
+ >>> subj_ids = ["sub-01", "sub-02"]
185
+ >>> output_table = "/path/to/output_table.csv"
186
+ >>> df, saved_path = create_processing_status_table(deriv_dir, subj_ids, output_table)
187
+ >>> print(df)
188
+ """
189
+
190
+ from joblib import Parallel, delayed
191
+ from . import morphometrytools as cltmorpho
192
+
193
+ # Initialize rich console
194
+ console = Console()
195
+
196
+ # Check if the derivatives directory exists
197
+ deriv_dir = cltmisc.remove_trailing_separators(deriv_dir)
198
+
199
+ if not os.path.isdir(deriv_dir):
200
+ raise FileNotFoundError(
201
+ f"The derivatives directory {deriv_dir} does not exist."
202
+ )
203
+
204
+ # Process subject IDs
205
+ if isinstance(subj_ids, str):
206
+ if not os.path.isfile(subj_ids):
207
+ raise FileNotFoundError(f"The file {subj_ids} does not exist.")
208
+ else:
209
+ with open(subj_ids, "r") as f:
210
+ subj_list = f.read().splitlines()
211
+ elif isinstance(subj_ids, list):
212
+ if len(subj_ids) == 0:
213
+ raise ValueError("The list of subject IDs is empty.")
214
+ else:
215
+ subj_list = subj_ids
216
+ else:
217
+ raise TypeError("subj_ids must be a list or a string path to a file")
218
+
219
+ # Number of Ids
220
+ n_subj = len(subj_list)
221
+
222
+ # Find all the derivatives folders
223
+ pipe_dirs = cltbids.get_derivatives_folders(deriv_dir)
224
+
225
+ if len(pipe_dirs) == 0:
226
+ raise ValueError(
227
+ "No derivatives folders were found in the specified directory."
228
+ )
229
+
230
+ # Create a message queue to communicate across threads
231
+ progress_queue = queue.Queue()
232
+
233
+ # Function to process a single subject
234
+ def process_subject(full_id):
235
+ try:
236
+ # Parse the subject ID
237
+ id_dict = cltbids.str2entity(full_id)
238
+ subject = id_dict["sub"]
239
+
240
+ # Get entity information for this subject
241
+ ent_list = cltbids.entities4table(selected_entities=full_id)
242
+ df_add = cltbids.entities_to_table(
243
+ filepath=full_id, entities_to_extract=ent_list
244
+ )
245
+
246
+ # Create a new DataFrame for this subject's processing status
247
+ proc_table = pd.DataFrame(
248
+ columns=pipe_dirs, index=[0]
249
+ ) # Single row for this subject
250
+
251
+ # Remove suffix and extension from entities
252
+ clean_id_dict = id_dict.copy()
253
+ if "suffix" in clean_id_dict:
254
+ del clean_id_dict["suffix"]
255
+ if "extension" in clean_id_dict:
256
+ del clean_id_dict["extension"]
257
+
258
+ # Create list of entity key-value pairs
259
+ subj_ent = [f"{k}-{v}" for k, v in clean_id_dict.items()]
260
+
261
+ # Process each derivatives directory
262
+ for tmp_pipe_deriv in pipe_dirs:
263
+ # Find subject's directory in this pipeline
264
+ ind_der_dir = glob(
265
+ os.path.join(
266
+ deriv_dir, tmp_pipe_deriv, "sub-" + clean_id_dict["sub"] + "*"
267
+ )
268
+ )
269
+
270
+ # Filter if multiple directories found
271
+ if len(ind_der_dir) > 1:
272
+ ind_der_dir = cltmisc.filter_by_substring(
273
+ ind_der_dir,
274
+ or_filter=[clean_id_dict["sub"]],
275
+ and_filter=subj_ent,
276
+ )
277
+
278
+ # Set count to 0 if no directory found
279
+ if len(ind_der_dir) == 0:
280
+ proc_table.at[0, tmp_pipe_deriv] = 0
281
+ continue
282
+
283
+ # Count files for this subject in this pipeline
284
+ all_pip_files = cltmisc.get_all_files(ind_der_dir[0])
285
+ subj_pipe_files = cltmisc.filter_by_substring(
286
+ all_pip_files, or_filter=clean_id_dict["sub"], and_filter=subj_ent
287
+ )
288
+ n_files = len(subj_pipe_files)
289
+
290
+ # Store the count
291
+ proc_table.at[0, tmp_pipe_deriv] = n_files
292
+
293
+ # Combine the entity info with processing counts
294
+ subj_proc_table = cltmisc.expand_and_concatenate(df_add, proc_table)
295
+
296
+ # Signal completion through the queue
297
+ progress_queue.put((True, full_id))
298
+
299
+ return subj_proc_table
300
+ except Exception as e:
301
+ # Signal error through the queue
302
+ progress_queue.put((False, f"{full_id}: {str(e)}"))
303
+ raise e
304
+
305
+ # Use Rich for progress tracking
306
+ all_results = []
307
+ stop_monitor = threading.Event()
308
+
309
+ # Start a separate thread for the progress bar
310
+ def progress_monitor(progress_queue, total_subjects, progress_task, stop_event):
311
+ completed = 0
312
+ errors = 0
313
+
314
+ while (completed + errors < total_subjects) and not stop_event.is_set():
315
+ try:
316
+ success, message = progress_queue.get(timeout=0.1)
317
+
318
+ if success:
319
+ completed += 1
320
+ else:
321
+ errors += 1
322
+ console.print(f"[bold red]Error: {message}[/bold red]")
323
+
324
+ # Update progress bar
325
+ progress.update(
326
+ progress_task,
327
+ completed=completed,
328
+ description=f"[yellow]Processing subjects - {completed}/{total_subjects} completed",
329
+ )
330
+
331
+ # Important: mark the task as done in the queue
332
+ progress_queue.task_done()
333
+
334
+ except queue.Empty:
335
+ # No updates, just continue waiting
336
+ pass
337
+
338
+ # Ensure the progress bar shows 100% completion
339
+ if not stop_event.is_set():
340
+ progress.update(progress_task, completed=total_subjects)
341
+
342
+ with Progress(
343
+ SpinnerColumn(),
344
+ TextColumn("[bold blue]{task.description}"),
345
+ BarColumn(bar_width=50),
346
+ TextColumn("[progress.percentage]{task.percentage:>3.1f}%"),
347
+ TimeRemainingColumn(),
348
+ MofNCompleteColumn(),
349
+ console=console,
350
+ refresh_per_second=10, # Increase refresh rate
351
+ ) as progress:
352
+ # Add main task to track progress
353
+ main_task = progress.add_task("[yellow]Processing subjects", total=n_subj)
354
+
355
+ # Start progress monitor thread
356
+ monitor_thread = threading.Thread(
357
+ target=progress_monitor,
358
+ args=(progress_queue, n_subj, main_task, stop_monitor),
359
+ daemon=True,
360
+ )
361
+ monitor_thread.start()
362
+
363
+ try:
364
+ # Process subjects in parallel with joblib
365
+ results = Parallel(n_jobs=n_jobs, backend="threading", verbose=0)(
366
+ delayed(process_subject)(subject) for subject in subj_list
367
+ )
368
+
369
+ # Allow some time for the queue to process final updates
370
+ time.sleep(0.5)
371
+
372
+ # Directly set progress to 100% after all processing is done
373
+ progress.update(main_task, completed=n_subj, refresh=True)
374
+
375
+ # Wait for the progress queue to be empty
376
+ progress_queue.join()
377
+
378
+ finally:
379
+ # Signal the monitor thread to stop
380
+ stop_monitor.set()
381
+
382
+ # Make absolutely sure progress shows completion
383
+ progress.update(main_task, completed=n_subj, refresh=True)
384
+
385
+ # Combine all results
386
+ proc_status_df = pd.concat(results, ignore_index=True)
387
+
388
+ # Save table if requested
389
+ if output_table is not None:
390
+ output_dir = os.path.dirname(output_table)
391
+ if output_dir and not os.path.exists(output_dir):
392
+ os.makedirs(output_dir, exist_ok=True)
393
+ proc_status_df.to_csv(output_table, sep="\t", index=False)
394
+
395
+ return proc_status_df, output_table
396
+
397
+
398
+ ####################################################################################################
399
+ def get_processing_status_details_json(
400
+ proc_status_df: Union[str, dict],
401
+ subj_ids: Union[List[str], str],
402
+ deriv_dir: str,
403
+ pipe_dirs: Union[List[str], str] = None,
404
+ out_json: str = None,
405
+ only_ids: bool = False,
406
+ ):
407
+ """
408
+ This function creates a dictionary with the details of the processing status of the subjects in the BIDs derivatives directory.
409
+ It provides the IDs of the subjects with incomplete or mismatched number of files.
410
+
411
+ Parameters
412
+ ----------
413
+ proc_status_df : str or dict
414
+ Path to the processing status DataFrame or a DataFrame itself. This DataFrame can be
415
+ obtained with the function "create_processing_status_table".
416
+
417
+ subj_ids : list or str
418
+ List of subject IDs or a text file containing the subject IDs.
419
+
420
+ deriv_dir : str
421
+ Path to the derivatives directory.
422
+
423
+ pipe_dirs : list or str, optional
424
+ List of processing pipelines to check. If None, all pipelines will be checked.
425
+
426
+ out_json : str, optional
427
+ Path to save the output JSON file. If None, the JSON file will not be saved.
428
+
429
+ only_ids : bool, optional
430
+ If True, only the IDs of the subjects with mismatches will be returned, without the file details.
431
+
432
+ Returns
433
+ -------
434
+ dict
435
+ Dictionary containing the details of the processing status of the subjects.
436
+ str
437
+ Path to the saved JSON file if out_json is provided, otherwise None.
438
+ """
439
+
440
+ from . import morphometrytools as cltmorpho
441
+ import os
442
+ import numpy as np
443
+
444
+ if isinstance(proc_status_df, str):
445
+ if not os.path.isfile(proc_status_df):
446
+ raise FileNotFoundError(f"The file {proc_status_df} does not exist.")
447
+ else:
448
+ proc_status_df = cltmisc.smart_read_table(proc_status_df)
449
+ elif not isinstance(proc_status_df, pd.DataFrame):
450
+ raise TypeError("proc_status_df must be a DataFrame or a string path to a file")
451
+
452
+ # Process subject IDs
453
+ if isinstance(subj_ids, str):
454
+ if not os.path.isfile(subj_ids):
455
+ raise FileNotFoundError(f"The file {subj_ids} does not exist.")
456
+ else:
457
+ with open(subj_ids, "r") as f:
458
+ subj_list = f.read().splitlines()
459
+ elif isinstance(subj_ids, list):
460
+ if len(subj_ids) == 0:
461
+ raise ValueError("The list of subject IDs is empty.")
462
+ else:
463
+ subj_list = subj_ids
464
+ else:
465
+ raise TypeError("subj_ids must be a list or a string path to a file")
466
+
467
+ # Check if the derivatives directory exists
468
+ deriv_dir = cltmisc.remove_trailing_separators(deriv_dir)
469
+
470
+ if not os.path.isdir(deriv_dir):
471
+ raise FileNotFoundError(
472
+ f"The derivatives directory {deriv_dir} does not exist."
473
+ )
474
+
475
+ # Find all the derivatives folders
476
+ all_pipe_dirs = cltbids.get_derivatives_folders(deriv_dir)
477
+
478
+ if len(all_pipe_dirs) == 0:
479
+ raise ValueError(
480
+ "No derivatives folders were found in the specified directory."
481
+ )
482
+
483
+ if pipe_dirs is not None:
484
+ if isinstance(pipe_dirs, str):
485
+ pipe_dirs = [pipe_dirs]
486
+
487
+ pipe_dirs = cltmisc.filter_by_substring(all_pipe_dirs, or_filter=pipe_dirs)
488
+ else:
489
+ pipe_dirs = all_pipe_dirs
490
+
491
+ # All entities
492
+ ent_list = cltbids.entities4table()
493
+
494
+ # Get all the columns names
495
+ col_names = proc_status_df.columns.tolist()
496
+
497
+ # Get all the columns that are not in the pipe_dirs
498
+ subj_columns = list(set(col_names) - set(pipe_dirs))
499
+
500
+ subj_ids_df = proc_status_df[subj_columns]
501
+
502
+ # Create a consistent structure for the output dictionary
503
+ missmatch_summary = {}
504
+
505
+ # Process each pipeline
506
+ for i in pipe_dirs:
507
+ proc_status_df[i] = proc_status_df[i].astype(int)
508
+ pipe_dir_fold = os.path.join(deriv_dir, i)
509
+
510
+ # Initialize consistent structure
511
+ missmatch_pipe = {"ref_fullid": "", "missmatch_fullid": {}}
512
+
513
+ # Get the mode for the column to determine the reference value
514
+ mode_value = proc_status_df[i].mode()[0]
515
+
516
+ # Find rows that match the mode (will be used as reference)
517
+ agreement_rows = proc_status_df[proc_status_df[i] == mode_value].index
518
+
519
+ # Get reference subject details (using the first row that matches the mode)
520
+ ref_ids = subj_ids_df.loc[agreement_rows].iloc[0, :]
521
+
522
+ # Create identifiers for the reference subject
523
+ cad2look_ref = [
524
+ f"{key}-{ref_ids[value]}"
525
+ for key, value in ent_list.items()
526
+ if value in subj_columns
527
+ ]
528
+
529
+ # Get files for the reference subject
530
+ ref_files = cltbids.get_individual_files_and_folders(
531
+ pipe_dir_fold,
532
+ cad2look_ref,
533
+ )
534
+
535
+ # Find the full ID of the reference subject
536
+ try:
537
+ ref_full_id = cltmisc.filter_by_substring(
538
+ subj_list, or_filter=cad2look_ref[0], and_filter=cad2look_ref
539
+ )[0]
540
+ except IndexError:
541
+ # Handle case where reference ID is not found
542
+ ref_full_id = "unknown_reference"
543
+
544
+ missmatch_pipe["ref_fullid"] = ref_full_id
545
+
546
+ # Find rows that don't match the mode (disagreement rows)
547
+ disagreement_rows = proc_status_df[proc_status_df[i] != mode_value].index
548
+
549
+ # Only process mismatches if reference files exist and there are disagreements
550
+ if ref_files and len(disagreement_rows) > 0:
551
+ # Process reference files to remove path prefixes for comparison
552
+ cad2look_ref.append(pipe_dir_fold)
553
+ tmp_ref_files = cltmisc.remove_substrings(ref_files, cad2look_ref)
554
+
555
+ # Get the ids of the subjects with disagreement
556
+ subtable_ids = subj_ids_df.loc[disagreement_rows]
557
+
558
+ # Loop through all subjects with disagreement
559
+ for j in range(len(disagreement_rows)):
560
+ # Get the subject ID
561
+ sub_row = subtable_ids.iloc[j, :]
562
+
563
+ # Create identifiers for this subject
564
+ cad2look_ind = [
565
+ f"{key}-{sub_row[value]}"
566
+ for key, value in ent_list.items()
567
+ if value in subj_columns
568
+ ]
569
+
570
+ # Get files for this subject
571
+ indiv_files = cltbids.get_individual_files_and_folders(
572
+ pipe_dir_fold,
573
+ cad2look_ind,
574
+ )
575
+
576
+ try:
577
+ # Find the full ID of this subject
578
+ indiv_full_id = cltmisc.filter_by_substring(
579
+ subj_list,
580
+ or_filter=cad2look_ind[0],
581
+ and_filter=cad2look_ind,
582
+ )[0]
583
+ except IndexError:
584
+ # Handle case where subject ID is not found
585
+ indiv_full_id = f"unknown_subject_{j}"
586
+
587
+ # Initialize results for this subject
588
+ missmatch_subject = {"missing_files": [], "extra_files": []}
589
+
590
+ if indiv_files:
591
+ # Process individual files to remove path prefixes for comparison
592
+ cad2look_ind.append(pipe_dir_fold)
593
+ tmp_indiv_files = cltmisc.remove_substrings(
594
+ indiv_files, cad2look_ind
595
+ )
596
+
597
+ # Find missing files (in reference but not in this subject)
598
+ tmp_miss = list(set(tmp_ref_files) - set(tmp_indiv_files))
599
+ if tmp_miss:
600
+ miss_indices = cltmisc.get_indexes_by_substring(
601
+ tmp_ref_files, tmp_miss
602
+ )
603
+ selected_files_ref = [ref_files[i] for i in miss_indices]
604
+ missmatch_subject["missing_files"] = cltmisc.replace_substrings(
605
+ selected_files_ref, cad2look_ref, cad2look_ind
606
+ )
607
+
608
+ # Find extra files (in this subject but not in reference)
609
+ tmp_extra = list(set(tmp_indiv_files) - set(tmp_ref_files))
610
+ if tmp_extra:
611
+ extra_indices = cltmisc.get_indexes_by_substring(
612
+ tmp_indiv_files, tmp_extra
613
+ )
614
+ selected_files_indiv = [indiv_files[i] for i in extra_indices]
615
+ missmatch_subject["extra_files"] = cltmisc.replace_substrings(
616
+ selected_files_indiv, cad2look_ind, cad2look_ref
617
+ )
618
+ else:
619
+ # If no files found for this subject, all reference files are missing
620
+ missmatch_subject["missing_files"] = cltmisc.replace_substrings(
621
+ ref_files, cad2look_ref, cad2look_ind
622
+ )
623
+
624
+ # Add this subject's details to the results
625
+ missmatch_pipe["missmatch_fullid"][indiv_full_id] = missmatch_subject
626
+
627
+ # Add this pipeline's results to the summary
628
+ missmatch_summary[i] = missmatch_pipe
629
+
630
+ # If only_ids is True, simplify the output to just include IDs
631
+ if only_ids:
632
+ for i in missmatch_summary.keys():
633
+ missmatch_summary[i]["missmatch_fullid"] = list(
634
+ missmatch_summary[i]["missmatch_fullid"].keys()
635
+ )
636
+
637
+ # Save results to JSON if requested
638
+ if out_json is not None:
639
+ json_path = os.path.dirname(out_json)
640
+ if not os.path.isdir(json_path):
641
+ # Raise an error if the directory does not exist
642
+ raise FileNotFoundError(f"The directory {json_path} does not exist.")
643
+
644
+ cltmisc.save_dictionary_to_json(missmatch_summary, out_json)
645
+
646
+ return missmatch_summary, out_json
647
+
648
+
649
+ ####################################################################################################
650
+ def get_processing_status_details_sqlite3(
651
+ proc_status_df: Union[str, dict],
652
+ subj_ids: Union[List[str], str],
653
+ deriv_dir: str,
654
+ pipe_dirs: Union[List[str], str] = None,
655
+ out_json: str = None,
656
+ db_path: str = None,
657
+ only_ids: bool = False,
658
+ ):
659
+ """
660
+ This function creates a dictionary with the details of the processing status of the subjects in the BIDs derivatives directory.
661
+ It provides the IDs of the subjects with incomplete or mismatched number of files.
662
+
663
+ Parameters
664
+ ----------
665
+ proc_status_df : str or dict
666
+ Path to the processing status DataFrame or a DataFrame itself. This DataFrame can be
667
+ obtained with the function "create_processing_status_table".
668
+
669
+ subj_ids : list or str
670
+ List of subject IDs or a text file containing the subject IDs.
671
+
672
+ deriv_dir : str
673
+ Path to the derivatives directory.
674
+
675
+ pipe_dirs : list or str, optional
676
+ List of processing pipelines to check. If None, all pipelines will be checked.
677
+
678
+ out_json : str, optional
679
+ Path to save the output JSON file. If None, the JSON file will not be saved.
680
+
681
+ db_path : str, optional
682
+ Path to save the SQLite database file. If None, the database will not be created.
683
+
684
+ only_ids : bool, optional
685
+ If True, only the IDs of the subjects with mismatches will be returned, without the file details.
686
+
687
+ Returns
688
+ -------
689
+ dict
690
+ Dictionary containing the details of the processing status of the subjects.
691
+
692
+ str
693
+ Path to the saved JSON file if out_json is provided, otherwise None.
694
+ """
695
+
696
+ from . import morphometrytools as cltmorpho
697
+ import sqlite3
698
+
699
+ if isinstance(proc_status_df, str):
700
+ if not os.path.isfile(proc_status_df):
701
+ raise FileNotFoundError(f"The file {proc_status_df} does not exist.")
702
+ else:
703
+ proc_status_df = cltmisc.smart_read_table(proc_status_df)
704
+ elif not isinstance(proc_status_df, pd.DataFrame):
705
+ raise TypeError("proc_status_df must be a DataFrame or a string path to a file")
706
+
707
+ # Process subject IDs
708
+ if isinstance(subj_ids, str):
709
+ if not os.path.isfile(subj_ids):
710
+ raise FileNotFoundError(f"The file {subj_ids} does not exist.")
711
+ else:
712
+ with open(subj_ids, "r") as f:
713
+ subj_list = f.read().splitlines()
714
+ elif isinstance(subj_ids, list):
715
+ if len(subj_ids) == 0:
716
+ raise ValueError("The list of subject IDs is empty.")
717
+ else:
718
+ subj_list = subj_ids
719
+ else:
720
+ raise TypeError("subj_ids must be a list or a string path to a file")
721
+
722
+ # Check if the derivatives directory exists
723
+ deriv_dir = cltmisc.remove_trailing_separators(deriv_dir)
724
+
725
+ if not os.path.isdir(deriv_dir):
726
+ raise FileNotFoundError(
727
+ f"The derivatives directory {deriv_dir} does not exist."
728
+ )
729
+
730
+ # Find all the derivatives folders
731
+ all_pipe_dirs = cltbids.get_derivatives_folders(deriv_dir)
732
+
733
+ if len(all_pipe_dirs) == 0:
734
+ raise ValueError(
735
+ "No derivatives folders were found in the specified directory."
736
+ )
737
+
738
+ if pipe_dirs is not None:
739
+ if isinstance(pipe_dirs, str):
740
+ pipe_dirs = [pipe_dirs]
741
+
742
+ pipe_dirs = cltmisc.filter_by_substring(all_pipe_dirs, or_filter=pipe_dirs)
743
+ else:
744
+ pipe_dirs = all_pipe_dirs
745
+
746
+ # All entities
747
+ ent_list = cltbids.entities4table()
748
+
749
+ # Get all the columns names
750
+ col_names = proc_status_df.columns.tolist()
751
+
752
+ # Get all the columns that are not in the pipe_dirs
753
+ subj_columns = list(set(col_names) - set(pipe_dirs))
754
+
755
+ subj_ids_df = proc_status_df[subj_columns]
756
+
757
+ # Create a consistent structure for the output dictionary
758
+ missmatch_summary = {}
759
+
760
+ # Initialize SQLite database if db_path is provided
761
+ if db_path:
762
+ conn = sqlite3.connect(db_path)
763
+ cursor = conn.cursor()
764
+
765
+ # Create tables
766
+ cursor.execute(
767
+ """
768
+ CREATE TABLE IF NOT EXISTS pipelines (
769
+ pipeline_id TEXT PRIMARY KEY,
770
+ ref_fullid TEXT
771
+ )"""
772
+ )
773
+
774
+ cursor.execute(
775
+ """
776
+ CREATE TABLE IF NOT EXISTS mismatches (
777
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
778
+ pipeline_id TEXT,
779
+ subject_id TEXT,
780
+ FOREIGN KEY (pipeline_id) REFERENCES pipelines(pipeline_id),
781
+ UNIQUE (pipeline_id, subject_id)
782
+ )"""
783
+ )
784
+
785
+ cursor.execute(
786
+ """
787
+ CREATE TABLE IF NOT EXISTS file_details (
788
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
789
+ mismatch_id INTEGER,
790
+ file_path TEXT,
791
+ status TEXT,
792
+ FOREIGN KEY (mismatch_id) REFERENCES mismatches(id)
793
+ )"""
794
+ )
795
+
796
+ # Clear existing data if needed
797
+ cursor.execute("DELETE FROM file_details")
798
+ cursor.execute("DELETE FROM mismatches")
799
+ cursor.execute("DELETE FROM pipelines")
800
+
801
+ # Process each pipeline
802
+ for i in pipe_dirs:
803
+ proc_status_df[i] = proc_status_df[i].astype(int)
804
+ pipe_dir_fold = os.path.join(deriv_dir, i)
805
+
806
+ # Initialize consistent structure
807
+ missmatch_pipe = {"ref_fullid": "", "missmatch_fullid": {}}
808
+
809
+ # Get the mode for the column to determine the reference value
810
+ mode_value = proc_status_df[i].mode()[0]
811
+
812
+ # Find rows that match the mode (will be used as reference)
813
+ agreement_rows = proc_status_df[proc_status_df[i] == mode_value].index
814
+
815
+ # Get reference subject details (using the first row that matches the mode)
816
+ ref_ids = subj_ids_df.loc[agreement_rows].iloc[0, :]
817
+
818
+ # Create identifiers for the reference subject
819
+ cad2look_ref = [
820
+ f"{key}-{ref_ids[value]}"
821
+ for key, value in ent_list.items()
822
+ if value in subj_columns
823
+ ]
824
+
825
+ # Get files for the reference subject
826
+ ref_files = cltbids.get_individual_files_and_folders(
827
+ pipe_dir_fold,
828
+ cad2look_ref,
829
+ )
830
+
831
+ # Find the full ID of the reference subject
832
+ try:
833
+ ref_full_id = cltmisc.filter_by_substring(
834
+ subj_list, or_filter=cad2look_ref[0], and_filter=cad2look_ref
835
+ )[0]
836
+ except IndexError:
837
+ # Handle case where reference ID is not found
838
+ ref_full_id = "unknown_reference"
839
+
840
+ missmatch_pipe["ref_fullid"] = ref_full_id
841
+
842
+ # If using SQLite, insert pipeline info
843
+ if db_path:
844
+ cursor.execute(
845
+ "INSERT OR REPLACE INTO pipelines VALUES (?, ?)", (i, ref_full_id)
846
+ )
847
+
848
+ # Find rows that don't match the mode (disagreement rows)
849
+ disagreement_rows = proc_status_df[proc_status_df[i] != mode_value].index
850
+
851
+ # Only process mismatches if reference files exist and there are disagreements
852
+ if ref_files and len(disagreement_rows) > 0:
853
+ # Process reference files to remove path prefixes for comparison
854
+ cad2look_ref.append(pipe_dir_fold)
855
+ tmp_ref_files = cltmisc.remove_substrings(ref_files, cad2look_ref)
856
+
857
+ # Get the ids of the subjects with disagreement
858
+ subtable_ids = subj_ids_df.loc[disagreement_rows]
859
+
860
+ # Loop through all subjects with disagreement
861
+ for j in range(len(disagreement_rows)):
862
+ # Get the subject ID
863
+ sub_row = subtable_ids.iloc[j, :]
864
+
865
+ # Create identifiers for this subject
866
+ cad2look_ind = [
867
+ f"{key}-{sub_row[value]}"
868
+ for key, value in ent_list.items()
869
+ if value in subj_columns
870
+ ]
871
+
872
+ # Get files for this subject
873
+ indiv_files = cltbids.get_individual_files_and_folders(
874
+ pipe_dir_fold,
875
+ cad2look_ind,
876
+ )
877
+
878
+ try:
879
+ # Find the full ID of this subject
880
+ indiv_full_id = cltmisc.filter_by_substring(
881
+ subj_list,
882
+ or_filter=cad2look_ind[0],
883
+ and_filter=cad2look_ind,
884
+ )[0]
885
+ except IndexError:
886
+ # Handle case where subject ID is not found
887
+ indiv_full_id = f"unknown_subject_{j}"
888
+
889
+ # Initialize results for this subject
890
+ missmatch_subject = {"missing_files": [], "extra_files": []}
891
+
892
+ # Insert subject into mismatches table if using SQLite
893
+ if db_path:
894
+ cursor.execute(
895
+ "INSERT OR REPLACE INTO mismatches (pipeline_id, subject_id) VALUES (?, ?)",
896
+ (i, indiv_full_id),
897
+ )
898
+ mismatch_id = cursor.lastrowid
899
+
900
+ if indiv_files:
901
+ # Process individual files to remove path prefixes for comparison
902
+ cad2look_ind.append(pipe_dir_fold)
903
+ tmp_indiv_files = cltmisc.remove_substrings(
904
+ indiv_files, cad2look_ind
905
+ )
906
+
907
+ # Find missing files (in reference but not in this subject)
908
+ tmp_miss = list(set(tmp_ref_files) - set(tmp_indiv_files))
909
+ if tmp_miss:
910
+ miss_indices = cltmisc.get_indexes_by_substring(
911
+ tmp_ref_files, tmp_miss
912
+ )
913
+ selected_files_ref = [ref_files[i] for i in miss_indices]
914
+ missing_files = cltmisc.replace_substrings(
915
+ selected_files_ref, cad2look_ref, cad2look_ind
916
+ )
917
+ missmatch_subject["missing_files"] = missing_files
918
+
919
+ # Insert missing files into database if using SQLite
920
+ if db_path:
921
+ for file_path in missing_files:
922
+ cursor.execute(
923
+ "INSERT INTO file_details (mismatch_id, file_path, status) VALUES (?, ?, ?)",
924
+ (mismatch_id, file_path, "missing"),
925
+ )
926
+
927
+ # Find extra files (in this subject but not in reference)
928
+ tmp_extra = list(set(tmp_indiv_files) - set(tmp_ref_files))
929
+ if tmp_extra:
930
+ extra_indices = cltmisc.get_indexes_by_substring(
931
+ tmp_indiv_files, tmp_extra
932
+ )
933
+ selected_files_indiv = [indiv_files[i] for i in extra_indices]
934
+ extra_files = cltmisc.replace_substrings(
935
+ selected_files_indiv, cad2look_ind, cad2look_ref
936
+ )
937
+ missmatch_subject["extra_files"] = extra_files
938
+
939
+ # Insert extra files into database if using SQLite
940
+ if db_path:
941
+ for file_path in extra_files:
942
+ cursor.execute(
943
+ "INSERT INTO file_details (mismatch_id, file_path, status) VALUES (?, ?, ?)",
944
+ (mismatch_id, file_path, "extra"),
945
+ )
946
+ else:
947
+ # If no files found for this subject, all reference files are missing
948
+ missing_files = cltmisc.replace_substrings(
949
+ ref_files, cad2look_ref, cad2look_ind
950
+ )
951
+ missmatch_subject["missing_files"] = missing_files
952
+
953
+ # Insert missing files into database if using SQLite
954
+ if db_path:
955
+ for file_path in missing_files:
956
+ cursor.execute(
957
+ "INSERT INTO file_details (mismatch_id, file_path, status) VALUES (?, ?, ?)",
958
+ (mismatch_id, file_path, "missing"),
959
+ )
960
+
961
+ # Add this subject's details to the results
962
+ missmatch_pipe["missmatch_fullid"][indiv_full_id] = missmatch_subject
963
+
964
+ # Add this pipeline's results to the summary
965
+ missmatch_summary[i] = missmatch_pipe
966
+
967
+ # If only_ids is True, simplify the output to just include IDs
968
+ if only_ids:
969
+ for i in missmatch_summary.keys():
970
+ missmatch_summary[i]["missmatch_fullid"] = list(
971
+ missmatch_summary[i]["missmatch_fullid"].keys()
972
+ )
973
+
974
+ # Commit changes and close database connection if using SQLite
975
+ if db_path:
976
+ conn.commit()
977
+ conn.close()
978
+
979
+ # Save results to JSON if requested
980
+ if out_json is not None:
981
+ json_path = os.path.dirname(out_json)
982
+ if not os.path.isdir(json_path):
983
+ # Raise an error if the directory does not exist
984
+ raise FileNotFoundError(f"The directory {json_path} does not exist.")
985
+
986
+ cltmisc.save_dictionary_to_json(missmatch_summary, out_json)
987
+
988
+ return missmatch_summary, out_json
989
+
990
+
991
+ ####################################################################################################
992
+ def query_processing_status_db(
993
+ db_path, query_type="subjects_with_mismatches", pipeline=None
994
+ ):
995
+ """
996
+ Query the processing status database to extract useful information.
997
+
998
+ Parameters
999
+ ----------
1000
+ db_path : str
1001
+ Path to the SQLite database file.
1002
+
1003
+ query_type : str, optional
1004
+ Type of query to run. Options:
1005
+ - "subjects_with_mismatches": Get all subjects with mismatches
1006
+ - "pipelines_with_mismatches": Get all pipelines with mismatches and count
1007
+ - "missing_files_count": Get number of missing files per subject
1008
+ - "extra_files_count": Get number of extra files per subject
1009
+
1010
+ pipeline : str, optional
1011
+ Name of the pipeline to filter by. Used only with certain query types.
1012
+
1013
+ Returns
1014
+ -------
1015
+ pd.DataFrame
1016
+ Result of the query as a DataFrame.
1017
+ """
1018
+ import sqlite3
1019
+ import pandas as pd
1020
+
1021
+ conn = sqlite3.connect(db_path)
1022
+
1023
+ if query_type == "subjects_with_mismatches":
1024
+ if pipeline:
1025
+ query = """
1026
+ SELECT subject_id, pipeline_id
1027
+ FROM mismatches
1028
+ WHERE pipeline_id = ?
1029
+ ORDER BY subject_id
1030
+ """
1031
+ df = pd.read_sql_query(query, conn, params=(pipeline,))
1032
+ else:
1033
+ query = """
1034
+ SELECT subject_id, GROUP_CONCAT(pipeline_id) as pipelines
1035
+ FROM mismatches
1036
+ GROUP BY subject_id
1037
+ ORDER BY subject_id
1038
+ """
1039
+ df = pd.read_sql_query(query, conn)
1040
+
1041
+ elif query_type == "pipelines_with_mismatches":
1042
+ query = """
1043
+ SELECT pipeline_id, COUNT(DISTINCT subject_id) as subject_count
1044
+ FROM mismatches
1045
+ GROUP BY pipeline_id
1046
+ ORDER BY subject_count DESC
1047
+ """
1048
+ df = pd.read_sql_query(query, conn)
1049
+
1050
+ elif query_type == "missing_files_count":
1051
+ if pipeline:
1052
+ query = """
1053
+ SELECT m.subject_id, COUNT(*) as missing_count
1054
+ FROM mismatches m
1055
+ JOIN file_details f ON m.id = f.mismatch_id
1056
+ WHERE f.status = 'missing' AND m.pipeline_id = ?
1057
+ GROUP BY m.subject_id
1058
+ ORDER BY missing_count DESC
1059
+ """
1060
+ df = pd.read_sql_query(query, conn, params=(pipeline,))
1061
+ else:
1062
+ query = """
1063
+ SELECT m.subject_id, m.pipeline_id, COUNT(*) as missing_count
1064
+ FROM mismatches m
1065
+ JOIN file_details f ON m.id = f.mismatch_id
1066
+ WHERE f.status = 'missing'
1067
+ GROUP BY m.subject_id, m.pipeline_id
1068
+ ORDER BY missing_count DESC
1069
+ """
1070
+ df = pd.read_sql_query(query, conn)
1071
+
1072
+ elif query_type == "extra_files_count":
1073
+ if pipeline:
1074
+ query = """
1075
+ SELECT m.subject_id, COUNT(*) as extra_count
1076
+ FROM mismatches m
1077
+ JOIN file_details f ON m.id = f.mismatch_id
1078
+ WHERE f.status = 'extra' AND m.pipeline_id = ?
1079
+ GROUP BY m.subject_id
1080
+ ORDER BY extra_count DESC
1081
+ """
1082
+ df = pd.read_sql_query(query, conn, params=(pipeline,))
1083
+ else:
1084
+ query = """
1085
+ SELECT m.subject_id, m.pipeline_id, COUNT(*) as extra_count
1086
+ FROM mismatches m
1087
+ JOIN file_details f ON m.id = f.mismatch_id
1088
+ WHERE f.status = 'extra'
1089
+ GROUP BY m.subject_id, m.pipeline_id
1090
+ ORDER BY extra_count DESC
1091
+ """
1092
+ df = pd.read_sql_query(query, conn)
1093
+
1094
+ else:
1095
+ raise ValueError(f"Unknown query type: {query_type}")
1096
+
1097
+ conn.close()
1098
+ return df
1099
+
1100
+
1101
+ ####################################################################################################
1102
+ def export_db_to_json(db_path, out_json):
1103
+ """
1104
+ Export the processing status database to a JSON file in the same format
1105
+ as returned by get_processing_status_details.
1106
+
1107
+ Parameters
1108
+ ----------
1109
+ db_path : str
1110
+ Path to the SQLite database file.
1111
+
1112
+ out_json : str
1113
+ Path to save the output JSON file.
1114
+
1115
+ Returns
1116
+ -------
1117
+ dict
1118
+ Dictionary containing the details of the processing status of the subjects.
1119
+ """
1120
+ import sqlite3
1121
+ import json
1122
+ import os
1123
+
1124
+ conn = sqlite3.connect(db_path)
1125
+ cursor = conn.cursor()
1126
+
1127
+ # Get all pipelines
1128
+ cursor.execute("SELECT pipeline_id, ref_fullid FROM pipelines")
1129
+ pipelines = cursor.fetchall()
1130
+
1131
+ # Create the output dictionary
1132
+ output_dict = {}
1133
+
1134
+ for pipe_id, ref_fullid in pipelines:
1135
+ # Initialize pipeline entry
1136
+ pipe_entry = {"ref_fullid": ref_fullid, "missmatch_fullid": {}}
1137
+
1138
+ # Get all mismatches for this pipeline
1139
+ cursor.execute(
1140
+ """
1141
+ SELECT id, subject_id
1142
+ FROM mismatches
1143
+ WHERE pipeline_id = ?
1144
+ """,
1145
+ (pipe_id,),
1146
+ )
1147
+ mismatches = cursor.fetchall()
1148
+
1149
+ for mismatch_id, subject_id in mismatches:
1150
+ # Get missing files
1151
+ cursor.execute(
1152
+ """
1153
+ SELECT file_path
1154
+ FROM file_details
1155
+ WHERE mismatch_id = ? AND status = 'missing'
1156
+ """,
1157
+ (mismatch_id,),
1158
+ )
1159
+ missing_files = [row[0] for row in cursor.fetchall()]
1160
+
1161
+ # Get extra files
1162
+ cursor.execute(
1163
+ """
1164
+ SELECT file_path
1165
+ FROM file_details
1166
+ WHERE mismatch_id = ? AND status = 'extra'
1167
+ """,
1168
+ (mismatch_id,),
1169
+ )
1170
+ extra_files = [row[0] for row in cursor.fetchall()]
1171
+
1172
+ # Add to dictionary
1173
+ pipe_entry["missmatch_fullid"][subject_id] = {
1174
+ "missing_files": missing_files,
1175
+ "extra_files": extra_files,
1176
+ }
1177
+
1178
+ # Add pipeline to output
1179
+ output_dict[pipe_id] = pipe_entry
1180
+
1181
+ conn.close()
1182
+
1183
+ # Save to JSON
1184
+ with open(out_json, "w") as f:
1185
+ json.dump(output_dict, f, indent=2)
1186
+
1187
+ return output_dict