simnibs_analyze.run
Main SimNIBS e-field analysis pipeline. Orchestrates target generation, preprocessing, feature extraction, analysis and visualisation.
1#!/usr/bin/env python3 2""" 3Main SimNIBS e-field analysis pipeline. 4Orchestrates target generation, preprocessing, feature extraction, analysis and visualisation. 5""" 6 7from __future__ import annotations 8 9import sys 10import argparse 11from pathlib import Path 12from typing import Dict, List 13 14# Allow running as `python run.py` from within simnibs_analyze/ 15# (has no effect when imported as part of the installed package) 16if __package__ is None or __package__ == "": 17 sys.path.insert(0, str(Path(__file__).parent.parent)) 18 __package__ = "simnibs_analyze" # noqa: A001 19 20import nibabel as nib 21import pandas as pd 22 23from simnibs_analyze.steps._0_anatomical_preparer import AnatomicalPreparer 24from simnibs_analyze.steps._1_preprocessing import Preprocessor 25from simnibs_analyze.steps._2_features_extraction import FeatureExtractor 26from simnibs_analyze.steps._3_analysis import Analysis 27from simnibs_analyze.steps._4_viz import Visualizer 28from simnibs_analyze._pipeline_io import ( 29 SPACE_MNI, 30 SPACE_NATIVE, 31 check_output, 32 find_efield_files, 33 find_simulation_dirs, 34 get_analysis_dir, 35 get_clusters_csv_path, 36 get_features_csv_path, 37 get_inter_subject_summary_csv_path, 38 get_intra_subject_diff_csv_path, 39 get_preproc_dir, 40 get_preproc_paths, 41 get_roi_mask_path, 42 get_subject_paths_from_config, 43 load_config, 44 save_dataframe, 45 save_nifti, 46 save_rows, 47 space_tag, 48) 49from simnibs_analyze._logging import get_logger 50from simnibs_analyze._config import PipelineConfig 51 52logger = get_logger(__name__) 53 54 55def process_subject_condition( 56 subject: str, 57 condition: str, 58 mode: str, 59 config: PipelineConfig, 60 skip_preprocessing: bool = False, 61 space: str = SPACE_MNI, 62 if_exists: str = "overwrite", 63) -> List[Dict]: 64 """ 65 Preprocess and extract features from all e-fields for a subject/condition/mode. 66 67 Parameters 68 ---------- 69 space : str 70 ``'mni'`` (default) or ``'native'`` — working space for e-fields and ROI masks. 71 72 Returns 73 ------- 74 List[Dict] 75 Extracted feature rows (one per e-field file found). 76 """ 77 results: List[Dict] = [] 78 subject_paths = get_subject_paths_from_config(config.paths, subject) 79 subject_dir = subject_paths["subject_dir"] 80 81 if not subject_dir.exists(): 82 logger.warning(f"Subject directory not found: {subject_dir}") 83 return results 84 85 simulation_dirs = find_simulation_dirs( 86 subject_dir, 87 condition, 88 mode, 89 folder_pattern=config.target_generation.rois[condition].folder_pattern, 90 ) 91 if not simulation_dirs: 92 logger.warning(f"No simulation found for {subject}/{condition}/{mode}") 93 return results 94 95 try: 96 from simnibs_analyze._pipeline_io import get_simu_root 97 98 roi_mask_path = get_roi_mask_path( 99 get_simu_root(config.paths), condition, space=space, subject=subject 100 ) 101 except (FileNotFoundError, ValueError) as e: 102 logger.error(str(e)) 103 return results 104 105 for sim_dir in simulation_dirs: 106 for efield_path in find_efield_files(sim_dir, mode, space=space): 107 preproc_dir = get_preproc_dir(sim_dir, mode, space=space) 108 base_name = efield_path.stem.replace(".nii", "") 109 paths = get_preproc_paths(preproc_dir, base_name, condition) 110 111 preproc_kwargs = dict( 112 smooth_fwhm=config.preprocessing.smooth_fwhm, 113 outlier_method=config.preprocessing.outlier_method, 114 portion=config.preprocessing.portion, 115 ) 116 117 # ── Preprocessing INTRA-ROI ────────────────────────────────── 118 if skip_preprocessing: 119 if not paths["intra_cleaned"].exists(): 120 logger.warning( 121 f"Preprocessed file not found, skipping: {paths['intra_cleaned']}" 122 ) 123 continue 124 logger.info(f"Using existing file: {paths['intra_cleaned'].name}") 125 else: 126 if paths["intra_cleaned"].exists() and paths["intra_masked"].exists(): 127 if if_exists == "skip": 128 logger.info( 129 f"Already preprocessed, skipping: {paths['intra_cleaned'].name}" 130 ) 131 pass # will fall through to feature extraction below 132 elif if_exists == "error": 133 logger.error( 134 f"Output exists (if_exists='error'): {paths['intra_cleaned'].name}" 135 ) 136 return [] 137 else: 138 try: 139 preproc = Preprocessor(**preproc_kwargs).run( 140 efield_path, roi_mask_path 141 ) 142 save_nifti(preproc.masked_img, paths["intra_masked"]) 143 save_nifti(preproc.cleaned_img, paths["intra_cleaned"]) 144 logger.info( 145 f"✓ Intra-ROI preprocessing: {paths['intra_cleaned'].name}" 146 ) 147 except Exception as e: 148 logger.error( 149 f"✗ Intra-ROI preprocessing failed ({efield_path.name}): {e}" 150 ) 151 continue 152 else: 153 try: 154 preproc = Preprocessor(**preproc_kwargs).run( 155 efield_path, roi_mask_path 156 ) 157 save_nifti(preproc.masked_img, paths["intra_masked"]) 158 save_nifti(preproc.cleaned_img, paths["intra_cleaned"]) 159 logger.info( 160 f"✓ Intra-ROI preprocessing: {paths['intra_cleaned'].name}" 161 ) 162 except Exception as e: 163 logger.error( 164 f"✗ Intra-ROI preprocessing failed ({efield_path.name}): {e}" 165 ) 166 continue 167 168 # ── Preprocessing EXTRA-ROI ────────────────────────────────── 169 if skip_preprocessing: 170 if not paths["extra_cleaned"].exists(): 171 logger.warning( 172 f"Extra preprocessed file not found, skipping: {paths['extra_cleaned']}" 173 ) 174 continue 175 else: 176 if paths["extra_cleaned"].exists() and paths["extra_masked"].exists(): 177 if if_exists == "skip": 178 logger.info( 179 f"Already preprocessed, skipping: {paths['extra_cleaned'].name}" 180 ) 181 pass 182 elif if_exists == "error": 183 logger.error( 184 f"Output exists (if_exists='error'): {paths['extra_cleaned'].name}" 185 ) 186 return [] 187 else: 188 try: 189 extra_mask = Preprocessor.build_extra_mask(roi_mask_path) 190 extra_masked_img = ( 191 Preprocessor(**preproc_kwargs) 192 .run(efield_path, extra_mask) 193 .masked_img 194 ) 195 save_nifti(extra_masked_img, paths["extra_masked"]) 196 save_nifti( 197 extra_masked_img, paths["extra_cleaned"] 198 ) # cleaned = masked 199 logger.info( 200 f"✓ Extra-ROI preprocessing: {paths['extra_masked'].name}" 201 ) 202 except Exception as e: 203 logger.error( 204 f"✗ Extra-ROI preprocessing failed ({efield_path.name}): {e}" 205 ) 206 continue 207 else: 208 try: 209 extra_mask = Preprocessor.build_extra_mask(roi_mask_path) 210 extra_masked_img = ( 211 Preprocessor(**preproc_kwargs) 212 .run(efield_path, extra_mask) 213 .masked_img 214 ) 215 save_nifti(extra_masked_img, paths["extra_masked"]) 216 save_nifti( 217 extra_masked_img, paths["extra_cleaned"] 218 ) # cleaned = masked 219 logger.info( 220 f"✓ Extra-ROI preprocessing: {paths['extra_masked'].name}" 221 ) 222 except Exception as e: 223 logger.error( 224 f"✗ Extra-ROI preprocessing failed ({efield_path.name}): {e}" 225 ) 226 continue 227 228 # ── Feature extraction ─────────────────────────────────────── 229 try: 230 row_intra = ( 231 FeatureExtractor() 232 .run( 233 paths["intra_cleaned"], 234 roi_path=None, 235 subject=subject, 236 condition=f"{condition}_{mode}", 237 ) 238 .row 239 ) 240 row_extra = ( 241 FeatureExtractor() 242 .run( 243 paths["extra_cleaned"], 244 roi_path=None, 245 subject=None, 246 condition=None, 247 ) 248 .row 249 ) 250 251 # Merge: intra columns without prefix, extra columns with extra_ prefix 252 row = {**row_intra} 253 for k in ["mean", "median", "std", "min", "max", "n_voxels"]: 254 if k in row_extra: 255 row[f"extra_{k}"] = row_extra[k] 256 row["space"] = space 257 258 # Ratio computed from cleaned values 259 intra_mean = row.get("mean", 0.0) 260 extra_mean = row.get("extra_mean", 1e-10) 261 row["efield_ratio_mean"] = intra_mean / max(float(extra_mean), 1e-10) 262 263 logger.info( 264 f"✓ Features : intra_mean={intra_mean:.6f} | " 265 f"extra n_voxels={row_extra.get('n_voxels','?')} mean={row_extra.get('mean', 'MISSING')!r} | " 266 f"extra_mean={extra_mean:.6e} | " 267 f"ratio={row['efield_ratio_mean']:.4f}" 268 ) 269 results.append(row) 270 except Exception as e: 271 logger.error( 272 f"✗ Feature extraction failed ({subject}/{condition}/{mode}): {e}" 273 ) 274 275 return results 276 277 278def run_analysis( 279 features_csv: Path, config: PipelineConfig, space: str, if_exists: str = "overwrite" 280) -> None: 281 """Inter/intra-subject analysis and simulation vs optimisation scatter plot.""" 282 logger.step("INTER/INTRA-SUBJECT ANALYSIS") 283 284 results_dir = config.paths.results_dir 285 analysis_dir = get_analysis_dir(results_dir, space) 286 analysis_dir.mkdir(parents=True, exist_ok=True) 287 288 metric = config.analysis.metric 289 subject_col = config.analysis.subject_col 290 condition_col = config.analysis.condition_col 291 292 df = pd.read_csv(features_csv) 293 logger.info(f"Loaded: {len(df)} rows from {features_csv}") 294 295 # Inter-subject 296 inter = Analysis(df).inter_subject_summary( 297 metric=metric, condition_col=condition_col 298 ) 299 inter_csv = get_inter_subject_summary_csv_path(results_dir, space) 300 if check_output(inter_csv, if_exists): 301 save_dataframe(inter, inter_csv, index=False) 302 logger.info(f"✓ Inter-subject summary: {inter_csv}") 303 304 # Intra-subject (simulation / optimization pairs) 305 conditions = set(df[condition_col].unique()) 306 for cond in config.stim_conditions: 307 sim_cond, opt_cond = f"{cond}_simulation", f"{cond}_optimization" 308 if sim_cond not in conditions or opt_cond not in conditions: 309 continue 310 df_cond = df[df[condition_col].isin([sim_cond, opt_cond])] 311 try: 312 diff_df = Analysis(df_cond).intra_subject_diff( 313 metric=metric, 314 subject_col=subject_col, 315 condition_col=condition_col, 316 cond_a=sim_cond, 317 cond_b=opt_cond, 318 ) 319 diff_csv = get_intra_subject_diff_csv_path(results_dir, space, cond) 320 if check_output(diff_csv, if_exists): 321 save_dataframe(diff_df, diff_csv, index=False) 322 logger.info(f"✓ Intra-subject diff: {diff_csv}") 323 except Exception as e: 324 logger.warning(f"Intra-subject analysis not possible for {cond}: {e}") 325 326 # Clustering 327 cl_params = config.analysis.clustering 328 cl_method = cl_params.method 329 cl_threshold = cl_params.specificity_threshold 330 cl_intensity_col = cl_params.intensity_col 331 ratio_col = f"efield_ratio_{cl_method}" 332 if ratio_col in df.columns: 333 try: 334 clustered_df = Analysis(df).assign_clusters( 335 method=cl_method, 336 specificity_threshold=cl_threshold, 337 intensity_col=cl_intensity_col, 338 ) 339 # clusters.csv retains all original columns (efield_path, subject, 340 # condition, stats…) + cluster — the link to e-fields is therefore direct. 341 clusters_csv = get_clusters_csv_path(results_dir, space) 342 if check_output(clusters_csv, if_exists): 343 save_dataframe(clustered_df, clusters_csv, index=False) 344 logger.info(f"✓ Clusters saved: {clusters_csv}") 345 dist = clustered_df["cluster"].value_counts().to_dict() 346 logger.info(f" Distribution: {dist}") 347 except Exception as e: 348 logger.warning(f"Clustering failed: {e}") 349 else: 350 logger.warning( 351 f"Column '{ratio_col}' not found in {features_csv.name} — clustering skipped. " 352 "Make sure compute_efield_ratio is called during feature extraction." 353 ) 354 355 viz_analysis = Visualizer(analysis_dir, if_exists=if_exists) 356 357 # Résumé par condition (fonctionne avec simulation seule ou les deux) 358 viz_analysis.plot_simulation_summary( 359 df, 360 metric=metric, 361 subject_col=subject_col, 362 condition_col=condition_col, 363 output_tag=space, 364 ) 365 logger.info("✓ Simulation summary figure created") 366 367 # Scatter simulation vs optimisation (seulement si les deux modes sont présents) 368 has_both_modes = ( 369 df[condition_col].str.contains("simulation").any() 370 and df[condition_col].str.contains("optimization").any() 371 ) 372 if has_both_modes: 373 viz_analysis.plot_simulation_vs_optimization( 374 df, 375 metric=metric, 376 subject_col=subject_col, 377 condition_col=condition_col, 378 output_tag=space, 379 ) 380 logger.info("✓ Simulation vs optimisation scatter plot created") 381 else: 382 logger.info("Simulation vs optimisation scatter skipped (single mode dataset)") 383 logger.step("ANALYSIS COMPLETE") 384 385 386def run_viz(config: PipelineConfig, space: str, if_exists: str = "overwrite") -> None: 387 """Collect paths (I/O) then generate all visualisations.""" 388 logger.step("GENERATING VISUALISATIONS") 389 390 from simnibs_analyze._pipeline_io import get_simu_root 391 392 simu_root = get_simu_root(config.paths) 393 results_dir = config.paths.results_dir 394 subjects: List[str] = config.subjects 395 conditions: List[str] = config.stim_conditions 396 modes: List[str] = config.mode 397 398 viz = Visualizer( 399 output_dir=results_dir, 400 cmap="hot", 401 threshold_percentile=50.0, 402 bins=50, 403 camera_position="xy", 404 if_exists=if_exists, 405 ) 406 407 # ── Masques ROI ───────────────────────────────────────────────────── 408 mni_target_dir = simu_root / "mni_target" 409 mask_paths = sorted(mni_target_dir.glob("*_mask_space-mni.nii.gz")) 410 if space == SPACE_MNI and mask_paths: 411 mni_template = ( 412 nib.load(str(config.paths.mni_template)) 413 if config.paths.mni_template 414 else None 415 ) 416 mask_imgs = [nib.load(str(p)) for p in mask_paths] 417 roi_names = [p.name.replace("_mask_space-mni.nii.gz", "") for p in mask_paths] 418 viz.visualize_roi_masks(mask_imgs, roi_names, mni_template) 419 logger.info("✓ ROI masks visualised") 420 421 # ── 3D e-field figures ──────────────────────────────────────────────────────── 422 # Brain backgrounds per subject (produced by AnatomicalPreparer.run()) 423 mni_brain_bg_by_subject: Dict[str, Path] = {} 424 subject_brain_bg_by_subject: Dict[str, Path] = {} 425 for subject in subjects: 426 subject_paths = get_subject_paths_from_config(config.paths, subject) 427 mni_bg = subject_paths["subject_target_dir"] / "T1_MNI_brain.nii.gz" 428 subj_bg = subject_paths["subject_target_dir"] / "T1_subject_brain.nii.gz" 429 if mni_bg.exists(): 430 mni_brain_bg_by_subject[subject] = mni_bg 431 if subj_bg.exists(): 432 subject_brain_bg_by_subject[subject] = subj_bg 433 434 file_info: Dict = {} 435 for mode in modes: 436 for condition in conditions: 437 for subject in subjects: 438 subject_paths = get_subject_paths_from_config(config.paths, subject) 439 sim_dirs = find_simulation_dirs( 440 subject_paths["subject_dir"], 441 condition, 442 mode, 443 folder_pattern=config.target_generation.rois[ 444 condition 445 ].folder_pattern, 446 ) 447 if not sim_dirs: 448 continue 449 efields = find_efield_files(sim_dirs[0], mode, space=space) 450 if not efields: 451 continue 452 file_info.setdefault((condition, mode), []).append( 453 (subject, efields[0]) 454 ) 455 if file_info: 456 brain_bgs = ( 457 mni_brain_bg_by_subject 458 if space == SPACE_MNI 459 else subject_brain_bg_by_subject 460 ) 461 viz.efields_figures( 462 file_info, t1_brain_by_subject=brain_bgs or None, space=space 463 ) 464 logger.info(f"✓ 3D e-field figures generated ({space.upper()})") 465 else: 466 logger.warning(f"No e-fields found for space={space}, figures skipped") 467 468 # ── Histogrammes preprocessing ─────────────────────────────────────── 469 intra_data: Dict = {} 470 extra_data: Dict = {} 471 for subject in subjects: 472 for mode in modes: 473 for condition in conditions: 474 subject_paths = get_subject_paths_from_config(config.paths, subject) 475 sim_dirs = find_simulation_dirs( 476 subject_paths["subject_dir"], 477 condition, 478 mode, 479 folder_pattern=config.target_generation.rois[ 480 condition 481 ].folder_pattern, 482 ) 483 if not sim_dirs: 484 continue 485 preproc_dir = get_preproc_dir(sim_dirs[0], mode, space=space) 486 for efield_path in find_efield_files(sim_dirs[0], mode, space=space): 487 base_name = efield_path.stem.replace(".nii", "") 488 p = get_preproc_paths(preproc_dir, base_name, condition) 489 if p["intra_masked"].exists() and p["intra_cleaned"].exists(): 490 intra_data.setdefault(subject, []).append( 491 (condition, mode, p["intra_masked"], p["intra_cleaned"]) 492 ) 493 if p["extra_masked"].exists() and p["extra_cleaned"].exists(): 494 extra_data.setdefault(subject, []).append( 495 (condition, mode, p["extra_masked"], p["extra_cleaned"]) 496 ) 497 if intra_data: 498 viz.efields_histograms(intra_data, region="intra", space=space) 499 logger.info("✓ Intra-ROI histograms generated") 500 if extra_data: 501 viz.efields_histograms(extra_data, region="extra", space=space) 502 logger.info("✓ Extra-ROI histograms generated") 503 logger.step("VISUALISATIONS COMPLETE") 504 505 506def main( 507 config_path: Path, 508 skip_target_generation: bool = False, 509 skip_preprocessing: bool = False, 510 skip_features: bool = False, 511 skip_analysis: bool = False, 512 skip_viz: bool = False, 513) -> int: 514 """Main pipeline entry point.""" 515 logger.step("STARTING E-FIELD ANALYSIS PIPELINE") 516 logger.info(f"Config: {config_path}") 517 518 config: PipelineConfig = load_config(config_path) 519 space = config.space 520 521 logger.info(f"Subjects : {config.subjects}") 522 logger.info(f"Conditions : {config.stim_conditions}") 523 logger.info(f"Modes : {config.mode}") 524 logger.info(f"Space : {space}") 525 526 results_dir = config.paths.results_dir 527 from simnibs_analyze._pipeline_io import get_simu_root 528 529 simu_root = get_simu_root(config.paths) 530 531 if_exists = config.running.if_exists 532 logger.info(f"if_exists = '{if_exists}'") 533 534 results_dir.mkdir(parents=True, exist_ok=True) 535 536 # ── Step 0: Setup targets (once, independent of subjects) ──────────── 537 rois = config.target_generation.rois 538 mni_target_dir = simu_root / "mni_target" 539 gen = AnatomicalPreparer( 540 reference_img_path=config.paths.mni_template, 541 radius_mm=config.target_generation.radius_mm, 542 mni_brain_mask_path=config.paths.mni_brain_mask, 543 ) 544 545 if not skip_target_generation: 546 logger.step("STEP 0: ROI MASK GENERATION (MNI)") 547 existing_masks = all( 548 (mni_target_dir / f"{roi}_mask_space-mni.nii.gz").exists() for roi in rois 549 ) 550 if existing_masks and if_exists == "skip": 551 logger.info(f"✓ ROI masks already present, skipping: {mni_target_dir}") 552 elif existing_masks and if_exists == "error": 553 logger.error( 554 f"ROI masks already present in {mni_target_dir} (if_exists='error')" 555 ) 556 return 1 557 else: 558 try: 559 gen.setup_mni_rois(rois, mni_target_dir, if_exists=if_exists) 560 except Exception as e: 561 logger.error(f"✗ Target generation failed: {e}") 562 return 1 563 else: 564 logger.info("ROI mask generation skipped") 565 566 # ── Steps 1+2: Preprocessing + Feature extraction ───────────────────────── 567 analysis_dir = get_analysis_dir(results_dir, space) 568 features_csv = get_features_csv_path(results_dir, space) 569 570 if skip_features: 571 if not features_csv.exists(): 572 logger.error( 573 f"all_features_{space_tag(space)}.csv not found: {features_csv}" 574 ) 575 return 1 576 logger.info(f"Feature extraction skipped — using {features_csv}") 577 else: 578 all_features: List[Dict] = [] 579 stats = {"total": 0, "success": 0, "failed": 0} 580 581 logger.info(f"Computing in {space.upper()} space") 582 583 for subject in config.subjects: 584 logger.step(f"SUBJECT: {subject}") 585 subject_paths = get_subject_paths_from_config(config.paths, subject) 586 m2m_dir = subject_paths["m2m_dir"] 587 subject_target_dir = subject_paths["subject_target_dir"] 588 589 if space == SPACE_NATIVE and skip_target_generation: 590 missing = [ 591 roi 592 for roi in rois 593 if not ( 594 subject_target_dir / f"{roi}_mask_space-native.nii.gz" 595 ).exists() 596 ] 597 if missing: 598 logger.warning( 599 f"Missing native-space ROI masks for {subject} ({missing}) with --skip-target-generation. " 600 "Subject skipped to avoid ambiguous outputs." 601 ) 602 continue 603 604 if not skip_target_generation and m2m_dir.exists(): 605 # Always generate T1 skull-stripped in both spaces 606 gen.run(m2m_dir, subject_target_dir, if_exists=if_exists) 607 608 # If working in native space, generate native-space ROI masks 609 if space == SPACE_NATIVE: 610 logger.info("Generating native-space ROI masks...") 611 try: 612 gen.create_subject_roi_from_mni( 613 m2m_dir, subject_target_dir, if_exists=if_exists 614 ) 615 logger.info(f"✓ Native-space ROI masks generated for {subject}") 616 except Exception as e: 617 logger.warning(f"Native-space ROI generation failed: {e}") 618 logger.warning( 619 f"Subject {subject} skipped to avoid mixing spaces" 620 ) 621 continue 622 elif space == SPACE_NATIVE and not m2m_dir.exists(): 623 logger.warning( 624 f"m2m not found for {subject}: {m2m_dir}. Subject skipped in native space." 625 ) 626 continue 627 628 for condition in config.stim_conditions: 629 for mode in config.mode: 630 logger.info(f"--- {subject} / {condition} / {mode} ---") 631 rows = process_subject_condition( 632 subject, 633 condition, 634 mode, 635 config, 636 skip_preprocessing=skip_preprocessing, 637 space=space, 638 if_exists=if_exists, 639 ) 640 stats["total"] += 1 641 if rows: 642 stats["success"] += 1 643 all_features.extend(rows) 644 else: 645 stats["failed"] += 1 646 647 if not all_features: 648 logger.warning("No features extracted!") 649 return 1 650 651 analysis_dir.mkdir(parents=True, exist_ok=True) 652 if features_csv.exists() and if_exists == "error": 653 logger.error(f"{features_csv.name} already exists (if_exists='error')") 654 return 1 655 save_rows(all_features, features_csv) 656 logger.info(f"✓ {len(all_features)} features saved → {features_csv}") 657 logger.info( 658 f" Successes: {stats['success']}/{stats['total']}, failures: {stats['failed']}" 659 ) 660 661 # ── Step 3: Analysis ────────────────────────────────────────────────── 662 if not skip_analysis: 663 run_analysis(features_csv, config, space=space, if_exists=if_exists) 664 else: 665 logger.info("Analysis skipped") 666 667 # ── Step 4: Visualisations ──────────────────────────────────────────────── 668 if not skip_viz: 669 run_viz(config, space=space, if_exists=if_exists) 670 else: 671 logger.info("Visualisations skipped") 672 673 logger.step("PIPELINE COMPLETE") 674 return 0 675 676 677def _parse_args(argv=None): 678 parser = argparse.ArgumentParser( 679 description="SimNIBS e-field analysis pipeline", 680 formatter_class=argparse.RawDescriptionHelpFormatter, 681 epilog=""" 682Examples: 683 python run.py # full run 684 python run.py --skip-preprocessing # reuse existing preprocessed files 685 python run.py --skip-preprocessing --skip-features # reuse all_features.csv 686 python run.py --skip-preprocessing --skip-features --skip-analysis # viz only 687 python run.py --config my_config.yaml 688 """, 689 ) 690 parser.add_argument( 691 "--config", type=Path, default=Path(__file__).parent / "config.yaml" 692 ) 693 parser.add_argument("--skip-target-generation", action="store_true") 694 parser.add_argument("--skip-preprocessing", action="store_true") 695 parser.add_argument("--skip-features", action="store_true") 696 parser.add_argument("--skip-analysis", action="store_true") 697 parser.add_argument("--skip-viz", action="store_true") 698 return parser.parse_args(argv) 699 700 701def cli(argv=None) -> None: 702 """CLI entry point: simnibs-analyze --config path/to/config.yaml""" 703 _args = _parse_args(argv) 704 raise SystemExit( 705 main( 706 config_path=_args.config, 707 skip_target_generation=_args.skip_target_generation, 708 skip_preprocessing=_args.skip_preprocessing, 709 skip_features=_args.skip_features, 710 skip_analysis=_args.skip_analysis, 711 skip_viz=_args.skip_viz, 712 ) 713 ) 714 715 716if __name__ == "__main__": 717 cli()
logger =
<simnibs_analyze._logging._PipelineLogger object>
def
process_subject_condition( subject: str, condition: str, mode: str, config: simnibs_analyze._config.PipelineConfig, skip_preprocessing: bool = False, space: str = 'mni', if_exists: str = 'overwrite') -> List[Dict]:
56def process_subject_condition( 57 subject: str, 58 condition: str, 59 mode: str, 60 config: PipelineConfig, 61 skip_preprocessing: bool = False, 62 space: str = SPACE_MNI, 63 if_exists: str = "overwrite", 64) -> List[Dict]: 65 """ 66 Preprocess and extract features from all e-fields for a subject/condition/mode. 67 68 Parameters 69 ---------- 70 space : str 71 ``'mni'`` (default) or ``'native'`` — working space for e-fields and ROI masks. 72 73 Returns 74 ------- 75 List[Dict] 76 Extracted feature rows (one per e-field file found). 77 """ 78 results: List[Dict] = [] 79 subject_paths = get_subject_paths_from_config(config.paths, subject) 80 subject_dir = subject_paths["subject_dir"] 81 82 if not subject_dir.exists(): 83 logger.warning(f"Subject directory not found: {subject_dir}") 84 return results 85 86 simulation_dirs = find_simulation_dirs( 87 subject_dir, 88 condition, 89 mode, 90 folder_pattern=config.target_generation.rois[condition].folder_pattern, 91 ) 92 if not simulation_dirs: 93 logger.warning(f"No simulation found for {subject}/{condition}/{mode}") 94 return results 95 96 try: 97 from simnibs_analyze._pipeline_io import get_simu_root 98 99 roi_mask_path = get_roi_mask_path( 100 get_simu_root(config.paths), condition, space=space, subject=subject 101 ) 102 except (FileNotFoundError, ValueError) as e: 103 logger.error(str(e)) 104 return results 105 106 for sim_dir in simulation_dirs: 107 for efield_path in find_efield_files(sim_dir, mode, space=space): 108 preproc_dir = get_preproc_dir(sim_dir, mode, space=space) 109 base_name = efield_path.stem.replace(".nii", "") 110 paths = get_preproc_paths(preproc_dir, base_name, condition) 111 112 preproc_kwargs = dict( 113 smooth_fwhm=config.preprocessing.smooth_fwhm, 114 outlier_method=config.preprocessing.outlier_method, 115 portion=config.preprocessing.portion, 116 ) 117 118 # ── Preprocessing INTRA-ROI ────────────────────────────────── 119 if skip_preprocessing: 120 if not paths["intra_cleaned"].exists(): 121 logger.warning( 122 f"Preprocessed file not found, skipping: {paths['intra_cleaned']}" 123 ) 124 continue 125 logger.info(f"Using existing file: {paths['intra_cleaned'].name}") 126 else: 127 if paths["intra_cleaned"].exists() and paths["intra_masked"].exists(): 128 if if_exists == "skip": 129 logger.info( 130 f"Already preprocessed, skipping: {paths['intra_cleaned'].name}" 131 ) 132 pass # will fall through to feature extraction below 133 elif if_exists == "error": 134 logger.error( 135 f"Output exists (if_exists='error'): {paths['intra_cleaned'].name}" 136 ) 137 return [] 138 else: 139 try: 140 preproc = Preprocessor(**preproc_kwargs).run( 141 efield_path, roi_mask_path 142 ) 143 save_nifti(preproc.masked_img, paths["intra_masked"]) 144 save_nifti(preproc.cleaned_img, paths["intra_cleaned"]) 145 logger.info( 146 f"✓ Intra-ROI preprocessing: {paths['intra_cleaned'].name}" 147 ) 148 except Exception as e: 149 logger.error( 150 f"✗ Intra-ROI preprocessing failed ({efield_path.name}): {e}" 151 ) 152 continue 153 else: 154 try: 155 preproc = Preprocessor(**preproc_kwargs).run( 156 efield_path, roi_mask_path 157 ) 158 save_nifti(preproc.masked_img, paths["intra_masked"]) 159 save_nifti(preproc.cleaned_img, paths["intra_cleaned"]) 160 logger.info( 161 f"✓ Intra-ROI preprocessing: {paths['intra_cleaned'].name}" 162 ) 163 except Exception as e: 164 logger.error( 165 f"✗ Intra-ROI preprocessing failed ({efield_path.name}): {e}" 166 ) 167 continue 168 169 # ── Preprocessing EXTRA-ROI ────────────────────────────────── 170 if skip_preprocessing: 171 if not paths["extra_cleaned"].exists(): 172 logger.warning( 173 f"Extra preprocessed file not found, skipping: {paths['extra_cleaned']}" 174 ) 175 continue 176 else: 177 if paths["extra_cleaned"].exists() and paths["extra_masked"].exists(): 178 if if_exists == "skip": 179 logger.info( 180 f"Already preprocessed, skipping: {paths['extra_cleaned'].name}" 181 ) 182 pass 183 elif if_exists == "error": 184 logger.error( 185 f"Output exists (if_exists='error'): {paths['extra_cleaned'].name}" 186 ) 187 return [] 188 else: 189 try: 190 extra_mask = Preprocessor.build_extra_mask(roi_mask_path) 191 extra_masked_img = ( 192 Preprocessor(**preproc_kwargs) 193 .run(efield_path, extra_mask) 194 .masked_img 195 ) 196 save_nifti(extra_masked_img, paths["extra_masked"]) 197 save_nifti( 198 extra_masked_img, paths["extra_cleaned"] 199 ) # cleaned = masked 200 logger.info( 201 f"✓ Extra-ROI preprocessing: {paths['extra_masked'].name}" 202 ) 203 except Exception as e: 204 logger.error( 205 f"✗ Extra-ROI preprocessing failed ({efield_path.name}): {e}" 206 ) 207 continue 208 else: 209 try: 210 extra_mask = Preprocessor.build_extra_mask(roi_mask_path) 211 extra_masked_img = ( 212 Preprocessor(**preproc_kwargs) 213 .run(efield_path, extra_mask) 214 .masked_img 215 ) 216 save_nifti(extra_masked_img, paths["extra_masked"]) 217 save_nifti( 218 extra_masked_img, paths["extra_cleaned"] 219 ) # cleaned = masked 220 logger.info( 221 f"✓ Extra-ROI preprocessing: {paths['extra_masked'].name}" 222 ) 223 except Exception as e: 224 logger.error( 225 f"✗ Extra-ROI preprocessing failed ({efield_path.name}): {e}" 226 ) 227 continue 228 229 # ── Feature extraction ─────────────────────────────────────── 230 try: 231 row_intra = ( 232 FeatureExtractor() 233 .run( 234 paths["intra_cleaned"], 235 roi_path=None, 236 subject=subject, 237 condition=f"{condition}_{mode}", 238 ) 239 .row 240 ) 241 row_extra = ( 242 FeatureExtractor() 243 .run( 244 paths["extra_cleaned"], 245 roi_path=None, 246 subject=None, 247 condition=None, 248 ) 249 .row 250 ) 251 252 # Merge: intra columns without prefix, extra columns with extra_ prefix 253 row = {**row_intra} 254 for k in ["mean", "median", "std", "min", "max", "n_voxels"]: 255 if k in row_extra: 256 row[f"extra_{k}"] = row_extra[k] 257 row["space"] = space 258 259 # Ratio computed from cleaned values 260 intra_mean = row.get("mean", 0.0) 261 extra_mean = row.get("extra_mean", 1e-10) 262 row["efield_ratio_mean"] = intra_mean / max(float(extra_mean), 1e-10) 263 264 logger.info( 265 f"✓ Features : intra_mean={intra_mean:.6f} | " 266 f"extra n_voxels={row_extra.get('n_voxels','?')} mean={row_extra.get('mean', 'MISSING')!r} | " 267 f"extra_mean={extra_mean:.6e} | " 268 f"ratio={row['efield_ratio_mean']:.4f}" 269 ) 270 results.append(row) 271 except Exception as e: 272 logger.error( 273 f"✗ Feature extraction failed ({subject}/{condition}/{mode}): {e}" 274 ) 275 276 return results
Preprocess and extract features from all e-fields for a subject/condition/mode.
Parameters
space : str
'mni' (default) or 'native' — working space for e-fields and ROI masks.
Returns
List[Dict] Extracted feature rows (one per e-field file found).
def
run_analysis( features_csv: pathlib.Path, config: simnibs_analyze._config.PipelineConfig, space: str, if_exists: str = 'overwrite') -> None:
279def run_analysis( 280 features_csv: Path, config: PipelineConfig, space: str, if_exists: str = "overwrite" 281) -> None: 282 """Inter/intra-subject analysis and simulation vs optimisation scatter plot.""" 283 logger.step("INTER/INTRA-SUBJECT ANALYSIS") 284 285 results_dir = config.paths.results_dir 286 analysis_dir = get_analysis_dir(results_dir, space) 287 analysis_dir.mkdir(parents=True, exist_ok=True) 288 289 metric = config.analysis.metric 290 subject_col = config.analysis.subject_col 291 condition_col = config.analysis.condition_col 292 293 df = pd.read_csv(features_csv) 294 logger.info(f"Loaded: {len(df)} rows from {features_csv}") 295 296 # Inter-subject 297 inter = Analysis(df).inter_subject_summary( 298 metric=metric, condition_col=condition_col 299 ) 300 inter_csv = get_inter_subject_summary_csv_path(results_dir, space) 301 if check_output(inter_csv, if_exists): 302 save_dataframe(inter, inter_csv, index=False) 303 logger.info(f"✓ Inter-subject summary: {inter_csv}") 304 305 # Intra-subject (simulation / optimization pairs) 306 conditions = set(df[condition_col].unique()) 307 for cond in config.stim_conditions: 308 sim_cond, opt_cond = f"{cond}_simulation", f"{cond}_optimization" 309 if sim_cond not in conditions or opt_cond not in conditions: 310 continue 311 df_cond = df[df[condition_col].isin([sim_cond, opt_cond])] 312 try: 313 diff_df = Analysis(df_cond).intra_subject_diff( 314 metric=metric, 315 subject_col=subject_col, 316 condition_col=condition_col, 317 cond_a=sim_cond, 318 cond_b=opt_cond, 319 ) 320 diff_csv = get_intra_subject_diff_csv_path(results_dir, space, cond) 321 if check_output(diff_csv, if_exists): 322 save_dataframe(diff_df, diff_csv, index=False) 323 logger.info(f"✓ Intra-subject diff: {diff_csv}") 324 except Exception as e: 325 logger.warning(f"Intra-subject analysis not possible for {cond}: {e}") 326 327 # Clustering 328 cl_params = config.analysis.clustering 329 cl_method = cl_params.method 330 cl_threshold = cl_params.specificity_threshold 331 cl_intensity_col = cl_params.intensity_col 332 ratio_col = f"efield_ratio_{cl_method}" 333 if ratio_col in df.columns: 334 try: 335 clustered_df = Analysis(df).assign_clusters( 336 method=cl_method, 337 specificity_threshold=cl_threshold, 338 intensity_col=cl_intensity_col, 339 ) 340 # clusters.csv retains all original columns (efield_path, subject, 341 # condition, stats…) + cluster — the link to e-fields is therefore direct. 342 clusters_csv = get_clusters_csv_path(results_dir, space) 343 if check_output(clusters_csv, if_exists): 344 save_dataframe(clustered_df, clusters_csv, index=False) 345 logger.info(f"✓ Clusters saved: {clusters_csv}") 346 dist = clustered_df["cluster"].value_counts().to_dict() 347 logger.info(f" Distribution: {dist}") 348 except Exception as e: 349 logger.warning(f"Clustering failed: {e}") 350 else: 351 logger.warning( 352 f"Column '{ratio_col}' not found in {features_csv.name} — clustering skipped. " 353 "Make sure compute_efield_ratio is called during feature extraction." 354 ) 355 356 viz_analysis = Visualizer(analysis_dir, if_exists=if_exists) 357 358 # Résumé par condition (fonctionne avec simulation seule ou les deux) 359 viz_analysis.plot_simulation_summary( 360 df, 361 metric=metric, 362 subject_col=subject_col, 363 condition_col=condition_col, 364 output_tag=space, 365 ) 366 logger.info("✓ Simulation summary figure created") 367 368 # Scatter simulation vs optimisation (seulement si les deux modes sont présents) 369 has_both_modes = ( 370 df[condition_col].str.contains("simulation").any() 371 and df[condition_col].str.contains("optimization").any() 372 ) 373 if has_both_modes: 374 viz_analysis.plot_simulation_vs_optimization( 375 df, 376 metric=metric, 377 subject_col=subject_col, 378 condition_col=condition_col, 379 output_tag=space, 380 ) 381 logger.info("✓ Simulation vs optimisation scatter plot created") 382 else: 383 logger.info("Simulation vs optimisation scatter skipped (single mode dataset)") 384 logger.step("ANALYSIS COMPLETE")
Inter/intra-subject analysis and simulation vs optimisation scatter plot.
def
run_viz( config: simnibs_analyze._config.PipelineConfig, space: str, if_exists: str = 'overwrite') -> None:
387def run_viz(config: PipelineConfig, space: str, if_exists: str = "overwrite") -> None: 388 """Collect paths (I/O) then generate all visualisations.""" 389 logger.step("GENERATING VISUALISATIONS") 390 391 from simnibs_analyze._pipeline_io import get_simu_root 392 393 simu_root = get_simu_root(config.paths) 394 results_dir = config.paths.results_dir 395 subjects: List[str] = config.subjects 396 conditions: List[str] = config.stim_conditions 397 modes: List[str] = config.mode 398 399 viz = Visualizer( 400 output_dir=results_dir, 401 cmap="hot", 402 threshold_percentile=50.0, 403 bins=50, 404 camera_position="xy", 405 if_exists=if_exists, 406 ) 407 408 # ── Masques ROI ───────────────────────────────────────────────────── 409 mni_target_dir = simu_root / "mni_target" 410 mask_paths = sorted(mni_target_dir.glob("*_mask_space-mni.nii.gz")) 411 if space == SPACE_MNI and mask_paths: 412 mni_template = ( 413 nib.load(str(config.paths.mni_template)) 414 if config.paths.mni_template 415 else None 416 ) 417 mask_imgs = [nib.load(str(p)) for p in mask_paths] 418 roi_names = [p.name.replace("_mask_space-mni.nii.gz", "") for p in mask_paths] 419 viz.visualize_roi_masks(mask_imgs, roi_names, mni_template) 420 logger.info("✓ ROI masks visualised") 421 422 # ── 3D e-field figures ──────────────────────────────────────────────────────── 423 # Brain backgrounds per subject (produced by AnatomicalPreparer.run()) 424 mni_brain_bg_by_subject: Dict[str, Path] = {} 425 subject_brain_bg_by_subject: Dict[str, Path] = {} 426 for subject in subjects: 427 subject_paths = get_subject_paths_from_config(config.paths, subject) 428 mni_bg = subject_paths["subject_target_dir"] / "T1_MNI_brain.nii.gz" 429 subj_bg = subject_paths["subject_target_dir"] / "T1_subject_brain.nii.gz" 430 if mni_bg.exists(): 431 mni_brain_bg_by_subject[subject] = mni_bg 432 if subj_bg.exists(): 433 subject_brain_bg_by_subject[subject] = subj_bg 434 435 file_info: Dict = {} 436 for mode in modes: 437 for condition in conditions: 438 for subject in subjects: 439 subject_paths = get_subject_paths_from_config(config.paths, subject) 440 sim_dirs = find_simulation_dirs( 441 subject_paths["subject_dir"], 442 condition, 443 mode, 444 folder_pattern=config.target_generation.rois[ 445 condition 446 ].folder_pattern, 447 ) 448 if not sim_dirs: 449 continue 450 efields = find_efield_files(sim_dirs[0], mode, space=space) 451 if not efields: 452 continue 453 file_info.setdefault((condition, mode), []).append( 454 (subject, efields[0]) 455 ) 456 if file_info: 457 brain_bgs = ( 458 mni_brain_bg_by_subject 459 if space == SPACE_MNI 460 else subject_brain_bg_by_subject 461 ) 462 viz.efields_figures( 463 file_info, t1_brain_by_subject=brain_bgs or None, space=space 464 ) 465 logger.info(f"✓ 3D e-field figures generated ({space.upper()})") 466 else: 467 logger.warning(f"No e-fields found for space={space}, figures skipped") 468 469 # ── Histogrammes preprocessing ─────────────────────────────────────── 470 intra_data: Dict = {} 471 extra_data: Dict = {} 472 for subject in subjects: 473 for mode in modes: 474 for condition in conditions: 475 subject_paths = get_subject_paths_from_config(config.paths, subject) 476 sim_dirs = find_simulation_dirs( 477 subject_paths["subject_dir"], 478 condition, 479 mode, 480 folder_pattern=config.target_generation.rois[ 481 condition 482 ].folder_pattern, 483 ) 484 if not sim_dirs: 485 continue 486 preproc_dir = get_preproc_dir(sim_dirs[0], mode, space=space) 487 for efield_path in find_efield_files(sim_dirs[0], mode, space=space): 488 base_name = efield_path.stem.replace(".nii", "") 489 p = get_preproc_paths(preproc_dir, base_name, condition) 490 if p["intra_masked"].exists() and p["intra_cleaned"].exists(): 491 intra_data.setdefault(subject, []).append( 492 (condition, mode, p["intra_masked"], p["intra_cleaned"]) 493 ) 494 if p["extra_masked"].exists() and p["extra_cleaned"].exists(): 495 extra_data.setdefault(subject, []).append( 496 (condition, mode, p["extra_masked"], p["extra_cleaned"]) 497 ) 498 if intra_data: 499 viz.efields_histograms(intra_data, region="intra", space=space) 500 logger.info("✓ Intra-ROI histograms generated") 501 if extra_data: 502 viz.efields_histograms(extra_data, region="extra", space=space) 503 logger.info("✓ Extra-ROI histograms generated") 504 logger.step("VISUALISATIONS COMPLETE")
Collect paths (I/O) then generate all visualisations.
def
main( config_path: pathlib.Path, skip_target_generation: bool = False, skip_preprocessing: bool = False, skip_features: bool = False, skip_analysis: bool = False, skip_viz: bool = False) -> int:
507def main( 508 config_path: Path, 509 skip_target_generation: bool = False, 510 skip_preprocessing: bool = False, 511 skip_features: bool = False, 512 skip_analysis: bool = False, 513 skip_viz: bool = False, 514) -> int: 515 """Main pipeline entry point.""" 516 logger.step("STARTING E-FIELD ANALYSIS PIPELINE") 517 logger.info(f"Config: {config_path}") 518 519 config: PipelineConfig = load_config(config_path) 520 space = config.space 521 522 logger.info(f"Subjects : {config.subjects}") 523 logger.info(f"Conditions : {config.stim_conditions}") 524 logger.info(f"Modes : {config.mode}") 525 logger.info(f"Space : {space}") 526 527 results_dir = config.paths.results_dir 528 from simnibs_analyze._pipeline_io import get_simu_root 529 530 simu_root = get_simu_root(config.paths) 531 532 if_exists = config.running.if_exists 533 logger.info(f"if_exists = '{if_exists}'") 534 535 results_dir.mkdir(parents=True, exist_ok=True) 536 537 # ── Step 0: Setup targets (once, independent of subjects) ──────────── 538 rois = config.target_generation.rois 539 mni_target_dir = simu_root / "mni_target" 540 gen = AnatomicalPreparer( 541 reference_img_path=config.paths.mni_template, 542 radius_mm=config.target_generation.radius_mm, 543 mni_brain_mask_path=config.paths.mni_brain_mask, 544 ) 545 546 if not skip_target_generation: 547 logger.step("STEP 0: ROI MASK GENERATION (MNI)") 548 existing_masks = all( 549 (mni_target_dir / f"{roi}_mask_space-mni.nii.gz").exists() for roi in rois 550 ) 551 if existing_masks and if_exists == "skip": 552 logger.info(f"✓ ROI masks already present, skipping: {mni_target_dir}") 553 elif existing_masks and if_exists == "error": 554 logger.error( 555 f"ROI masks already present in {mni_target_dir} (if_exists='error')" 556 ) 557 return 1 558 else: 559 try: 560 gen.setup_mni_rois(rois, mni_target_dir, if_exists=if_exists) 561 except Exception as e: 562 logger.error(f"✗ Target generation failed: {e}") 563 return 1 564 else: 565 logger.info("ROI mask generation skipped") 566 567 # ── Steps 1+2: Preprocessing + Feature extraction ───────────────────────── 568 analysis_dir = get_analysis_dir(results_dir, space) 569 features_csv = get_features_csv_path(results_dir, space) 570 571 if skip_features: 572 if not features_csv.exists(): 573 logger.error( 574 f"all_features_{space_tag(space)}.csv not found: {features_csv}" 575 ) 576 return 1 577 logger.info(f"Feature extraction skipped — using {features_csv}") 578 else: 579 all_features: List[Dict] = [] 580 stats = {"total": 0, "success": 0, "failed": 0} 581 582 logger.info(f"Computing in {space.upper()} space") 583 584 for subject in config.subjects: 585 logger.step(f"SUBJECT: {subject}") 586 subject_paths = get_subject_paths_from_config(config.paths, subject) 587 m2m_dir = subject_paths["m2m_dir"] 588 subject_target_dir = subject_paths["subject_target_dir"] 589 590 if space == SPACE_NATIVE and skip_target_generation: 591 missing = [ 592 roi 593 for roi in rois 594 if not ( 595 subject_target_dir / f"{roi}_mask_space-native.nii.gz" 596 ).exists() 597 ] 598 if missing: 599 logger.warning( 600 f"Missing native-space ROI masks for {subject} ({missing}) with --skip-target-generation. " 601 "Subject skipped to avoid ambiguous outputs." 602 ) 603 continue 604 605 if not skip_target_generation and m2m_dir.exists(): 606 # Always generate T1 skull-stripped in both spaces 607 gen.run(m2m_dir, subject_target_dir, if_exists=if_exists) 608 609 # If working in native space, generate native-space ROI masks 610 if space == SPACE_NATIVE: 611 logger.info("Generating native-space ROI masks...") 612 try: 613 gen.create_subject_roi_from_mni( 614 m2m_dir, subject_target_dir, if_exists=if_exists 615 ) 616 logger.info(f"✓ Native-space ROI masks generated for {subject}") 617 except Exception as e: 618 logger.warning(f"Native-space ROI generation failed: {e}") 619 logger.warning( 620 f"Subject {subject} skipped to avoid mixing spaces" 621 ) 622 continue 623 elif space == SPACE_NATIVE and not m2m_dir.exists(): 624 logger.warning( 625 f"m2m not found for {subject}: {m2m_dir}. Subject skipped in native space." 626 ) 627 continue 628 629 for condition in config.stim_conditions: 630 for mode in config.mode: 631 logger.info(f"--- {subject} / {condition} / {mode} ---") 632 rows = process_subject_condition( 633 subject, 634 condition, 635 mode, 636 config, 637 skip_preprocessing=skip_preprocessing, 638 space=space, 639 if_exists=if_exists, 640 ) 641 stats["total"] += 1 642 if rows: 643 stats["success"] += 1 644 all_features.extend(rows) 645 else: 646 stats["failed"] += 1 647 648 if not all_features: 649 logger.warning("No features extracted!") 650 return 1 651 652 analysis_dir.mkdir(parents=True, exist_ok=True) 653 if features_csv.exists() and if_exists == "error": 654 logger.error(f"{features_csv.name} already exists (if_exists='error')") 655 return 1 656 save_rows(all_features, features_csv) 657 logger.info(f"✓ {len(all_features)} features saved → {features_csv}") 658 logger.info( 659 f" Successes: {stats['success']}/{stats['total']}, failures: {stats['failed']}" 660 ) 661 662 # ── Step 3: Analysis ────────────────────────────────────────────────── 663 if not skip_analysis: 664 run_analysis(features_csv, config, space=space, if_exists=if_exists) 665 else: 666 logger.info("Analysis skipped") 667 668 # ── Step 4: Visualisations ──────────────────────────────────────────────── 669 if not skip_viz: 670 run_viz(config, space=space, if_exists=if_exists) 671 else: 672 logger.info("Visualisations skipped") 673 674 logger.step("PIPELINE COMPLETE") 675 return 0
Main pipeline entry point.
def
cli(argv=None) -> None:
702def cli(argv=None) -> None: 703 """CLI entry point: simnibs-analyze --config path/to/config.yaml""" 704 _args = _parse_args(argv) 705 raise SystemExit( 706 main( 707 config_path=_args.config, 708 skip_target_generation=_args.skip_target_generation, 709 skip_preprocessing=_args.skip_preprocessing, 710 skip_features=_args.skip_features, 711 skip_analysis=_args.skip_analysis, 712 skip_viz=_args.skip_viz, 713 ) 714 )
CLI entry point: simnibs-analyze --config path/to/config.yaml