Skip to content

charm

tit.pre.charm

SimNIBS CHARM head-mesh creation and subject atlas segmentation.

Wraps the SimNIBS charm command to generate m2m head-mesh directories and the subject_atlas command to create cortical parcellation .annot files for a2009s, DK40, and HCP_MMP1 atlases.

Public API

run_charm Run SimNIBS charm for a subject. run_subject_atlas Create atlas .annot files from an existing m2m directory. copy_charm_report Copy SimNIBS's charm_report.html into the subject's report folder.

See Also

tit.pre.fastsurfer : FastSurfer --seg_only deep segmentation. tit.pre.structural.run_pipeline : Full preprocessing pipeline.

run_charm

run_charm(project_dir: str, subject_id: str, *, logger, runner: CommandRunner | None = None, threads: int | None = None, options: dict | None = None) -> None

Run SimNIBS charm to generate a head mesh for a subject.

Creates an m2m directory at the standard BIDS derivatives location containing the volumetric head model required for TI simulations.

Parameters

project_dir : str BIDS project root. subject_id : str Subject identifier without the sub- prefix. logger : logging.Logger Logger used for progress and command output. runner : CommandRunner or None, optional Subprocess runner used to stream output.

Raises

PreprocessError If no T1 image is found, the m2m directory already exists, or charm exits with a non-zero code.

See Also

run_subject_atlas : Create atlas .annot files after CHARM. run_fastsurfer : FastSurfer deep segmentation.

Source code in tit/pre/charm.py
def run_charm(
    project_dir: str,
    subject_id: str,
    *,
    logger,
    runner: CommandRunner | None = None,
    threads: int | None = None,
    options: dict | None = None,
) -> None:
    """Run SimNIBS ``charm`` to generate a head mesh for a subject.

    Creates an m2m directory at the standard BIDS derivatives location
    containing the volumetric head model required for TI simulations.

    Parameters
    ----------
    project_dir : str
        BIDS project root.
    subject_id : str
        Subject identifier without the ``sub-`` prefix.
    logger : logging.Logger
        Logger used for progress and command output.
    runner : CommandRunner or None, optional
        Subprocess runner used to stream output.

    Raises
    ------
    PreprocessError
        If no T1 image is found, the m2m directory already exists, or
        ``charm`` exits with a non-zero code.

    See Also
    --------
    run_subject_atlas : Create atlas ``.annot`` files after CHARM.
    run_fastsurfer : FastSurfer deep segmentation.
    """
    from tit.telemetry import track_operation
    from tit import constants as _const

    with track_operation(_const.TELEMETRY_OP_PRE_CHARM):
        pm = get_path_manager(project_dir)

        simnibs_subject_dir = Path(pm.sub(subject_id))
        simnibs_subject_dir.mkdir(parents=True, exist_ok=True)
        m2m_dir = Path(pm.m2m(subject_id))

        t1_file, t2_file = _find_anat_files(subject_id)
        if not t1_file:
            bids_anat_dir = Path(pm.bids_anat(subject_id))
            raise PreprocessError(f"No T1 image found in {bids_anat_dir}")

        if m2m_dir.exists():
            raise PreprocessError(
                f"m2m output already exists at {m2m_dir}. "
                "Remove the directory manually before rerunning."
            )

        form_flag = _get_form_flag(t1_file)
        cmd = ["charm", form_flag, subject_id, str(t1_file)]
        if t2_file:
            cmd.append(str(t2_file))

        from tit.surfer_settings import effective_threads

        thread_count = effective_threads("charm", threads)
        logger.info(f"Running SimNIBS charm for subject {subject_id}")
        if runner is None:
            runner = CommandRunner()
        with tempfile.TemporaryDirectory(prefix="tit-charm-") as temporary:
            settings_file = Path(temporary) / "charm.ini"
            _write_thread_settings(settings_file, thread_count, options)
            cmd.extend(["--usesettings", str(settings_file)])
            env = {
                **os.environ,
                "OMP_NUM_THREADS": str(thread_count),
                "ITK_GLOBAL_DEFAULT_NUMBER_OF_THREADS": str(thread_count),
                # OpenBLAS falls back to OMP_NUM_THREADS when OPENBLAS_NUM_THREADS
                # is unset, so raising OMP_NUM_THREADS above also lets OpenBLAS
                # spawn thread_count threads per samseg/gems caller; with several
                # concurrent callers this overflows OpenBLAS's thread-metadata
                # table and deadlocks (seen on many-core Windows hosts).
                "OPENBLAS_NUM_THREADS": "1",
            }
            exit_code = runner.run(
                cmd, logger=logger, cwd=str(simnibs_subject_dir), env=env
            )

        if exit_code != 0:
            raise PreprocessError(
                f"charm failed for subject {subject_id} (exit {exit_code})."
            )

        copy_charm_report(project_dir, subject_id, logger=logger)

