Skip to content

Optimization

TI-Toolbox provides two optimization strategies for finding optimal electrode placements: flex-search (differential evolution) for continuous optimization and exhaustive search for discrete combinatorial search.

graph LR
    ROI[Target ROI] --> OPT{Strategy}
    OPT -->|continuous| FLEX[Flex-Search]
    OPT -->|discrete| EX[Ex-Search]
    FLEX --> RESULT[Optimal Montage]
    EX --> RESULT
    RESULT -->|simulate| SIM[Simulation]
    style RESULT fill:#2d5a27,stroke:#4a8,color:#fff

Flex-Search (Differential Evolution)

Flex-search uses differential evolution to find optimal electrode positions on the EEG cap. It explores the continuous space of all possible electrode combinations.

from tit.opt import FlexConfig, run_flex_search

config = FlexConfig(
    subject_id="001",
    project_dir="/data/my_project",
    goal="mean",              # "mean", "max", or "focality"
    postproc="max_TI",        # "max_TI", "dir_TI_normal", "dir_TI_tangential"
    current_mA=1.0,
    electrode=FlexConfig.ElectrodeConfig(shape="ellipse", dimensions=[8.0, 8.0]),
    roi=FlexConfig.SphericalROI(x=-42, y=-20, z=55, radius=10, use_mni=True),
    eeg_net="GSN-HydroCel-185",
    n_multistart=3,
)

result = run_flex_search(config)
print(f"Best value: {result.best_value}")
print(f"Output: {result.output_folder}")

Optimization Goals

Goal Description
"mean" Maximize mean field intensity within the ROI
"max" Maximize peak field intensity within the ROI
"focality" Maximize the ratio of ROI intensity to whole-brain intensity

ROI Types

roi = FlexConfig.SphericalROI(
    x=-42, y=-20, z=55,  # MNI or subject coordinates
    radius=10,            # radius in mm
    use_mni=True,         # True for MNI, False for subject space
)
roi = FlexConfig.AtlasROI(
    atlas_path="/path/to/annotation",
    label=1024,            # integer label from the atlas
    hemisphere="lh",       # "lh" or "rh"
)
roi = FlexConfig.SubcorticalROI(
    atlas_path="/path/to/volumetric_atlas",
    label=10,              # integer label from the atlas
    tissues="GM",          # "GM", "WM", or "both"
)

Multi-start

Use n_multistart to run multiple optimization restarts with different initial conditions. This helps avoid local optima. A value of 3-5 is usually sufficient.

Exhaustive search tests all possible electrode combinations from a predefined pool. This is useful when you want to find the best combination from a specific set of electrodes.

from tit.opt import ExConfig, run_ex_search

config = ExConfig(
    subject_id="001",
    project_dir="/data/my_project",
    leadfield_hdf="leadfield.hdf5",  # filename within the leadfields directory
    roi_name="motor_roi",
    electrodes=ExConfig.PoolElectrodes(electrodes=["C3", "C4", "F3", "F4", "P3", "P4"]),
)

result = run_ex_search(config)
print(f"Combinations tested: {result.n_combinations}")
print(f"Results CSV: {result.results_csv}")

You can also use bucket electrodes to specify separate pools for each channel position:

config = ExConfig(
    subject_id="001",
    project_dir="/data/my_project",
    leadfield_hdf="leadfield.hdf5",
    roi_name="motor_roi",
    electrodes=ExConfig.BucketElectrodes(
        e1_plus=["C3", "C1"],
        e1_minus=["C4", "C2"],
        e2_plus=["F3", "F1"],
        e2_minus=["F4", "F2"],
    ),
)

Leadfield Prerequisite

Exhaustive search requires a pre-computed leadfield matrix. Generate one using tit.opt.leadfield before running the search.

Leadfield Generation

The leadfield matrix maps electrode currents to brain fields and is required for exhaustive search. Use the LeadfieldGenerator class:

from tit.opt.leadfield import LeadfieldGenerator

generator = LeadfieldGenerator(
    subject_id="001",
    electrode_cap="GSN-HydroCel-185",
)

# Generate a new leadfield (requires SimNIBS)
leadfield_path = generator.generate()

# List available leadfields for the subject
available = generator.list_leadfields()
for net_name, hdf5_path, size_gb in available:
    print(f"{net_name}: {hdf5_path} ({size_gb:.2f} GB)")

