Skip to content

calc

tit.calc

Temporal interference field calculation utilities.

Vectorised NumPy implementations of the TI/mTI modulation-amplitude envelope from Grossman et al. (2017), extended to an arbitrary even number of electrode pairs (mTI).

Public API

get_TI_vectors Modulation-amplitude vectors for K >= 1 carriers (K=1 exact closed form; K>=2 direction search). get_TI_avg Direction-averaged modulation depth for K >= 1 carriers. get_TI_dir Modulation depth evaluated along a fixed per-element direction (e.g. the cortical surface normal); backs TI_normal for mTI.

Attribution

The K >= 2 envelope and the _fibonacci_sphere / _validate_field_list helpers originate from collaborator Larissa Albantakis's branch alba/mTI_testing and are ported here with attribution, not reimplemented.

get_TI_vectors

get_TI_vectors(fields: Sequence[ArrayLike], psi: ArrayLike | None = None) -> NDArray[float64]

Compute TI modulation-amplitude vectors for K >= 1 carriers.

fields is [E_1a, E_1b, ..., E_Ka, E_Kb], 2K arrays of shape (N, 3) -- one per channel, paired positionally: each two consecutive fields are the two channels sharing one carrier. K=1 (standard TI) is solved by an exact closed form (Grossman et al. 2017; Hirata et al. 2024) with no direction search; K>=2 (mTI) returns best_direction * md from the verified :func:_mti_modulation_depth envelope.

Parameters

fields : list of np.ndarray, each shape (N, 3) Channel field vectors in V/m, consecutive fields sharing a carrier: [E1, E2] for TI, [E1a, E1b, E2a, E2b] for two-carrier mTI. The list length must be even and >= 2; all arrays must have the same shape. psi : array-like, shape (K,), or None Per-carrier envelope phase offset (radians); None means phase-aligned carriers (psi_k=0), the standard case. Ignored at K=1 (phase-invariant there).

Returns

np.ndarray, shape (N, 3) Modulation-amplitude vectors [V/m]; norm is the modulation depth (TI_max).

Raises

ValueError If fields has an odd length or fewer than two entries, the arrays are not all (N, 3) of one shape, or psi is not None/shape (K,).

Examples

Two collinear unit fields interfere fully: the envelope equals 2 * min(|E1|, |E2|) along the common axis.

import numpy as np from tit.calc import get_TI_vectors E1 = np.array([[1.0, 0.0, 0.0]]) E2 = np.array([[0.5, 0.0, 0.0]]) get_TI_vectors([E1, E2]) array([[1., 0., 0.]]) np.linalg.norm(get_TI_vectors([E1, E2, E1, E2]), axis=1).shape # mTI, K=2 (1,)

References

Grossman, N. et al. (2017). Cell, 169(6), 1029-1041 (K=1 closed form). Botzanowski, B. et al. (2025). Bioelectronic Medicine, 11(1), 7 -- multipolar TI (K carrier bands, phase-aligned envelopes); describes the square/low-pass/sqrt envelope procedure in prose (no published equation), which the (P, Q) form here formalizes.

