Skip to content

Simulation

The simulation engine runs finite element method (FEM) calculations using SimNIBS to compute temporal interference electric fields in the brain. TI-Toolbox supports two simulation modes: TI (2 channels) and mTI (4 or more channels, even). A channel is one electrode pair, electrodes Ek+ and Ek-.

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

Running a Simulation

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

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

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

# Run (auto-detects TI vs mTI from the number of channels)
results = run_simulation(config)

Simulation Modes

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

Mode Channels Description
TI 2 Standard temporal interference — two channels at slightly different carrier frequencies that beat at Δf
mTI 4 or more (even) Multi-channel TI — 4 or more channels, beating in pairs at a common Δf; mTI_max is the joint envelope of all channels (tit.calc.envelope_max), not a recursive TI of TIs

Auto-Detection

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

Montage Configuration

Montages define which electrodes form each channel (electrode_pairs, one pair per channel). They are stored in montage_list.json within your project:

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

Load montages programmatically:

from tit.sim import load_montages

montages = load_montages(
    montage_names=["motor_cortex", "frontal_target"],
    eeg_net="GSN-HydroCel-185.csv",
)

Conductivity Types

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

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

Electrode Configuration

Electrode parameters are flat fields on SimulationConfig:

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

Output Fields

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

Field Description
TI_max Maximum TI envelope over all directions — scalar field on volume elements (TI mode)
TI_normal TI envelope along the cortical surface normal — node field on the central surface mesh/surfaces/<montage>_<TI|mTI>_central.msh
mTI_max Maximum joint envelope of all channels — scalar field on volume elements (mTI mode)
hf_peak, hf_rms Saved by default with TI_max/mTI_max (output_fields=None): peak carrier field (V/m, zero-to-peak), hf_rms = √tit.calc.hf_power, the RMS carrier field over time (V/m)
TI_avg, hf_power_density Opt-in volume fields (output_fields): direction-averaged envelope, absorbed carrier power with the run's conductivity (W/m³)

The selected fields are written into one field mesh, <montage>_TI.msh (<montage>_mTI.msh), and onto its central surface with TI_normal (tit.volume_fields.central_surface). NIfTI volumes are opt-in: map_to_vol=True writes one 3-D NIfTI per mesh field for all head tissues (electrodes and gel removed) and its grey- and white-matter parts (map_to_mni=True, which needs it, adds MNI copies). A field or NIfTI an analysis or group tool needs and the run did not save is computed from the kept channel meshes (high_Frequency/mesh/) and saved by tit.volume_fields (ensure_mesh_fields, ensure_niftis), except hf_power_density, whose conductivity is not kept.

Output Directory Structure

derivatives/SimNIBS/sub-001/Simulations/
└── motor_cortex/
    ├── documentation/        # config.json, SimNIBS session log
    ├── high_Frequency/
    │   ├── mesh/             # Per-channel SimNIBS meshes and electrode-current .geo files
    │   ├── niftis/           # Per-channel magnE NIfTI volumes (map_to_vol)
    │   └── analysis/         # fields_summary.txt
    ├── TI/                   # (mTI/ instead for an mTI run, mTI_max in place of TI_max)
    │   ├── mesh/motor_cortex_TI.msh                    # every selected volume field
    │   ├── mesh/surfaces/motor_cortex_TI_central.msh   # those fields + TI_normal, lh then rh
    │   ├── niftis/           # NIfTI volumes (map_to_vol, or written on demand)
    │   └── montage_imgs/     # Electrode placement visualizations
    ├── fsaverage/            # map_to_fsavg
    └── Analyses/             # Analyzer outputs

API Reference

tit.sim.config.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.

tit.sim.config.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.

tit.sim.config.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).

tit.sim.config.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.

tit.sim.config.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}"
    )

tit.sim.base.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,
    }

tit.sim.utils.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
        )

tit.volume_fields.ensure_mesh_fields

ensure_mesh_fields(subject_id: str, simulation: str, fields, *, say: Callable[[str], None] = _quiet, surface: bool = True) -> list[str]

Make the volume mesh carry every field of fields, and its central surface all of them.

A missing field is computed with :func:compute_volume_fields from the channel meshes and added to the volume mesh, and the central surface is rewritten from it with every field. A central surface that is missing or lacks a field of the volume mesh or TI_normal is rebuilt even when fields is empty (an old run, :func:_produce_surface). Returns one sentence per production (empty when nothing was needed), each handed to say before the work it announces starts.

Parameters:

Name Type Description Default
subject_id str

Subject identifier (no sub-).

required
simulation str

Simulation (montage) folder name.

required
fields

Logical or on-disk volume field names (TI_max and mTI_max alike); may be empty, to make sure of the central surface only.

required
say Callable[[str], None]

Called with each sentence first (a caller's logger.info).

_quiet
surface bool

Also rebuild an incomplete central surface when no field is missing. False for a step that reads only NIfTIs (:func:ensure_niftis).

True

Raises:

Type Description
FieldUnavailableError

a missing field or the surface cannot be produced.

ValueError

a name is not a volume field (TI_normal is a surface field).

RuntimeError

the head model's central surface is not the one the run used.

Source code in tit/volume_fields.py
def ensure_mesh_fields(
    subject_id: str,
    simulation: str,
    fields,
    *,
    say: Callable[[str], None] = _quiet,
    surface: bool = True,
) -> list[str]:
    """Make the volume mesh carry every field of *fields*, and its central surface all of them.

    A missing field is computed with :func:`compute_volume_fields` from the channel meshes and
    added to the volume mesh, and the central surface is rewritten from it with every field.
    A central surface that is missing or lacks a field of the volume mesh or ``TI_normal`` is
    rebuilt even when *fields* is empty (an old run, :func:`_produce_surface`). Returns one
    sentence per production (empty when nothing was needed), each handed to *say* before the
    work it announces starts.

    Args:
        subject_id: Subject identifier (no ``sub-``).
        simulation: Simulation (montage) folder name.
        fields: Logical or on-disk volume field names (``TI_max`` and ``mTI_max`` alike); may be
            empty, to make sure of the central surface only.
        say: Called with each sentence first (a caller's ``logger.info``).
        surface: Also rebuild an incomplete central surface when no field is missing.
            ``False`` for a step that reads only NIfTIs (:func:`ensure_niftis`).

    Raises:
        FieldUnavailableError: a missing field or the surface cannot be produced.
        ValueError: a name is not a volume field (``TI_normal`` is a surface field).
        RuntimeError: the head model's central surface is not the one the run used.
    """
    sim = _simulation(subject_id, simulation)
    names = _validated(fields)
    if not _missing(sim, names) and (not surface or _surface_lacks(sim) == []):
        return []
    with _producing(sim):
        missing = _missing(sim, names)  # another producer may have finished meanwhile
        if missing:
            config = _check_producible(sim, missing)
            names = [_on_disk(sim, n) for n in missing]
            was, it = _was(names)
            spaces = _recorded_nifti_spaces(config)
            also = ""
            if spaces:
                kind = "subject and MNI-space NIfTIs" if "mni" in spaces else "NIfTIs"
                also = f", with the {kind} this run writes for every field"
            notice = (
                f"{_listed(names)} {was} not saved for {sim.name}; computing {it} from the "
                f"channel fields and saving {it} to {sim.volume_mesh.name}{also}."
            )
            say(notice)
            _produce_mesh_fields(sim, config, missing, names, spaces)
            return [notice]
        if not surface:
            return []
        lacks = _surface_lacks(sim)
        if lacks == []:
            return []
        config = _recorded_config(sim)
        _check_surface_producible(sim, config)
        what = (
            "has no central surface"
            if lacks is None
            else (f"has a central surface without {_listed(lacks)}")
        )
        notice = f"{sim.name} {what}; writing {sim.central.name} from its field mesh."
        say(notice)
        _produce_surface(sim, config)
    return [notice]

tit.volume_fields.ensure_niftis

ensure_niftis(subject_id: str, simulation: str, fields, *, tissue: str | None, space: str = 'subject', say: Callable[[str], None] = _quiet) -> tuple[dict[str, Path], list[str]]

The NIfTI of each field of fields for one tissue and space, produced if not saved.

Parameters:

Name Type Description Default
subject_id str

Subject identifier (no sub-).

required
simulation str

Simulation (montage) folder name.

required
fields

Volume field names, as for :func:ensure_mesh_fields.

required
tissue str | None

"grey", "white" or None (the whole brain-tissue mesh); a tissue's NIfTI is converted from the volume mesh cropped to it.

required
space str

"subject" or "mni".

'subject'
say Callable[[str], None]

As for :func:ensure_mesh_fields.

_quiet

Returns:

Type Description
tuple[dict[str, Path], list[str]]

{on-disk field name: path} and the sentences said while producing them.

Raises:

Type Description
FieldUnavailableError

a missing field cannot be produced.

RuntimeError

the conversion ran but did not write a requested file.

Source code in tit/volume_fields.py
def ensure_niftis(
    subject_id: str,
    simulation: str,
    fields,
    *,
    tissue: str | None,
    space: str = "subject",
    say: Callable[[str], None] = _quiet,
) -> tuple[dict[str, Path], list[str]]:
    """The NIfTI of each field of *fields* for one tissue and space, produced if not saved.

    Args:
        subject_id: Subject identifier (no ``sub-``).
        simulation: Simulation (montage) folder name.
        fields: Volume field names, as for :func:`ensure_mesh_fields`.
        tissue: ``"grey"``, ``"white"`` or ``None`` (the whole brain-tissue mesh); a tissue's
            NIfTI is converted from the volume mesh cropped to it.
        space: ``"subject"`` or ``"mni"``.
        say: As for :func:`ensure_mesh_fields`.

    Returns:
        ``{on-disk field name: path}`` and the sentences said while producing them.

    Raises:
        FieldUnavailableError: a missing field cannot be produced.
        RuntimeError: the conversion ran but did not write a requested file.
    """
    if tissue not in ("grey", "white", None) or space not in ("subject", "mni"):
        raise ValueError(
            f"tissue {tissue!r} / space {space!r} is not one a simulation writes"
        )
    sim = _simulation(subject_id, simulation)
    names = _validated(fields)
    base = f"{tissue + '_' if tissue else ''}{sim.name}_{sim.mode}"
    paths = {
        _on_disk(sim, n): sim.niftis_dir / _nifti_name(base, space, _on_disk(sim, n))
        for n in names
    }
    if all(_saved(p) for p in paths.values()):
        return {f: _saved(p) for f, p in paths.items()}, []
    notices = ensure_mesh_fields(subject_id, simulation, names, say=say, surface=False)
    with _producing(sim):
        absent = [f for f, p in paths.items() if not _saved(p)]
        if absent:
            mesh = sim.volume_mesh
            was, it = _was(absent)
            kind = "an MNI-space NIfTI" if space == "mni" else "a NIfTI"
            if len(absent) > 1:
                kind = "MNI-space NIfTIs" if space == "mni" else "NIfTIs"
            notice = (
                f"{_listed(absent)} {was} not saved as {kind} for {sim.name}; writing "
                f"{it} from {mesh.name}{f' ({tissue} matter)' if tissue else ''}."
            )
            say(notice)
            notices.append(notice)
            from tit.paths import get_path_manager

            m2m = get_path_manager().m2m(sim.subject_id)
            with _staging(sim) as stage:
                _convert(mesh, m2m, stage, base, space, absent, tissue)
                _move_all(stage, sim.niftis_dir)
    return {f: _saved(p) or p for f, p in paths.items()}, notices

tit.volume_fields.FieldUnavailableError

Bases: FileNotFoundError

A field a step needs was not saved and cannot be produced from what the simulation kept.

tit.sim.utils.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

tit.sim.utils.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())

tit.sim.utils.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)

tit.sim.utils.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)

tit.sim.utils.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

tit.sim.utils.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)