copy_charm_report

copy_charm_report(project_dir: str, subject_id: str, *, logger) -> Path | None

Copy m2m_<id>/charm_report.html to reports/sub-<id>/charm_report.html.

The report is SimNIBS's own, self-contained (images are inline data: URIs), so one file is the whole report. It is copied, not moved: SimNIBS and users expect it in m2m. A missing or uncopyable report is logged and never fails the head model.

Source code in tit/pre/charm.py
def copy_charm_report(project_dir: str, subject_id: str, *, logger) -> Path | None:
    """Copy ``m2m_<id>/charm_report.html`` to ``reports/sub-<id>/charm_report.html``.

    The report is SimNIBS's own, self-contained (images are inline ``data:`` URIs), so one
    file is the whole report. It is copied, not moved: SimNIBS and users expect it in m2m.
    A missing or uncopyable report is logged and never fails the head model.
    """
    pm = get_path_manager(project_dir)
    source = Path(pm.m2m(subject_id)) / "charm_report.html"
    if not source.is_file():
        logger.warning(f"charm wrote no report at {source}")
        return None
    dest = Path(pm.reports()) / f"sub-{subject_id}" / "charm_report.html"
    try:
        dest.parent.mkdir(parents=True, exist_ok=True)
        shutil.copyfile(source, dest)
    except OSError as exc:
        logger.warning(f"Could not copy the charm report to {dest}: {exc}")
        return None
    from tit.jobs import events

    events.emit_artifact(str(dest), kind="report", label="Head model (charm) report")
    logger.info(f"charm report: {dest}")
    return dest

run_subject_atlas

run_subject_atlas(project_dir: str, subject_id: str, *, logger, runner: CommandRunner | None = None) -> None

Run subject_atlas to create .annot files for a subject.

Should be called after run_charm completes successfully. Generates all three atlases: a2009s, DK40, and HCP_MMP1.

Parameters

project_dir : str BIDS project root. subject_id : str Subject identifier without the sub- prefix. logger : logging.Logger Logger used for progress and command output. runner : CommandRunner or None, optional Subprocess runner used to stream output.

Raises

PreprocessError If the m2m directory does not exist or subject_atlas fails.

See Also

run_charm : Generate the m2m head mesh (prerequisite).

Source code in tit/pre/charm.py
def run_subject_atlas(
    project_dir: str,
    subject_id: str,
    *,
    logger,
    runner: CommandRunner | None = None,
) -> None:
    """Run ``subject_atlas`` to create ``.annot`` files for a subject.

    Should be called after ``run_charm`` completes successfully.
    Generates all three atlases: a2009s, DK40, and HCP_MMP1.

    Parameters
    ----------
    project_dir : str
        BIDS project root.
    subject_id : str
        Subject identifier without the ``sub-`` prefix.
    logger : logging.Logger
        Logger used for progress and command output.
    runner : CommandRunner or None, optional
        Subprocess runner used to stream output.

    Raises
    ------
    PreprocessError
        If the m2m directory does not exist or ``subject_atlas`` fails.

    See Also
    --------
    run_charm : Generate the m2m head mesh (prerequisite).
    """

    pm = get_path_manager(project_dir)
    m2m_dir = Path(pm.m2m(subject_id))

    if not m2m_dir.exists():
        raise PreprocessError(f"m2m folder not found at {m2m_dir}. Run charm first.")

    # Output directory for atlas segmentation
    output_dir = m2m_dir / "segmentation"
    output_dir.mkdir(parents=True, exist_ok=True)

    if runner is None:
        runner = CommandRunner()

    logger.info(
        f"Running subject_atlas for subject {subject_id} with atlases: {', '.join(ATLASES)}"
    )

    for atlas in ATLASES:
        cmd = [
            "subject_atlas",
            "-a",
            atlas,
            "-o",
            str(output_dir),
            str(m2m_dir),
        ]

        logger.info(f"  Creating {atlas} atlas...")
        exit_code = runner.run(cmd, logger=logger)
        if exit_code != 0:
            raise PreprocessError(
                f"subject_atlas failed for atlas {atlas} (exit code {exit_code})"
            )

    logger.info(f"All atlases created successfully for subject {subject_id}")