# Get electrode names from a cap
electrodes = generator.get_electrode_names()

API Reference

tit.opt.config.FlexConfig dataclass

FlexConfig(subject_id: str, project_dir: str, goal: OptGoal, postproc: FieldPostproc, current_mA: float, electrode: ElectrodeConfig, roi: SphericalROI | AtlasROI | SubcorticalROI, anisotropy_type: str = 'scalar', aniso_maxratio: float = 10.0, aniso_maxcond: float = 2.0, non_roi_method: NonROIMethod | None = None, non_roi: SphericalROI | AtlasROI | SubcorticalROI | None = None, thresholds: str | None = None, eeg_net: str | None = None, enable_mapping: bool = False, disable_mapping_simulation: bool = False, output_folder: str | None = None, run_final_electrode_simulation: bool = False, n_multistart: int = 1, max_iterations: int | None = None, population_size: int | None = None, tolerance: float | None = None, mutation: str | None = None, recombination: float | None = None, cpus: int | None = None, detailed_results: bool = False, visualize_valid_skin_region: bool = False, skin_visualization_net: str | None = None)

Full configuration for flex-search optimization.

OptGoal

Bases: StrEnum

Optimization goal.

FieldPostproc

Bases: StrEnum

Field post-processing method.

NonROIMethod

Bases: StrEnum

Non-ROI specification method for focality optimization.

SphericalROI dataclass

SphericalROI(x: float, y: float, z: float, radius: float = 10.0, use_mni: bool = False)

Spherical region of interest defined by center + radius.

AtlasROI dataclass

AtlasROI(atlas_path: str, label: int, hemisphere: str = 'lh')

Cortical surface ROI from a FreeSurfer annotation atlas.

SubcorticalROI dataclass

SubcorticalROI(atlas_path: str, label: int, tissues: str = 'GM')

Subcortical volume ROI from a volumetric atlas.

ElectrodeConfig dataclass

ElectrodeConfig(shape: str = 'ellipse', dimensions: list[float] = (lambda: [8.0, 8.0])(), gel_thickness: float = 4.0)

Electrode geometry for flex-search.

Only gel_thickness is needed here — the optimization leadfield uses point electrodes; gel_thickness is recorded in the manifest for downstream simulation.

run_flex_search(config: FlexConfig) -> FlexResult

Run flex-search optimization from a typed FlexConfig.

Source code in tit/opt/flex/flex.py
def run_flex_search(config: FlexConfig) -> FlexResult:
    """Run flex-search optimization from a typed FlexConfig."""

    from .manifest import write_manifest
    from .utils import generate_label, generate_run_dirname

    pm = get_path_manager(config.project_dir)

    logger = logging.getLogger(__name__)

    n = config.n_multistart

    # Resolve base output folder
    if config.output_folder:
        base_folder = config.output_folder
    else:
        flex_root = pm.flex_search(config.subject_id)
        os.makedirs(flex_root, exist_ok=True)
        dirname = generate_run_dirname(flex_root)
        base_folder = os.path.join(flex_root, dirname)

    os.makedirs(base_folder, exist_ok=True)
    fvals = np.full(n, float("inf"))

    logger.info(
        f"Flex-search ({config.subject_id}): "
        f"goal={config.goal}, postproc={config.postproc}, runs={n}"
    )

    folders = [os.path.join(base_folder, f"{i:02d}") for i in range(n)]

    # -- Run optimizations --
    for i in range(n):
        opt = builder.build_optimization(config)
        opt.output_folder = folders[i]
        os.makedirs(opt.output_folder, exist_ok=True)
        builder.configure_optimizer_options(opt, config, logger)

        step = f"Run {i + 1}/{n}" if n > 1 else "Optimization"
        logger.info(f"├─ {step}: started")

        opt.run(cpus=config.cpus)
        fvals[i] = opt.optim_funvalue
        logger.info(f"├─ {step}: value={fvals[i]:.6f}")

    # -- Select best --
    valid_mask = fvals < float("inf")
    if not valid_mask.any():
        logger.error("All optimization runs failed")
        result = FlexResult(
            success=False,
            output_folder=base_folder,
            function_values=fvals.tolist(),
            best_value=float("inf"),
            best_run_index=-1,
        )
        label = generate_label(config)
        write_manifest(base_folder, config, result, label)
        return result

    best_idx = int(np.argmin(fvals))
    logger.info(f"Best run: #{best_idx + 1} (value={fvals[best_idx]:.6f})")

    # -- Promote best to base folder --
    best_folder = folders[best_idx]
    for item in os.listdir(best_folder):
        src = os.path.join(best_folder, item)
        dst = os.path.join(base_folder, item)
        if os.path.isdir(src):
            if os.path.exists(dst):
                shutil.rmtree(dst)
            shutil.copytree(src, dst)
        else:
            shutil.copy2(src, dst)

    # -- Cleanup temp subdirs --
    for folder in folders:
        if os.path.isdir(folder):
            shutil.rmtree(folder)

    # -- Report --
    builder.generate_report(config, n, fvals, best_idx, base_folder, logger)

    result = FlexResult(
        success=True,
        output_folder=base_folder,
        function_values=fvals.tolist(),
        best_value=float(fvals[best_idx]),
        best_run_index=best_idx,
    )

    # -- Write manifest --
    label = generate_label(config)
    write_manifest(base_folder, config, result, label)

    return result

