Skip to content

Simulation (tit.sim)

Temporal Interference (TI) Simulation Module

This module provides a unified interface for running TI and mTI simulations. It automatically detects the simulation type based on montage configuration.

ConductivityType

Bases: Enum

SimNIBS conductivity type enumeration.

ElectrodeConfig dataclass

ElectrodeConfig(shape: str = 'ellipse', dimensions: List[float] = (lambda: [8.0, 8.0])(), thickness: float = 4.0, sponge_thickness: float = 2.0)

Configuration for electrode properties.

IntensityConfig dataclass

IntensityConfig(pair1: float = 1.0, pair2: float = 1.0, pair3: float = 1.0, pair4: float = 1.0)

Configuration for current intensities in TI simulations.

Each pair requires one intensity value (in mA). SimNIBS automatically applies equal and opposite currents to the two electrodes in each pair. For example: pair1=2.0 means electrode1=+2.0mA and electrode2=-2.0mA

TI mode (2 pairs): Uses pair1 and pair2 mTI mode (4 pairs): Uses pair1, pair2, pair3, and pair4

from_string classmethod

from_string(intensity_str: str) -> IntensityConfig

Parse intensity from string format.

Formats: - "2.0" -> all pairs: 2.0 mA - "2.0,1.5" -> pair1: 2.0, pair2: 1.5 (both set to 1.0 for pair3/pair4) - "2.0,1.5,1.0,0.5" -> pair1: 2.0, pair2: 1.5, pair3: 1.0, pair4: 0.5

Args: intensity_str: Comma-separated intensity values

Returns: IntensityConfig object

Source code in tit/sim/config.py
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
@classmethod
def from_string(cls, intensity_str: str) -> 'IntensityConfig':
    """
    Parse intensity from string format.

    Formats:
    - "2.0" -> all pairs: 2.0 mA
    - "2.0,1.5" -> pair1: 2.0, pair2: 1.5 (both set to 1.0 for pair3/pair4)
    - "2.0,1.5,1.0,0.5" -> pair1: 2.0, pair2: 1.5, pair3: 1.0, pair4: 0.5

    Args:
        intensity_str: Comma-separated intensity values

    Returns:
        IntensityConfig object
    """
    intensities = [float(x.strip()) for x in intensity_str.split(',')]

    if len(intensities) == 1:
        # Single value: use for all pairs
        val = intensities[0]
        return cls(val, val, val, val)
    elif len(intensities) == 2:
        # Two values: pair1, pair2 (TI mode)
        return cls(intensities[0], intensities[1], 1.0, 1.0)
    elif len(intensities) == 4:
        # Four values: all pairs specified (mTI mode)
        return cls(*intensities)
    else:
        raise ValueError(
            f"Invalid intensity format: {intensity_str}. "
            f"Expected 1, 2, or 4 comma-separated values."
        )

MontageConfig dataclass

MontageConfig(name: str, electrode_pairs: List[Tuple[Union[str, List[float]], Union[str, List[float]]]], is_xyz: bool = False, eeg_net: Optional[str] = None)

Configuration for a single montage.

num_pairs property

num_pairs: int

Get number of electrode pairs.

simulation_mode property

simulation_mode: SimulationMode

Determine simulation mode based on number of electrode pairs.

ParallelConfig dataclass

ParallelConfig(enabled: bool = False, max_workers: int = 0)

Configuration for parallel simulation execution.

effective_workers property

effective_workers: int

Get the effective number of workers.

__post_init__

__post_init__()

Set max_workers to sensible default if auto-detect.

