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