Skip to content

opt

tit.opt

TI-Toolbox optimization package.

Provides two electrode-placement optimization strategies for temporal interference (TI) brain stimulation:

  • Flex-search -- differential-evolution (DE) optimization via SimNIBS TesFlexOptimization. Finds continuous electrode positions on the scalp that maximize field strength, peak intensity, or focality in a user-defined ROI.
  • Exhaustive search -- brute-force grid evaluation over a discrete electrode pool, sweeping current amplitudes at fixed step sizes.
  • Multipolar exhaustive search -- the same brute-force evaluation extended to four bipolar electrode pairs (eight electrodes), scored with the N>2 mTI envelope.

Public API

FlexConfig Configuration dataclass for flex-search optimization. FlexResult Result container returned by :func:run_flex_search. ExConfig Configuration dataclass for exhaustive search. ExResult Result container returned by :func:run_ex_search. MExConfig Configuration dataclass for multipolar exhaustive search. MExResult Result container returned by :func:run_m_ex_search. run_flex_search Run differential-evolution electrode placement optimization. run_ex_search Run exhaustive grid search over electrode combinations. run_m_ex_search Run multipolar exhaustive grid search over four electrode pairs. run_adaptive_focality Flex-search mean calibration, then focality with thresholds from it. run_pareto_sweep Flex-search mean calibration, then a grid of focality thresholds.

See Also

tit.opt.flex : Flex-search subpackage with builder, manifest, and pareto utilities. tit.opt.ex : Exhaustive-search subpackage with engine and result handling. tit.opt.mex : Multipolar exhaustive-search subpackage. tit.opt.leadfield : Leadfield matrix generation via SimNIBS.

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(), ... roi=FlexConfig.SphericalROI(x=-35.0, y=5.0, z=5.0, radius=10.0, use_mni=True), ... ) res = run_flex_search(cfg) # doctest: +SKIP res.success, res.best_value # doctest: +SKIP (True, 0.31)

Notes

The runners (run_flex_search, the flex drivers, run_ex_search and run_m_ex_search) are loaded lazily on first attribute access so that importing tit.opt for its config classes does not import SimNIBS.

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 channels 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 Focality thresholds in V/m for the ROC-based "focality" goal: one value (non-ROI max = ROI min) or two, "<non-ROI max>,<ROI min>" (e.g. "0.1,0.2"), each finite and greater than 0, the first not above the second (SimNIBS measures.ROC's safety gap). A number or a list of numbers is stored as that text (0.2 -> "0.2"). None and the placeholders "", "dynamic", "auto" mean unset and are stored as None. A single "focality" run requires them -- SimNIBS has no default -- while mode="flex_adaptive" / "flex_pareto" (or a config carrying adaptive / pareto settings, whose mode the job kind supplies) derive them per step. 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" in mode="flex" requires explicit thresholds (the adaptive and multi-threshold modes derive them per step), 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; a relative name is a folder under the subject's flex-search/. 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 distance (mm) SimNIBS keeps between any two electrodes, greater than 0. Default 5.0. SimNIBS measures it as the straight-line distance between the skin-mesh nodes under each electrode (check_electrode_distance), not edge to edge: nodes sit inside the footprint, so the physical gap between two discs (centre distance minus both radii) can be several millimetres smaller -- 1.75 mm for the 5 mm default between 8 mm discs on a real head (audit 2026-10-22). 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 Accepted for compatibility and ignored: every flex-search writes valid_skin_region.png (the report shows it), so :func:tit.opt.flex.builder.build_optimization always turns the picture on. 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 or equal to roi, if thresholds is malformed (see thresholds) or missing for a single "focality" run, if current_mA, min_electrode_distance, an electrode size or a sphere radius is not a positive finite number (or a sphere centre is not finite), if n_multistart or cpus is below 1, if an "ellipse" electrode has unequal dimensions, if a subcortical atlas_space is neither "subject" nor "mni", if output_folder's last component breaks :data:tit.paths.NAME_RE, 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" in mode="flex" without explicit thresholds, if detailed_results is combined with a callable-goal configuration (goal="focality_tf", optimize_current_ratio or observe_background), 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 envelope over all directions. DIR_TI_NORMAL : str TI envelope along the cortical surface normal. DIR_TI_TANGENTIAL : str Maximum TI envelope over the directions tangential to the cortical surface (within its plane).

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, or tissues is not "GM", "WM" or "both".

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, or a hemisphere is not "lh" or "rh".

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, or tissues is not "GM", "WM" or "both".

ElectrodeConfig dataclass

ElectrodeConfig(shape: str = 'ellipse', dimensions: list[float] = (lambda: [8.0, 8.0])(), gel_thickness: float = 1.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 (saline) thickness in mm. Default 1.0.

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 objective value across all restarts: the lowest, because SimNIBS minimises. A maximised goal is minimised as its negation, so for "mean" this is -mean field in the ROI (V/m). 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.

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 (used only with a channel_limit). Default 0.5. channel_limit : float or None Maximum current per channel in mA. None (default) runs the single balanced split, total_current / 2 on each channel; set a limit above total_current / 2 to sweep splits in current_step increments. 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 channel's minus electrode is the mirror of its plus electrode (e.g. F7-F8). "cross_pairs": channel 2 is the mirror image of channel 1 (e2+ = mirror(e1+), e2- = mirror(e1-)).

Raises

ValueError If current_step, total_current, channel_limit or roi_radius is not a finite number above 0, 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.

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.

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 (four-channel) exhaustive search.

Evaluates every valid combination of four channels (eight electrodes) from a user-defined pool or bucket set, at one fixed current per channel, and scores each candidate with the joint K-pair envelope maximum (:func:tit.calc.envelope_max).

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 separate per-channel buckets (:class:BucketElectrodes). A plain dict is auto-converted in __post_init__. current_mA : float Current in mA delivered by each of the four channels. 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 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 for bucket mode when symmetric_bucket is True. "within_pairs": each channel's minus electrode is the mirror of its plus electrode. "cross_pairs" instead (not in addition): channel 3 mirrors channel 1 and channel 4 mirrors channel 2 (e3+ = mirror(e1+), e3- = mirror(e1-), and so on).

Raises

ValueError If current_mA or roi_radius is not a finite number above 0, 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.envelope_max : Maximum of the TI/mTI envelope over direction.

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 channels' electrodes.

Attributes

e1_plus, e1_minus, e2_plus, e2_minus, e3_plus, e3_minus, e4_plus, e4_minus : list of str Candidate electrodes for each channel's Ek+ / Ek- 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 of the eight positions.

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_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), an electrode is not in the leadfield (raised before it is loaded), a referenced ROI CSV or atlas file does not exist, or the ROI selects no leadfield mesh element (raised before the search; no results are written). 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), an electrode is not in the
        leadfield (raised before it is loaded), a referenced ROI CSV or atlas
        file does not exist, or the ROI selects no leadfield mesh element
        (raised before the search; no results are written).
    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)

run_adaptive_focality

run_adaptive_focality(config: FlexConfig) -> FlexResult

Run step 1 (mean) then step 2 (focality with adaptive thresholds).

Parameters

config : FlexConfig config.mode should be "flex_adaptive" and config.goal must be "focality" (enforced by :meth:FlexConfig.__post_init__); config.adaptive supplies roi_percentage/nonroi_percentage (defaulted to 80/20 if None).

Both steps write into the job's one run folder (config.output_folder or a new timestamped one): the mean calibration in its mean_calibration/ subfolder without a report, the focality run in the folder itself with the job's report. A previous run's outputs in that folder are removed first.

Returns

FlexResult The step-2 focality run's result, unless step 1 fails -- in which case that (failed) mean-run result is returned unchanged and step 2 never starts.

See Also

tit.opt.flex.flex.run_flex_search : The single-run primitive used for both steps. FlexConfig.AdaptiveFocalityConfig : The threshold-percentage config.

Source code in tit/opt/flex/drivers.py
def run_adaptive_focality(config: FlexConfig) -> FlexResult:
    """Run step 1 (mean) then step 2 (focality with adaptive thresholds).

    Parameters
    ----------
    config : FlexConfig
        ``config.mode`` should be ``"flex_adaptive"`` and ``config.goal``
        must be ``"focality"`` (enforced by
        :meth:`FlexConfig.__post_init__`); ``config.adaptive`` supplies
        *roi_percentage*/*nonroi_percentage* (defaulted to 80/20 if
        ``None``).

    Both steps write into the job's one run folder (``config.output_folder``
    or a new timestamped one): the mean calibration in its
    ``mean_calibration/`` subfolder without a report, the focality run in
    the folder itself with the job's report. A previous run's outputs in
    that folder are removed first.

    Returns
    -------
    FlexResult
        The step-2 focality run's result, unless step 1 fails -- in which
        case that (failed) mean-run result is returned unchanged and step
        2 never starts.

    See Also
    --------
    tit.opt.flex.flex.run_flex_search : The single-run primitive used for
        both steps.
    FlexConfig.AdaptiveFocalityConfig : The threshold-percentage config.
    """
    from tit.jobs import events

    from tit.opt.flex.flex import clear_previous_run, run_flex_search

    adaptive = config.adaptive or FlexConfig.AdaptiveFocalityConfig()
    folder = _job_folder(config)
    # Before calibrating, as the Pareto driver does: a failed calibration must not leave the
    # previous run's manifest beside the new mean_calibration/.
    clear_previous_run(folder, _logger(config))

    events.emit_stage("mean_optimization", i=0, n=2)
    mean_result = _calibrate(config, folder)
    events.emit_progress(50.0)
    if not mean_result.success:
        return mean_result

    achievable = _achievable_intensity(mean_result)
    nonroi_threshold = achievable * adaptive.nonroi_percentage / 100.0
    roi_threshold = achievable * adaptive.roi_percentage / 100.0

    events.emit_stage("focality_optimization", i=1, n=2)
    result = run_flex_search(
        _step_config(
            config,
            goal=FlexConfig.OptGoal.FOCALITY,
            thresholds=f"{nonroi_threshold:.4f},{roi_threshold:.4f}",
            output_folder=folder,
        )
    )
    events.emit_progress(100.0)
    return result

run_pareto_sweep

run_pareto_sweep(config: FlexConfig) -> FlexResult

Run the mean calibration, then every (roi_pct, nonroi_pct) combination.

Persists pareto_results.json and a scatter-plot PNG under the sweep's base output folder (:func:tit.opt.flex.pareto.save_results), which also promotes the best-scoring point's optimizer output into that folder, records the sweep in its flex_meta.json (pareto) and removes the per-point numbered subdirectories. The mean calibration runs in mean_calibration/; the points and the calibration write no report, the sweep folder gets the job's one. A previous run's outputs in the base folder are removed first.

Parameters

config : FlexConfig config.mode should be "flex_pareto" and config.goal must be "focality" (enforced by :meth:FlexConfig.__post_init__); config.pareto supplies roi_pcts/nonroi_pcts (defaulted if None). config.output_folder, if set, names the sweep's base folder; otherwise one is auto-generated under the subject's flex-search directory.

Returns

FlexResult A summary over the whole sweep: success is True if at least one grid point completed, output_folder is the sweep's base folder (post-promotion), function_values lists every completed point's focality score, and best_value/ best_run_index name the best-scoring point (most negative focality_score, matching tit.opt.flex.pareto's own best-run convention) and its position in run order.

See Also

tit.opt.flex.pareto : Grid computation and persistence helpers used here. FlexConfig.ParetoSweepConfig : The threshold-percentage-grid config.

Source code in tit/opt/flex/drivers.py
def run_pareto_sweep(config: FlexConfig) -> FlexResult:
    """Run the mean calibration, then every (roi_pct, nonroi_pct) combination.

    Persists ``pareto_results.json`` and a scatter-plot PNG under the
    sweep's base output folder (:func:`tit.opt.flex.pareto.save_results`),
    which also promotes the best-scoring point's optimizer output into
    that folder, records the sweep in its ``flex_meta.json`` (``pareto``)
    and removes the per-point numbered subdirectories. The mean
    calibration runs in ``mean_calibration/``; the points and the
    calibration write no report, the sweep folder gets the job's one.
    A previous run's outputs in the base folder are removed first.

    Parameters
    ----------
    config : FlexConfig
        ``config.mode`` should be ``"flex_pareto"`` and ``config.goal``
        must be ``"focality"`` (enforced by
        :meth:`FlexConfig.__post_init__`); ``config.pareto`` supplies
        *roi_pcts*/*nonroi_pcts* (defaulted if ``None``).
        ``config.output_folder``, if set, names the sweep's base folder;
        otherwise one is auto-generated under the subject's flex-search
        directory.

    Returns
    -------
    FlexResult
        A summary over the whole sweep: ``success`` is True if at least
        one grid point completed, ``output_folder`` is the sweep's base
        folder (post-promotion), ``function_values`` lists every
        completed point's focality score, and ``best_value``/
        ``best_run_index`` name the best-scoring point (most negative
        ``focality_score``, matching ``tit.opt.flex.pareto``'s own
        best-run convention) and its position in run order.

    See Also
    --------
    tit.opt.flex.pareto : Grid computation and persistence helpers used
        here.
    FlexConfig.ParetoSweepConfig : The threshold-percentage-grid config.
    """
    from tit.jobs import events

    from tit.opt.flex.flex import _write_report, clear_previous_run, run_flex_search
    from tit.opt.flex.pareto import (
        ParetoSweepConfig as _InternalParetoConfig,
        ParetoSweepResult,
        build_focality_config,
        compute_sweep_grid,
        save_results,
    )

    pareto = config.pareto or FlexConfig.ParetoSweepConfig()
    base_folder = _job_folder(config)
    clear_previous_run(base_folder, _logger(config))

    n_points = len(pareto.roi_pcts) * len(pareto.nonroi_pcts)
    total_steps = n_points + 1

    events.emit_stage("mean_optimization", i=0, n=total_steps)
    mean_result = _calibrate(config, base_folder)
    events.emit_progress(100.0 / total_steps)
    if not mean_result.success:
        return FlexResult(
            success=False,
            output_folder=mean_result.output_folder,
            function_values=[],
            best_value=mean_result.best_value,
            best_run_index=-1,
        )

    achievable = _achievable_intensity(mean_result)
    points = compute_sweep_grid(
        pareto.roi_pcts, pareto.nonroi_pcts, achievable, base_folder
    )
    sweep_config = _InternalParetoConfig(
        roi_pcts=pareto.roi_pcts,
        nonroi_pcts=pareto.nonroi_pcts,
        achievable_roi_mean=achievable,
        base_output_folder=base_folder,
    )
    sweep_result = ParetoSweepResult(config=sweep_config, points=points)

    for idx, point in enumerate(points):
        events.emit_stage(
            f"sweep_point_{idx + 1}_of_{n_points}", i=idx + 1, n=total_steps
        )
        os.makedirs(point.output_folder, exist_ok=True)
        point.status = "running"
        # A point's own report would name its folder, which save_results removes.
        point_result = run_flex_search(
            build_focality_config(config, point), report=False
        )
        if point_result.success:
            point.focality_score = point_result.best_value
            point.status = "done"
        else:
            point.status = "failed"
        events.emit_progress(100.0 * (idx + 2) / total_steps)

    json_path, plot_path = save_results(sweep_result, base_folder)
    events.emit_artifact(json_path, kind="json", label="pareto_results")
    events.emit_artifact(plot_path, kind="png", label="pareto_sweep_plot")
    _write_report(config, base_folder, _logger(config))

    done = [p for p in points if p.status == "done" and p.focality_score is not None]
    best_value = 0.0
    best_index = -1
    if done:
        best = min(done, key=lambda p: p.focality_score)
        best_value = best.focality_score
        best_index = points.index(best)

    return FlexResult(
        success=bool(done),
        output_folder=base_folder,
        function_values=[p.focality_score for p in done],
        best_value=best_value,
        best_run_index=best_index,
    )
run_flex_search(config: FlexConfig, *, report: bool = True) -> 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.

config.mode "flex_adaptive" or "flex_pareto" runs :func:~tit.opt.flex.drivers.run_adaptive_focality or :func:~tit.opt.flex.drivers.run_pareto_sweep instead (they write their own report; report applies to a single run).

A relative output_folder names a run folder under the subject's flex-search/ directory.

An existing output_folder is re-used in place: the outputs a previous run wrote there (:data:RUN_OUTPUTS) are removed first, so the folder describes exactly this run; other files are left alone.

Parameters

config : FlexConfig Fully specified optimization configuration including subject, ROI definition, electrode geometry, and DE hyperparameters. report : bool, optional Write the flex-search report (default). The adaptive and Pareto drivers pass False for their intermediate steps and write one report for the job's own folder.

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, *, report: bool = True) -> 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.

    ``config.mode`` ``"flex_adaptive"`` or ``"flex_pareto"`` runs
    :func:`~tit.opt.flex.drivers.run_adaptive_focality` or
    :func:`~tit.opt.flex.drivers.run_pareto_sweep` instead (they write
    their own report; *report* applies to a single run).

    A relative ``output_folder`` names a run folder under the subject's
    ``flex-search/`` directory.

    An existing ``output_folder`` is re-used in place: the outputs a
    previous run wrote there (:data:`RUN_OUTPUTS`) are removed first, so
    the folder describes exactly this run; other files are left alone.

    Parameters
    ----------
    config : FlexConfig
        Fully specified optimization configuration including subject,
        ROI definition, electrode geometry, and DE hyperparameters.
    report : bool, optional
        Write the flex-search report (default). The adaptive and Pareto
        drivers pass ``False`` for their intermediate steps and write one
        report for the job's own folder.

    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

    if config.mode is not FlexConfig.Mode.FLEX:
        from .drivers import run_adaptive_focality, run_pareto_sweep

        if config.mode is FlexConfig.Mode.FLEX_ADAPTIVE:
            return run_adaptive_focality(config)
        return run_pareto_sweep(config)

    validate_mask_paths(config)
    _validate_flex_inputs(config)
    # Set up file logging -- capture both tit and simnibs output -- for this run only.
    logs_dir = get_path_manager().logs(config.subject_id)
    os.makedirs(logs_dir, exist_ok=True)
    log_file = os.path.join(
        logs_dir, f'flex_search_{time.strftime("%Y%m%d_%H%M%S")}.log'
    )
    handlers = [
        (name, add_file_handler(log_file, logger_name=name))
        for name in (f"tit.opt.flex.{config.subject_id}", "simnibs")
    ]
    try:
        with track_operation(const.TELEMETRY_OP_FLEX_SEARCH):
            return _run_flex_search_inner(config, report=report)
    finally:
        for name, handler in handlers:
            logging.getLogger(name).removeHandler(handler)
            handler.close()
run_m_ex_search(config: MExConfig) -> MExResult

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

Enumerates every four-channel combination allowed by config.electrodes at one fixed current_mA per channel, scores each candidate with the joint K-pair envelope maximum (:func:tit.calc.envelope_max), 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, an electrode is not in the leadfield (raised before it is loaded), a referenced ROI CSV or atlas file does not exist, or the ROI selects no leadfield mesh element (raised before the search; no results are written). 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-channel (TI) variant.

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

    Enumerates every four-channel combination allowed by
    ``config.electrodes`` at one fixed ``current_mA`` per channel, scores each
    candidate with the joint K-pair envelope maximum
    (:func:`tit.calc.envelope_max`), 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, an electrode is not in
        the leadfield (raised before it is loaded), a referenced ROI CSV or
        atlas file does not exist, or the ROI selects no leadfield mesh
        element (raised before the search; no results are written).
    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-channel (TI) variant.
    """
    return _run_m_ex_search_inner(config)