Source code in tit/sim/config.py
117
118
119
120
121
122
123
def __post_init__(self):
    """Set max_workers to sensible default if auto-detect."""
    if self.max_workers <= 0:
        # Use half of available CPUs (simulations are memory-intensive)
        cpu_count = os.cpu_count() or 4
        # Limit to max 4 workers by default (memory constraint)
        self.max_workers = min(4, max(1, cpu_count // 2))

get_memory_warning

get_memory_warning() -> Optional[str]

Return memory warning if parallel execution may cause issues.

Source code in tit/sim/config.py
130
131
132
133
134
135
136
137
138
139
140
def get_memory_warning(self) -> Optional[str]:
    """Return memory warning if parallel execution may cause issues."""
    if not self.enabled:
        return None
    if self.max_workers > 2:
        return (
            f"⚠️ Running {self.max_workers} parallel simulations may require "
            f"significant memory (~4-8 GB per simulation). Consider reducing "
            f"workers if you experience memory issues."
        )
    return None

PostProcessor

PostProcessor(subject_id: str, conductivity_type: str, m2m_dir: str, logger)

Post-processor for TI simulation results.

Initialize post-processor.

Args: subject_id: Subject identifier conductivity_type: Conductivity type string m2m_dir: Path to m2m directory logger: Logger instance

Source code in tit/sim/post_processor.py
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
def __init__(
    self,
    subject_id: str,
    conductivity_type: str,
    m2m_dir: str,
    logger
):
    """
    Initialize post-processor.

    Args:
        subject_id: Subject identifier
        conductivity_type: Conductivity type string
        m2m_dir: Path to m2m directory
        logger: Logger instance
    """
    self.subject_id = subject_id
    self.conductivity_type = conductivity_type
    self.m2m_dir = m2m_dir
    self.logger = logger

    # Path to tools directory
    self.tools_dir = os.path.join(os.path.dirname(__file__), '..', 'tools')

process_mti_results

process_mti_results(hf_dir: str, ti_dir: str, mti_dir: str, mti_nifti_dir: str, hf_mesh_dir: str, hf_analysis_dir: str, documentation_dir: str, montage_name: str) -> str

Process 4-pair mTI simulation results with full pipeline.

Args: hf_dir: High-frequency output directory ti_dir: TI intermediate output directory mti_dir: mTI final output directory mti_nifti_dir: mTI NIfTI output directory hf_mesh_dir: High-frequency mesh output directory hf_analysis_dir: High-frequency analysis output directory documentation_dir: Documentation output directory montage_name: Montage name

Returns: Path to output mTI mesh file

Source code in tit/sim/post_processor.py
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
def process_mti_results(
    self,
    hf_dir: str,
    ti_dir: str,
    mti_dir: str,
    mti_nifti_dir: str,
    hf_mesh_dir: str,
    hf_analysis_dir: str,
    documentation_dir: str,
    montage_name: str
) -> str:
    """
    Process 4-pair mTI simulation results with full pipeline.

    Args:
        hf_dir: High-frequency output directory
        ti_dir: TI intermediate output directory
        mti_dir: mTI final output directory
        mti_nifti_dir: mTI NIfTI output directory
        hf_mesh_dir: High-frequency mesh output directory
        hf_analysis_dir: High-frequency analysis output directory
        documentation_dir: Documentation output directory
        montage_name: Montage name

    Returns:
        Path to output mTI mesh file
    """
    self.logger.info(f"Processing mTI results for {montage_name}")

    # Step 1: Load 4 HF meshes
    hf_meshes = []
    for i in range(1, 5):
        mesh_file = os.path.join(hf_dir, f"{self.subject_id}_TDCS_{i}_{self.conductivity_type}.msh")

        if not os.path.exists(mesh_file):
            raise FileNotFoundError(f"Mesh file not found: {mesh_file}")

        m = mesh_io.read_msh(mesh_file)
        tags_keep = np.hstack((np.arange(1, 100), np.arange(1001, 1100)))
        m = m.crop_mesh(tags=tags_keep)
        hf_meshes.append(m)

    # Step 2: Calculate TI pairs (AB and CD)
    ti_ab_vectors = get_TI_vectors(hf_meshes[0].field["E"].value, hf_meshes[1].field["E"].value)
    ti_cd_vectors = get_TI_vectors(hf_meshes[2].field["E"].value, hf_meshes[3].field["E"].value)

    # Step 3: Save intermediate TI meshes
    self._save_ti_intermediate(hf_meshes[0], ti_ab_vectors, ti_dir, f"{montage_name}_TI_AB.msh")
    self._save_ti_intermediate(hf_meshes[0], ti_cd_vectors, ti_dir, f"{montage_name}_TI_CD.msh")

    # Step 4: Calculate and save final mTI
    mti_field = TI.get_maxTI(ti_ab_vectors, ti_cd_vectors)
    mout = deepcopy(hf_meshes[0])
    mout.elmdata = []
    mout.add_element_field(mti_field, "TI_Max")

    mti_path = os.path.join(mti_dir, f"{montage_name}_mTI.msh")
    mesh_io.write_msh(mout, mti_path)
    mout.view(visible_tags=[1002, 1006], visible_fields="TI_Max").write_opt(mti_path)

    # Step 5: Extract GM/WM fields for mTI
    self.logger.info("Field extraction: Started")
    self._extract_fields(mti_path, mti_dir, f"{montage_name}_mTI")
    self.logger.info("Field extraction: ✓ Complete")

    # Step 6: Extract GM/WM fields for intermediate TI meshes
    ti_ab_path = os.path.join(ti_dir, f"{montage_name}_TI_AB.msh")
    ti_cd_path = os.path.join(ti_dir, f"{montage_name}_TI_CD.msh")
    if os.path.exists(ti_ab_path):
        self._extract_fields(ti_ab_path, ti_dir, f"{montage_name}_TI_AB")
    if os.path.exists(ti_cd_path):
        self._extract_fields(ti_cd_path, ti_dir, f"{montage_name}_TI_CD")

    # Step 7: Convert mTI meshes to NIfTI
    self.logger.info("NIfTI transformation: Started")
    self._transform_to_nifti(mti_dir, mti_nifti_dir)
    self.logger.info("NIfTI transformation: ✓ Complete")

    # Step 8: Organize HF files with mTI naming
    self._organize_mti_files(
        hf_dir=hf_dir,
        hf_mesh_dir=hf_mesh_dir,
        hf_analysis_dir=hf_analysis_dir,
        documentation_dir=documentation_dir
    )

    # Step 9: Convert T1 to MNI space
    self._convert_t1_to_mni()

    self.logger.info(f"Saved mTI mesh: {mti_path}")
    return mti_path

process_ti_results

process_ti_results(hf_dir: str, output_dir: str, nifti_dir: str, surface_overlays_dir: str, hf_mesh_dir: str, hf_nifti_dir: str, hf_analysis_dir: str, documentation_dir: str, montage_name: str) -> str

Process 2-pair TI simulation results with full pipeline.

Args: hf_dir: High-frequency output directory (SimNIBS writes here) output_dir: TI mesh output directory nifti_dir: TI NIfTI output directory surface_overlays_dir: Surface overlays output directory hf_mesh_dir: High-frequency mesh output directory hf_nifti_dir: High-frequency NIfTI output directory hf_analysis_dir: High-frequency analysis output directory documentation_dir: Documentation output directory montage_name: Montage name

Returns: Path to output TI mesh file

Source code in tit/sim/post_processor.py
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
def process_ti_results(
    self,
    hf_dir: str,
    output_dir: str,
    nifti_dir: str,
    surface_overlays_dir: str,
    hf_mesh_dir: str,
    hf_nifti_dir: str,
    hf_analysis_dir: str,
    documentation_dir: str,
    montage_name: str
) -> str:
    """
    Process 2-pair TI simulation results with full pipeline.

    Args:
        hf_dir: High-frequency output directory (SimNIBS writes here)
        output_dir: TI mesh output directory
        nifti_dir: TI NIfTI output directory
        surface_overlays_dir: Surface overlays output directory
        hf_mesh_dir: High-frequency mesh output directory
        hf_nifti_dir: High-frequency NIfTI output directory
        hf_analysis_dir: High-frequency analysis output directory
        documentation_dir: Documentation output directory
        montage_name: Montage name

    Returns:
        Path to output TI mesh file
    """
    self.logger.info(f"Processing TI results for {montage_name}")

    # Step 1: Calculate TI field
    ti_path = self._calculate_ti_field(hf_dir, output_dir, montage_name)

    # Step 2: Calculate TI normal (cortical surface)
    self._process_ti_normal(hf_dir, output_dir, montage_name)

    # Step 3: Extract GM/WM fields
    self.logger.info("Field extraction: Started")
    self._extract_fields(ti_path, output_dir, f"{montage_name}_TI")
    self.logger.info("Field extraction: ✓ Complete")

    # Step 4: Convert to NIfTI
    self.logger.info("NIfTI transformation: Started")
    self._transform_to_nifti(output_dir, nifti_dir)
    self.logger.info("NIfTI transformation: ✓ Complete")

    # Step 5: Organize files
    self._organize_ti_files(
        hf_dir=hf_dir,
        hf_mesh_dir=hf_mesh_dir,
        hf_nifti_dir=hf_nifti_dir,
        hf_analysis_dir=hf_analysis_dir,
        surface_overlays_dir=surface_overlays_dir,
        documentation_dir=documentation_dir
    )

    # Step 6: Convert T1 to MNI space
    self._convert_t1_to_mni()

    self.logger.info(f"Saved TI mesh: {ti_path}")
    return ti_path

SessionBuilder

SessionBuilder(config: SimulationConfig)

Builder class for constructing SimNIBS SESSION objects.

Initialize session builder.

Args: config: Simulation configuration

Source code in tit/sim/session_builder.py
19
20
21
22
23
24
25
26
27
28
29
30
31
32
def __init__(self, config: SimulationConfig):
    """
    Initialize session builder.

    Args:
        config: Simulation configuration
    """
    self.config = config
    self.pm = get_path_manager()

    # Setup paths
    self.m2m_dir = self.pm.path("m2m", subject_id=config.subject_id)
    self.mesh_file = os.path.join(self.m2m_dir, f"{config.subject_id}.msh")
    self.tensor_file = os.path.join(self.m2m_dir, "DTI_coregT1_tensor.nii.gz")

build_session

build_session(montage: MontageConfig, output_dir: str) -> sim_struct.SESSION

Build SimNIBS SESSION object for a montage.

Args: montage: Montage configuration output_dir: Output directory for simulation results

Returns: Configured SESSION object

Source code in tit/sim/session_builder.py
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
def build_session(
    self,
    montage: MontageConfig,
    output_dir: str
) -> sim_struct.SESSION:
    """
    Build SimNIBS SESSION object for a montage.

    Args:
        montage: Montage configuration
        output_dir: Output directory for simulation results

    Returns:
        Configured SESSION object
    """
    # Create base session
    S = sim_struct.SESSION()
    S.subpath = self.m2m_dir
    S.fnamehead = self.mesh_file
    S.anisotropy_type = self.config.conductivity_type.value
    S.pathfem = output_dir

    # Set EEG cap if using electrode names (not XYZ)
    if not montage.is_xyz:
        eeg_net = montage.eeg_net or self.config.eeg_net
        S.eeg_cap = os.path.join(self.pm.path("eeg_positions", subject_id=self.config.subject_id), eeg_net)

    # Mapping options
    S.map_to_surf = self.config.map_to_surf
    S.map_to_vol = self.config.map_to_vol
    S.map_to_mni = self.config.map_to_mni
    S.map_to_fsavg = self.config.map_to_fsavg
    S.open_in_gmsh = self.config.open_in_gmsh
    S.tissues_in_niftis = self.config.tissues_in_niftis

    # DTI tensor for anisotropic conductivity
    if os.path.exists(self.tensor_file):
        S.dti_nii = self.tensor_file

    # Add electrode pairs based on simulation mode
    if montage.simulation_mode == SimulationMode.TI:
        self._add_ti_pairs(S, montage)
    elif montage.simulation_mode == SimulationMode.MTI:
        self._add_mti_pairs(S, montage)

    return S

SimulationConfig dataclass

SimulationConfig(subject_id: str, project_dir: str, conductivity_type: ConductivityType, intensities: IntensityConfig, electrode: ElectrodeConfig, eeg_net: str = 'GSN-HydroCel-185.csv', map_to_surf: bool = True, map_to_vol: bool = True, map_to_mni: bool = True, map_to_fsavg: bool = False, tissues_in_niftis: str = 'all', open_in_gmsh: bool = False, parallel: ParallelConfig = ParallelConfig())

Main configuration for TI simulation.

__post_init__

__post_init__()

Convert string conductivity type to enum if needed.

Source code in tit/sim/config.py
160
161
162
163
164
165
def __post_init__(self):
    """Convert string conductivity type to enum if needed."""
    if isinstance(self.conductivity_type, str):
        self.conductivity_type = ConductivityType(self.conductivity_type)
    if isinstance(self.parallel, dict):
        self.parallel = ParallelConfig(**self.parallel)

SimulationMode

Bases: Enum

Simulation mode enumeration.

load_montages

load_montages(montage_names: List[str], project_dir: str, eeg_net: str, include_flex: bool = True) -> List[MontageConfig]

Load all montages (regular + flex).

Args: montage_names: List of montage names to load project_dir: Project directory path eeg_net: EEG net name include_flex: Whether to include flex montages

Returns: List of MontageConfig objects

Source code in tit/sim/montage_loader.py
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
def load_montages(
    montage_names: List[str],
    project_dir: str,
    eeg_net: str,
    include_flex: bool = True
) -> List[MontageConfig]:
    """
    Load all montages (regular + flex).

    Args:
        montage_names: List of montage names to load
        project_dir: Project directory path
        eeg_net: EEG net name
        include_flex: Whether to include flex montages

    Returns:
        List of MontageConfig objects
    """
    montages = []

    # Load regular montages
    net_montages = load_montage_file(project_dir, eeg_net)

    for name in montage_names:
        # Try multi_polar first, then uni_polar
        montage_data = net_montages.get('multi_polar_montages', {}).get(name)
        if not montage_data:
            montage_data = net_montages.get('uni_polar_montages', {}).get(name)

        if montage_data:
            # Determine if freehand mode (XYZ coordinates)
            is_xyz = eeg_net in ["freehand", "flex_mode"]

            montages.append(MontageConfig(
                name=name,
                electrode_pairs=montage_data,
                is_xyz=is_xyz,
                eeg_net=eeg_net
            ))

    # Load flex montages
    if include_flex:
        flex_montages = load_flex_montages()
        for flex_data in flex_montages:
            try:
                montages.append(parse_flex_montage(flex_data))
            except Exception as e:
                print(f"Warning: Failed to parse flex montage: {e}")

    return montages

run_montage_visualization

run_montage_visualization(montage_name: str, simulation_mode: SimulationMode, eeg_net: str, output_dir: str, project_dir: str, logger, electrode_pairs: Optional[List] = None) -> bool

Run montage visualization using visualize-montage.sh.

Args: montage_name: Name of the montage simulation_mode: TI or MTI mode eeg_net: EEG net name output_dir: Output directory for montage images project_dir: Project directory path logger: Logger instance electrode_pairs: Optional list of electrode pairs for direct visualization

Returns: True if successful, False otherwise

Source code in tit/sim/simulator.py
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
def run_montage_visualization(
    montage_name: str,
    simulation_mode: SimulationMode,
    eeg_net: str,
    output_dir: str,
    project_dir: str,
    logger,
    electrode_pairs: Optional[List] = None
) -> bool:
    """
    Run montage visualization using visualize-montage.sh.

    Args:
        montage_name: Name of the montage
        simulation_mode: TI or MTI mode
        eeg_net: EEG net name
        output_dir: Output directory for montage images
        project_dir: Project directory path
        logger: Logger instance
        electrode_pairs: Optional list of electrode pairs for direct visualization

    Returns:
        True if successful, False otherwise
    """
    # Skip visualization for freehand/flex modes with XYZ coordinates (no electrode_pairs provided)
    if eeg_net in ["freehand", "flex_mode"] and not electrode_pairs:
        logger.info(f"Skipping montage visualization for {eeg_net} mode")
        return True

    # Determine sim_mode string for visualize-montage.sh
    sim_mode_str = "U" if simulation_mode == SimulationMode.TI else "M"

    # Path to visualize-montage.sh
    script_dir = os.path.dirname(__file__)
    visualize_script = os.path.join(script_dir, "visualize-montage.sh")

    if not os.path.exists(visualize_script):
        logger.warning(f"Visualize montage script not found: {visualize_script}")
        return True  # Non-fatal, continue without visualization

    logger.info(f"Visualizing montage: {montage_name}")

    try:
        # Set PROJECT_DIR_NAME environment variable for the script
        env = os.environ.copy()
        project_dir_name = os.path.basename(project_dir)
        env['PROJECT_DIR_NAME'] = project_dir_name

        # Build command
        cmd = ['bash', visualize_script, sim_mode_str, eeg_net, output_dir]

        if electrode_pairs:
            # For flex montages with known electrode pairs, pass --pairs
            pairs_str = ",".join([f"{pair[0]}-{pair[1]}" for pair in electrode_pairs])
            pairs_arg = f"{montage_name}:{pairs_str}"
            cmd.extend(['--pairs', pairs_arg])
        else:
            # For standard montages, pass montage name
            cmd.insert(2, montage_name)  # Insert after script path and sim_mode

        result = subprocess.run(
            cmd,
            env=env,
            capture_output=True,
            text=True,
            timeout=120
        )

        if result.returncode != 0:
            logger.warning(f"Montage visualization returned non-zero: {result.stderr}")

        return result.returncode == 0

    except subprocess.TimeoutExpired:
        logger.warning(f"Montage visualization timed out for {montage_name}")
        return False
    except Exception as e:
        logger.warning(f"Montage visualization failed: {e}")
        return False

run_simulation

run_simulation(config: SimulationConfig, montages: List[MontageConfig], logger=None, progress_callback: Optional[Callable[[int, int, str], None]] = None) -> List[dict]

Run TI/mTI simulations for given montages.

Supports both sequential and parallel execution based on config.parallel settings.

Args: config: Simulation configuration montages: List of montage configurations logger: Optional logger instance progress_callback: Optional callback for progress updates (current, total, montage_name)

Returns: List of dictionaries with simulation results

Source code in tit/sim/simulator.py
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
def run_simulation(
    config: SimulationConfig,
    montages: List[MontageConfig],
    logger=None,
    progress_callback: Optional[Callable[[int, int, str], None]] = None
) -> List[dict]:
    """
    Run TI/mTI simulations for given montages.

    Supports both sequential and parallel execution based on config.parallel settings.

    Args:
        config: Simulation configuration
        montages: List of montage configurations
        logger: Optional logger instance
        progress_callback: Optional callback for progress updates (current, total, montage_name)

    Returns:
        List of dictionaries with simulation results
    """
    # Initialize logger if not provided
    if logger is None:
        import logging
        pm = get_path_manager()
        log_dir = pm.path("ti_logs", subject_id=config.subject_id)
        os.makedirs(log_dir, exist_ok=True)
        log_file = os.path.join(log_dir, f'Simulator_{time.strftime("%Y%m%d_%H%M%S")}.log')

        # Use file-only logger by default (no console output)
        # Console output should be controlled by the caller (GUI or CLI)
        logger = logging_util.get_file_only_logger('TI-Simulator', log_file, level=logging.DEBUG)

        # Configure external loggers to also use file-only logging
        for ext_name in ['simnibs', 'mesh_io', 'sim_struct', 'TI']:
            ext_logger = logging.getLogger(ext_name)
            ext_logger.setLevel(logging.DEBUG)
            ext_logger.propagate = False
            for handler in list(ext_logger.handlers):
                ext_logger.removeHandler(handler)
            file_handler = logging.FileHandler(log_file, mode='a')
            file_handler.setLevel(logging.DEBUG)
            file_handler.setFormatter(logging.Formatter('[%(asctime)s] [%(name)s] [%(levelname)s] %(message)s'))
            ext_logger.addHandler(file_handler)

    # Get simulation directory
    pm = get_path_manager()
    simulation_dir = pm.path("simulations", subject_id=config.subject_id)

    # Check if parallel execution is enabled and we have multiple montages
    use_parallel = (
        config.parallel.enabled and 
        len(montages) > 1 and 
        config.parallel.effective_workers > 1
    )

    if use_parallel:
        return _run_parallel(config, montages, simulation_dir, logger, progress_callback)
    else:
        return _run_sequential(config, montages, simulation_dir, logger, progress_callback)

setup_montage_directories

setup_montage_directories(montage_dir: str, simulation_mode: SimulationMode) -> dict

Create the complete directory structure for a montage simulation.

Args: montage_dir: Base montage directory simulation_mode: TI or MTI simulation mode

Returns: Dictionary of created directory paths

Source code in tit/sim/simulator.py
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
def setup_montage_directories(montage_dir: str, simulation_mode: SimulationMode) -> dict:
    """
    Create the complete directory structure for a montage simulation.

    Args:
        montage_dir: Base montage directory
        simulation_mode: TI or MTI simulation mode

    Returns:
        Dictionary of created directory paths
    """
    dirs = {
        'montage_dir': montage_dir,
        'hf_dir': os.path.join(montage_dir, "high_Frequency"),
        'hf_mesh': os.path.join(montage_dir, "high_Frequency", "mesh"),
        'hf_niftis': os.path.join(montage_dir, "high_Frequency", "niftis"),
        'hf_analysis': os.path.join(montage_dir, "high_Frequency", "analysis"),
        'ti_mesh': os.path.join(montage_dir, "TI", "mesh"),
        'ti_niftis': os.path.join(montage_dir, "TI", "niftis"),
        'ti_surface_overlays': os.path.join(montage_dir, "TI", "surface_overlays"),
        'ti_montage_imgs': os.path.join(montage_dir, "TI", "montage_imgs"),
        'documentation': os.path.join(montage_dir, "documentation"),
    }

    # Add mTI directories for multipolar mode
    if simulation_mode == SimulationMode.MTI:
        dirs['mti_mesh'] = os.path.join(montage_dir, "mTI", "mesh")
        dirs['mti_niftis'] = os.path.join(montage_dir, "mTI", "niftis")
        dirs['mti_montage_imgs'] = os.path.join(montage_dir, "mTI", "montage_imgs")

    # Create all directories
    for path in dirs.values():
        os.makedirs(path, exist_ok=True)

    return dirs

Config (tit.sim.config)

Configuration dataclasses for TI simulations.

ConductivityType

Bases: Enum

SimNIBS conductivity type enumeration.

ElectrodeConfig dataclass

ElectrodeConfig(shape: str = 'ellipse', dimensions: List[float] = (lambda: [8.0, 8.0])(), thickness: float = 4.0, sponge_thickness: float = 2.0)

Configuration for electrode properties.

IntensityConfig dataclass

IntensityConfig(pair1: float = 1.0, pair2: float = 1.0, pair3: float = 1.0, pair4: float = 1.0)

Configuration for current intensities in TI simulations.

Each pair requires one intensity value (in mA). SimNIBS automatically applies equal and opposite currents to the two electrodes in each pair. For example: pair1=2.0 means electrode1=+2.0mA and electrode2=-2.0mA

TI mode (2 pairs): Uses pair1 and pair2 mTI mode (4 pairs): Uses pair1, pair2, pair3, and pair4

from_string classmethod

from_string(intensity_str: str) -> IntensityConfig

Parse intensity from string format.

Formats: - "2.0" -> all pairs: 2.0 mA - "2.0,1.5" -> pair1: 2.0, pair2: 1.5 (both set to 1.0 for pair3/pair4) - "2.0,1.5,1.0,0.5" -> pair1: 2.0, pair2: 1.5, pair3: 1.0, pair4: 0.5

Args: intensity_str: Comma-separated intensity values

Returns: IntensityConfig object

Source code in tit/sim/config.py
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
@classmethod
def from_string(cls, intensity_str: str) -> 'IntensityConfig':
    """
    Parse intensity from string format.

    Formats:
    - "2.0" -> all pairs: 2.0 mA
    - "2.0,1.5" -> pair1: 2.0, pair2: 1.5 (both set to 1.0 for pair3/pair4)
    - "2.0,1.5,1.0,0.5" -> pair1: 2.0, pair2: 1.5, pair3: 1.0, pair4: 0.5

    Args:
        intensity_str: Comma-separated intensity values

    Returns:
        IntensityConfig object
    """
    intensities = [float(x.strip()) for x in intensity_str.split(',')]

    if len(intensities) == 1:
        # Single value: use for all pairs
        val = intensities[0]
        return cls(val, val, val, val)
    elif len(intensities) == 2:
        # Two values: pair1, pair2 (TI mode)
        return cls(intensities[0], intensities[1], 1.0, 1.0)
    elif len(intensities) == 4:
        # Four values: all pairs specified (mTI mode)
        return cls(*intensities)
    else:
        raise ValueError(
            f"Invalid intensity format: {intensity_str}. "
            f"Expected 1, 2, or 4 comma-separated values."
        )

MontageConfig dataclass

MontageConfig(name: str, electrode_pairs: List[Tuple[Union[str, List[float]], Union[str, List[float]]]], is_xyz: bool = False, eeg_net: Optional[str] = None)

Configuration for a single montage.

num_pairs property

num_pairs: int

Get number of electrode pairs.

simulation_mode property

simulation_mode: SimulationMode

Determine simulation mode based on number of electrode pairs.

ParallelConfig dataclass

ParallelConfig(enabled: bool = False, max_workers: int = 0)

Configuration for parallel simulation execution.

effective_workers property

effective_workers: int

Get the effective number of workers.

__post_init__

__post_init__()

Set max_workers to sensible default if auto-detect.

Source code in tit/sim/config.py
117
118
119
120
121
122
123
def __post_init__(self):
    """Set max_workers to sensible default if auto-detect."""
    if self.max_workers <= 0:
        # Use half of available CPUs (simulations are memory-intensive)
        cpu_count = os.cpu_count() or 4
        # Limit to max 4 workers by default (memory constraint)
        self.max_workers = min(4, max(1, cpu_count // 2))

get_memory_warning

get_memory_warning() -> Optional[str]

Return memory warning if parallel execution may cause issues.

Source code in tit/sim/config.py
130
131
132
133
134
135
136
137
138
139
140
def get_memory_warning(self) -> Optional[str]:
    """Return memory warning if parallel execution may cause issues."""
    if not self.enabled:
        return None
    if self.max_workers > 2:
        return (
            f"⚠️ Running {self.max_workers} parallel simulations may require "
            f"significant memory (~4-8 GB per simulation). Consider reducing "
            f"workers if you experience memory issues."
        )
    return None

SimulationConfig dataclass

SimulationConfig(subject_id: str, project_dir: str, conductivity_type: ConductivityType, intensities: IntensityConfig, electrode: ElectrodeConfig, eeg_net: str = 'GSN-HydroCel-185.csv', map_to_surf: bool = True, map_to_vol: bool = True, map_to_mni: bool = True, map_to_fsavg: bool = False, tissues_in_niftis: str = 'all', open_in_gmsh: bool = False, parallel: ParallelConfig = ParallelConfig())

Main configuration for TI simulation.

__post_init__

__post_init__()

Convert string conductivity type to enum if needed.

Source code in tit/sim/config.py
160
161
162
163
164
165
def __post_init__(self):
    """Convert string conductivity type to enum if needed."""
    if isinstance(self.conductivity_type, str):
        self.conductivity_type = ConductivityType(self.conductivity_type)
    if isinstance(self.parallel, dict):
        self.parallel = ParallelConfig(**self.parallel)

SimulationMode

Bases: Enum

Simulation mode enumeration.

Simulator (tit.sim.simulator)

Unified TI/mTI simulation runner.

This module provides a single entry point for running both TI (2-pair) and mTI (4-pair) simulations. The simulation type is automatically detected based on montage configuration.

This refactored module includes all features from the original pipeline: - Montage visualization - SimNIBS simulation - Field extraction (GM/WM) - NIfTI transformation - T1 to MNI conversion - File organization

Parallel execution support: - Multiple montages can be simulated in parallel - Worker count is configurable (default: half of CPU cores)

create_simulation_config_file

create_simulation_config_file(config: SimulationConfig, montage: MontageConfig, documentation_dir: str, logger) -> str

Create a config.json file with all simulation parameters.

This file is used by visualization tools to auto-populate parameters without requiring manual input.

Args: config: Simulation configuration montage: Montage configuration documentation_dir: Documentation directory path logger: Logger instance

Returns: Path to created config.json file

Source code in tit/sim/simulator.py
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
def create_simulation_config_file(
    config: SimulationConfig,
    montage: MontageConfig,
    documentation_dir: str,
    logger
) -> str:
    """
    Create a config.json file with all simulation parameters.

    This file is used by visualization tools to auto-populate parameters
    without requiring manual input.

    Args:
        config: Simulation configuration
        montage: Montage configuration
        documentation_dir: Documentation directory path
        logger: Logger instance

    Returns:
        Path to created config.json file
    """
    config_file = os.path.join(documentation_dir, "config.json")

    # Build configuration dictionary
    sim_config = {
        "subject_id": config.subject_id,
        "simulation_name": montage.name,
        "simulation_mode": montage.simulation_mode.value,
        "eeg_net": montage.eeg_net or config.eeg_net,
        "conductivity_type": config.conductivity_type.value,
        "electrode_pairs": montage.electrode_pairs,
        "is_xyz_montage": montage.is_xyz,
        "intensities": {
            "pair1": config.intensities.pair1,
            "pair2": config.intensities.pair2,
            "pair3": config.intensities.pair3,
            "pair4": config.intensities.pair4
        },
        "electrode_geometry": {
            "shape": config.electrode.shape,
            "dimensions": config.electrode.dimensions,
            "gel_thickness": config.electrode.thickness,
            "sponge_thickness": config.electrode.sponge_thickness
        },
        "mapping_options": {
            "map_to_surf": config.map_to_surf,
            "map_to_vol": config.map_to_vol,
            "map_to_mni": config.map_to_mni,
            "map_to_fsavg": config.map_to_fsavg
        },
        "created_at": datetime.now().isoformat(),
        "ti_toolbox_version": "2.0.0"  # TODO: Get from version.py
    }

    # Write config file
    try:
        with open(config_file, 'w') as f:
            json.dump(sim_config, f, indent=2)
        logger.info(f"Created simulation config: {config_file}")
    except Exception as e:
        logger.warning(f"Failed to create config file: {e}")

    return config_file

main

main()

Command-line entry point for the simulator.

Parses command-line arguments, loads montages, runs simulations, and generates a completion report. This function is called when the module is executed directly or via the CLI wrapper script.

Command-line Arguments
  1. subject_id : str Subject identifier (e.g., '001')
  2. conductivity_str : str Conductivity type ('scalar', 'vn', 'dir', 'mc')
  3. project_dir : str Project directory path
  4. simulation_dir : str Simulation output directory (legacy, not used)
  5. mode : str Simulation mode (legacy, not used - auto-detected)
  6. intensity_str : str Current intensity string (e.g., '2.0' or '2.0,1.5' or '2.0,1.5,1.0,0.5')
  7. electrode_shape : str Electrode shape ('ellipse' or 'rect')
  8. dimensions : str Electrode dimensions as 'x,y' in mm (e.g., '8.0,8.0')
  9. thickness : float Gel thickness in mm
  10. eeg_net : str EEG cap name (e.g., 'EGI_template.csv') 11+. montage_names : str One or more montage names to simulate
Exit Codes

0 : All simulations completed successfully 1 : One or more simulations failed

Output

Creates simulation_completion__.json in derivatives/temp/ with detailed results of all simulations.

Examples:

>>> python simulator.py 001 scalar /path/to/project /tmp TI 2.0 ellipse 8.0,8.0 4.0 EGI_template.csv montage1 montage2
Source code in tit/sim/simulator.py
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
def main():
    """
    Command-line entry point for the simulator.

    Parses command-line arguments, loads montages, runs simulations, and generates
    a completion report. This function is called when the module is executed directly
    or via the CLI wrapper script.

    Command-line Arguments
    ----------------------
    1. subject_id : str
        Subject identifier (e.g., '001')
    2. conductivity_str : str
        Conductivity type ('scalar', 'vn', 'dir', 'mc')
    3. project_dir : str
        Project directory path
    4. simulation_dir : str
        Simulation output directory (legacy, not used)
    5. mode : str
        Simulation mode (legacy, not used - auto-detected)
    6. intensity_str : str
        Current intensity string (e.g., '2.0' or '2.0,1.5' or '2.0,1.5,1.0,0.5')
    7. electrode_shape : str
        Electrode shape ('ellipse' or 'rect')
    8. dimensions : str
        Electrode dimensions as 'x,y' in mm (e.g., '8.0,8.0')
    9. thickness : float
        Gel thickness in mm
    10. eeg_net : str
        EEG cap name (e.g., 'EGI_template.csv')
    11+. montage_names : str
        One or more montage names to simulate

    Exit Codes
    ----------
    0 : All simulations completed successfully
    1 : One or more simulations failed

    Output
    ------
    Creates simulation_completion_<subject_id>_<timestamp>.json in derivatives/temp/
    with detailed results of all simulations.

    Examples
    --------
    >>> python simulator.py 001 scalar /path/to/project /tmp TI 2.0 ellipse 8.0,8.0 4.0 EGI_template.csv montage1 montage2
    """
    if len(sys.argv) < 11:
        print("Usage: simulator.py SUBJECT_ID CONDUCTIVITY PROJECT_DIR SIMULATION_DIR MODE "
              "INTENSITY ELECTRODE_SHAPE DIMENSIONS THICKNESS EEG_NET MONTAGE_NAMES...")
        sys.exit(1)

    # Parse command-line arguments
    subject_id = sys.argv[1]
    conductivity_str = sys.argv[2]
    project_dir = sys.argv[3]
    simulation_dir = sys.argv[4]
    mode = sys.argv[5]  # Kept for backward compatibility but not used
    intensity_str = sys.argv[6]
    electrode_shape = sys.argv[7]
    dimensions = [float(x) for x in sys.argv[8].split(',')]
    thickness = float(sys.argv[9])
    eeg_net = sys.argv[10]
    montage_names = sys.argv[11:]

    # Filter out flags
    montage_names = [name for name in montage_names if not name.startswith('--')]

    # Build configuration
    config = SimulationConfig(
        subject_id=subject_id,
        project_dir=project_dir,
        conductivity_type=ConductivityType(conductivity_str),
        intensities=IntensityConfig.from_string(intensity_str),
        electrode=ElectrodeConfig(
            shape=electrode_shape,
            dimensions=dimensions,
            thickness=thickness
        ),
        eeg_net=eeg_net
    )

    # Load montages
    montages = load_montages(
        montage_names=montage_names,
        project_dir=project_dir,
        eeg_net=eeg_net,
        include_flex=True
    )

    # Run simulations
    results = run_simulation(config, montages)

    # Generate completion report
    pm = get_path_manager()
    derivatives_dir = pm.path("derivatives")

    report = {
        'session_id': os.environ.get('SIMULATION_SESSION_ID', 'unknown'),
        'subject_id': subject_id,
        'project_dir': project_dir,
        'simulation_dir': simulation_dir,
        'completed_simulations': [r for r in results if r['status'] == 'completed'],
        'failed_simulations': [r for r in results if r['status'] == 'failed'],
        'timestamp': datetime.now().isoformat(),
        'total_simulations': len(results),
        'success_count': len([r for r in results if r['status'] == 'completed']),
        'error_count': len([r for r in results if r['status'] == 'failed'])
    }

    report_file = os.path.join(derivatives_dir, 'temp', f'simulation_completion_{subject_id}_{int(time.time())}.json')
    os.makedirs(os.path.dirname(report_file), exist_ok=True)
    with open(report_file, 'w') as f:
        json.dump(report, f, indent=2)

    print(f"Completed {report['success_count']}/{report['total_simulations']} simulations")

    # Exit with error if any simulations failed
    if report['error_count'] > 0:
        sys.exit(1)

run_montage_visualization

run_montage_visualization(montage_name: str, simulation_mode: SimulationMode, eeg_net: str, output_dir: str, project_dir: str, logger, electrode_pairs: Optional[List] = None) -> bool

Run montage visualization using visualize-montage.sh.

Args: montage_name: Name of the montage simulation_mode: TI or MTI mode eeg_net: EEG net name output_dir: Output directory for montage images project_dir: Project directory path logger: Logger instance electrode_pairs: Optional list of electrode pairs for direct visualization

Returns: True if successful, False otherwise

Source code in tit/sim/simulator.py
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
def run_montage_visualization(
    montage_name: str,
    simulation_mode: SimulationMode,
    eeg_net: str,
    output_dir: str,
    project_dir: str,
    logger,
    electrode_pairs: Optional[List] = None
) -> bool:
    """
    Run montage visualization using visualize-montage.sh.

    Args:
        montage_name: Name of the montage
        simulation_mode: TI or MTI mode
        eeg_net: EEG net name
        output_dir: Output directory for montage images
        project_dir: Project directory path
        logger: Logger instance
        electrode_pairs: Optional list of electrode pairs for direct visualization

    Returns:
        True if successful, False otherwise
    """
    # Skip visualization for freehand/flex modes with XYZ coordinates (no electrode_pairs provided)
    if eeg_net in ["freehand", "flex_mode"] and not electrode_pairs:
        logger.info(f"Skipping montage visualization for {eeg_net} mode")
        return True

    # Determine sim_mode string for visualize-montage.sh
    sim_mode_str = "U" if simulation_mode == SimulationMode.TI else "M"

    # Path to visualize-montage.sh
    script_dir = os.path.dirname(__file__)
    visualize_script = os.path.join(script_dir, "visualize-montage.sh")

    if not os.path.exists(visualize_script):
        logger.warning(f"Visualize montage script not found: {visualize_script}")
        return True  # Non-fatal, continue without visualization

    logger.info(f"Visualizing montage: {montage_name}")

    try:
        # Set PROJECT_DIR_NAME environment variable for the script
        env = os.environ.copy()
        project_dir_name = os.path.basename(project_dir)
        env['PROJECT_DIR_NAME'] = project_dir_name

        # Build command
        cmd = ['bash', visualize_script, sim_mode_str, eeg_net, output_dir]

        if electrode_pairs:
            # For flex montages with known electrode pairs, pass --pairs
            pairs_str = ",".join([f"{pair[0]}-{pair[1]}" for pair in electrode_pairs])
            pairs_arg = f"{montage_name}:{pairs_str}"
            cmd.extend(['--pairs', pairs_arg])
        else:
            # For standard montages, pass montage name
            cmd.insert(2, montage_name)  # Insert after script path and sim_mode

        result = subprocess.run(
            cmd,
            env=env,
            capture_output=True,
            text=True,
            timeout=120
        )

        if result.returncode != 0:
            logger.warning(f"Montage visualization returned non-zero: {result.stderr}")

        return result.returncode == 0

    except subprocess.TimeoutExpired:
        logger.warning(f"Montage visualization timed out for {montage_name}")
        return False
    except Exception as e:
        logger.warning(f"Montage visualization failed: {e}")
        return False

run_simulation

run_simulation(config: SimulationConfig, montages: List[MontageConfig], logger=None, progress_callback: Optional[Callable[[int, int, str], None]] = None) -> List[dict]

Run TI/mTI simulations for given montages.

Supports both sequential and parallel execution based on config.parallel settings.

Args: config: Simulation configuration montages: List of montage configurations logger: Optional logger instance progress_callback: Optional callback for progress updates (current, total, montage_name)

Returns: List of dictionaries with simulation results

Source code in tit/sim/simulator.py
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
def run_simulation(
    config: SimulationConfig,
    montages: List[MontageConfig],
    logger=None,
    progress_callback: Optional[Callable[[int, int, str], None]] = None
) -> List[dict]:
    """
    Run TI/mTI simulations for given montages.

    Supports both sequential and parallel execution based on config.parallel settings.

    Args:
        config: Simulation configuration
        montages: List of montage configurations
        logger: Optional logger instance
        progress_callback: Optional callback for progress updates (current, total, montage_name)

    Returns:
        List of dictionaries with simulation results
    """
    # Initialize logger if not provided
    if logger is None:
        import logging
        pm = get_path_manager()
        log_dir = pm.path("ti_logs", subject_id=config.subject_id)
        os.makedirs(log_dir, exist_ok=True)
        log_file = os.path.join(log_dir, f'Simulator_{time.strftime("%Y%m%d_%H%M%S")}.log')

        # Use file-only logger by default (no console output)
        # Console output should be controlled by the caller (GUI or CLI)
        logger = logging_util.get_file_only_logger('TI-Simulator', log_file, level=logging.DEBUG)

        # Configure external loggers to also use file-only logging
        for ext_name in ['simnibs', 'mesh_io', 'sim_struct', 'TI']:
            ext_logger = logging.getLogger(ext_name)
            ext_logger.setLevel(logging.DEBUG)
            ext_logger.propagate = False
            for handler in list(ext_logger.handlers):
                ext_logger.removeHandler(handler)
            file_handler = logging.FileHandler(log_file, mode='a')
            file_handler.setLevel(logging.DEBUG)
            file_handler.setFormatter(logging.Formatter('[%(asctime)s] [%(name)s] [%(levelname)s] %(message)s'))
            ext_logger.addHandler(file_handler)

    # Get simulation directory
    pm = get_path_manager()
    simulation_dir = pm.path("simulations", subject_id=config.subject_id)

    # Check if parallel execution is enabled and we have multiple montages
    use_parallel = (
        config.parallel.enabled and 
        len(montages) > 1 and 
        config.parallel.effective_workers > 1
    )

    if use_parallel:
        return _run_parallel(config, montages, simulation_dir, logger, progress_callback)
    else:
        return _run_sequential(config, montages, simulation_dir, logger, progress_callback)

setup_montage_directories

setup_montage_directories(montage_dir: str, simulation_mode: SimulationMode) -> dict

Create the complete directory structure for a montage simulation.

Args: montage_dir: Base montage directory simulation_mode: TI or MTI simulation mode

Returns: Dictionary of created directory paths

Source code in tit/sim/simulator.py
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
def setup_montage_directories(montage_dir: str, simulation_mode: SimulationMode) -> dict:
    """
    Create the complete directory structure for a montage simulation.

    Args:
        montage_dir: Base montage directory
        simulation_mode: TI or MTI simulation mode

    Returns:
        Dictionary of created directory paths
    """
    dirs = {
        'montage_dir': montage_dir,
        'hf_dir': os.path.join(montage_dir, "high_Frequency"),
        'hf_mesh': os.path.join(montage_dir, "high_Frequency", "mesh"),
        'hf_niftis': os.path.join(montage_dir, "high_Frequency", "niftis"),
        'hf_analysis': os.path.join(montage_dir, "high_Frequency", "analysis"),
        'ti_mesh': os.path.join(montage_dir, "TI", "mesh"),
        'ti_niftis': os.path.join(montage_dir, "TI", "niftis"),
        'ti_surface_overlays': os.path.join(montage_dir, "TI", "surface_overlays"),
        'ti_montage_imgs': os.path.join(montage_dir, "TI", "montage_imgs"),
        'documentation': os.path.join(montage_dir, "documentation"),
    }

    # Add mTI directories for multipolar mode
    if simulation_mode == SimulationMode.MTI:
        dirs['mti_mesh'] = os.path.join(montage_dir, "mTI", "mesh")
        dirs['mti_niftis'] = os.path.join(montage_dir, "mTI", "niftis")
        dirs['mti_montage_imgs'] = os.path.join(montage_dir, "mTI", "montage_imgs")

    # Create all directories
    for path in dirs.values():
        os.makedirs(path, exist_ok=True)

    return dirs

Session builder (tit.sim.session_builder)

SimNIBS session builder for TI simulations.

SessionBuilder

SessionBuilder(config: SimulationConfig)

Builder class for constructing SimNIBS SESSION objects.

Initialize session builder.

Args: config: Simulation configuration

Source code in tit/sim/session_builder.py
19
20
21
22
23
24
25
26
27
28
29
30
31
32
def __init__(self, config: SimulationConfig):
    """
    Initialize session builder.

    Args:
        config: Simulation configuration
    """
    self.config = config
    self.pm = get_path_manager()

    # Setup paths
    self.m2m_dir = self.pm.path("m2m", subject_id=config.subject_id)
    self.mesh_file = os.path.join(self.m2m_dir, f"{config.subject_id}.msh")
    self.tensor_file = os.path.join(self.m2m_dir, "DTI_coregT1_tensor.nii.gz")

build_session

build_session(montage: MontageConfig, output_dir: str) -> sim_struct.SESSION

Build SimNIBS SESSION object for a montage.

Args: montage: Montage configuration output_dir: Output directory for simulation results

Returns: Configured SESSION object

Source code in tit/sim/session_builder.py
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
def build_session(
    self,
    montage: MontageConfig,
    output_dir: str
) -> sim_struct.SESSION:
    """
    Build SimNIBS SESSION object for a montage.

    Args:
        montage: Montage configuration
        output_dir: Output directory for simulation results

    Returns:
        Configured SESSION object
    """
    # Create base session
    S = sim_struct.SESSION()
    S.subpath = self.m2m_dir
    S.fnamehead = self.mesh_file
    S.anisotropy_type = self.config.conductivity_type.value
    S.pathfem = output_dir

    # Set EEG cap if using electrode names (not XYZ)
    if not montage.is_xyz:
        eeg_net = montage.eeg_net or self.config.eeg_net
        S.eeg_cap = os.path.join(self.pm.path("eeg_positions", subject_id=self.config.subject_id), eeg_net)

    # Mapping options
    S.map_to_surf = self.config.map_to_surf
    S.map_to_vol = self.config.map_to_vol
    S.map_to_mni = self.config.map_to_mni
    S.map_to_fsavg = self.config.map_to_fsavg
    S.open_in_gmsh = self.config.open_in_gmsh
    S.tissues_in_niftis = self.config.tissues_in_niftis

    # DTI tensor for anisotropic conductivity
    if os.path.exists(self.tensor_file):
        S.dti_nii = self.tensor_file

    # Add electrode pairs based on simulation mode
    if montage.simulation_mode == SimulationMode.TI:
        self._add_ti_pairs(S, montage)
    elif montage.simulation_mode == SimulationMode.MTI:
        self._add_mti_pairs(S, montage)

    return S

Post-processor (tit.sim.post_processor)

Post-processing utilities for TI simulations.

This module handles all post-simulation processing including: - TI/mTI field calculation - Field extraction (GM/WM mesh creation) - NIfTI transformation - T1 to MNI conversion - File organization

PostProcessor

PostProcessor(subject_id: str, conductivity_type: str, m2m_dir: str, logger)

Post-processor for TI simulation results.

Initialize post-processor.

Args: subject_id: Subject identifier conductivity_type: Conductivity type string m2m_dir: Path to m2m directory logger: Logger instance

Source code in tit/sim/post_processor.py
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
def __init__(
    self,
    subject_id: str,
    conductivity_type: str,
    m2m_dir: str,
    logger
):
    """
    Initialize post-processor.

    Args:
        subject_id: Subject identifier
        conductivity_type: Conductivity type string
        m2m_dir: Path to m2m directory
        logger: Logger instance
    """
    self.subject_id = subject_id
    self.conductivity_type = conductivity_type
    self.m2m_dir = m2m_dir
    self.logger = logger

    # Path to tools directory
    self.tools_dir = os.path.join(os.path.dirname(__file__), '..', 'tools')

process_mti_results

process_mti_results(hf_dir: str, ti_dir: str, mti_dir: str, mti_nifti_dir: str, hf_mesh_dir: str, hf_analysis_dir: str, documentation_dir: str, montage_name: str) -> str

Process 4-pair mTI simulation results with full pipeline.

Args: hf_dir: High-frequency output directory ti_dir: TI intermediate output directory mti_dir: mTI final output directory mti_nifti_dir: mTI NIfTI output directory hf_mesh_dir: High-frequency mesh output directory hf_analysis_dir: High-frequency analysis output directory documentation_dir: Documentation output directory montage_name: Montage name

Returns: Path to output mTI mesh file

Source code in tit/sim/post_processor.py
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
def process_mti_results(
    self,
    hf_dir: str,
    ti_dir: str,
    mti_dir: str,
    mti_nifti_dir: str,
    hf_mesh_dir: str,
    hf_analysis_dir: str,
    documentation_dir: str,
    montage_name: str
) -> str:
    """
    Process 4-pair mTI simulation results with full pipeline.

    Args:
        hf_dir: High-frequency output directory
        ti_dir: TI intermediate output directory
        mti_dir: mTI final output directory
        mti_nifti_dir: mTI NIfTI output directory
        hf_mesh_dir: High-frequency mesh output directory
        hf_analysis_dir: High-frequency analysis output directory
        documentation_dir: Documentation output directory
        montage_name: Montage name

    Returns:
        Path to output mTI mesh file
    """
    self.logger.info(f"Processing mTI results for {montage_name}")

    # Step 1: Load 4 HF meshes
    hf_meshes = []
    for i in range(1, 5):
        mesh_file = os.path.join(hf_dir, f"{self.subject_id}_TDCS_{i}_{self.conductivity_type}.msh")

        if not os.path.exists(mesh_file):
            raise FileNotFoundError(f"Mesh file not found: {mesh_file}")

        m = mesh_io.read_msh(mesh_file)
        tags_keep = np.hstack((np.arange(1, 100), np.arange(1001, 1100)))
        m = m.crop_mesh(tags=tags_keep)
        hf_meshes.append(m)

    # Step 2: Calculate TI pairs (AB and CD)
    ti_ab_vectors = get_TI_vectors(hf_meshes[0].field["E"].value, hf_meshes[1].field["E"].value)
    ti_cd_vectors = get_TI_vectors(hf_meshes[2].field["E"].value, hf_meshes[3].field["E"].value)

    # Step 3: Save intermediate TI meshes
    self._save_ti_intermediate(hf_meshes[0], ti_ab_vectors, ti_dir, f"{montage_name}_TI_AB.msh")
    self._save_ti_intermediate(hf_meshes[0], ti_cd_vectors, ti_dir, f"{montage_name}_TI_CD.msh")

    # Step 4: Calculate and save final mTI
    mti_field = TI.get_maxTI(ti_ab_vectors, ti_cd_vectors)
    mout = deepcopy(hf_meshes[0])
    mout.elmdata = []
    mout.add_element_field(mti_field, "TI_Max")

    mti_path = os.path.join(mti_dir, f"{montage_name}_mTI.msh")
    mesh_io.write_msh(mout, mti_path)
    mout.view(visible_tags=[1002, 1006], visible_fields="TI_Max").write_opt(mti_path)

    # Step 5: Extract GM/WM fields for mTI
    self.logger.info("Field extraction: Started")
    self._extract_fields(mti_path, mti_dir, f"{montage_name}_mTI")
    self.logger.info("Field extraction: ✓ Complete")

    # Step 6: Extract GM/WM fields for intermediate TI meshes
    ti_ab_path = os.path.join(ti_dir, f"{montage_name}_TI_AB.msh")
    ti_cd_path = os.path.join(ti_dir, f"{montage_name}_TI_CD.msh")
    if os.path.exists(ti_ab_path):
        self._extract_fields(ti_ab_path, ti_dir, f"{montage_name}_TI_AB")
    if os.path.exists(ti_cd_path):
        self._extract_fields(ti_cd_path, ti_dir, f"{montage_name}_TI_CD")

    # Step 7: Convert mTI meshes to NIfTI
    self.logger.info("NIfTI transformation: Started")
    self._transform_to_nifti(mti_dir, mti_nifti_dir)
    self.logger.info("NIfTI transformation: ✓ Complete")

    # Step 8: Organize HF files with mTI naming
    self._organize_mti_files(
        hf_dir=hf_dir,
        hf_mesh_dir=hf_mesh_dir,
        hf_analysis_dir=hf_analysis_dir,
        documentation_dir=documentation_dir
    )

    # Step 9: Convert T1 to MNI space
    self._convert_t1_to_mni()

    self.logger.info(f"Saved mTI mesh: {mti_path}")
    return mti_path

process_ti_results

process_ti_results(hf_dir: str, output_dir: str, nifti_dir: str, surface_overlays_dir: str, hf_mesh_dir: str, hf_nifti_dir: str, hf_analysis_dir: str, documentation_dir: str, montage_name: str) -> str

Process 2-pair TI simulation results with full pipeline.

Args: hf_dir: High-frequency output directory (SimNIBS writes here) output_dir: TI mesh output directory nifti_dir: TI NIfTI output directory surface_overlays_dir: Surface overlays output directory hf_mesh_dir: High-frequency mesh output directory hf_nifti_dir: High-frequency NIfTI output directory hf_analysis_dir: High-frequency analysis output directory documentation_dir: Documentation output directory montage_name: Montage name

Returns: Path to output TI mesh file

Source code in tit/sim/post_processor.py
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
def process_ti_results(
    self,
    hf_dir: str,
    output_dir: str,
    nifti_dir: str,
    surface_overlays_dir: str,
    hf_mesh_dir: str,
    hf_nifti_dir: str,
    hf_analysis_dir: str,
    documentation_dir: str,
    montage_name: str
) -> str:
    """
    Process 2-pair TI simulation results with full pipeline.

    Args:
        hf_dir: High-frequency output directory (SimNIBS writes here)
        output_dir: TI mesh output directory
        nifti_dir: TI NIfTI output directory
        surface_overlays_dir: Surface overlays output directory
        hf_mesh_dir: High-frequency mesh output directory
        hf_nifti_dir: High-frequency NIfTI output directory
        hf_analysis_dir: High-frequency analysis output directory
        documentation_dir: Documentation output directory
        montage_name: Montage name

    Returns:
        Path to output TI mesh file
    """
    self.logger.info(f"Processing TI results for {montage_name}")

    # Step 1: Calculate TI field
    ti_path = self._calculate_ti_field(hf_dir, output_dir, montage_name)

    # Step 2: Calculate TI normal (cortical surface)
    self._process_ti_normal(hf_dir, output_dir, montage_name)

    # Step 3: Extract GM/WM fields
    self.logger.info("Field extraction: Started")
    self._extract_fields(ti_path, output_dir, f"{montage_name}_TI")
    self.logger.info("Field extraction: ✓ Complete")

    # Step 4: Convert to NIfTI
    self.logger.info("NIfTI transformation: Started")
    self._transform_to_nifti(output_dir, nifti_dir)
    self.logger.info("NIfTI transformation: ✓ Complete")

    # Step 5: Organize files
    self._organize_ti_files(
        hf_dir=hf_dir,
        hf_mesh_dir=hf_mesh_dir,
        hf_nifti_dir=hf_nifti_dir,
        hf_analysis_dir=hf_analysis_dir,
        surface_overlays_dir=surface_overlays_dir,
        documentation_dir=documentation_dir
    )

    # Step 6: Convert T1 to MNI space
    self._convert_t1_to_mni()

    self.logger.info(f"Saved TI mesh: {ti_path}")
    return ti_path