Source code in tit/calc.py
def get_TI_vectors(
    fields: Sequence[ArrayLike], psi: ArrayLike | None = None
) -> NDArray[np.float64]:
    """Compute TI modulation-amplitude vectors for K >= 1 carriers.

    ``fields`` is ``[E_1a, E_1b, ..., E_Ka, E_Kb]``, 2K arrays of shape
    ``(N, 3)`` -- one per channel, paired positionally: each two
    consecutive fields are the two channels sharing one carrier. K=1
    (standard TI) is solved by an exact closed form (Grossman et al.
    2017; Hirata et al. 2024) with no direction search; K>=2 (mTI)
    returns ``best_direction * md`` from the verified
    :func:`_mti_modulation_depth` envelope.

    Parameters
    ----------
    fields : list of np.ndarray, each shape (N, 3)
        Channel field vectors in V/m, consecutive fields sharing a
        carrier: ``[E1, E2]`` for TI, ``[E1a, E1b, E2a, E2b]`` for
        two-carrier mTI.  The list length must be even and >= 2; all
        arrays must have the same shape.
    psi : array-like, shape (K,), or None
        Per-carrier envelope phase offset (radians); ``None`` means
        phase-aligned carriers (``psi_k=0``), the standard case. Ignored
        at K=1 (phase-invariant there).

    Returns
    -------
    np.ndarray, shape (N, 3)
        Modulation-amplitude vectors [V/m]; norm is the modulation depth
        (``TI_max``).

    Raises
    ------
    ValueError
        If ``fields`` has an odd length or fewer than two entries, the
        arrays are not all ``(N, 3)`` of one shape, or ``psi`` is not
        ``None``/shape ``(K,)``.

    Examples
    --------
    Two collinear unit fields interfere fully: the envelope equals
    ``2 * min(|E1|, |E2|)`` along the common axis.

    >>> import numpy as np
    >>> from tit.calc import get_TI_vectors
    >>> E1 = np.array([[1.0, 0.0, 0.0]])
    >>> E2 = np.array([[0.5, 0.0, 0.0]])
    >>> get_TI_vectors([E1, E2])
    array([[1., 0., 0.]])
    >>> np.linalg.norm(get_TI_vectors([E1, E2, E1, E2]), axis=1).shape   # mTI, K=2
    (1,)

    References
    ----------
    Grossman, N. et al. (2017). Cell, 169(6), 1029-1041 (K=1 closed form).
    Botzanowski, B. et al. (2025). Bioelectronic Medicine, 11(1), 7 --
    multipolar TI (K carrier bands, phase-aligned envelopes); describes the
    square/low-pass/sqrt envelope procedure in prose (no published
    equation), which the (P, Q) form here formalizes.
    """
    arrs = _validate_field_list(fields)
    n_pairs = len(arrs) // 2
    _validate_psi(psi, n_pairs)

    if n_pairs == 1:
        return _get_TI_vectors_k1(arrs[0], arrs[1])

    result = _mti_modulation_depth(arrs, psi=psi)
    return result["best_direction"] * result["md"][:, None]

get_TI_avg

get_TI_avg(fields: Sequence[ArrayLike], psi: ArrayLike | None = None) -> NDArray[float64]

Direction-averaged modulation depth for K >= 1 electrode pairs.

TI_max (:func:get_TI_vectors) maximizes the envelope over direction -- a best case for a neuron aligned with the optimal axis. TI_avg instead averages the same coarse Fibonacci-sphere sweep over all sampled directions, giving what a randomly-oriented neuron sees on average. Local refinement (accurate for a single best direction only) is skipped as irrelevant to an average.

Parameters

fields : list of np.ndarray, each shape (N, 3) Channel field vectors, consecutive fields sharing a carrier. psi : array-like, shape (K,), or None Per-carrier envelope phase offset (radians); see :func:get_TI_vectors.

Returns

np.ndarray, shape (N,) Modulation depth [V/m], averaged over sampled directions (TI_avg). Never exceeds |get_TI_vectors(...)|.

Raises

ValueError Same conditions as :func:get_TI_vectors.

Examples

import numpy as np from tit.calc import get_TI_avg, get_TI_vectors E1 = np.array([[1.0, 0.0, 0.0]]) E2 = np.array([[0.5, 0.0, 0.0]]) avg = get_TI_avg([E1, E2]) avg.shape, bool(avg[0] <= np.linalg.norm(get_TI_vectors([E1, E2]))) ((1,), True)

Source code in tit/calc.py
def get_TI_avg(
    fields: Sequence[ArrayLike], psi: ArrayLike | None = None
) -> NDArray[np.float64]:
    """Direction-averaged modulation depth for K >= 1 electrode pairs.

    ``TI_max`` (:func:`get_TI_vectors`) maximizes the envelope over
    direction -- a best case for a neuron aligned with the optimal axis.
    ``TI_avg`` instead averages the same coarse Fibonacci-sphere sweep
    over all sampled directions, giving what a randomly-oriented neuron
    sees on average. Local refinement (accurate for a single best
    direction only) is skipped as irrelevant to an average.

    Parameters
    ----------
    fields : list of np.ndarray, each shape (N, 3)
        Channel field vectors, consecutive fields sharing a carrier.
    psi : array-like, shape (K,), or None
        Per-carrier envelope phase offset (radians); see
        :func:`get_TI_vectors`.

    Returns
    -------
    np.ndarray, shape (N,)
        Modulation depth [V/m], averaged over sampled directions
        (``TI_avg``).  Never exceeds ``|get_TI_vectors(...)|``.

    Raises
    ------
    ValueError
        Same conditions as :func:`get_TI_vectors`.

    Examples
    --------
    >>> import numpy as np
    >>> from tit.calc import get_TI_avg, get_TI_vectors
    >>> E1 = np.array([[1.0, 0.0, 0.0]])
    >>> E2 = np.array([[0.5, 0.0, 0.0]])
    >>> avg = get_TI_avg([E1, E2])
    >>> avg.shape, bool(avg[0] <= np.linalg.norm(get_TI_vectors([E1, E2])))
    ((1,), True)
    """
    arrs = _validate_field_list(fields)
    n_pairs = len(arrs) // 2
    psi_arr = _validate_psi(psi, n_pairs)
    return _mti_modulation_depth_avg(arrs, psi_arr)

