Skip to content

Optimization

TI-Toolbox provides flex-search (differential evolution) for continuous electrode-position optimization, exhaustive search for two-pair TI, and multipolar exhaustive search for four-pair mTI.

graph LR
    ROI([Target ROI]) --> OPT{Strategy}
    MESH([Head Mesh]) --> FLEX
    MESH --> EX
    NET([EEG Net]) --> FLEX
    NET --> EX
    OPT -->|continuous| FLEX[Flex-Search]
    OPT -->|discrete| EX[Ex-Search]
    FLEX --> RESULT([Optimal Montage])
    EX --> RESULT
    style ROI fill:#1a3a5c,stroke:#48a,color:#fff
    style MESH fill:#1a3a5c,stroke:#48a,color:#fff
    style NET fill:#1a3a5c,stroke:#48a,color:#fff
    style OPT fill:#5c4a1a,stroke:#a84,color:#fff
    style FLEX fill:#2d5a27,stroke:#4a8,color:#fff
    style EX fill:#2d5a27,stroke:#4a8,color:#fff
    style RESULT fill:#1a5c4a,stroke:#4a8,color:#fff

Flex-Search (Differential Evolution)

Flex-search uses differential evolution to find optimal electrode positions on the EEG cap. It optimizes continuous scalp positions; the EEG net can be used to map the resulting positions to physical electrodes.

from tit.opt import FlexConfig, run_flex_search

config = FlexConfig(
    subject_id="001",
    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" Optimize the threshold-based ROI-to-non-ROI ROC measure
"focality_tf" Optimize threshold-free contrast: mean(E_ROI) ** (1 + intensity_weight) / p95(E_nonROI)

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. Choose the restart count for your computational budget and assess convergence across runs.

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",
    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",
    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.

Use MExConfig and run_m_ex_search from tit.opt for a four-pair search. Like two-pair exhaustive search, it requires a precomputed leadfield and target ROI. MExConfig.PoolElectrodes draws all eight electrode positions from a shared pool; MExConfig.BucketElectrodes provides separate pair buckets. current_mA sets the fixed current delivered by each pair. See the generated API below for configuration.

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, 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, intensity_weight: float = 0.0, optimize_current_ratio: bool = False, ratio_total_mA: float | None = None, ratio_levels: int = 21, eeg_net: str | None = None, enable_mapping: bool = False, disable_mapping_simulation: bool = False, observe_background: 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, min_electrode_distance: float = 5.0, detailed_results: bool = False, visualize_valid_skin_region: bool = True, skin_visualization_net: str | None = None, skin_region_margin_mm: float = 0.0, avoid_landmark_regions: bool = True, mode: Mode = FLEX, adaptive: AdaptiveFocalityConfig | None = None, pareto: ParetoSweepConfig | None = None)

Full configuration for flex-search optimization.

Wraps all parameters needed to drive a SimNIBS TesFlexOptimization run, including subject, ROI definition, electrode geometry, DE hyperparameters, and output control.

Attributes

subject_id : str Subject identifier matching the m2m directory name. goal : OptGoal Optimization objective ("mean", "max", "focality", or "focality_tf"). postproc : FieldPostproc Field post-processing method ("max_TI", "dir_TI_normal", or "dir_TI_tangential"). current_mA : float Current per channel in mA -- each of the two electrode pairs injects +current_mA / -current_mA (a 1:1 split), so the total injected current is 2 * current_mA. electrode : ElectrodeConfig Electrode geometry configuration. roi : SphericalROI or AtlasROI or SubcorticalROI Target region of interest. anisotropy_type : str Conductivity tensor type ("scalar" or "vn"). Default "scalar". aniso_maxratio : float Maximum anisotropy eigenvalue ratio. Default 10.0. aniso_maxcond : float Maximum anisotropic conductivity (S/m). Default 2.0. non_roi_method : NonROIMethod or None How to define the non-ROI region for focality optimization. None when goal is not focality. non_roi : SphericalROI or AtlasROI or SubcorticalROI or None Explicit non-ROI region when non_roi_method is "specific". thresholds : str or None Comma-separated focality threshold values (e.g. "0.1,0.2"). Only used by the ROC-based "focality" goal. None (or a placeholder such as "dynamic") lets SimNIBS supply its own defaults -- but that fallback exists only inside SimNIBS, so combining goal="focality" with optimize_current_ratio makes explicit numeric thresholds required: the ratio search scores candidates with its own ROC objective, which has no defaults. intensity_weight : float Weight w in [0, 1] trading ROI intensity against focality for the "focality_tf" goal. 0.0 gives the balanced form, 1.0 weights raw ROI intensity most heavily. Ignored by the other goals. optimize_current_ratio : bool If True, jointly search the electrode placement and the current split between the two channels instead of fixing it at 1:1. Applies to any goal. Scoring then happens in a Python callable rather than in SimNIBS, which has two consequences: goal="focality" requires explicit thresholds, and detailed_results cannot be used. ratio_total_mA : float or None Total current (mA) shared by the two channels during the ratio search. None uses 2 * current_mA, i.e. the 1:1 split is contained in the search range. When set explicitly it must be greater than zero. ratio_levels : int Number of discrete current splits evaluated per candidate placement. Must be at least 2 when optimize_current_ratio is True. eeg_net : str or None EEG net name or filename (e.g. "GSN-HydroCel-185" or "GSN-HydroCel-185.csv") for electrode-name mapping. None (default) to use raw electrode indices. enable_mapping : bool If True, map optimal indices to named EEG positions. Default False. disable_mapping_simulation : bool If True, skip the final named-electrode simulation after mapping. Default False. observe_background : bool If True, also record observation-only background (non-ROI) field metrics for every candidate, independent of the goal. Like the callable goals it is incompatible with detailed_results. Default False. output_folder : str or None Override for the output directory path. Defaults to an auto-generated timestamped folder. run_final_electrode_simulation : bool If True, run a full SimNIBS simulation with the winning electrode configuration. n_multistart : int Number of independent DE restarts (default 1). Higher values reduce sensitivity to local optima; this is the only thing cpus parallelises. max_iterations : int or None Maximum DE generations per restart. None for solver default. population_size : int or None DE population size. None for solver default. tolerance : float or None Convergence tolerance for DE. None for solver default. mutation : str or None DE mutation strategy string. None for solver default. recombination : float or None DE crossover probability. None for solver default. cpus : int or None Number of parallel workers for the multi-start restarts (the DE search itself is single-process). None uses the global CPU limit (:func:tit.cpu.cpu_limit); a larger value is clamped to it. min_electrode_distance : float Minimum geodesic distance (mm) between any two electrodes. Default 5.0. detailed_results : bool If True, save per-restart detailed output. Incompatible with any configuration whose goal is a Python callable -- "focality_tf" or optimize_current_ratio -- because SimNIBS writes opt.goal into the detailed-results HDF5 file and h5py cannot serialise a function. The combination is rejected at config time rather than after the (potentially hours-long) optimization has finished. visualize_valid_skin_region : bool If True, save a mesh showing the valid electrode placement region. skin_visualization_net : str or None EEG net to overlay on the skin visualization. skin_region_margin_mm : float Signed margin in millimeters applied to the SimNIBS valid-skin region. Positive values expand the region, negative values constrict it. The default 0.0 preserves SimNIBS behavior. avoid_landmark_regions : bool If True, positive skin-region margins keep fiducial-derived ear and orbital exclusion regions invalid. mode : Mode Which driver runs this config: a single run ("flex", the default), :func:tit.opt.flex.drivers.run_adaptive_focality ("flex_adaptive"), or :func:tit.opt.flex.drivers.run_pareto_sweep ("flex_pareto"). Set by the job kind (tit.jobs.kinds.MODULE_FOR_KIND); tit.opt.flex.__main__ dispatches on it. adaptive : AdaptiveFocalityConfig or None Threshold percentages for mode="flex_adaptive". Defaulted when left None under that mode. pareto : ParetoSweepConfig or None Threshold percentage grid for mode="flex_pareto". Defaulted when left None under that mode.

