Skip to content

Simulation

The simulation engine runs finite element method (FEM) calculations using SimNIBS to compute temporal interference electric fields in the brain. TI-Toolbox supports two simulation modes: TI (2-pair) and mTI (4+ pairs, even).

graph LR
    MESH[Head Mesh] --> SIM[SimNIBS FEM]
    MONT[Montage Config] --> SIM
    INT[Intensity Config] --> SIM
    SIM --> TI_MAX[TI_max Field]
    SIM --> TI_NORM[TI_normal Field]
    SIM --> NIFTI[NIfTI Volumes]
    style SIM fill:#2d5a27,stroke:#4a8,color:#fff

Running a Simulation

from tit.sim import (
    SimulationConfig, Montage,
    run_simulation, load_montages,
)

# Load montages from the project's montage_list.json
montages = load_montages(
    montage_names=["motor_cortex"],
    project_dir="/data/my_project",
    eeg_net="GSN-HydroCel-185",
)

# Configure the simulation (montages are part of the config)
config = SimulationConfig(
    subject_id="001",
    project_dir="/data/my_project",
    montages=montages,
    conductivity="scalar",
    intensities=[1.0, 1.0],
    electrode_shape="ellipse",
    electrode_dimensions=[8.0, 8.0],
    gel_thickness=4.0,
)

# Run (auto-detects TI vs mTI based on number of electrode pairs)
results = run_simulation(config)

Simulation Modes

The simulation mode is auto-detected from the montage configuration:

Mode Electrode Pairs Description
TI 2 pairs Standard temporal interference — two pairs of electrodes at slightly different frequencies
mTI 4+ pairs (even) Multi-channel TI — N electrode pairs combined via binary-tree algorithm

Auto-Detection

You do not need to specify the mode manually. TI-Toolbox inspects the montage: 2 pairs triggers TI mode, 4 or more pairs (even) triggers mTI mode.

Montage Configuration

Montages define which electrodes form each pair. They are stored in montage_list.json within your project:

{
  "nets": {
    "GSN-HydroCel-185": {
      "uni_polar_montages": {
        "motor_cortex": [["E36", "E224"], ["E104", "E148"]]
      }
    }
  }
}

Load montages programmatically:

from tit.sim import load_montages

montages = load_montages(
    montage_names=["motor_cortex", "frontal_target"],
    project_dir="/data/my_project",
    eeg_net="GSN-HydroCel-185",
)

Conductivity Types

The conductivity field on SimulationConfig accepts one of four string values:

Value Description
"scalar" Isotropic, piecewise-constant conductivity (default, faster)
"vn" Volume-normalized anisotropic conductivity from DTI data
"dir" Direct linear rescaling of diffusion tensor eigenvalues
"mc" Mean-conductivity (isotropic but spatially varying, from DTI)

Electrode Configuration

Electrode parameters are flat fields on SimulationConfig:

config = SimulationConfig(
    ...,
    electrode_shape="ellipse",         # "ellipse" or "rect"
    electrode_dimensions=[8.0, 8.0],   # [width, height] in mm
    gel_thickness=4.0,                  # gel layer thickness in mm
    rubber_thickness=2.0,              # rubber layer thickness in mm
)

Output Fields

After simulation, TI-Toolbox produces several derived field types:

Field Description
TI_max Maximum TI envelope magnitude — scalar field on volume elements (TI mode)
TI_normal TI field component normal to cortical surface — node field on surface overlay (TI mode)
TI_Max Maximum mTI envelope magnitude — scalar field on volume elements (mTI mode)
TI_vectors Intermediate pairwise TI vector fields for each adjacent pair (mTI mode)

Outputs are saved as both mesh files (for surface analysis) and NIfTI volumes (for voxel analysis).

Output Directory Structure

derivatives/SimNIBS/sub-001/Simulations/
└── motor_cortex/
    ├── high_Frequency/
    │   ├── mesh/             # Per-pair HF mesh files
    │   ├── niftis/           # Per-pair HF NIfTI volumes
    │   └── analysis/         # fields_summary.txt
    ├── TI/
    │   ├── mesh/             # TI_max and GM/WM mesh files
    │   ├── niftis/           # TI NIfTI volumes (subject + MNI space)
    │   ├── surface_overlays/ # Cortical surface overlays (for TI_normal)
    │   └── montage_imgs/     # Electrode placement visualizations
    ├── mTI/                  # (mTI mode only)
    │   ├── mesh/             # mTI_max and intermediate TI meshes
    │   ├── niftis/           # mTI NIfTI volumes
    │   └── montage_imgs/     # Electrode placement visualizations
    └── documentation/        # config.json, SimNIBS logs