tit.opt.config.FlexConfig.SphericalROI dataclass

SphericalROI(x: float, y: float, z: float, radius: float = 10.0, use_mni: bool = False)

Spherical region of interest defined by center + radius.

tit.opt.config.FlexConfig.AtlasROI dataclass

AtlasROI(atlas_path: str, label: int, hemisphere: str = 'lh')

Cortical surface ROI from a FreeSurfer annotation atlas.

tit.opt.config.FlexConfig.SubcorticalROI dataclass

SubcorticalROI(atlas_path: str, label: int, tissues: str = 'GM')

Subcortical volume ROI from a volumetric atlas.

tit.opt.config.FlexConfig.ElectrodeConfig dataclass

ElectrodeConfig(shape: str = 'ellipse', dimensions: list[float] = (lambda: [8.0, 8.0])(), gel_thickness: float = 4.0)

Electrode geometry for flex-search.

Only gel_thickness is needed here — the optimization leadfield uses point electrodes; gel_thickness is recorded in the manifest for downstream simulation.

tit.opt.config.FlexResult dataclass

FlexResult(success: bool, output_folder: str, function_values: list[float], best_value: float, best_run_index: int)

Result from a flex-search optimization run.

Exhaustive Search

tit.opt.config.ExConfig dataclass

ExConfig(subject_id: str, project_dir: str, leadfield_hdf: str, roi_name: str, electrodes: BucketElectrodes | PoolElectrodes, total_current: float = 2.0, current_step: float = 0.5, channel_limit: float | None = None, roi_radius: float = 3.0, run_name: str | None = None)

Full configuration for exhaustive search optimization.

BucketElectrodes dataclass

BucketElectrodes(e1_plus: list[str], e1_minus: list[str], e2_plus: list[str], e2_minus: list[str])

Separate electrode lists for each bipolar channel position.

PoolElectrodes dataclass

PoolElectrodes(electrodes: list[str])

Single electrode pool — all positions draw from the same set.

run_ex_search(config: ExConfig) -> ExResult

Run exhaustive search from a typed config object.

Source code in tit/opt/ex/ex.py
def run_ex_search(config: ExConfig) -> ExResult:
    """Run exhaustive search from a typed config object."""

    pm = get_path_manager(config.project_dir)

    logs_dir = pm.logs(config.subject_id)
    os.makedirs(logs_dir, exist_ok=True)
    log_file = os.path.join(logs_dir, f'ex_search_{time.strftime("%Y%m%d_%H%M%S")}.log')
    logger_name = f"tit.opt.ex_search.{config.subject_id}"
    add_file_handler(log_file, logger_name=logger_name)
    logger = logging.getLogger(logger_name)

    logger.info(f"{'=' * 60}\nTI Exhaustive Search\n{'=' * 60}")
    logger.info(f"Project: {config.project_dir}")
    logger.info(f"Subject: {config.subject_id}")

    run_name = config.run_name or time.strftime("%Y%m%d_%H%M%S")
    output_dir = pm.ex_search_run(config.subject_id, run_name)
    os.makedirs(output_dir, exist_ok=True)
    logger.info(f"Output: {output_dir}")

    roi_file = os.path.join(pm.rois(config.subject_id), config.roi_name)

    if isinstance(config.electrodes, ExConfig.PoolElectrodes):
        pool = config.electrodes.electrodes
        e1_plus = e1_minus = e2_plus = e2_minus = pool
        all_combinations = True
    else:
        e1_plus = config.electrodes.e1_plus
        e1_minus = config.electrodes.e1_minus
        e2_plus = config.electrodes.e2_plus
        e2_minus = config.electrodes.e2_minus
        all_combinations = False

    leadfield_path = os.path.join(pm.leadfields(config.subject_id), config.leadfield_hdf)

    engine = ExSearchEngine(leadfield_path, roi_file, config.roi_name, logger)
    engine.initialize(roi_radius=config.roi_radius)

    ratios = generate_current_ratios(
        config.total_current,
        config.current_step,
        config.channel_limit or config.total_current - config.current_step,
    )

    logger.info(f"Generated {len(ratios)} current ratio combinations")

    results = engine.run(
        e1_plus, e1_minus, e2_plus, e2_minus, ratios, all_combinations, output_dir
    )

    output_info = process_and_save(results, config, output_dir, logger)
    logger.info(f"Config: {output_info['config_json_path']}")
    logger.info(f"CSV: {output_info['csv_path']}")

    return ExResult(
        success=True,
        output_dir=output_dir,
        n_combinations=len(results),
        results_csv=output_info.get("csv_path"),
        config_json=output_info.get("config_json_path"),
    )

tit.opt.config.ExConfig.PoolElectrodes dataclass

PoolElectrodes(electrodes: list[str])

Single electrode pool — all positions draw from the same set.

tit.opt.config.ExConfig.BucketElectrodes dataclass

BucketElectrodes(e1_plus: list[str], e1_minus: list[str], e2_plus: list[str], e2_minus: list[str])

Separate electrode lists for each bipolar channel position.

tit.opt.config.ExResult dataclass

ExResult(success: bool, output_dir: str, n_combinations: int, results_csv: str | None = None, config_json: str | None = None)

Result from an exhaustive search run.

Leadfield

tit.opt.leadfield.LeadfieldGenerator

LeadfieldGenerator(subject_id: str, electrode_cap: str = 'EEG10-10', progress_callback: Callable | None = None, termination_flag: Callable[[], bool] | None = None)

Generate and list leadfield matrices for TI optimization.

Initialize leadfield generator.

Parameters:

Name Type Description Default
subject_id str

Subject identifier (e.g. "101").

required
electrode_cap str

EEG cap name without .csv (e.g. "GSN-HydroCel-185").

'EEG10-10'
progress_callback Callable | None

Optional callback(message, level) for GUI updates.

None
termination_flag Callable[[], bool] | None

Optional callable returning True when cancelled.

None
Source code in tit/opt/leadfield.py
def __init__(
    self,
    subject_id: str,
    electrode_cap: str = "EEG10-10",
    progress_callback: Callable | None = None,
    termination_flag: Callable[[], bool] | None = None,
) -> None:
    """Initialize leadfield generator.

    Args:
        subject_id: Subject identifier (e.g. "101").
        electrode_cap: EEG cap name without .csv (e.g. "GSN-HydroCel-185").
        progress_callback: Optional callback(message, level) for GUI updates.
        termination_flag: Optional callable returning True when cancelled.
    """
    self.subject_id = subject_id
    self.electrode_cap = electrode_cap
    self._progress_callback = progress_callback
    self._termination_flag = termination_flag
    self.pm = get_path_manager()

generate

generate(output_dir: str | Path | None = None, tissues: list[int] | None = None, cleanup: bool = True) -> Path

Generate a leadfield matrix via SimNIBS.

Parameters:

Name Type Description Default
output_dir str | Path | None

Output directory (default: pm.leadfields(subject_id)).

None
tissues list[int] | None

Tissue tags [1=WM, 2=GM]. Default: [1, 2].

None
cleanup bool

Remove stale SimNIBS artefacts before running.

True

Returns:

Type Description
Path

Path to the generated HDF5 leadfield file.

Raises:

Type Description
InterruptedError

If cancelled via termination_flag.