get_TI_dir

get_TI_dir(fields: Sequence[ArrayLike], directions: ArrayLike, psi: ArrayLike | None = None) -> NDArray[float64]

Modulation depth along a fixed per-element direction, K >= 1.

The multi-carrier analogue of SimNIBS's 2-field TI.get_dirTI: instead of maximizing the envelope over direction (:func:get_TI_vectors), evaluate it at a given direction per element -- typically the cortical surface normal, which yields the TI_normal quantity. Exact (no direction search) for any K.

Parameters

fields : list of np.ndarray, each shape (N, 3) Field vectors ordered [E_1a, E_1b, E_2a, E_2b, ...], one per channel; consecutive fields are the two channels of one carrier. directions : np.ndarray, shape (N, 3) Per-element direction to evaluate the envelope along. Need not be unit length; zero vectors yield 0. psi : array-like, shape (K,), or None Per-carrier envelope phase offset (radians); see :func:get_TI_vectors.

Returns

np.ndarray, shape (N,) Modulation depth [V/m] along directions (TI_normal when they are the cortical normals).

Raises

ValueError Same conditions as :func:get_TI_vectors, or if directions is not shape (N, 3).

Examples

import numpy as np from tit.calc import get_TI_dir E1 = np.array([[1.0, 0.0, 0.0]]) E2 = np.array([[0.5, 0.0, 0.0]]) get_TI_dir([E1, E2], directions=np.array([[1.0, 0.0, 0.0]])) array([1.]) get_TI_dir([E1, E2], directions=np.array([[0.0, 1.0, 0.0]])) array([0.])

Source code in tit/calc.py
def get_TI_dir(
    fields: Sequence[ArrayLike],
    directions: ArrayLike,
    psi: ArrayLike | None = None,
) -> NDArray[np.float64]:
    """Modulation depth along a fixed per-element direction, K >= 1.

    The multi-carrier analogue of SimNIBS's 2-field ``TI.get_dirTI``:
    instead of maximizing the envelope over direction
    (:func:`get_TI_vectors`), evaluate it at a given direction per
    element -- typically the cortical surface normal, which yields the
    ``TI_normal`` quantity. Exact (no direction search) for any K.

    Parameters
    ----------
    fields : list of np.ndarray, each shape (N, 3)
        Field vectors ordered ``[E_1a, E_1b, E_2a, E_2b, ...]``, one per
        channel; consecutive fields are the two channels of one carrier.
    directions : np.ndarray, shape (N, 3)
        Per-element direction to evaluate the envelope along. Need not be
        unit length; zero vectors yield 0.
    psi : array-like, shape (K,), or None
        Per-carrier envelope phase offset (radians); see
        :func:`get_TI_vectors`.

    Returns
    -------
    np.ndarray, shape (N,)
        Modulation depth [V/m] along ``directions`` (``TI_normal`` when
        they are the cortical normals).

    Raises
    ------
    ValueError
        Same conditions as :func:`get_TI_vectors`, or if ``directions`` is
        not shape ``(N, 3)``.

    Examples
    --------
    >>> import numpy as np
    >>> from tit.calc import get_TI_dir
    >>> E1 = np.array([[1.0, 0.0, 0.0]])
    >>> E2 = np.array([[0.5, 0.0, 0.0]])
    >>> get_TI_dir([E1, E2], directions=np.array([[1.0, 0.0, 0.0]]))
    array([1.])
    >>> get_TI_dir([E1, E2], directions=np.array([[0.0, 1.0, 0.0]]))
    array([0.])
    """
    result = _mti_modulation_depth(fields, psi=psi, directions=directions)
    return result["md"]