Raises

ValueError If goal is "focality" with non_roi_method "specific" but non_roi is None, if thresholds contains non-numeric values, if intensity_weight falls outside [0, 1], if ratio_levels is below 2 while optimize_current_ratio is True, if ratio_total_mA is set but not positive, if optimize_current_ratio is combined with goal="focality" without explicit thresholds, if detailed_results is combined with a callable-goal configuration (goal="focality_tf" or optimize_current_ratio), or if mode is "flex_adaptive"/"flex_pareto" while goal is not "focality".

Examples

Enum-valued fields accept their string values:

from tit.opt import FlexConfig cfg = FlexConfig( ... subject_id="ernie", ... goal="mean", # "mean" | "max" | "focality" | "focality_tf" ... 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=-35.0, y=5.0, z=5.0, radius=10.0, use_mni=True), ... n_multistart=3, ... ) cfg.goal is FlexConfig.OptGoal.MEAN, cfg.is_focality (True, False)

A focality goal defaults its non-ROI region to everything outside the ROI:

foc = FlexConfig( ... subject_id="ernie", goal="focality_tf", postproc="max_TI", current_mA=1.0, ... electrode=FlexConfig.ElectrodeConfig(), ... roi=FlexConfig.AtlasROI(atlas_path="lh.aparc.annot", label=24, hemisphere="lh"), ... ) foc.non_roi_method.value 'everything_else'

Then run_flex_search(cfg) (needs SimNIBS and the subject's m2m directory).

See Also

FlexResult : Result container returned by :func:~tit.opt.flex.flex.run_flex_search. tit.opt.flex.flex.run_flex_search : Consumes this config.

is_focality property

is_focality: bool

True for any focality goal (ROC-based or threshold-free).

Focality goals share the same ROI/non-ROI setup (a target ROI plus a non-ROI region), so callers use this to gate non-ROI construction and reporting. Threshold-specific logic keeps comparing against OptGoal.FOCALITY directly, since only the ROC goal uses thresholds.

OptGoal

Bases: StrEnum

Optimization goal.

Attributes

MEAN : str Maximize mean field intensity in the ROI. MAX : str Maximize the 99.9th percentile field intensity in the ROI. FOCALITY : str Maximize ROI-to-non-ROI focality via SimNIBS's threshold-based ROC measure (measures.ROC). FOCALITY_TF : str Maximize a threshold-free focality contrast, mean(E_ROI) ** (1 + w) / mean(E_nonROI). Because it needs no thresholds it avoids the threshold-selection failure mode of the ROC goal, whose landscape flattens when the requested ROI and non-ROI thresholds are jointly infeasible (as happens at deep targets). The weight w is :attr:FlexConfig.intensity_weight.

FieldPostproc

Bases: StrEnum

Field post-processing method applied to the TI envelope.

Attributes

MAX_TI : str Maximum TI amplitude (direction-independent). DIR_TI_NORMAL : str TI component normal to the cortical surface. DIR_TI_TANGENTIAL : str TI component tangential to the cortical surface.

NonROIMethod

Bases: StrEnum

Non-ROI specification method for focality optimization.

Attributes

EVERYTHING_ELSE : str Use all mesh elements outside the ROI. SPECIFIC : str Use an explicitly defined non-ROI region.

Mode

Bases: StrEnum

Which flex-search driver runs this config (tit.jobs.kinds.MODULE_FOR_KIND maps flex/flex_adaptive/flex_pareto job kinds to this same module; tit.opt.flex.__main__ dispatches on this field to pick the driver).

Attributes

FLEX : str A single :func:~tit.opt.flex.flex.run_flex_search run (any goal). FLEX_ADAPTIVE : str Two-step adaptive focality: a "mean" run to find the achievable ROI intensity, then a "focality" run with thresholds derived from :attr:FlexConfig.adaptive. Requires goal="focality". FLEX_PARETO : str A "mean" calibration run followed by a grid of "focality" runs over :attr:FlexConfig.pareto's threshold percentages. Requires goal="focality".

AdaptiveFocalityConfig dataclass

AdaptiveFocalityConfig(roi_percentage: float = 80.0, nonroi_percentage: float = 20.0)

Thresholds for :func:tit.opt.flex.drivers.run_adaptive_focality.

Both percentages are applied to the achievable mean ROI intensity found by the driver's own step-1 "mean" optimization run -- never to a value the caller supplies directly, since that intensity is subject- and ROI-specific and cannot be known ahead of time.

Attributes

roi_percentage : float ROI focality threshold, as a percentage (0-100 exclusive) of the achievable mean ROI intensity. nonroi_percentage : float Non-ROI focality threshold, as a percentage (0-100 exclusive) of the same achievable intensity. Must be strictly less than roi_percentage.

Raises

ValueError If either percentage is outside (0, 100), or if nonroi_percentage is not strictly less than roi_percentage.

ParetoSweepConfig dataclass

ParetoSweepConfig(roi_pcts: list[float] = (lambda: [80.0])(), nonroi_pcts: list[float] = (lambda: [20.0, 30.0, 40.0])())

Threshold grid for :func:tit.opt.flex.drivers.run_pareto_sweep.

The driver runs one "focality" optimization per (roi_pct, nonroi_pct) combination in the Cartesian product of roi_pcts and *nonroi_pcts| -- len(roi_pcts) * len(nonroi_pcts) runs total, in addition to the single step-1 "mean" calibration run. See :func:tit.opt.flex.pareto.compute_sweep_grid.

Attributes

roi_pcts : list of float ROI threshold percentages to sweep (each in (0, 100)). nonroi_pcts : list of float Non-ROI threshold percentages to sweep (each in (0, 100)).

Raises

ValueError If either list is empty, if any value falls outside (0, 100), or if any (roi_pct, nonroi_pct) combination has nonroi_pct >= roi_pct.

SphericalROI dataclass

SphericalROI(x: float | list[float], y: float | list[float], z: float | list[float], radius: float | list[float] = 10.0, use_mni: bool = False, volumetric: bool = False, tissues: str = 'GM')

Spherical region of interest defined by center and radius.

By default the sphere is evaluated on the cortical surface (volumetric=False). Set volumetric=True to evaluate on volume tetrahedra instead -- useful for deep/subcortical targets like the amygdala or hippocampus where surface-only evaluation would capture overlying cortex rather than the target structure.

When volumetric=True, the tissues field controls which tissue compartments are included (same semantics as :class:SubcorticalROI.tissues).

Each of x, y, z, radius accepts either a single value (one sphere) or a list of values (a union of several spheres evaluated as one combined target). The coordinate lists must be non-empty and of equal length; radius may be a scalar (shared by every sphere) or a list matching the number of centers.

Attributes

x : float or list of float Center x-coordinate(s) (mm). y : float or list of float Center y-coordinate(s) (mm). z : float or list of float Center z-coordinate(s) (mm). radius : float or list of float Sphere radius/radii in mm. A scalar is shared by all spheres. use_mni : bool If True, coordinates are in MNI space and SimNIBS will transform them to subject space during ROI setup. volumetric : bool If True, evaluate on volume tetrahedra instead of the cortical surface. tissues : str Tissue compartments to include when volumetric is True. One of "GM", "WM", or "both".

Raises

ValueError If x/y/z are empty or unequal length, or radius is a list whose length neither equals 1 nor the number of centers.

AtlasROI dataclass

AtlasROI(atlas_path: str | list[str], label: int | list[int], hemisphere: str | list[str] = 'lh')

Cortical surface ROI from a FreeSurfer annotation atlas.

Each of atlas_path, label, hemisphere accepts either a single value (one region) or a list (a union of several regions evaluated as one combined target). Because .annot files are per-hemisphere, carrying a per-region hemisphere (and matching atlas_path) allows a target that spans both hemispheres, or even different atlases. Scalars broadcast to the number of labels; lists must match its length.

Attributes

atlas_path : str or list of str Path(s) to the FreeSurfer .annot annotation file(s). label : int or list of int Integer label index/indices within the annotation atlas. hemisphere : str or list of str Hemisphere(s) to use ("lh" or "rh"), one per label.

Raises

ValueError If label is empty, or atlas_path/hemisphere is a list whose length neither equals 1 nor the number of labels.

SubcorticalROI dataclass

SubcorticalROI(atlas_path: str | list[str], label: int | list[int] | None, tissues: str = 'GM', atlas_space: Literal['subject', 'mni'] = 'subject')

Subcortical volume ROI from a volumetric atlas.

label accepts either a single value (one region) or a list (a union of several regions -- e.g. both hippocampi from one aseg atlas -- evaluated as one combined target). atlas_path may be a scalar (shared by every label) or a list matching the number of labels; a single shared tissues and atlas_space apply to the whole union.

Attributes

atlas_path : str or list of str Path(s) to the volumetric atlas NIfTI file(s). label : int or list of int Integer label index/indices, or None to select all positive mask voxels. tissues : str Tissue compartments to include. One of "GM", "WM", or "both". atlas_space : str Space of the atlas NIfTI. One of "subject" or "mni". MNI-space masks are transformed by SimNIBS during ROI setup.

Raises

ValueError If label is empty, or atlas_path is a list whose length neither equals 1 nor the number of labels.

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.

Attributes

shape : str Electrode shape ("ellipse" or "rect"). Default "ellipse". Flex-search supports circular electrodes only, so an "ellipse" must have equal dimensions. dimensions : list of float Electrode dimensions in mm ([width, height]). Default [8.0, 8.0]. gel_thickness : float Conductive gel thickness in mm. Default 4.0.

run_flex_search(config: FlexConfig) -> FlexResult

Run differential-evolution electrode placement optimization.

Uses scipy.optimize.differential_evolution (via SimNIBS TesFlexOptimization) to find electrode positions that maximize field strength, peak intensity, or focality in a target ROI.

Multiple independent restarts (controlled by config.n_multistart) are executed sequentially; the best run's output is promoted to the base output folder.

Parameters

config : FlexConfig Fully specified optimization configuration including subject, ROI definition, electrode geometry, and DE hyperparameters.

Returns

FlexResult success, output_folder (the run directory under the subject's flex-search/), per-restart function_values, best_value and best_run_index.

Raises

ValueError If the subject's m2m directory or head mesh is missing, a referenced ROI/atlas/EEG-net file is missing, cpus or n_multistart is below 1, min_electrode_distance is not positive, enable_mapping is set without eeg_net, or an "ellipse" electrode has unequal dimensions (flex-search supports circular electrodes only).

Notes

When every restart fails the function does not raise: it returns a :class:FlexResult with success=False, best_value=inf and best_run_index=-1. Always check result.success.

Examples

from tit.opt import FlexConfig, run_flex_search cfg = FlexConfig( ... subject_id="ernie", goal="mean", postproc="max_TI", current_mA=1.0, ... electrode=FlexConfig.ElectrodeConfig(shape="ellipse", dimensions=[8.0, 8.0]), ... roi=FlexConfig.SphericalROI(x=-35.0, y=5.0, z=5.0, radius=10.0, use_mni=True), ... n_multistart=3, output_folder="insula_mean", ... ) res = run_flex_search(cfg) # doctest: +SKIP res.success, res.best_run_index, len(res.function_values) # doctest: +SKIP (True, 1, 3)

See Also

FlexConfig : Configuration dataclass for flex-search. FlexResult : Result container with per-restart function values. tit.opt.ex.ex.run_ex_search : Alternative exhaustive grid search.

Source code in tit/opt/flex/flex.py
def run_flex_search(config: FlexConfig) -> FlexResult:
    """Run differential-evolution electrode placement optimization.

    Uses ``scipy.optimize.differential_evolution`` (via SimNIBS
    ``TesFlexOptimization``) to find electrode positions that maximize
    field strength, peak intensity, or focality in a target ROI.

    Multiple independent restarts (controlled by
    ``config.n_multistart``) are executed sequentially; the best run's
    output is promoted to the base output folder.

    Parameters
    ----------
    config : FlexConfig
        Fully specified optimization configuration including subject,
        ROI definition, electrode geometry, and DE hyperparameters.

    Returns
    -------
    FlexResult
        ``success``, ``output_folder`` (the run directory under the
        subject's ``flex-search/``), per-restart ``function_values``,
        ``best_value`` and ``best_run_index``.

    Raises
    ------
    ValueError
        If the subject's m2m directory or head mesh is missing, a
        referenced ROI/atlas/EEG-net file is missing, ``cpus`` or
        ``n_multistart`` is below 1, ``min_electrode_distance`` is not
        positive, ``enable_mapping`` is set without ``eeg_net``, or an
        ``"ellipse"`` electrode has unequal dimensions (flex-search
        supports circular electrodes only).

    Notes
    -----
    When every restart fails the function does not raise: it returns a
    :class:`FlexResult` with ``success=False``, ``best_value=inf`` and
    ``best_run_index=-1``.  Always check ``result.success``.

    Examples
    --------
    >>> from tit.opt import FlexConfig, run_flex_search
    >>> cfg = FlexConfig(
    ...     subject_id="ernie", goal="mean", postproc="max_TI", current_mA=1.0,
    ...     electrode=FlexConfig.ElectrodeConfig(shape="ellipse", dimensions=[8.0, 8.0]),
    ...     roi=FlexConfig.SphericalROI(x=-35.0, y=5.0, z=5.0, radius=10.0, use_mni=True),
    ...     n_multistart=3, output_folder="insula_mean",
    ... )
    >>> res = run_flex_search(cfg)  # doctest: +SKIP
    >>> res.success, res.best_run_index, len(res.function_values)  # doctest: +SKIP
    (True, 1, 3)

    See Also
    --------
    FlexConfig : Configuration dataclass for flex-search.
    FlexResult : Result container with per-restart function values.
    tit.opt.ex.ex.run_ex_search : Alternative exhaustive grid search.
    """
    from tit.telemetry import track_operation
    from tit import constants as const

    from tit.opt.masks import validate_mask_paths

    validate_mask_paths(config)
    _validate_flex_inputs(config)
    with track_operation(const.TELEMETRY_OP_FLEX_SEARCH):
        return _run_flex_search_inner(config)

tit.opt.config.FlexConfig.SphericalROI dataclass

SphericalROI(x: float | list[float], y: float | list[float], z: float | list[float], radius: float | list[float] = 10.0, use_mni: bool = False, volumetric: bool = False, tissues: str = 'GM')

Spherical region of interest defined by center and radius.

By default the sphere is evaluated on the cortical surface (volumetric=False). Set volumetric=True to evaluate on volume tetrahedra instead -- useful for deep/subcortical targets like the amygdala or hippocampus where surface-only evaluation would capture overlying cortex rather than the target structure.

When volumetric=True, the tissues field controls which tissue compartments are included (same semantics as :class:SubcorticalROI.tissues).

Each of x, y, z, radius accepts either a single value (one sphere) or a list of values (a union of several spheres evaluated as one combined target). The coordinate lists must be non-empty and of equal length; radius may be a scalar (shared by every sphere) or a list matching the number of centers.

Attributes

x : float or list of float Center x-coordinate(s) (mm). y : float or list of float Center y-coordinate(s) (mm). z : float or list of float Center z-coordinate(s) (mm). radius : float or list of float Sphere radius/radii in mm. A scalar is shared by all spheres. use_mni : bool If True, coordinates are in MNI space and SimNIBS will transform them to subject space during ROI setup. volumetric : bool If True, evaluate on volume tetrahedra instead of the cortical surface. tissues : str Tissue compartments to include when volumetric is True. One of "GM", "WM", or "both".

Raises

ValueError If x/y/z are empty or unequal length, or radius is a list whose length neither equals 1 nor the number of centers.

tit.opt.config.FlexConfig.AtlasROI dataclass

AtlasROI(atlas_path: str | list[str], label: int | list[int], hemisphere: str | list[str] = 'lh')

Cortical surface ROI from a FreeSurfer annotation atlas.

Each of atlas_path, label, hemisphere accepts either a single value (one region) or a list (a union of several regions evaluated as one combined target). Because .annot files are per-hemisphere, carrying a per-region hemisphere (and matching atlas_path) allows a target that spans both hemispheres, or even different atlases. Scalars broadcast to the number of labels; lists must match its length.

Attributes

atlas_path : str or list of str Path(s) to the FreeSurfer .annot annotation file(s). label : int or list of int Integer label index/indices within the annotation atlas. hemisphere : str or list of str Hemisphere(s) to use ("lh" or "rh"), one per label.

Raises

ValueError If label is empty, or atlas_path/hemisphere is a list whose length neither equals 1 nor the number of labels.

tit.opt.config.FlexConfig.SubcorticalROI dataclass

SubcorticalROI(atlas_path: str | list[str], label: int | list[int] | None, tissues: str = 'GM', atlas_space: Literal['subject', 'mni'] = 'subject')

Subcortical volume ROI from a volumetric atlas.

label accepts either a single value (one region) or a list (a union of several regions -- e.g. both hippocampi from one aseg atlas -- evaluated as one combined target). atlas_path may be a scalar (shared by every label) or a list matching the number of labels; a single shared tissues and atlas_space apply to the whole union.

Attributes

atlas_path : str or list of str Path(s) to the volumetric atlas NIfTI file(s). label : int or list of int Integer label index/indices, or None to select all positive mask voxels. tissues : str Tissue compartments to include. One of "GM", "WM", or "both". atlas_space : str Space of the atlas NIfTI. One of "subject" or "mni". MNI-space masks are transformed by SimNIBS during ROI setup.

Raises

ValueError If label is empty, or atlas_path is a list whose length neither equals 1 nor the number of labels.

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.

Attributes

shape : str Electrode shape ("ellipse" or "rect"). Default "ellipse". Flex-search supports circular electrodes only, so an "ellipse" must have equal dimensions. dimensions : list of float Electrode dimensions in mm ([width, height]). Default [8.0, 8.0]. gel_thickness : float Conductive gel thickness in mm. Default 4.0.

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.

Attributes

success : bool True if the optimization completed without error. output_folder : str Absolute path to the output directory containing manifests, logs, and optional simulation results. function_values : list of float Objective function value for each multistart run. best_value : float Best (highest) objective value across all restarts. best_run_index : int Zero-based index of the restart that produced the best result.

Examples

res = run_flex_search(cfg) # doctest: +SKIP res.success, res.best_value, res.output_folder # doctest: +SKIP (True, 0.31, '.../flex-search/ernie_...')

See Also

FlexConfig : Configuration consumed by :func:~tit.opt.flex.flex.run_flex_search. tit.opt.flex.flex.run_flex_search : Returns this result.

Exhaustive Search

tit.opt.config.ExConfig dataclass

ExConfig(subject_id: 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, roi_names: list[str] | None = None, roi_atlas: list[AtlasROI] | None = None, roi_coordinate_space: Literal['subject', 'mni'] = 'subject', run_name: str | None = None, n_jobs: int = -1, symmetric_bucket: bool = False, symmetry_eeg_csv: str | None = None, symmetry_pairing: str = 'within_pairs')

Full configuration for exhaustive search optimization.

Exhaustive search evaluates every valid electrode combination from a user-defined pool or bucket set, sweeping current amplitudes at discrete steps.

Attributes

subject_id : str Subject identifier matching the m2m directory name. leadfield_hdf : str Filename of the precomputed leadfield HDF5 (e.g. "ernie_leadfield_EEG10-10_UI_Jurak_2007.hdf5"), resolved under the subject's leadfields/ directory (:meth:tit.paths.PathManager.leadfields); an absolute path is also accepted. roi_name : str ROI CSV filename (e.g. "target.csv") in the subject's ROIs/ directory (:meth:tit.paths.PathManager.rois). The ".csv" suffix is appended automatically if missing. Used as the metric-key prefix and (with the net name) the output-directory label. roi_names : list of str or None Optional list of ROI CSV filenames to union into a single target. When provided (combined mode), the spherical masks of every listed ROI are OR-folded into one region. None (default) keeps single-ROI behavior driven by roi_name. An explicit empty list means "no spherical centers at all" -- useful for a purely atlas-driven ROI (roi_atlas only). Each entry gets the ".csv" suffix appended if missing. roi_atlas : list of AtlasROI or None Volumetric atlas or mask ROI(s) to union with the spherical centers from roi_name/roi_names. None (default) keeps the existing spherical-only behavior. roi_coordinate_space : str Space of the roi_name/roi_names CSV centers -- "subject" (default) or "mni". MNI centers are transformed to subject space with simnibs.mni2subject_coords before the search runs. Does not affect roi_atlas, which declares its own atlas_space. electrodes : BucketElectrodes or PoolElectrodes Electrode specification, either a single shared pool (:class:PoolElectrodes) or separate per-channel buckets (:class:BucketElectrodes). A plain dict is auto-converted in __post_init__. total_current : float Total injected current in mA, split across the two channels. Default 2.0. current_step : float Current amplitude step size in mA for the sweep. Default 0.5. channel_limit : float or None Maximum current per channel in mA. None (default) means total_current - current_step. roi_radius : float Spherical ROI radius in mm for the target region. Default 3.0. run_name : str or None Optional name for this run. Defaults to a datetime stamp. The run name is the output directory, so a repeated name overwrites the earlier run in place. n_jobs : int Worker processes evaluating candidates in parallel. -1 (default) uses the global CPU limit (Settings; 70 % of the container's cores by default, see :func:tit.cpu.cpu_limit); a larger explicit value is clamped to that limit; 1 evaluates in-process. Results and CSV ordering do not depend on it. symmetric_bucket : bool When True in bucket mode, evaluate only left/right mirrored montages (see :func:tit.opt.ex.buckets.build_electrode_mirror_map). symmetry_eeg_csv : str or None EEG-position CSV used to derive mirrored electrode pairs. If unset, it is inferred from the leadfield's net name. symmetry_pairing : str Symmetry interpretation when symmetric_bucket is True. "within_pairs": each pair's minus electrode is the mirror of its plus electrode (e.g. F7-F8). "cross_pairs": pair 2 is the mirror image of pair 1 (e2+ = mirror(e1+), e2- = mirror(e1-)).

Raises

ValueError If current_step, total_current, or channel_limit are non-positive, if symmetric_bucket is set with pool electrodes, if symmetry_pairing is not "within_pairs"/"cross_pairs", or if roi_coordinate_space is not "subject" or "mni".

Examples

from tit.opt import ExConfig cfg = ExConfig( ... subject_id="ernie", ... leadfield_hdf="ernie_leadfield_EEG10-10_UI_Jurak_2007.hdf5", ... roi_name="L-Insula", # ".csv" is appended ... electrodes=ExConfig.PoolElectrodes( ... electrodes=["Fp1", "Fp2", "C3", "C4", "Cz", "Pz", "T7", "T8"]), ... total_current=2.0, current_step=0.5, channel_limit=1.2, ... roi_coordinate_space="mni", ... ) cfg.roi_name 'L-Insula.csv'

Per-channel buckets instead of one pool:

ExConfig.BucketElectrodes(e1_plus=["F7"], e1_minus=["F8"], ... e2_plus=["P7"], e2_minus=["P8"]).e2_minus ['P8']

Then run_ex_search(cfg) (needs the leadfield HDF5 under the subject's leadfield folder).

See Also

ExResult : Result container returned by :func:~tit.opt.ex.ex.run_ex_search. tit.opt.ex.ex.run_ex_search : Consumes this config.

AtlasROI dataclass

AtlasROI(atlas_path: str, label: int | None = None, atlas_space: Literal['subject', 'mni'] = 'subject')

Volumetric atlas or mask ROI, unioned with the spherical center(s).

Attributes

atlas_path : str Path to a volumetric atlas or mask file -- NIfTI (.nii, .nii.gz) or FreeSurfer (.mgz), e.g. one discovered by :class:tit.atlas.voxel.VoxelAtlasManager. label : int or None Integer label to select within the atlas (elements are included where the voxel value equals label). None treats the whole file as a binary mask (voxel value > 0). atlas_space : str Space of the atlas file, "subject" (default) or "mni". MNI masks are warped to subject space before the search.

Raises

ValueError If atlas_space is not "subject" or "mni".

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.

Attributes

e1_plus : list of str Candidate electrodes for channel 1 anode. e1_minus : list of str Candidate electrodes for channel 1 cathode. e2_plus : list of str Candidate electrodes for channel 2 anode. e2_minus : list of str Candidate electrodes for channel 2 cathode.

PoolElectrodes dataclass

PoolElectrodes(electrodes: list[str])

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

Attributes

electrodes : list of str List of electrode names available for any channel position.

run_ex_search(config: ExConfig) -> ExResult

Run an exhaustive two-pair TI search over a precomputed leadfield.

Enumerates every electrode-pair combination allowed by config.electrodes and every current split allowed by total_current/current_step/channel_limit, scores each against the ROI, and writes a ranked CSV plus a config JSON to the run directory under the subject's ex-search/ folder.

Parameters

config : ExConfig Fully specified search configuration (subject, leadfield file, ROI, electrode pool or buckets, current sweep).

Returns

ExResult success, output_dir, n_combinations evaluated, and the results_csv / config_json paths.

Raises

ValueError If no candidate montage can be enumerated (empty pool/buckets or an over-restrictive symmetry rule), or a referenced ROI CSV or atlas file does not exist. FileNotFoundError If config.leadfield_hdf cannot be found under the subject's leadfields/ directory.

Examples

from tit.opt import ExConfig, run_ex_search cfg = ExConfig( ... subject_id="ernie", ... leadfield_hdf="ernie_leadfield_EEG10-10_UI_Jurak_2007.hdf5", ... roi_name="L-Insula", ... electrodes=ExConfig.PoolElectrodes(electrodes=["Fp1", "Fp2", "C3", "C4"]), ... total_current=2.0, current_step=0.5, channel_limit=1.2, ... run_name="insula_pool", ... ) res = run_ex_search(cfg) # doctest: +SKIP res.success, res.n_combinations, res.results_csv # doctest: +SKIP (True, 18, '.../ex-search/insula_pool/final_output.csv')

See Also

ExConfig : Configuration dataclass for this search. ExResult : Returned container. tit.opt.mex.mex.run_m_ex_search : Four-pair (mTI) variant.

Source code in tit/opt/ex/ex.py
def run_ex_search(config: ExConfig) -> ExResult:
    """Run an exhaustive two-pair TI search over a precomputed leadfield.

    Enumerates every electrode-pair combination allowed by
    ``config.electrodes`` and every current split allowed by
    ``total_current``/``current_step``/``channel_limit``, scores each
    against the ROI, and writes a ranked CSV plus a config JSON to the
    run directory under the subject's ``ex-search/`` folder.

    Parameters
    ----------
    config : ExConfig
        Fully specified search configuration (subject, leadfield file,
        ROI, electrode pool or buckets, current sweep).

    Returns
    -------
    ExResult
        ``success``, ``output_dir``, ``n_combinations`` evaluated, and the
        ``results_csv`` / ``config_json`` paths.

    Raises
    ------
    ValueError
        If no candidate montage can be enumerated (empty pool/buckets or
        an over-restrictive symmetry rule), or a referenced ROI CSV or
        atlas file does not exist.
    FileNotFoundError
        If ``config.leadfield_hdf`` cannot be found under the subject's
        ``leadfields/`` directory.

    Examples
    --------
    >>> from tit.opt import ExConfig, run_ex_search
    >>> cfg = ExConfig(
    ...     subject_id="ernie",
    ...     leadfield_hdf="ernie_leadfield_EEG10-10_UI_Jurak_2007.hdf5",
    ...     roi_name="L-Insula",
    ...     electrodes=ExConfig.PoolElectrodes(electrodes=["Fp1", "Fp2", "C3", "C4"]),
    ...     total_current=2.0, current_step=0.5, channel_limit=1.2,
    ...     run_name="insula_pool",
    ... )
    >>> res = run_ex_search(cfg)  # doctest: +SKIP
    >>> res.success, res.n_combinations, res.results_csv  # doctest: +SKIP
    (True, 18, '.../ex-search/insula_pool/final_output.csv')

    See Also
    --------
    ExConfig : Configuration dataclass for this search.
    ExResult : Returned container.
    tit.opt.mex.mex.run_m_ex_search : Four-pair (mTI) variant.
    """
    from tit.telemetry import track_operation
    from tit import constants as const

    with track_operation(const.TELEMETRY_OP_EX_SEARCH):
        return _run_ex_search_inner(config)

tit.opt.config.ExConfig.PoolElectrodes dataclass

PoolElectrodes(electrodes: list[str])

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

Attributes

electrodes : list of str List of electrode names available for any channel position.

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.

Attributes

e1_plus : list of str Candidate electrodes for channel 1 anode. e1_minus : list of str Candidate electrodes for channel 1 cathode. e2_plus : list of str Candidate electrodes for channel 2 anode. e2_minus : list of str Candidate electrodes for channel 2 cathode.

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.

Attributes

success : bool True if the search completed without error. output_dir : str Absolute path to the output directory. n_combinations : int Total number of electrode/current combinations evaluated. results_csv : str or None Path to the CSV file containing ranked results. None if the run failed before writing results. config_json : str or None Path to the saved configuration JSON. None if the run failed before writing config.

See Also

ExConfig : Configuration consumed by :func:~tit.opt.ex.ex.run_ex_search. tit.opt.ex.ex.run_ex_search : Returns this result.

Multipolar Exhaustive Search

tit.opt.config.MExConfig dataclass

MExConfig(subject_id: str, leadfield_hdf: str, roi_name: str, electrodes: BucketElectrodes | PoolElectrodes, current_mA: float = 2.0, roi_radius: float = 3.0, roi_names: list[str] | None = None, roi_atlas: list[AtlasROI] | None = None, roi_coordinate_space: Literal['subject', 'mni'] = 'subject', run_name: str | None = None, n_jobs: int = -1, symmetric_bucket: bool = False, symmetry_eeg_csv: str | None = None, symmetry_pairing: str = 'within_pairs')

Full configuration for multipolar (4-pair, 8-electrode) exhaustive search.

Evaluates every valid combination of four bipolar electrode pairs from a user-defined pool or bucket set, at one fixed current per pair, and scores each candidate with the verified N>2 mTI envelope (:func:tit.calc.get_TI_vectors).

Attributes

subject_id : str Subject identifier matching the m2m directory name. leadfield_hdf : str Filename of the precomputed leadfield HDF5, resolved under the subject's leadfields/ directory (an absolute path also works). roi_name : str ROI CSV filename (e.g. "target.csv") in the subject's ROIs/ directory. The ".csv" suffix is appended automatically if missing. electrodes : BucketElectrodes or PoolElectrodes Electrode specification, either a single shared pool (:class:PoolElectrodes) or four separate per-pair buckets (:class:BucketElectrodes). A plain dict is auto-converted in __post_init__. current_mA : float Current in mA delivered by each of the four pairs. Default 2.0. roi_radius : float Spherical ROI radius in mm for the target region. Default 3.0. roi_names : list of str or None Optional list of ROI CSV filenames to union into a single target. None (default) keeps single-ROI behavior driven by roi_name. An explicit empty list means "no spherical centers at all" -- required for a purely atlas-driven ROI (roi_atlas only), in which case roi_name is only a naming label and no CSV is read. Each entry gets the ".csv" suffix appended if missing. roi_atlas : list of AtlasROI or None Volumetric atlas or mask ROI(s) to union with the spherical center from roi_name. None (default) keeps the existing spherical-only behavior. roi_coordinate_space : str Space of the roi_name CSV center -- "subject" (default) or "mni". An MNI center is transformed to subject space with simnibs.mni2subject_coords before the search runs. Does not affect roi_atlas, which declares its own atlas_space. run_name : str or None Optional name for this run. Defaults to a datetime stamp. n_jobs : int Worker processes evaluating candidates in parallel. -1 (default) uses the global CPU limit (Settings; 70 % of the container's cores by default, see :func:tit.cpu.cpu_limit); a larger explicit value is clamped to that limit; 1 evaluates in-process. Results and CSV ordering do not depend on it. symmetric_bucket : bool When True in bucket mode, evaluate only left/right mirrored electrode pairs (see :func:tit.opt.ex.buckets.build_electrode_mirror_map). symmetry_eeg_csv : str or None EEG-position CSV used to derive mirrored electrode pairs. If unset, it is inferred from the leadfield's net name. symmetry_pairing : str Symmetry interpretation for bucket mode when symmetric_bucket is True. "within_pairs" mirrors each pair's plus/minus electrodes independently; "cross_pairs" additionally mirrors pair 1<->3 and pair 2<->4.

Raises

ValueError If current_mA is non-positive, if symmetric_bucket is set with pool electrodes, if symmetry_pairing is not one of "within_pairs"/"cross_pairs", or if roi_coordinate_space is not "subject" or "mni".

Examples

from tit.opt import MExConfig cfg = MExConfig( ... subject_id="ernie", ... leadfield_hdf="ernie_leadfield_EEG10-10_UI_Jurak_2007.hdf5", ... roi_name="L-Insula", ... electrodes=MExConfig.PoolElectrodes( ... electrodes=["Fp1", "Fp2", "F3", "F4", "C3", "C4", "P3", "P4", "O1", "O2"]), ... current_mA=1.0, ... ) cfg.roi_name, cfg.current_mA ('L-Insula.csv', 1.0)

Then run_m_ex_search(cfg).

See Also

MExResult : Result container returned by :func:~tit.opt.mex.mex.run_m_ex_search. tit.opt.mex.mex.run_m_ex_search : Consumes this config. tit.calc.get_TI_vectors : Modulation-amplitude envelope.

AtlasROI dataclass

AtlasROI(atlas_path: str, label: int | None = None, atlas_space: Literal['subject', 'mni'] = 'subject')

Volumetric atlas or mask ROI, unioned with the spherical center.

Attributes

atlas_path : str Path to a volumetric atlas or mask file -- NIfTI (.nii, .nii.gz) or FreeSurfer (.mgz), e.g. one discovered by :class:tit.atlas.voxel.VoxelAtlasManager. label : int or None Integer label to select within the atlas (elements are included where the voxel value equals label). None treats the whole file as a binary mask (voxel value > 0). atlas_space : str Space of the atlas file, "subject" (default) or "mni". MNI masks are warped to subject space before the search.

Raises

ValueError If atlas_space is not "subject" or "mni".

BucketElectrodes dataclass

BucketElectrodes(e1_plus: list[str], e1_minus: list[str], e2_plus: list[str], e2_minus: list[str], e3_plus: list[str], e3_minus: list[str], e4_plus: list[str], e4_minus: list[str])

Separate electrode lists for each of the four bipolar pairs.

Attributes

e1_plus, e1_minus, e2_plus, e2_minus, e3_plus, e3_minus, e4_plus, e4_minus : list of str Candidate electrodes for each pair's anode/cathode position.

PoolElectrodes dataclass

PoolElectrodes(electrodes: list[str])

Single electrode pool -- all eight positions draw from the same set.

Attributes

electrodes : list of str List of electrode names available for any pair position.

tit.opt.config.MExResult dataclass

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

Result from a multipolar exhaustive search run.

Attributes

success : bool True if the search completed without error. output_dir : str Absolute path to the output directory. n_combinations : int Total number of eight-electrode combinations evaluated. results_csv : str or None Path to the CSV file containing ranked results. None if the run failed before writing results. config_json : str or None Path to the saved configuration JSON. None if the run failed before writing config.

See Also

MExConfig : Configuration consumed by :func:~tit.opt.mex.mex.run_m_ex_search. tit.opt.mex.mex.run_m_ex_search : Returns this result.

run_m_ex_search(config: MExConfig) -> MExResult

Run an exhaustive four-pair (eight-electrode) mTI search over a leadfield.

Enumerates every four-pair combination allowed by config.electrodes at one fixed current_mA per pair, scores each candidate with the N>2 mTI envelope (:func:tit.calc.get_TI_vectors), and writes a ranked CSV plus a config JSON under the subject's m-ex-search/ folder.

Parameters

config : MExConfig Fully specified search configuration.

Returns

MExResult success, output_dir, n_combinations evaluated, and the results_csv / config_json paths.

Raises

ValueError If no candidate montage can be enumerated, or a referenced ROI CSV or atlas file does not exist. FileNotFoundError If config.leadfield_hdf cannot be found under the subject's leadfields/ directory.

Examples

from tit.opt import MExConfig, run_m_ex_search cfg = MExConfig( ... subject_id="ernie", ... leadfield_hdf="ernie_leadfield_EEG10-10_UI_Jurak_2007.hdf5", ... roi_name="L-Insula", ... electrodes=MExConfig.PoolElectrodes( ... electrodes=["Fp1", "Fp2", "F3", "F4", "C3", "C4", "P3", "P4"]), ... current_mA=1.0, ... ) res = run_m_ex_search(cfg) # doctest: +SKIP res.success, res.n_combinations # doctest: +SKIP (True, 105)

See Also

MExConfig : Configuration dataclass for this search. MExResult : Returned container. tit.opt.ex.ex.run_ex_search : Two-pair (TI) variant.

Source code in tit/opt/mex/mex.py
def run_m_ex_search(config: MExConfig) -> MExResult:
    """Run an exhaustive four-pair (eight-electrode) mTI search over a leadfield.

    Enumerates every four-pair combination allowed by
    ``config.electrodes`` at one fixed ``current_mA`` per pair, scores each
    candidate with the N>2 mTI envelope
    (:func:`tit.calc.get_TI_vectors`), and writes a ranked CSV plus a
    config JSON under the subject's ``m-ex-search/`` folder.

    Parameters
    ----------
    config : MExConfig
        Fully specified search configuration.

    Returns
    -------
    MExResult
        ``success``, ``output_dir``, ``n_combinations`` evaluated, and the
        ``results_csv`` / ``config_json`` paths.

    Raises
    ------
    ValueError
        If no candidate montage can be enumerated, or a referenced ROI
        CSV or atlas file does not exist.
    FileNotFoundError
        If ``config.leadfield_hdf`` cannot be found under the subject's
        ``leadfields/`` directory.

    Examples
    --------
    >>> from tit.opt import MExConfig, run_m_ex_search
    >>> cfg = MExConfig(
    ...     subject_id="ernie",
    ...     leadfield_hdf="ernie_leadfield_EEG10-10_UI_Jurak_2007.hdf5",
    ...     roi_name="L-Insula",
    ...     electrodes=MExConfig.PoolElectrodes(
    ...         electrodes=["Fp1", "Fp2", "F3", "F4", "C3", "C4", "P3", "P4"]),
    ...     current_mA=1.0,
    ... )
    >>> res = run_m_ex_search(cfg)  # doctest: +SKIP
    >>> res.success, res.n_combinations  # doctest: +SKIP
    (True, 105)

    See Also
    --------
    MExConfig : Configuration dataclass for this search.
    MExResult : Returned container.
    tit.opt.ex.ex.run_ex_search : Two-pair (TI) variant.
    """
    return _run_m_ex_search_inner(config)

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.

Wraps SimNIBS TDCSLEADFIELD to produce HDF5 leadfield files that the exhaustive-search and flex-search pipelines consume.

Parameters

subject_id : str Subject identifier (e.g. "101"). electrode_cap : str EEG cap name without .csv (e.g. "GSN-HydroCel-185"). progress_callback : callable or None Optional callback(message, level) for GUI progress updates. termination_flag : callable or None Optional callable returning True when the user cancels.

See Also

tit.opt.ex.engine.ExSearchEngine : Consumes the generated leadfield.

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:
    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

output_dir : str or Path or None Output directory. Defaults to pm.leadfields(subject_id). tissues : list of int or None Tissue tags (1 = WM, 2 = GM). Default: [1, 2]. cleanup : bool Remove stale SimNIBS artefacts before running.

Returns

Path Path to the generated HDF5 leadfield file.

Raises

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.

    Parameters
    ----------
    output_dir : str or Path or None
        Output directory.  Defaults to
        ``pm.leadfields(subject_id)``.
    tissues : list of int or None
        Tissue tags (1 = WM, 2 = GM).  Default: ``[1, 2]``.
    cleanup : bool
        Remove stale SimNIBS artefacts before running.

    Returns
    -------
    Path
        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

subject_id : str or None Subject ID. Defaults to self.subject_id.

Returns

list of 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.

    Parameters
    ----------
    subject_id : str or None
        Subject ID.  Defaults to ``self.subject_id``.

    Returns
    -------
    list of tuple[str, str, float]
        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]] = []
    if not leadfields_dir.is_dir():
        return out

    for item in leadfields_dir.iterdir():
        if item.suffix != ".hdf5":
            continue

        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

cap_name : str or None EEG cap name (without .csv). Defaults to self.electrode_cap.

Returns

list of 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.

    Parameters
    ----------
    cap_name : str or None
        EEG cap name (without ``.csv``).  Defaults to
        ``self.electrode_cap``.

    Returns
    -------
    list of str
        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())