Skip to content
vcubiomagPublic

About

GPU FEM solver for the TMS E-field

Resources

Stars

1 star

Watchers

0 watching

Forks

Repository files navigation

cuNIBS

pypi

cuNIBS computes the electric field that transcranial magnetic stimulation (TMS) induces in an individual head model. It solves the full-volume finite-element model on an NVIDIA GPU and reads head models built with the SimNIBS CHARM pipeline.

Many TMS analyses solve the same head model hundreds or thousands of times. cuNIBS builds the solver for a head once, keeps it on the GPU, and reuses it for every solve. Setup takes under a second, and each additional placement takes milliseconds. Use it to:

  • simulate one coil placement or a sweep of placements,
  • run the same analysis across a cohort of subjects,
  • estimate how uncertain tissue conductivities affect the field,
  • find the coil position and orientation that best stimulates a target.

The accompanying manuscript describes the numerical method and its validation against analytic solutions and SimNIBS.

Installation

python -m pip install cunibs

cuNIBS needs Python 3.12 or later, an NVIDIA GPU, and a driver that supports CUDA 13 (r580 or later). Wheels are available for x86-64 Linux and Windows, for CPython 3.12 through 3.14, including free-threaded Python 3.14. The package installs its own CUDA libraries, so a separate CUDA toolkit is not needed.

Quick start

from cunibs import Coil, Placement, Subject

subject = Subject.from_mesh("subject.msh")
coil = Coil.load("MagStim_D70")

placement = Placement(
    center_mm=[0.0, 20.0, 80.0],
    handle_mm=[0.0, 70.0, 80.0],
    distance_mm=4.0,
)

result = subject.simulate(coil, placement, didt=1.0e6)

print(result.peak_magnE())
print(result.peak_location_mm())
print(result.focality(0.5))

center_mm is the target on the scalp and handle_mm sets the direction of the coil handle. cuNIBS projects the target onto the scalp and places the coil distance_mm above it. didt is the rate of change of the coil current in A/s. The field scales linearly with it.

Inputs

Head models

Build a head model from MRI with CHARM:

charm subject_id T1w.nii.gz T2w.nii.gz

This writes m2m_subject_id/subject_id.msh. A T1-weighted image is enough; a T2-weighted image improves the skull segmentation. Check the segmentation before you simulate.

cuNIBS assigns each tissue the standard SimNIBS conductivity. To check a mesh before using it, call validate_mesh("subject.msh"). It returns a report instead of raising, so you can screen a whole cohort up front.

A mesh can be saved as HDF5, which is smaller and loads faster than the .msh. Pass the CHARM eeg_positions directory to keep the electrode positions in the same file. load_mesh, Subject.from_mesh and validate_mesh read either format, and read_cap reads the positions back.

from cunibs import Subject, load_mesh
from cunibs.head import read_cap

load_mesh("m2m_subject_id/subject_id.msh").save(
    "subject_id.h5", eeg_positions="m2m_subject_id/eeg_positions"
)
subject = Subject.from_mesh("subject_id.h5")
c3 = read_cap("subject_id.h5")["C3"]

Coils

cuNIBS includes the 25 validated coil models from Drakaki et al. (2022). Load one by name, and call bundled_coils() to list them. SimNIBS .ccd files load directly.

coil = Coil.load("MagStim_D70")
coil = Coil.load("my_coil.ccd")

Placement sweeps and cohorts

Pass a list of placements to iter_simulate. It yields results in input order and reuses the subject setup for all of them.

placements = [
    Placement([0.0, 20.0, 80.0], [0.0, 70.0, 80.0]),
    Placement([20.0, 0.0, 80.0], [70.0, 0.0, 80.0]),
]

for result in subject.iter_simulate(coil, placements, didt=1.0e6):
    print(result.peak_magnE())

A Subject holds GPU memory until it is freed. Use it as a context manager when you process many subjects:

from pathlib import Path

for mesh_file in Path("subjects").glob("m2m_*/*.msh"):
    with Subject.from_mesh(mesh_file) as subject:
        result = subject.simulate(coil, placement, didt=1.0e6)

Results

Each result carries summary metrics for gray matter: peak field and its location, focality, centre of gravity, and distribution statistics. On a mesh with no gray matter, such as a phantom, result.summary is None and everything else works as usual. Results stay small because full-volume arrays are kept only when you ask for them:

result = subject.simulate(
    coil, placement, didt=1.0e6, magnitude=True, vectors=True, potential=True
)

result.magnE  # |E| per tetrahedron, V/m
result.E  # E per tetrahedron, V/m
result.v  # potential per node, V

Save and load results as HDF5 with result.save(path) and FieldResult.load(path). Saved files record the software versions, GPU, mesh, and coil used.

Full-volume arrays follow subject.mesh, which reorders the elements and nodes of the file you loaded. Index them with result.tet_tags and result.barycenters_mm, or with subject.mesh, not with the arrays from load_mesh. subject.element_order and subject.node_order convert between the two:

import numpy as np

atlas_in_result_order = atlas[subject.element_order]

magnE_in_file_order = np.empty_like(result.magnE)
magnE_in_file_order[subject.element_order] = result.magnE

The recovery argument selects how the reported field is computed from the finite-element solution. The default, "harmonic", is accurate at tissue interfaces. Use "spr_tissue" or "spr_global" to compare with SimNIBS outputs, or "raw" for the unprocessed field.

Conductivity uncertainty

simulate_conductivity_uq samples tissue conductivities and reports the mean field and its variability.

from cunibs import ConductivityUQConfig

config = ConductivityUQConfig(
    n_samples=500,
    tissue_cov={"gray_matter": 0.15, "csf": 0.05, "cortical_bone": 0.35},
    seed=1,
)

uq = subject.simulate_conductivity_uq(coil, placement, config, didt=1.0e6)

print(uq.peak_mean_magnE())
print(uq.max_local_cov())
print(uq.peak_samples)

A tissue named in tissue_cov that the mesh does not hold is skipped with a warning.

tissue_cov sets the coefficient of variation for each tissue. Only the tissues it names are varied. Samples are lognormal by default.

Coil-placement optimization

cunibs.adm searches candidate coil positions and orientations for the one that maximizes the field at a target. It uses the auxiliary dipole method (Gomez et al., 2021), so it does not need a separate solve for each candidate.

from cunibs import Target, adm, head

target = Target(position_mm=[-45.0, -5.0, 25.0], region="gray_matter")
centers = head.scalp_grid(subject, [-50.0, -10.0, 80.0], radius_mm=20.0, spacing_mm=2.0)

search = adm.optimize(subject, coil, target, centers)

result = subject.simulate(coil, search.best_placement)

Give Target a direction to maximize the field along that direction instead of its magnitude.

cunibs.head also measures scalp-to-cortex distance and tissue thickness, and reads the electrode positions and fiducials that CHARM writes.

Reproducibility

A result depends only on the mesh, coil, placement, didt, solver tolerance, and recovery method. It does not depend on the other placements in a sweep or their order. The same calculation on the same hardware and software gives bitwise-identical results. Results can differ across GPU models and software versions.

Citation

No archival citation is available yet. Cite the software name, author, version, and Git commit, and archive the mesh, coil, placements, and settings with your study data.

License

cuNIBS is released under the GNU General Public License v3.0.

References

About

GPU FEM solver for the TMS E-field

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages