Skip to content

sim

tit.sim

TI/mTI simulation engine.

This package implements temporal interference (TI) and multi-channel temporal interference (mTI) brain stimulation simulations. It wraps the SimNIBS finite-element solver, providing electrode configuration, field computation, and BIDS-compliant output organization.

Public API

BaseSimulation Abstract base class for TI/mTI simulation pipelines. SimulationConfig Dataclass holding all parameters for a simulation run. Montage Dataclass describing a named electrode montage. MontageMode Enum saying how a montage's electrodes are specified (net labels or XYZ coordinates); also reachable as Montage.Mode. SimulationMode Enum distinguishing TI (2-pair) from mTI (4+-pair) mode. parse_intensities Parse a comma-separated intensity string into a float list. run_simulation Execute simulations for every montage in a configuration. load_montages Load the named montages (only those, in that order) from the project's montage_list.json. list_montage_names List the montage names available under an EEG net -- call this first to see what load_montages can return. load_montage_data Load the full montage_list.json as a dict. save_montage_data Write a montage dict to montage_list.json. ensure_montage_file Return (and optionally create) the path to montage_list.json. upsert_montage Insert or update a montage definition in montage_list.json.

See Also

tit.sim.config : Configuration dataclasses and enums. tit.sim.utils : Orchestration, montage I/O, and post-processing helpers. tit.sim.TI : 2-pair TI simulation implementation. tit.sim.mTI : N-pair mTI simulation implementation. tit.opt : Optimization modules that consume simulation results. tit.analyzer : Field analysis applied to simulation outputs.

Examples

from tit.sim import SimulationConfig, run_simulation, load_montages, list_montage_names list_montage_names("GSN-HydroCel-185.csv", mode="U") # doctest: +SKIP ['L_Insula', 'R_Insula'] montages = load_montages(["L_Insula"], eeg_net="GSN-HydroCel-185.csv") # doctest: +SKIP cfg = SimulationConfig(subject_id="ernie", montages=montages) # doctest: +SKIP run_simulation(cfg) # doctest: +SKIP

SimulationConfig dataclass

SimulationConfig(subject_id: str, montages: list[Montage], conductivity: str = 'scalar', intensities: list[float] = (lambda: [1.0, 1.0])(), electrode_shape: str = 'ellipse', electrode_dimensions: list[float] = (lambda: [8.0, 8.0])(), gel_thickness: float = 1.0, rubber_thickness: float = 2.0, map_to_vol: bool = False, map_to_mni: bool = False, map_to_fsavg: bool = True, aniso_maxratio: float = 10.0, aniso_maxcond: float = 2.0, output_fields: list[str] | None = None, tissue_conductivities: dict[int, float] | None = None, carrier_only: bool = False)

Full configuration for a TI or mTI simulation run.

Passed to :func:tit.sim.run_simulation to execute one or more montage simulations for a single subject. Electrode geometry, conductivity model, and output mapping options are all set here.

Attributes

subject_id : str Subject identifier without the sub- prefix (e.g. "ernie", "101"); must match an existing m2m_<subject_id> directory. montages : list[Montage] One or more :class:Montage definitions to simulate. Two channels (electrode pairs) is TI, four or more (even) is mTI -- detected per montage, so a list may mix both. conductivity : str Tissue conductivity model. One of:

- ``"scalar"`` -- isotropic scalar conductivities (default).
- ``"vn"`` -- volume-normalized anisotropic conductivities.
- ``"dir"`` -- directly-mapped anisotropic conductivities.
- ``"mc"`` -- mean-conductivity anisotropic conductivities.

The anisotropic modes (``"vn"``, ``"dir"``, ``"mc"``) require
DTI tensors registered to the head mesh.

intensities : list[float] Current per channel in mA, in electrode_pairs order (one value per channel). A TI montage reads the first two values; an mTI montage needs one value per channel (len(intensities) >= num_pairs). A single value is not broadcast -- a 4-channel montage with the default two values is rejected. Default [1.0, 1.0]. electrode_shape : str Electrode shape, "ellipse" or "rect". Default "ellipse". electrode_dimensions : list[float] [width, height] of each electrode in mm. Default [8.0, 8.0]. gel_thickness : float Conductive-gel (saline) layer thickness in mm, between the skin and the rubber. Default 1.0. rubber_thickness : float Rubber (silicone) layer thickness in mm. Default 2.0. map_to_vol : bool Write NIfTI volumes of the field meshes (SimNIBS's own meaning of the name; tit.tools.mesh2nii does the conversion after the run): one 3-D <mesh>_subject_<field>.nii.gz per field of each field mesh -- the brain-tissue mesh and its grey- and white-matter copies -- so the NIfTI set is the mesh field set, name for name, plus each channel's magnE. Default False: the field meshes are the output, and a step that needs a NIfTI (a voxel analysis, the group tools) writes the one it reads on demand (:mod:tit.volume_fields). map_to_mni : bool Also write each field NIfTI in MNI space (<mesh>_MNI_MNI_<field>.nii.gz) and the T1 in MNI space. MNI output is itself a NIfTI, so True turns map_to_vol on as well (subject and MNI NIfTIs). Default False. map_to_fsavg : bool After each TI montage finishes, project its surface fields (TI_max, TI_normal, hf_peak, hf_rms) onto fsaverage5 for group surface analysis. Default True; set False to skip. Failures are logged and never abort the simulation. aniso_maxratio : float Maximum eigenvalue ratio clamp for anisotropic conductivity tensors. Default 10.0. aniso_maxcond : float Maximum absolute conductivity clamp (S/m) for anisotropic tensors. Default 2.0. output_fields : list[str] Which volume-mesh fields to compute and write. Logical names from :data:tit.constants.SELECTABLE_OUTPUT_FIELDS: "TI_max", "TI_avg", "hf_peak", "hf_rms", "hf_power_density". "TI_max" maps to mTI_max on disk for mTI meshes. None (the default) is :data:tit.constants.DEFAULT_OUTPUT_FIELDS -- ["TI_max", "hf_peak", "hf_rms"], the envelope and the carrier fields an analysis's carrier metrics read -- or, for a carrier_only run, which has no envelope, ["hf_peak", "hf_rms"]. TI_avg and hf_power_density are opt-in; a field not saved here is computed later, from the kept channel meshes, by the step that needs it (hf_power_density excepted: its conductivity is not kept). hf_power_density (W/m^3) uses the conductivity of this run's own solve, the full tensor under the anisotropic models. tissue_conductivities : dict[int, float] or None Per-tissue conductivity overrides (S/m), keyed by SimNIBS tissue number (1-based, matching tdcs.cond index + 1). None (the default) uses SimNIBS's own tissue defaults for every tissue. JSON object keys are always strings, so this round-trips as {"<tissue number>": <S/m>} on disk; :func:tit.config_io.deserialize_config coerces the keys back to int, and this dataclass's own __post_init__ does the same for an instance built directly with string keys. Applied to every channel's TDCS list by tit.sim.base.BaseSimulation._apply_tissue_conductivities and recorded in the run's config.json. carrier_only : bool Carrier-only control: both channels run at the same frequency, in phase (ΔF = 0), so there is no beat. The carrier fields hf_peak, hf_rms and hf_power_density then add the two channel fields coherently (|E1+E2|, see :func:tit.calc.hf_peak) and no envelope field (TI_max, TI_avg, TI_normal) is computed. Requires a two-channel TI montage; an explicit output_fields must be drawn from those carrier fields (the default already is). Default False.

Raises

ValueError If conductivity is not one of the valid model names, if output_fields contains an unknown name or is empty, if any montage has a channel that is not exactly two electrodes, if intensities is shorter than a montage needs (2 for TI, one per channel for mTI), if tissue_conductivities contains a tissue number outside 1..1000 or a non-positive value, if electrode_shape is not "ellipse" or "rect" or any electrode dimension, gel_thickness or rubber_thickness is not a finite number above 0, if carrier_only is set with an envelope output field or a montage that is not two-channel TI. Filesystem checks (the m2m directory, the EEG-net CSV) happen later, in :func:run_simulation.

Examples

from tit.sim import SimulationConfig, Montage, MontageMode montage = Montage( ... name="L_Insula", mode=MontageMode.NET, ... electrode_pairs=[("E010", "E011"), ("E012", "E013")], ... eeg_net="GSN-HydroCel-185.csv", ... ) cfg = SimulationConfig( ... subject_id="ernie", ... montages=[montage], ... conductivity="scalar", ... intensities=[1.0, 1.0], ... electrode_shape="ellipse", ... electrode_dimensions=[8.0, 8.0], ... output_fields=["TI_max", "hf_peak"], ... ) cfg.output_fields ['TI_max', 'hf_peak']

A carrier-only control of the same montage (both channels at one frequency, in phase; no envelope, so only carrier fields):

sham = SimulationConfig( ... subject_id="ernie", montages=[montage], intensities=[1.0, 1.0], ... carrier_only=True, ... ) sham.output_fields ['hf_peak', 'hf_rms']

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

See Also

Montage : Electrode montage contained in montages. run_simulation : Entry point that consumes this config. parse_intensities : Helper to build the intensities list.

Montage dataclass

Montage(name: str, mode: MontageMode, electrode_pairs: list[tuple[str | list[float], str | list[float]]], eeg_net: str | None = None, display_name: str | None = None, electrode_poses: list[list[list[float]]] | None = None, provenance: dict[str, str] | None = None)

A named electrode montage used in a TI/mTI simulation.

Wraps the channel definitions of a single montage: one electrode pair per channel, channel k being electrodes Ek+ and Ek-. Electrodes may be referenced by EEG-cap label names (NET / FLEX_MAPPED modes) or by 3-D XYZ coordinates (FLEX_FREE / FREEHAND modes).

The simulation type is auto-detected from the number of channels: 2 channels = standard TI, 4 or more (even) = multi-channel mTI.

Attributes

name : str Human-readable montage name (e.g. "M1_left"). mode : MontageMode How electrode positions are specified. See :class:MontageMode. electrode_pairs : list[tuple] One entry per channel, in channel order. Each element is a tuple of two electrode identifiers (label strings or XYZ coordinate lists), (Ek+, Ek-). eeg_net : str or None Filename of the EEG-net CSV (e.g. "GSN-HydroCel-185.csv"). Required for NET and FLEX_MAPPED modes, ignored otherwise. display_name : str or None Optional user-facing label. name remains the storage and lookup key. electrode_poses : list[list[list[float]]] or None Optional full 4x4 homogeneous pose (row-major) per electrode, one per XYZ position in electrode_pairs order. Only meaningful for XYZ modes; preserves rectangular-electrode orientation when a flex-search candidate is replayed. None (default) lets SimNIBS orient the electrodes itself. provenance : dict[str, str] or None Free-form origin metadata (e.g. {"head_mesh_sha256": ...}) checked by :func:run_simulation when present.

Raises

ValueError If electrode_poses is given for a label-based montage, does not contain exactly one 4x4 right-handed homogeneous transform per electrode, or its translations do not match electrode_pairs.

Examples

from tit.sim import Montage, MontageMode m = Montage( ... name="L_Insula", ... mode=MontageMode.NET, ... electrode_pairs=[("E010", "E011"), ("E012", "E013")], ... eeg_net="GSN-HydroCel-185.csv", ... ) m.num_pairs, m.simulation_mode.value, m.is_xyz (2, 'TI', False)

A free-hand montage uses subject-space millimetre coordinates and no net:

free = Montage( ... name="custom", ... mode=MontageMode.FREEHAND, ... electrode_pairs=[([-60.0, 10.0, 40.0], [60.0, 10.0, 40.0]), ... ([-60.0, -40.0, 40.0], [60.0, -40.0, 40.0])], ... ) free.is_xyz True

See Also

SimulationConfig : Holds one or more Montage instances. load_montages : Build Montage objects from montage_list.json.

is_xyz property

is_xyz: bool

Whether electrodes are specified as 3-D XYZ coordinates.

Returns

bool True for FLEX_FREE and FREEHAND modes.

simulation_mode property

simulation_mode: SimulationMode

Infer TI vs mTI from the number of channels (electrode pairs).

Returns

SimulationMode SimulationMode.TI for 2 channels, SimulationMode.MTI for 4 or more (even).

Raises

ValueError If the channel count is not allowed by :func:tit.constants.is_valid_pair_count -- an even count of at least 2. One channel is tACS, not TI; an odd count leaves a channel with nothing to beat against.

See Also

SimulationMode : The returned enum type.

num_pairs property

num_pairs: int

Number of channels (electrode pairs) in this montage.

Returns

int Length of :attr:electrode_pairs.

MontageMode

Bases: Enum

How electrode positions are specified.

NET and FLEX_MAPPED use EEG-cap label names resolved against an EEG-net CSV. FLEX_FREE and FREEHAND use raw 3-D XYZ coordinates (no net required).

Attributes

NET : str Standard EEG-cap electrode labels. FLEX_MAPPED : str Flex-search result mapped back to EEG-cap labels. FLEX_FREE : str Flex-search result with free XYZ coordinates. FREEHAND : str User-specified XYZ coordinates (manual placement).

SimulationMode

Bases: Enum

Simulation type: standard two-channel TI or multi-channel mTI.

Attributes

TI : str Standard two-channel temporal interference. MTI : str Multi-channel temporal interference (4 or more channels).

See Also

Montage.simulation_mode : Auto-detects mode from the channel count.

BaseSimulation

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

Bases: ABC

Abstract base class for TI/mTI simulations.

Provides the template-method run pipeline that subclasses customise by implementing _build_session and _post_process.

Parameters

config : SimulationConfig Fully specified simulation configuration. montage : Montage The electrode montage to simulate. logger : logging.Logger Logger instance for status and diagnostic messages.

Attributes

config : SimulationConfig Simulation configuration supplied at construction. montage : Montage Electrode montage supplied at construction. logger : logging.Logger Logger instance used throughout the pipeline. pm : tit.paths.PathManager Singleton path manager for BIDS path resolution. m2m_dir : str Absolute path to the subject's m2m_<subject> directory.

See Also

TISimulation : Concrete two-channel TI subclass. mTISimulation : Concrete N-channel mTI subclass. run_simulation : Orchestrates BaseSimulation.run across montages.

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

run

run(simulation_dir: str, *, overwrite: bool = False) -> dict

Execute the full simulation pipeline for one montage.

This template method orchestrates directory setup, montage visualisation, SimNIBS FEM execution, and subclass-specific post-processing.

Parameters

simulation_dir : str Root simulations directory for the subject. overwrite : bool Replace an earlier result of this montage. The earlier run's derived outputs are removed first (see :func:tit.sim.utils.clear_previous_outputs), so the folder holds exactly what the new config.json says.

Returns

dict Result dictionary with keys montage_name, montage_type, status, and output_mesh.

Raises

FileExistsError If overwrite is False and the montage folder already holds a result mesh. Nothing is written, config.json included.

See Also

run_simulation : Calls run for each montage in a config.

Source code in tit/sim/base.py
def run(self, simulation_dir: str, *, overwrite: bool = False) -> dict:
    """Execute the full simulation pipeline for one montage.

    This template method orchestrates directory setup, montage
    visualisation, SimNIBS FEM execution, and subclass-specific
    post-processing.

    Parameters
    ----------
    simulation_dir : str
        Root simulations directory for the subject.
    overwrite : bool
        Replace an earlier result of this montage.  The earlier run's
        derived outputs are removed first (see
        :func:`tit.sim.utils.clear_previous_outputs`), so the folder holds
        exactly what the new ``config.json`` says.

    Returns
    -------
    dict
        Result dictionary with keys ``montage_name``, ``montage_type``,
        ``status``, and ``output_mesh``.

    Raises
    ------
    FileExistsError
        If *overwrite* is ``False`` and the montage folder already holds a
        result mesh.  Nothing is written, ``config.json`` included.

    See Also
    --------
    run_simulation : Calls ``run`` for each montage in a config.
    """
    montage_dir = self.pm.simulation(
        self.config.subject_id,
        self.montage.name,
    )
    # SimNIBS's own guard looks for its session record in pathfem, which
    # _organize_files moves into documentation/, so after a completed run it
    # never fires: the result mesh is the toolbox's own marker of a result.
    if overwrite:
        clear_previous_outputs(montage_dir)
    else:
        refuse_existing_result(montage_dir, self.montage.name)
    dirs = setup_montage_directories(
        montage_dir, self._simulation_mode, niftis=self.config.map_to_vol
    )
    create_simulation_config_file(
        self.config, self.montage, dirs["documentation"], self.logger
    )

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

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

    self.logger.info("SimNIBS simulation: Started")
    session = self._build_session(dirs["hf_dir"])
    self._session = session  # its conductivity settings feed hf_power_density
    if overwrite:
        # run_simnibs does not expose this supported SESSION option. Keep the
        # native existence guard for ordinary runs; never delete marker files.
        session.run(allow_multiple_runs=True)
    else:
        run_simnibs(session)
    self.logger.info("SimNIBS simulation: \u2713 Complete")

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

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

parse_intensities

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

Parse a comma-separated intensity string into a list of floats.

Intensities are one current per channel (electrode pair). A single value is used for both channels of a TI montage ("2.0" becomes [2.0, 2.0]). Otherwise the value count must be even: two channels for TI, four or more for mTI.

Parameters

s : str Comma-separated intensity values (e.g. "1.0,2.0").

Returns

list[float] List of floats with an even number of elements.

Raises

ValueError If the number of values is odd and greater than 1.

See Also

SimulationConfig.intensities : Field populated by this function.

Source code in tit/sim/config.py
def parse_intensities(s: str) -> list[float]:
    """Parse a comma-separated intensity string into a list of floats.

    Intensities are one current per channel (electrode pair).  A single
    value is used for both channels of a TI montage (``"2.0"`` becomes
    ``[2.0, 2.0]``).  Otherwise the value count must be even: two channels
    for TI, four or more for mTI.

    Parameters
    ----------
    s : str
        Comma-separated intensity values (e.g. ``"1.0,2.0"``).

    Returns
    -------
    list[float]
        List of floats with an even number of elements.

    Raises
    ------
    ValueError
        If the number of values is odd and greater than 1.

    See Also
    --------
    SimulationConfig.intensities : Field populated by this function.
    """
    v = [float(x.strip()) for x in s.split(",")]
    n = len(v)
    if n == 1:
        return [v[0], v[0]]
    if n >= 2 and n % 2 == 0:
        return v
    raise ValueError(
        f"Invalid intensity format: expected 1 or an even number of values; got {n}: {s!r}"
    )

run_simulation

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

Run TI or mTI simulations for every montage in config.

For each montage in config.montages, this function:

  1. Auto-detects TI (2 pairs) vs mTI (4+ pairs) from the montage.
  2. Builds a SimNIBS SESSION with electrode geometry and conductivity settings from config.
  3. Runs the FEM solver to compute electric-field distributions.
  4. Computes temporal-interference envelope fields: TI_max/TI_avg plus TI_normal (2-pair TI only), or the multi-channel superposition for mTI.
  5. Writes the field mesh, its central surface and (opt-in) NIfTIs to the BIDS-compliant simulation directory.

Montages are processed sequentially. If no logger is provided, a file logger is created under the subject's log directory.

Parameters

config : SimulationConfig Full simulation configuration including subject ID, montage list, electrode geometry, and conductivity model. logger : logging.Logger or None, optional Logger instance. If None, a file logger is created automatically in the subject's BIDS logs directory. progress_callback : callable or None, optional Optional callback invoked before each montage as callback(current_index, total, montage_name) and once more with (total, total, "Complete") when finished.

bool, optional

Replace an earlier result of a montage. Defaults to False; existing-result protection stays enabled unless confirmed. When set, the earlier run's derived outputs (TI/, mTI/, fsaverage/, high_Frequency/) are removed before solving, keeping documentation/ and Analyses/, so the folder holds exactly what the new config.json says.

Returns

list[dict] One result dict per montage with keys montage_name, montage_type, status, and output_mesh.

Raises

ValueError If config.montages is empty, the subject's m2m_<id> directory does not exist, a montage has an invalid pair count (must be an even number >= 2), too few intensities are given for a montage, a label-based montage has no eeg_net or its EEG-net CSV is missing from the subject's eeg_positions folder, or an XYZ montage holds a malformed coordinate. OSError FileExistsError when a montage folder already holds a result mesh (TI/mesh or mTI/mesh) and overwrite is False; every montage is checked before the first is solved, so nothing is written. SimNIBS raises its own OSError when an interrupted run left a session record in high_Frequency/.

Examples

from tit.sim import SimulationConfig, load_montages, run_simulation montages = load_montages(["L_Insula"], eeg_net="GSN-HydroCel-185.csv") # doctest: +SKIP cfg = SimulationConfig(subject_id="ernie", montages=montages, ... intensities=[1.0, 1.0], output_fields=["TI_max"]) # doctest: +SKIP results = run_simulation(cfg) # doctest: +SKIP results[0]["status"], results[0]["output_mesh"] # doctest: +SKIP ('completed', '.../Simulations/L_Insula/TI/mesh/ernie_L_Insula_TI.msh')

See Also

SimulationConfig : The configuration consumed by this function. BaseSimulation.run : Per-montage pipeline called internally. TISimulation : Concrete class for 2-pair simulations. mTISimulation : Concrete class for N-pair simulations.

Source code in tit/sim/utils.py
def run_simulation(
    config: SimulationConfig,
    logger: logging.Logger | None = None,
    progress_callback: Callable[[int, int, str], None] | None = None,
    *,
    overwrite: bool = False,
) -> list[dict]:
    """Run TI or mTI simulations for every montage in *config*.

    For each montage in ``config.montages``, this function:

    1. Auto-detects TI (2 pairs) vs mTI (4+ pairs) from the montage.
    2. Builds a SimNIBS SESSION with electrode geometry and conductivity
       settings from *config*.
    3. Runs the FEM solver to compute electric-field distributions.
    4. Computes temporal-interference envelope fields: ``TI_max``/``TI_avg``
       plus ``TI_normal`` (2-pair TI only), or the multi-channel
       superposition for mTI.
    5. Writes the field mesh, its central surface and (opt-in) NIfTIs to the
       BIDS-compliant simulation directory.

    Montages are processed sequentially.  If no *logger* is provided,
    a file logger is created under the subject's log directory.

    Parameters
    ----------
    config : SimulationConfig
        Full simulation configuration including subject ID, montage
        list, electrode geometry, and conductivity model.
    logger : logging.Logger or None, optional
        Logger instance.  If ``None``, a file logger is created
        automatically in the subject's BIDS logs directory.
    progress_callback : callable or None, optional
        Optional callback invoked before each montage as
        ``callback(current_index, total, montage_name)`` and once more
        with ``(total, total, "Complete")`` when finished.

    overwrite : bool, optional
        Replace an earlier result of a montage. Defaults to False; existing-result
        protection stays enabled unless confirmed. When set, the earlier run's
        derived outputs (``TI/``, ``mTI/``, ``fsaverage/``, ``high_Frequency/``) are
        removed before solving, keeping ``documentation/`` and ``Analyses/``, so the
        folder holds exactly what the new ``config.json`` says.

    Returns
    -------
    list[dict]
        One result dict per montage with keys ``montage_name``,
        ``montage_type``, ``status``, and ``output_mesh``.

    Raises
    ------
    ValueError
        If ``config.montages`` is empty, the subject's ``m2m_<id>``
        directory does not exist, a montage has an invalid pair count
        (must be an even number >= 2), too few *intensities* are given
        for a montage, a label-based montage has no *eeg_net* or its
        EEG-net CSV is missing from the subject's ``eeg_positions``
        folder, or an XYZ montage holds a malformed coordinate.
    OSError
        ``FileExistsError`` when a montage folder already holds a result mesh
        (``TI/mesh`` or ``mTI/mesh``) and *overwrite* is ``False``; every
        montage is checked before the first is solved, so nothing is
        written.  SimNIBS raises its own ``OSError`` when an interrupted run
        left a session record in ``high_Frequency/``.

    Examples
    --------
    >>> from tit.sim import SimulationConfig, load_montages, run_simulation
    >>> montages = load_montages(["L_Insula"], eeg_net="GSN-HydroCel-185.csv")  # doctest: +SKIP
    >>> cfg = SimulationConfig(subject_id="ernie", montages=montages,
    ...                        intensities=[1.0, 1.0], output_fields=["TI_max"])  # doctest: +SKIP
    >>> results = run_simulation(cfg)  # doctest: +SKIP
    >>> results[0]["status"], results[0]["output_mesh"]  # doctest: +SKIP
    ('completed', '.../Simulations/L_Insula/TI/mesh/ernie_L_Insula_TI.msh')

    See Also
    --------
    SimulationConfig : The configuration consumed by this function.
    BaseSimulation.run : Per-montage pipeline called internally.
    TISimulation : Concrete class for 2-pair simulations.
    mTISimulation : Concrete class for N-pair simulations.
    """
    # Determine dominant simulation type for telemetry
    from tit.telemetry import track_operation

    _validate_simulation_inputs(config)
    if not overwrite:
        # Every montage before the first solve, not each one as its turn comes.
        pm = get_path_manager()
        for montage in config.montages:
            refuse_existing_result(
                pm.simulation(config.subject_id, montage.name), montage.name
            )
    has_mti = any(m.simulation_mode == SimulationMode.MTI for m in config.montages)
    _tel_op = const.TELEMETRY_OP_SIM_MTI if has_mti else const.TELEMETRY_OP_SIM_TI

    with track_operation(_tel_op):
        return _run_simulation_inner(
            config, logger, progress_callback, overwrite=overwrite
        )

load_montages

load_montages(montage_names: list[str], eeg_net: str, include_flex: bool = False) -> list[Montage]

Load the named montages from the project's montage_list.json.

Only the montages named in montage_names are returned, in that order -- this is not a listing of every montage under the net. load_montages(["L_Insula"], ...) returns a one-element list, so indexing [1] raises IndexError. Use :func:list_montage_names to see which names exist. A name that is not defined under eeg_net is silently skipped (no error), so the result can be shorter than montage_names; check len() or the returned .name values before relying on positions. A name saved under both the uni- and the multi-polar section raises ValueError.

Reads the montage_list.json file (managed by :func:ensure_montage_file), looks up each name under the given EEG net's uni- and multi-polar sections, and returns them as :class:Montage instances.

The eeg_net value determines the montage mode:

  • "freehand" sets Montage.Mode.FREEHAND
  • "flex_mode" sets Montage.Mode.FLEX_FREE
  • Any other value (e.g. "GSN-HydroCel-185.csv") sets Montage.Mode.NET
Parameters

montage_names : list[str] Names to look up in the montage file, e.g. ["L_Insula"]. Order is preserved in the result. eeg_net : str EEG-net CSV filename (e.g. "GSN-HydroCel-185.csv", "EEG10-10_UI_Jurak_2007.csv") that selects the sub-dict inside montage_list.json["nets"]; it is also stored on each returned :class:Montage as eeg_net. include_flex : bool, optional Ignored. It once appended flex montages named by an environment variable that nothing sets any more; it is still accepted so scripts and notebooks that pass it keep running.

Returns

list[Montage] One :class:Montage per found name, in montage_names order, ready to pass as SimulationConfig(montages=...).

Examples

from tit.sim import list_montage_names, load_montages list_montage_names("GSN-HydroCel-185.csv", mode="U") # doctest: +SKIP ['L_Insula', 'R_Insula'] montages = load_montages(["L_Insula"], eeg_net="GSN-HydroCel-185.csv") # doctest: +SKIP len(montages), montages[0].name # doctest: +SKIP (1, 'L_Insula') both = load_montages(["L_Insula", "R_Insula"], eeg_net="GSN-HydroCel-185.csv") # doctest: +SKIP [m.name for m in both] # doctest: +SKIP ['L_Insula', 'R_Insula']

See Also

list_montage_names : Discover available names before loading. upsert_montage : Add montages that can then be loaded. Montage : The returned dataclass type.

Source code in tit/sim/utils.py
def load_montages(
    montage_names: list[str],
    eeg_net: str,
    include_flex: bool = False,
) -> list[Montage]:
    """Load the named montages from the project's ``montage_list.json``.

    **Only the montages named in** *montage_names* **are returned, in that
    order** -- this is not a listing of every montage under the net.
    ``load_montages(["L_Insula"], ...)`` returns a one-element list, so
    indexing ``[1]`` raises ``IndexError``.  Use
    :func:`list_montage_names` to see which names exist.  A name that is
    not defined under *eeg_net* is silently skipped (no error), so the
    result can be shorter than *montage_names*; check ``len()`` or the
    returned ``.name`` values before relying on positions.  A name saved
    under both the uni- and the multi-polar section raises ``ValueError``.

    Reads the ``montage_list.json`` file (managed by
    :func:`ensure_montage_file`), looks up each name under the given
    EEG net's uni- and multi-polar sections, and returns them as
    :class:`Montage` instances.

    The *eeg_net* value determines the montage mode:

    * ``"freehand"`` sets ``Montage.Mode.FREEHAND``
    * ``"flex_mode"`` sets ``Montage.Mode.FLEX_FREE``
    * Any other value (e.g. ``"GSN-HydroCel-185.csv"``) sets
      ``Montage.Mode.NET``

    Parameters
    ----------
    montage_names : list[str]
        Names to look up in the montage file, e.g. ``["L_Insula"]``.
        Order is preserved in the result.
    eeg_net : str
        EEG-net CSV filename (e.g. ``"GSN-HydroCel-185.csv"``,
        ``"EEG10-10_UI_Jurak_2007.csv"``) that selects the sub-dict
        inside ``montage_list.json["nets"]``; it is also stored on each
        returned :class:`Montage` as ``eeg_net``.
    include_flex : bool, optional
        Ignored. It once appended flex montages named by an environment
        variable that nothing sets any more; it is still accepted so
        scripts and notebooks that pass it keep running.

    Returns
    -------
    list[Montage]
        One :class:`Montage` per *found* name, in *montage_names* order,
        ready to pass as ``SimulationConfig(montages=...)``.

    Examples
    --------
    >>> from tit.sim import list_montage_names, load_montages
    >>> list_montage_names("GSN-HydroCel-185.csv", mode="U")  # doctest: +SKIP
    ['L_Insula', 'R_Insula']
    >>> montages = load_montages(["L_Insula"], eeg_net="GSN-HydroCel-185.csv")  # doctest: +SKIP
    >>> len(montages), montages[0].name  # doctest: +SKIP
    (1, 'L_Insula')
    >>> both = load_montages(["L_Insula", "R_Insula"], eeg_net="GSN-HydroCel-185.csv")  # doctest: +SKIP
    >>> [m.name for m in both]  # doctest: +SKIP
    ['L_Insula', 'R_Insula']

    See Also
    --------
    list_montage_names : Discover available names before loading.
    upsert_montage : Add montages that can then be loaded.
    Montage : The returned dataclass type.
    """
    data = load_montage_data()
    net = data.get("nets", {}).get(eeg_net, {})
    uni = net.get("uni_polar_montages", {})
    multi = net.get("multi_polar_montages", {})

    ambiguous = [n for n in montage_names if n in uni and n in multi]
    if ambiguous:
        # It used to return the multi-polar pairs without a word, whichever was meant.
        raise ValueError(
            f"Montage(s) {', '.join(map(repr, ambiguous))} are saved under both "
            f"uni_polar and multi_polar for {eeg_net!r}; delete one before loading."
        )

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

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

    return montages

list_montage_names

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

List all montage names defined under an EEG net.

These are exactly the names :func:load_montages can resolve for that net; a name not in this list is silently skipped by :func:load_montages.

Parameters

eeg_net : str EEG-net CSV filename (e.g. "GSN-HydroCel-185.csv", "EEG10-10_UI_Jurak_2007.csv"), the key under montage_list.json["nets"]. mode : str "U" for uni-polar (2-pair TI) montage names or "M" for multi-polar (4+-pair mTI) montage names. Case-insensitive.

Returns

list[str] Sorted montage names. Returns an empty list if the net or mode key does not exist (never raises for an unknown net).

Examples

from tit.sim import list_montage_names list_montage_names("GSN-HydroCel-185.csv", mode="U") # doctest: +SKIP ['L_Insula', 'R_Insula'] list_montage_names("GSN-HydroCel-185.csv", mode="M") # doctest: +SKIP []

See Also

upsert_montage : Add montage names to the list. load_montages : Load the named montages as Montage objects.

Source code in tit/sim/utils.py
def list_montage_names(eeg_net: str, *, mode: str) -> list[str]:
    """List all montage names defined under an EEG net.

    These are exactly the names :func:`load_montages` can resolve for
    that net; a name not in this list is silently skipped by
    :func:`load_montages`.

    Parameters
    ----------
    eeg_net : str
        EEG-net CSV filename (e.g. ``"GSN-HydroCel-185.csv"``,
        ``"EEG10-10_UI_Jurak_2007.csv"``), the key under
        ``montage_list.json["nets"]``.
    mode : str
        ``"U"`` for uni-polar (2-pair TI) montage names or ``"M"`` for
        multi-polar (4+-pair mTI) montage names.  Case-insensitive.

    Returns
    -------
    list[str]
        Sorted montage names.  Returns an empty list if the net or
        mode key does not exist (never raises for an unknown net).

    Examples
    --------
    >>> from tit.sim import list_montage_names
    >>> list_montage_names("GSN-HydroCel-185.csv", mode="U")  # doctest: +SKIP
    ['L_Insula', 'R_Insula']
    >>> list_montage_names("GSN-HydroCel-185.csv", mode="M")  # doctest: +SKIP
    []

    See Also
    --------
    upsert_montage : Add montage names to the list.
    load_montages : Load the named montages as ``Montage`` objects.
    """
    data = load_montage_data()
    net = data.get("nets", {}).get(eeg_net, {})
    key = "uni_polar_montages" if mode.upper() == "U" else "multi_polar_montages"
    return sorted(net.get(key, {}).keys())

load_montage_data

load_montage_data(*, pm: PathManager | None = None) -> dict

Load the full montage_list.json as a dict.

Returns

dict Parsed JSON with top-level key "nets" mapping EEG net names to their uni/multi polar montage definitions.

See Also

save_montage_data : Write the dict back to disk. ensure_montage_file : Guarantees the file exists before reading.

Source code in tit/sim/utils.py
def load_montage_data(*, pm: PathManager | None = None) -> dict:
    """Load the full ``montage_list.json`` as a dict.

    Returns
    -------
    dict
        Parsed JSON with top-level key ``"nets"`` mapping EEG net
        names to their uni/multi polar montage definitions.

    See Also
    --------
    save_montage_data : Write the dict back to disk.
    ensure_montage_file : Guarantees the file exists before reading.
    """
    with open(ensure_montage_file(pm=pm)) as f:
        return json.load(f)

save_montage_data

save_montage_data(data: dict, *, pm: PathManager | None = None) -> None

Write data to montage_list.json, overwriting the file.

Parameters

data : dict Full montage dict (must contain a "nets" key).

See Also

load_montage_data : Read the data back after saving.

Source code in tit/sim/utils.py
def save_montage_data(data: dict, *, pm: PathManager | None = None) -> None:
    """Write *data* to ``montage_list.json``, overwriting the file.

    Parameters
    ----------
    data : dict
        Full montage dict (must contain a ``"nets"`` key).

    See Also
    --------
    load_montage_data : Read the data back after saving.
    """
    with open(ensure_montage_file(pm=pm), "w") as f:
        json.dump(data, f, indent=4)

ensure_montage_file

ensure_montage_file(*, pm: PathManager | None = None) -> str

Return the path to montage_list.json, creating it if absent.

If the file does not exist, creates it with the default schema {"nets": {}}.

Returns

str Absolute path to the montage_list.json file.

See Also

load_montage_data : Read the file returned by this function. save_montage_data : Write data to the file returned by this function.

Source code in tit/sim/utils.py
def ensure_montage_file(*, pm: PathManager | None = None) -> str:
    """Return the path to ``montage_list.json``, creating it if absent.

    If the file does not exist, creates it with the default schema
    ``{"nets": {}}``.

    Returns
    -------
    str
        Absolute path to the ``montage_list.json`` file.

    See Also
    --------
    load_montage_data : Read the file returned by this function.
    save_montage_data : Write data to the file returned by this function.
    """
    path = _montage_list_path(pm)
    os.makedirs(os.path.dirname(path), exist_ok=True)
    if not os.path.exists(path):
        with open(path, "w") as f:
            json.dump({"nets": {}}, f, indent=4)
    return path

upsert_montage

upsert_montage(*, eeg_net: str, montage_name: str, electrode_pairs: list[list[str]], mode: str, pm: PathManager | None = None) -> None

Insert or update a montage definition in montage_list.json.

Creates the EEG net entry if it does not already exist.

Parameters

eeg_net : str EEG-net CSV filename exactly as it appears in the subject's eeg_positions folder, e.g. "GSN-HydroCel-185.csv" or "EEG10-10_UI_Jurak_2007.csv". Created in the file if absent. montage_name : str Montage name; an existing montage of the same name under this net is replaced, whichever polarity it was saved under (one name, one Simulations/<name>/ folder). electrode_pairs : list[list[str]] Electrode pairs, each a two-element list of labels from that net (e.g. [["E010", "E011"], ["E012", "E013"]]). Two pairs for mode="U", four (or more, even) for mode="M". mode : str "U" for uni-polar montages (2-pair TI) or "M" for multi-polar montages (4+-pair mTI). Case-insensitive; anything other than "U" is treated as "M". pm : PathManager or None, optional Path manager to locate the project; None uses the global one.

Returns

None The file is rewritten in place.

Examples

from tit.sim import upsert_montage, list_montage_names upsert_montage( ... eeg_net="GSN-HydroCel-185.csv", ... montage_name="L_Insula", ... electrode_pairs=[["E010", "E011"], ["E012", "E013"]], ... mode="U", ... ) # doctest: +SKIP list_montage_names("GSN-HydroCel-185.csv", mode="U") # doctest: +SKIP ['L_Insula']

See Also

list_montage_names : List montage names after upserting. load_montages : Load upserted montages as Montage objects.

Source code in tit/sim/utils.py
def upsert_montage(
    *,
    eeg_net: str,
    montage_name: str,
    electrode_pairs: list[list[str]],
    mode: str,
    pm: PathManager | None = None,
) -> None:
    """Insert or update a montage definition in ``montage_list.json``.

    Creates the EEG net entry if it does not already exist.

    Parameters
    ----------
    eeg_net : str
        EEG-net CSV filename exactly as it appears in the subject's
        ``eeg_positions`` folder, e.g. ``"GSN-HydroCel-185.csv"`` or
        ``"EEG10-10_UI_Jurak_2007.csv"``.  Created in the file if absent.
    montage_name : str
        Montage name; an existing montage of the same name under this net is
        replaced, whichever polarity it was saved under (one name, one
        ``Simulations/<name>/`` folder).
    electrode_pairs : list[list[str]]
        Electrode pairs, each a two-element list of labels from that net
        (e.g. ``[["E010", "E011"], ["E012", "E013"]]``).  Two pairs for
        ``mode="U"``, four (or more, even) for ``mode="M"``.
    mode : str
        ``"U"`` for uni-polar montages (2-pair TI) or ``"M"`` for
        multi-polar montages (4+-pair mTI).  Case-insensitive; anything
        other than ``"U"`` is treated as ``"M"``.
    pm : PathManager or None, optional
        Path manager to locate the project; ``None`` uses the global one.

    Returns
    -------
    None
        The file is rewritten in place.

    Examples
    --------
    >>> from tit.sim import upsert_montage, list_montage_names
    >>> upsert_montage(
    ...     eeg_net="GSN-HydroCel-185.csv",
    ...     montage_name="L_Insula",
    ...     electrode_pairs=[["E010", "E011"], ["E012", "E013"]],
    ...     mode="U",
    ... )  # doctest: +SKIP
    >>> list_montage_names("GSN-HydroCel-185.csv", mode="U")  # doctest: +SKIP
    ['L_Insula']

    See Also
    --------
    list_montage_names : List montage names after upserting.
    load_montages : Load upserted montages as ``Montage`` objects.
    """
    data = load_montage_data(pm=pm)
    net = data["nets"].setdefault(
        eeg_net, {"uni_polar_montages": {}, "multi_polar_montages": {}}
    )
    key = "uni_polar_montages" if mode.upper() == "U" else "multi_polar_montages"
    other = (
        "multi_polar_montages" if key == "uni_polar_montages" else "uni_polar_montages"
    )
    # One name is one Simulations/<name>/ folder, and load_montages refuses a name in both.
    net.get(other, {}).pop(montage_name, None)
    net[key][montage_name] = electrode_pairs
    save_montage_data(data, pm=pm)