API Reference

tit.sim.config.SimulationConfig dataclass

SimulationConfig(subject_id: str, project_dir: str, montages: list[Montage], conductivity: str = 'scalar', intensities: list[float] = (lambda: [1.0, 1.0])(), electrode_shape: str = 'ellipse', electrode_dimensions: list[float] = (lambda: [8.0, 8.0])(), gel_thickness: float = 4.0, rubber_thickness: float = 2.0, map_to_surf: bool = True, map_to_vol: bool = False, map_to_mni: bool = False, map_to_fsavg: bool = False, open_in_gmsh: bool = False, tissues_in_niftis: str = 'all', aniso_maxratio: float = 10.0, aniso_maxcond: float = 2.0)

tit.sim.config.Montage dataclass

Montage(name: str, mode: MontageMode, electrode_pairs: list[tuple], eeg_net: str | None = None)

Unified montage: EEG-cap labels or 3-D XYZ coordinates.

tit.sim.config.MontageMode

Bases: Enum

tit.sim.config.SimulationMode

Bases: Enum

tit.sim.config.parse_intensities

parse_intensities(s: str) -> list[float]

Parse comma-separated intensity string into list of floats.

Source code in tit/sim/config.py
def parse_intensities(s: str) -> list[float]:
    """Parse comma-separated intensity string into list of floats."""
    v = [float(x.strip()) for x in s.split(",")]
    n = len(v)
    if n == 1:
        return [v[0], v[0]]
    if n >= 2 and n % 2 == 0:
        return v
    raise ValueError(
        f"Invalid intensity format: expected 1 or an even number of values; got {n}: {s!r}"
    )

tit.sim.base.BaseSimulation

BaseSimulation(config: SimulationConfig, montage: Montage, logger)

Bases: ABC

Abstract base class for TI/mTI simulations.

Subclasses must implement :pyattr:_simulation_mode, :pyattr:_montage_type_label, :pyattr:_montage_imgs_key, :meth:_build_session, and :meth:_post_process.

Source code in tit/sim/base.py
def __init__(self, config: SimulationConfig, montage: Montage, logger):
    self.config = config
    self.montage = montage
    self.logger = logger
    self.pm = get_path_manager()
    self.m2m_dir = self.pm.m2m(config.subject_id)

run

run(simulation_dir: str) -> dict

Execute the full simulation pipeline. Returns a result dict.

Source code in tit/sim/base.py
def run(self, simulation_dir: str) -> dict:
    """Execute the full simulation pipeline. Returns a result dict."""
    montage_dir = self.pm.simulation(
        self.config.subject_id,
        self.montage.name,
    )
    dirs = setup_montage_directories(montage_dir, self._simulation_mode)
    create_simulation_config_file(
        self.config, self.montage, dirs["documentation"], self.logger
    )

    viz_pairs = None if self.montage.is_xyz else self.montage.electrode_pairs

    run_montage_visualization(
        montage_name=self.montage.name,
        simulation_mode=self._simulation_mode,
        eeg_net=self.montage.eeg_net,
        output_dir=dirs[self._montage_imgs_key],
        project_dir=self.config.project_dir,
        logger=self.logger,
        electrode_pairs=viz_pairs,
    )

    self.logger.info("SimNIBS simulation: Started")
    run_simnibs(self._build_session(dirs["hf_dir"]))
    self.logger.info("SimNIBS simulation: \u2713 Complete")

    output_mesh = self._post_process(dirs)
    self.logger.info(f"\u2713 {self.montage.name} complete")

    return {
        "montage_name": self.montage.name,
        "montage_type": self._montage_type_label,
        "status": "completed",
        "output_mesh": output_mesh,
    }

tit.sim.utils.run_simulation

run_simulation(config: SimulationConfig, logger=None, progress_callback: Callable[[int, int, str], None] | None = None) -> list[dict]

Run TI/mTI simulations sequentially. Mode is auto-detected per montage. Montages are read from config.montages. Returns list of result dicts: montage_name, montage_type, status, output_mesh.

Source code in tit/sim/utils.py
def run_simulation(
    config: SimulationConfig,
    logger=None,
    progress_callback: Callable[[int, int, str], None] | None = None,
) -> list[dict]:
    """
    Run TI/mTI simulations sequentially. Mode is auto-detected per montage.
    Montages are read from ``config.montages``.
    Returns list of result dicts: montage_name, montage_type, status, output_mesh.
    """
    if logger is None:
        pm = get_path_manager()
        log_dir = pm.logs(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'
        )
        logger = _make_file_logger("tit.sim", log_file)

    pm = get_path_manager()
    simulation_dir = pm.simulations(config.subject_id)

    from tit.sim.TI import TISimulation
    from tit.sim.mTI import mTISimulation

    montages = config.montages
    results = []
    total = len(montages)
    for idx, montage in enumerate(montages):
        logger.info(
            f"[{idx+1}/{total}] {montage.simulation_mode.value}: {montage.name}"
        )
        if progress_callback:
            progress_callback(idx, total, montage.name)
        cls = (
            TISimulation
            if montage.simulation_mode == SimulationMode.TI
            else mTISimulation
        )
        results.append(cls(config, montage, logger).run(simulation_dir))
    if progress_callback:
        progress_callback(total, total, "Complete")
    return results

tit.sim.utils.load_montages

load_montages(montage_names: list[str], project_dir: str, eeg_net: str, include_flex: bool = True) -> list[Montage]
Source code in tit/sim/utils.py
def load_montages(
    montage_names: list[str],
    project_dir: str,
    eeg_net: str,
    include_flex: bool = True,
) -> list[Montage]:
    data = load_montage_data(project_dir)
    net = data.get("nets", {}).get(eeg_net, {})
    uni = net.get("uni_polar_montages", {})
    multi = net.get("multi_polar_montages", {})

    if eeg_net == "freehand":
        mode = Montage.Mode.FREEHAND
    elif eeg_net == "flex_mode":
        mode = Montage.Mode.FLEX_FREE
    else:
        mode = Montage.Mode.NET

    montages = [
        Montage(
            name=n,
            mode=mode,
            electrode_pairs=multi.get(n) or uni[n],
            eeg_net=eeg_net,
        )
        for n in montage_names
        if n in multi or n in uni
    ]

    if include_flex:
        for flex in load_flex_montages():
            montages.append(parse_flex_montage(flex))

    return montages

tit.sim.utils.list_montage_names

list_montage_names(project_dir: str, eeg_net: str, *, mode: str) -> list[str]

mode: 'U' or 'M'. Returns [] for missing nets.

Source code in tit/sim/utils.py
def list_montage_names(project_dir: str, eeg_net: str, *, mode: str) -> list[str]:
    """mode: 'U' or 'M'. Returns [] for missing nets."""
    data = load_montage_data(project_dir)
    net = data.get("nets", {}).get(eeg_net, {})
    key = "uni_polar_montages" if mode.upper() == "U" else "multi_polar_montages"
    return sorted(net.get(key, {}).keys())

tit.sim.utils.load_montage_data

load_montage_data(project_dir: str) -> dict
Source code in tit/sim/utils.py
def load_montage_data(project_dir: str) -> dict:
    with open(ensure_montage_file(project_dir)) as f:
        return json.load(f)

tit.sim.utils.save_montage_data

save_montage_data(project_dir: str, data: dict) -> None
Source code in tit/sim/utils.py
def save_montage_data(project_dir: str, data: dict) -> None:
    with open(ensure_montage_file(project_dir), "w") as f:
        json.dump(data, f, indent=4)

tit.sim.utils.ensure_montage_file

ensure_montage_file(project_dir: str) -> str

Return path to montage_list.json, creating it with default schema if absent.

Source code in tit/sim/utils.py
def ensure_montage_file(project_dir: str) -> str:
    """Return path to montage_list.json, creating it with default schema if absent."""
    path = _montage_list_path(project_dir)
    os.makedirs(os.path.dirname(path), exist_ok=True)
    if not os.path.exists(path):
        with open(path, "w") as f:
            json.dump({"nets": {}}, f, indent=4)
    return path

tit.sim.utils.upsert_montage

upsert_montage(*, project_dir: str, eeg_net: str, montage_name: str, electrode_pairs: list[list[str]], mode: str) -> None

mode: 'U' → uni_polar_montages, 'M' → multi_polar_montages

Source code in tit/sim/utils.py
def upsert_montage(
    *,
    project_dir: str,
    eeg_net: str,
    montage_name: str,
    electrode_pairs: list[list[str]],
    mode: str,
) -> None:
    """mode: 'U' → uni_polar_montages, 'M' → multi_polar_montages"""
    data = load_montage_data(project_dir)
    net = data["nets"].setdefault(
        eeg_net, {"uni_polar_montages": {}, "multi_polar_montages": {}}
    )
    key = "uni_polar_montages" if mode.upper() == "U" else "multi_polar_montages"
    net[key][montage_name] = electrode_pairs
    save_montage_data(project_dir, data)