Source code in tit/opt/leadfield.py
def generate(
    self,
    output_dir: str | Path | None = None,
    tissues: list[int] | None = None,
    cleanup: bool = True,
) -> Path:
    """Generate a leadfield matrix via SimNIBS.

    Args:
        output_dir: Output directory (default: pm.leadfields(subject_id)).
        tissues: Tissue tags [1=WM, 2=GM]. Default: [1, 2].
        cleanup: Remove stale SimNIBS artefacts before running.

    Returns:
        Path to the generated HDF5 leadfield file.

    Raises:
        InterruptedError: If cancelled via termination_flag.
    """
    from simnibs import sim_struct
    import simnibs

    tissues = [1, 2]
    m2m_dir = Path(self.pm.m2m(self.subject_id))
    output_dir = Path(
        output_dir or self.pm.ensure(self.pm.leadfields(self.subject_id))
    )
    output_dir.mkdir(parents=True, exist_ok=True)

    self._cleanup(output_dir, m2m_dir)

    tdcs_lf = sim_struct.TDCSLEADFIELD()
    tdcs_lf.fnamehead = str(m2m_dir / f"{self.subject_id}.msh")
    tdcs_lf.subpath = str(m2m_dir)
    tdcs_lf.pathfem = str(output_dir)
    tdcs_lf.interpolation = None
    tdcs_lf.map_to_surf = False
    tdcs_lf.tissues = tissues
    tdcs_lf.eeg_cap = str(
        Path(self.pm.eeg_positions(self.subject_id)) / f"{self.electrode_cap}.csv"
    )

    if self._termination_flag and self._termination_flag():
        raise InterruptedError("Leadfield generation cancelled before starting")

    self._log(
        f"Generating leadfield for {self.subject_id} (cap={self.electrode_cap})"
    )
    simnibs.run_simnibs(tdcs_lf)

    if self._termination_flag and self._termination_flag():
        raise InterruptedError("Leadfield generation cancelled after SimNIBS")

    hdf5_path = next(output_dir.glob("*.hdf5"))
    self._log(f"Leadfield ready: {hdf5_path}")
    return hdf5_path

list_leadfields

list_leadfields(subject_id: str | None = None) -> list[tuple[str, str, float]]

List available leadfield HDF5 files for a subject.

Parameters:

Name Type Description Default
subject_id str | None

Subject ID (defaults to self.subject_id).

None

Returns:

Type Description
list[tuple[str, str, float]]

Sorted list of (net_name, hdf5_path, size_gb) tuples.

Source code in tit/opt/leadfield.py
def list_leadfields(
    self, subject_id: str | None = None
) -> list[tuple[str, str, float]]:
    """List available leadfield HDF5 files for a subject.

    Args:
        subject_id: Subject ID (defaults to self.subject_id).

    Returns:
        Sorted list of (net_name, hdf5_path, size_gb) tuples.
    """
    sid = subject_id or self.subject_id
    leadfields_dir = Path(self.pm.leadfields(sid))

    out: list[tuple[str, str, float]] = []
    for item in leadfields_dir.iterdir():

        stem = item.stem
        if "_leadfield_" in stem:
            net_name = stem.split("_leadfield_", 1)[-1]
        elif stem.endswith("_leadfield"):
            net_name = stem[: -len("_leadfield")]
        else:
            net_name = stem

        for prefix in (f"{sid}_", sid):
            if net_name.startswith(prefix):
                net_name = net_name[len(prefix) :]
                break

        net_name = net_name.strip("_") or "unknown"
        out.append((net_name, str(item), item.stat().st_size / (1024**3)))

    return sorted(out)

get_electrode_names

get_electrode_names(cap_name: str | None = None) -> list[str]

Extract electrode labels from an EEG cap via SimNIBS.

Parameters:

Name Type Description Default
cap_name str | None

EEG cap name (without .csv). Defaults to self.electrode_cap.

None

Returns:

Type Description
list[str]

Sorted list of electrode label strings.

Source code in tit/opt/leadfield.py
def get_electrode_names(self, cap_name: str | None = None) -> list[str]:
    """Extract electrode labels from an EEG cap via SimNIBS.

    Args:
        cap_name: EEG cap name (without .csv). Defaults to self.electrode_cap.

    Returns:
        Sorted list of electrode label strings.
    """
    from simnibs.utils.csv_reader import eeg_positions

    cap_name = cap_name or self.electrode_cap
    eeg_pos = eeg_positions(str(self.pm.m2m(self.subject_id)), cap_name=cap_name)
    return sorted(eeg_pos.keys())