Skip to content

analyzer

tit.analyzer

Unified field analysis for mesh and voxel spaces.

Provides single-subject ROI analysis (spherical and cortical), multi-subject group analysis with summary statistics, and automatic field file selection for TI and mTI simulations.

Public API

Analyzer Single-subject field analyzer for mesh and voxel spaces. AnalysisResult Typed container for per-subject ROI statistics. GroupResult Container for multi-subject group analysis outcomes. run_group_analysis Run the same ROI analysis across multiple subjects and summarise. select_field_file Resolve the correct field file path for a given subject/simulation/space.

See Also

tit.stats : Cluster-based permutation testing for group-level inference. tit.sim : TI/mTI simulation engine that produces the field files analyzed here.

Analyzer

Analyzer(subject_id: str, simulation: str, space: str = 'mesh', tissue_type: str = 'GM', output_dir: str | None = None, field: str | None = None)

Unified analyzer for mesh and voxel field data.

Lazily loads the field file on first analysis call. All coordinate transforms and ROI masking are handled internally.

Parameters

subject_id : str Subject identifier (without sub- prefix). simulation : str Simulation (montage) folder name. space : str, optional "mesh" or "voxel". Default "mesh". tissue_type : str, optional "GM", "WM", or "both". Only affects voxel analyses; mesh analyses always use the GM cortical surface. Default "GM". output_dir : str or None, optional Override output directory. If None, derived from PathManager. field : str or None, optional Field to analyze, from constants.FIELD_REGISTRY (e.g. "hf_peak", "TI_normal"). Default None resolves the TI_max/mTI_max envelope. See :func:select_field_file.

Attributes

subject_id : str Subject identifier. simulation : str Simulation folder name. space : str Analysis space ("mesh" or "voxel"). tissue_type : str Normalised tissue selection ("GM", "WM", or "BOTH"). field_path : pathlib.Path Resolved path to the field file. field_name : str Short name of the field (e.g. "TI_max"). m2m_path : str Path to the subject's m2m_* directory. output_dir : str or None Output directory override, or None.

Raises

FileNotFoundError If no field file exists for subject_id/simulation in space (run the simulation first). ValueError If space is not "mesh"/"voxel" or field is not a known field name.

Examples

from tit.analyzer import Analyzer analyzer = Analyzer("ernie", "L_Insula", space="voxel") # doctest: +SKIP result = analyzer.analyze_sphere( ... center=(-35.0, 5.0, 5.0), radius=10.0, coordinate_space="MNI", ... ) # doctest: +SKIP result.roi_mean, result.roi_focality # doctest: +SKIP (0.21, 1.8) cortex = Analyzer("ernie", "L_Insula", space="mesh").analyze_cortex( ... atlas="DK40", region="lh.insula", visualize=True) # doctest: +SKIP cortex.analysis_type, cortex.focality_50_area # doctest: +SKIP ('cortical', 42.1)

See Also

AnalysisResult : Container for single-subject analysis outputs. run_group_analysis : Multi-subject group analysis.

Source code in tit/analyzer/analyzer.py
def __init__(
    self,
    subject_id: str,
    simulation: str,
    space: str = "mesh",
    tissue_type: str = "GM",
    output_dir: str | None = None,
    field: str | None = None,
) -> None:
    self.subject_id = subject_id
    self.simulation = simulation
    self.space = space
    self.tissue_type = self._normalize_tissue_type(tissue_type)
    if self.space == "mesh":
        self.tissue_type = "GM"

    field_path, field_name = select_field_file(
        subject_id,
        simulation,
        space,
        tissue_type=self.tissue_type,
        field=field,
    )
    self.field_path = field_path
    self.field_name = field_name
    # mTI outputs live under <simulation>/mTI/; TI outputs under <simulation>/TI/.
    self._is_mti = "mTI" in Path(field_path).parts

    pm = get_path_manager()
    self.m2m_path = pm.m2m(subject_id)
    self.output_dir = output_dir
    self._pm = pm

    # Attach a file handler so every log message is persisted to disk
    logs_dir = pm.logs(subject_id)
    pm.ensure(logs_dir)
    timestamp = time.strftime("%Y%m%d_%H%M%S")
    log_file = Path(logs_dir) / f"analyzer_{simulation}_{timestamp}.log"
    self._log_handler = add_file_handler(log_file)

    logger.info(
        "Analyzer initialised: subject=%s sim=%s space=%s tissue=%s field=%s",
        subject_id,
        simulation,
        space,
        self.tissue_type,
        field_name,
    )

    # Cached lazily
    self._surface_mesh = None
    self._surface_mesh_path: Path | None = None

analyze_sphere

analyze_sphere(center: tuple[float, float, float], radius: float, coordinate_space: str = 'subject', visualize: bool = False) -> AnalysisResult

Analyze a spherical ROI.

Parameters

center : tuple of float (x, y, z) coordinates of the sphere centre. radius : float Radius in mm. coordinate_space : str, optional "subject" (default) or "MNI". When "MNI", coordinates are transformed to subject space via SimNIBS mni2subject_coords. visualize : bool, optional Write the ROI overlay, its scene, histogram.png and analysis.json.

Returns

AnalysisResult ROI and whole-GM statistics for the spherical region.

Raises

FileNotFoundError If the required field or surface mesh file does not exist.

See Also

analyze_cortex : Atlas-based cortical ROI analysis.

Source code in tit/analyzer/analyzer.py
def analyze_sphere(
    self,
    center: tuple[float, float, float],
    radius: float,
    coordinate_space: str = "subject",
    visualize: bool = False,
) -> AnalysisResult:
    """Analyze a spherical ROI.

    Parameters
    ----------
    center : tuple of float
        ``(x, y, z)`` coordinates of the sphere centre.
    radius : float
        Radius in mm.
    coordinate_space : str, optional
        ``"subject"`` (default) or ``"MNI"``. When ``"MNI"``,
        coordinates are transformed to subject space via SimNIBS
        ``mni2subject_coords``.
    visualize : bool, optional
        Write the ROI overlay, its scene, ``histogram.png`` and ``analysis.json``.

    Returns
    -------
    AnalysisResult
        ROI and whole-GM statistics for the spherical region.

    Raises
    ------
    FileNotFoundError
        If the required field or surface mesh file does not exist.

    See Also
    --------
    analyze_cortex : Atlas-based cortical ROI analysis.
    """
    from tit.telemetry import track_operation
    from tit import constants as const

    with track_operation(const.TELEMETRY_OP_ANALYSIS):
        dispatch = {"mesh": self._sphere_mesh, "voxel": self._sphere_voxel}
        return dispatch[self.space](
            [(center[0], center[1], center[2], radius)],
            coordinate_space,
            visualize,
        )

analyze_spheres

analyze_spheres(spheres, coordinate_space: str = 'subject', visualize: bool = False) -> AnalysisResult

Analyze several spherical ROIs unioned into a single ROI.

The spheres are combined with a logical OR before any statistic is computed, so the result describes one ROI covering all of them -- overlapping spheres are not double-counted. Passing a single sphere is equivalent to :meth:analyze_sphere.

Parameters

spheres : sequence of tuple of float One (x, y, z, r) per sphere. coordinate_space : str, optional "subject" (default) or "MNI", applied to every sphere. visualize : bool, optional Write the ROI overlay, its scene, histogram.png and analysis.json.

Returns

AnalysisResult ROI and whole-GM statistics for the combined region.

Raises

ValueError If spheres is empty.

See Also

analyze_sphere : Single spherical ROI analysis.

Source code in tit/analyzer/analyzer.py
def analyze_spheres(
    self,
    spheres,
    coordinate_space: str = "subject",
    visualize: bool = False,
) -> AnalysisResult:
    """Analyze several spherical ROIs unioned into a single ROI.

    The spheres are combined with a logical OR before any statistic is
    computed, so the result describes one ROI covering all of them --
    overlapping spheres are not double-counted. Passing a single sphere
    is equivalent to :meth:`analyze_sphere`.

    Parameters
    ----------
    spheres : sequence of tuple of float
        One ``(x, y, z, r)`` per sphere.
    coordinate_space : str, optional
        ``"subject"`` (default) or ``"MNI"``, applied to every sphere.
    visualize : bool, optional
        Write the ROI overlay, its scene, ``histogram.png`` and ``analysis.json``.

    Returns
    -------
    AnalysisResult
        ROI and whole-GM statistics for the combined region.

    Raises
    ------
    ValueError
        If *spheres* is empty.

    See Also
    --------
    analyze_sphere : Single spherical ROI analysis.
    """
    from tit.telemetry import track_operation
    from tit import constants as const

    spheres = [tuple(float(v) for v in s) for s in spheres]
    if not spheres:
        raise ValueError("analyze_spheres requires at least one sphere.")

    with track_operation(const.TELEMETRY_OP_ANALYSIS):
        dispatch = {"mesh": self._sphere_mesh, "voxel": self._sphere_voxel}
        return dispatch[self.space](spheres, coordinate_space, visualize)

analyze_cortex

analyze_cortex(atlas: str, region: str | list[str], visualize: bool = False) -> AnalysisResult

Analyze a cortical atlas region.

Parameters

atlas : str Atlas name recognised by SimNIBS (e.g. "DK40", "HCP_MMP1"), or an absolute path to an atlas NIfTI (voxel mode only). region : str or list of str Region name within the atlas (e.g. "lh.cuneus"), or a list of region names whose masks are unioned into a single combined ROI. Bare names like "cuneus" expand to both hemispheres in mesh mode. visualize : bool, optional Write the ROI overlay, its scene, histogram.png and analysis.json.

Returns

AnalysisResult ROI and whole-GM statistics for the cortical region.

Raises

KeyError If a region name cannot be resolved in the atlas. FileNotFoundError If the atlas or field file does not exist.

See Also

analyze_sphere : Spherical ROI analysis.

Source code in tit/analyzer/analyzer.py
def analyze_cortex(
    self,
    atlas: str,
    region: str | list[str],
    visualize: bool = False,
) -> AnalysisResult:
    """Analyze a cortical atlas region.

    Parameters
    ----------
    atlas : str
        Atlas name recognised by SimNIBS (e.g. ``"DK40"``,
        ``"HCP_MMP1"``), or an absolute path to an atlas NIfTI
        (voxel mode only).
    region : str or list of str
        Region name within the atlas (e.g. ``"lh.cuneus"``), or a
        list of region names whose masks are unioned into a single
        combined ROI. Bare names like ``"cuneus"`` expand to both
        hemispheres in mesh mode.
    visualize : bool, optional
        Write the ROI overlay, its scene, ``histogram.png`` and ``analysis.json``.

    Returns
    -------
    AnalysisResult
        ROI and whole-GM statistics for the cortical region.

    Raises
    ------
    KeyError
        If a region name cannot be resolved in the atlas.
    FileNotFoundError
        If the atlas or field file does not exist.

    See Also
    --------
    analyze_sphere : Spherical ROI analysis.
    """
    from tit.telemetry import track_operation
    from tit import constants as const

    with track_operation(const.TELEMETRY_OP_ANALYSIS):
        dispatch = {"mesh": self._cortex_mesh, "voxel": self._cortex_voxel}
        return dispatch[self.space](atlas, region, visualize)

analyze_mask

analyze_mask(mask_path: str, coordinate_space: str = 'subject', visualize: bool = False) -> AnalysisResult

Analyze positive NIfTI voxels sampled onto subject geometry.

MNI masks use the optimizer's nonlinear m2m registration. Nearest-neighbour sampling preserves binary membership; mesh results remain GM surface-area statistics and voxel results retain the selected tissue and volume units.

Parameters

mask_path : str Path to a 3-D NIfTI mask (.nii/.nii.gz); voxels > 0 form the ROI. coordinate_space : str, optional "subject" (default) or "mni" (case-insensitive). visualize : bool, optional Write the ROI overlay, its scene, histogram.png and analysis.json.

Returns

AnalysisResult ROI and whole-GM statistics for the mask region, with analysis_type="mask".

Raises

ValueError If coordinate_space is not "subject"/"mni", the file is not a readable finite 3-D NIfTI, or (mesh mode) the mask does not overlap the grey-matter surface.

See Also

analyze_sphere : Spherical ROI analysis. analyze_cortex : Atlas-based cortical ROI analysis.

Source code in tit/analyzer/analyzer.py
def analyze_mask(
    self,
    mask_path: str,
    coordinate_space: str = "subject",
    visualize: bool = False,
) -> AnalysisResult:
    """Analyze positive NIfTI voxels sampled onto subject geometry.

    MNI masks use the optimizer's nonlinear m2m registration. Nearest-neighbour
    sampling preserves binary membership; mesh results remain GM surface-area
    statistics and voxel results retain the selected tissue and volume units.

    Parameters
    ----------
    mask_path : str
        Path to a 3-D NIfTI mask (``.nii``/``.nii.gz``); voxels ``> 0``
        form the ROI.
    coordinate_space : str, optional
        ``"subject"`` (default) or ``"mni"`` (case-insensitive).
    visualize : bool, optional
        Write the ROI overlay, its scene, ``histogram.png`` and ``analysis.json``.

    Returns
    -------
    AnalysisResult
        ROI and whole-GM statistics for the mask region, with
        ``analysis_type="mask"``.

    Raises
    ------
    ValueError
        If *coordinate_space* is not ``"subject"``/``"mni"``, the file is
        not a readable finite 3-D NIfTI, or (mesh mode) the mask does not
        overlap the grey-matter surface.

    See Also
    --------
    analyze_sphere : Spherical ROI analysis.
    analyze_cortex : Atlas-based cortical ROI analysis.
    """
    import tempfile
    import nibabel as nib
    from nibabel.processing import resample_from_to
    from scipy.ndimage import map_coordinates
    from tit.opt.masks import prepare_mask
    from tit.analyzer.masks import mask_region_name
    from tit import constants as const
    from tit.telemetry import track_operation

    coordinate_space = coordinate_space.lower()
    region_name = mask_region_name(mask_path, coordinate_space)
    with (
        track_operation(const.TELEMETRY_OP_ANALYSIS),
        tempfile.TemporaryDirectory() as scratch,
    ):
        prepared = prepare_mask(
            mask_path, coordinate_space, str(self.m2m_path), scratch, binary=True
        )
        mask_img = nib.load(prepared)
        if self.space == "mesh":
            surface = self._load_surface_mesh()
            coords = nib.affines.apply_affine(
                np.linalg.inv(mask_img.affine), surface.nodes.node_coord
            )
            mask = (
                map_coordinates(
                    mask_img.get_fdata(), coords.T, order=0, mode="constant", cval=0
                )
                > 0
            )
            if not mask.any():
                raise ValueError("Mask does not overlap the gray-matter surface")
            result = self._analyze_mesh_roi(
                surface,
                self._field_values(surface),
                self._node_areas(surface),
                mask,
                region_name=region_name,
                analysis_type="mask",
                visualize=visualize,
            )
            return result
        img = nib.load(str(self.field_path))
        field_arr = self._squeeze_4d(img.get_fdata())
        sampled = (
            resample_from_to(
                mask_img,
                (field_arr.shape[:3], img.affine),
                order=0,
                mode="constant",
                cval=0,
            ).get_fdata()
            > 0
        )
        analysis_mask = (field_arr > 0) & self._voxel_tissue_mask(
            img, field_arr.shape[:3], img.affine
        )
        roi_mask = sampled & analysis_mask
        if not roi_mask.any():
            raise ValueError(
                "Mask does not overlap positive field values in the selected tissue"
            )
        result = self._analyze_voxel_roi(
            field_arr,
            roi_mask,
            analysis_mask,
            img.affine,
            region_name=region_name,
            analysis_type="mask",
            visualize=visualize,
        )
        return result

AnalysisResult dataclass

AnalysisResult(field_name: str, region_name: str, space: str, analysis_type: str, roi_mean: float, roi_max: float, roi_min: float, roi_focality: float, gm_mean: float, gm_max: float, normal_mean: float | None = None, normal_max: float | None = None, normal_focality: float | None = None, percentile_95: float | None = None, percentile_99: float | None = None, percentile_99_9: float | None = None, focality_50_area: float | None = None, focality_75_area: float | None = None, focality_90_area: float | None = None, focality_95_area: float | None = None, n_elements: int = 0, total_area_or_volume: float = 0.0)

Immutable container for ROI analysis statistics.

Returned by :meth:Analyzer.analyze_sphere and :meth:Analyzer.analyze_cortex.

Attributes

field_name : str Name of the field that was analyzed (e.g. "TI_max"). region_name : str Human-readable ROI label. space : str "mesh" or "voxel". analysis_type : str "spherical", "cortical" or "mask". roi_mean : float Area/volume-weighted mean field value inside the ROI. roi_max : float Maximum field value inside the ROI. roi_min : float Minimum field value inside the ROI. roi_focality : float Ratio of ROI mean to whole-GM mean (> 1 means the ROI is stronger than the GM average). gm_mean : float Mean field value across all positive GM elements. gm_max : float Maximum field value across all GM elements. normal_mean : float or None Weighted mean of the normal-component field in the ROI (mesh only; None when unavailable). normal_max : float or None Maximum normal-component field in the ROI. normal_focality : float or None Normal-component focality ratio. percentile_95 : float or None 95th percentile of the whole-GM field distribution. percentile_99 : float or None 99th percentile of the whole-GM field distribution. percentile_99_9 : float or None 99.9th percentile of the whole-GM field distribution. focality_50_area : float or None Area/volume where the field exceeds 50 % of the 99.9th percentile value -- cm^2 in mesh space, cm^3 in voxel space (the _area name is kept for both spaces). focality_75_area : float or None Same for 75 % threshold. focality_90_area : float or None Same for 90 % threshold. focality_95_area : float or None Same for 95 % threshold. n_elements : int Number of mesh nodes or voxels in the ROI mask. total_area_or_volume : float Total surface area (mm^2) or volume (mm^3) of positive-valued ROI elements.

See Also

Analyzer : Single-subject field analyzer. GroupResult : Container for multi-subject group analysis.

GroupResult dataclass

GroupResult(subject_results: dict[str, AnalysisResult], summary_csv_path: Path, comparison_plot_path: Path | None)

Outcome of a multi-subject group analysis.

Attributes

subject_results : dict of str to AnalysisResult Mapping of subject ID to its :class:~tit.analyzer.analyzer.AnalysisResult. summary_csv_path : pathlib.Path Path to the summary CSV (one row per subject plus an AVERAGE row). comparison_plot_path : pathlib.Path or None Path to the 2x2 comparison bar-chart PDF, or None if plotting failed.

See Also

run_group_analysis : Factory function that produces this result. AnalysisResult : Per-subject analysis container.

select_field_file

select_field_file(subject_id: str, simulation: str, space: str, tissue_type: str = 'GM', field: str | None = None) -> tuple[Path, str]

Return the field file path and SimNIBS field name.

Detects whether the simulation is TI (2-pair) or mTI (4-pair) by checking for the existence of the mTI mesh directory.

Parameters

subject_id : str Subject identifier (without sub- prefix). simulation : str Simulation (montage) folder name. space : str "mesh" or "voxel". tissue_type : str, optional "GM", "WM", or "both" (voxel only). Default "GM". field : str or None, optional Field name from constants.FIELD_REGISTRY (e.g. "hf_peak"). Default None resolves TI_max (TI) or mTI_max (mTI). "TI_max"/"mTI_max" are treated as aliases for the same quantity; the on-disk spelling is always chosen by the detected simulation type, regardless of which alias is passed.

Returns

field_path : pathlib.Path Resolved absolute path to the field file. field_name : str SimNIBS field name (e.g. "TI_max", "mTI_max").

Raises

FileNotFoundError If the expected field file does not exist. ValueError If space is not "mesh"/"voxel", field is unknown, or TI_normal is requested in voxel space (it has no NIfTI export).

See Also

Analyzer : Consumes the resolved path to load and analyze fields.

Source code in tit/analyzer/field_selector.py
def select_field_file(
    subject_id: str,
    simulation: str,
    space: str,
    tissue_type: str = "GM",
    field: str | None = None,
) -> tuple[Path, str]:
    """Return the field file path and SimNIBS field name.

    Detects whether the simulation is TI (2-pair) or mTI (4-pair) by checking
    for the existence of the mTI mesh directory.

    Parameters
    ----------
    subject_id : str
        Subject identifier (without ``sub-`` prefix).
    simulation : str
        Simulation (montage) folder name.
    space : str
        ``"mesh"`` or ``"voxel"``.
    tissue_type : str, optional
        ``"GM"``, ``"WM"``, or ``"both"`` (voxel only). Default ``"GM"``.
    field : str or None, optional
        Field name from ``constants.FIELD_REGISTRY`` (e.g. ``"hf_peak"``).
        Default ``None`` resolves ``TI_max`` (TI) or ``mTI_max`` (mTI).
        ``"TI_max"``/``"mTI_max"`` are treated as aliases for the same
        quantity; the on-disk spelling is always chosen by the detected
        simulation type, regardless of which alias is passed.

    Returns
    -------
    field_path : pathlib.Path
        Resolved absolute path to the field file.
    field_name : str
        SimNIBS field name (e.g. ``"TI_max"``, ``"mTI_max"``).

    Raises
    ------
    FileNotFoundError
        If the expected field file does not exist.
    ValueError
        If *space* is not ``"mesh"``/``"voxel"``, *field* is unknown, or
        ``TI_normal`` is requested in voxel space (it has no NIfTI export).

    See Also
    --------
    Analyzer : Consumes the resolved path to load and analyze fields.
    """
    if field is not None:
        try:
            const.get_field_spec(field)
        except KeyError as exc:
            valid = ", ".join(const.get_field_names())
            raise ValueError(
                f"Unknown field: {field!r}. Valid fields: {valid}."
            ) from exc

    pm = get_path_manager()
    sim_dir = Path(pm.simulation(subject_id, simulation))
    is_mti = is_mti_simulation(subject_id, simulation)

    if space == "mesh":
        return _select_mesh(sim_dir, simulation, is_mti, field)
    if space == "voxel":
        return _select_voxel(sim_dir, is_mti, tissue_type, field)
    raise ValueError(f"Unsupported space: {space!r} (expected 'mesh' or 'voxel')")

run_group_analysis

run_group_analysis(subject_ids: list[str], simulation: str, space: str = 'mesh', tissue_type: str = 'GM', analysis_type: str = 'spherical', center: tuple[float, float, float] | None = None, radius: float | None = None, coordinate_space: str = 'subject', spheres: Sequence[tuple[float, float, float, float]] | None = None, atlas: str | None = None, region: str | list[str] | None = None, visualize: bool = False, output_dir: str | Path | None = None, field: str | None = None, mask_path: str | None = None) -> GroupResult

Run the same ROI analysis across multiple subjects and summarise.

Dispatches to analyze_sphere or analyze_cortex on each subject, builds a summary CSV (with an AVERAGE row), and generates a 2x2 comparison bar-chart PDF.

Parameters

subject_ids : list of str Subject identifiers (without sub- prefix). simulation : str Simulation (montage) folder name, shared by all subjects. space : str, optional "mesh" or "voxel". Default "mesh". tissue_type : str, optional "GM", "WM", or "both" (voxel only). Default "GM". analysis_type : str, optional "spherical", "cortical" or "mask". Default "spherical". center : tuple of float or None, optional (x, y, z) sphere centre; required when analysis_type is "spherical". radius : float or None, optional Sphere radius in mm; required when analysis_type is "spherical". coordinate_space : str, optional "subject" or "MNI" (spherical and mask). Default "subject"; a group mask analysis requires "MNI". spheres : sequence of tuple of float or None, optional One (x, y, z, r) per sphere, unioned into a single ROI (spherical only). Takes precedence over center/radius. atlas : str or None, optional Atlas name (cortical only). region : str, list of str, or None, optional Region name or list of region names (cortical only). visualize : bool, optional Generate per-subject visualization artifacts. Default False. output_dir : str, pathlib.Path, or None, optional Override output directory. If None, derived from PathManager. field : str or None, optional Field to analyze (constants.FIELD_REGISTRY name, e.g. "TI_max", "TI_normal", "hf_peak"). Default None resolves the TI_max/mTI_max envelope per subject. mask_path : str or None, optional MNI-space NIfTI mask (analysis_type="mask" only).

Returns

GroupResult Per-subject results, the summary CSV path (group_summary.csv), and the comparison plot path.

Raises

KeyError If analysis_type is not "spherical", "cortical" or "mask". ValueError If analysis_type="mask" with a non-MNI coordinate_space or an unreadable mask_path. FileNotFoundError If a subject has no field file for simulation in space.

Examples

from tit.analyzer import run_group_analysis res = run_group_analysis( ... subject_ids=["ernie", "101"], simulation="L_Insula", space="voxel", ... analysis_type="spherical", center=(-35.0, 5.0, 5.0), radius=10.0, ... coordinate_space="MNI", ... ) # doctest: +SKIP res.summary_csv_path.name, sorted(res.subject_results) # doctest: +SKIP ('group_summary.csv', ['101', 'ernie']) run_group_analysis(["ernie", "101"], "L_Insula", space="mesh", ... analysis_type="cortical", atlas="DK40", ... region="lh.insula") # doctest: +SKIP

See Also

Analyzer : Single-subject analyzer used internally per subject. GroupResult : Container for the returned outcomes.

Source code in tit/analyzer/group.py
def run_group_analysis(
    subject_ids: list[str],
    simulation: str,
    space: str = "mesh",
    tissue_type: str = "GM",
    analysis_type: str = "spherical",
    center: tuple[float, float, float] | None = None,
    radius: float | None = None,
    coordinate_space: str = "subject",
    spheres: Sequence[tuple[float, float, float, float]] | None = None,
    atlas: str | None = None,
    region: str | list[str] | None = None,
    visualize: bool = False,
    output_dir: str | Path | None = None,
    field: str | None = None,
    mask_path: str | None = None,
) -> GroupResult:
    """Run the same ROI analysis across multiple subjects and summarise.

    Dispatches to ``analyze_sphere`` or ``analyze_cortex`` on each subject,
    builds a summary CSV (with an AVERAGE row), and generates a 2x2
    comparison bar-chart PDF.

    Parameters
    ----------
    subject_ids : list of str
        Subject identifiers (without ``sub-`` prefix).
    simulation : str
        Simulation (montage) folder name, shared by all subjects.
    space : str, optional
        ``"mesh"`` or ``"voxel"``. Default ``"mesh"``.
    tissue_type : str, optional
        ``"GM"``, ``"WM"``, or ``"both"`` (voxel only). Default ``"GM"``.
    analysis_type : str, optional
        ``"spherical"``, ``"cortical"`` or ``"mask"``. Default
        ``"spherical"``.
    center : tuple of float or None, optional
        ``(x, y, z)`` sphere centre; required when *analysis_type* is
        ``"spherical"``.
    radius : float or None, optional
        Sphere radius in mm; required when *analysis_type* is
        ``"spherical"``.
    coordinate_space : str, optional
        ``"subject"`` or ``"MNI"`` (spherical and mask). Default
        ``"subject"``; a group mask analysis requires ``"MNI"``.
    spheres : sequence of tuple of float or None, optional
        One ``(x, y, z, r)`` per sphere, unioned into a single ROI
        (spherical only). Takes precedence over *center*/*radius*.
    atlas : str or None, optional
        Atlas name (cortical only).
    region : str, list of str, or None, optional
        Region name or list of region names (cortical only).
    visualize : bool, optional
        Generate per-subject visualization artifacts. Default ``False``.
    output_dir : str, pathlib.Path, or None, optional
        Override output directory. If ``None``, derived from PathManager.
    field : str or None, optional
        Field to analyze (``constants.FIELD_REGISTRY`` name, e.g.
        ``"TI_max"``, ``"TI_normal"``, ``"hf_peak"``). Default ``None``
        resolves the TI_max/mTI_max envelope per subject.
    mask_path : str or None, optional
        MNI-space NIfTI mask (``analysis_type="mask"`` only).

    Returns
    -------
    GroupResult
        Per-subject results, the summary CSV path
        (``group_summary.csv``), and the comparison plot path.

    Raises
    ------
    KeyError
        If *analysis_type* is not ``"spherical"``, ``"cortical"`` or
        ``"mask"``.
    ValueError
        If ``analysis_type="mask"`` with a non-MNI *coordinate_space* or an
        unreadable *mask_path*.
    FileNotFoundError
        If a subject has no field file for *simulation* in *space*.

    Examples
    --------
    >>> from tit.analyzer import run_group_analysis
    >>> res = run_group_analysis(
    ...     subject_ids=["ernie", "101"], simulation="L_Insula", space="voxel",
    ...     analysis_type="spherical", center=(-35.0, 5.0, 5.0), radius=10.0,
    ...     coordinate_space="MNI",
    ... )  # doctest: +SKIP
    >>> res.summary_csv_path.name, sorted(res.subject_results)  # doctest: +SKIP
    ('group_summary.csv', ['101', 'ernie'])
    >>> run_group_analysis(["ernie", "101"], "L_Insula", space="mesh",
    ...                    analysis_type="cortical", atlas="DK40",
    ...                    region="lh.insula")  # doctest: +SKIP

    See Also
    --------
    Analyzer : Single-subject analyzer used internally per subject.
    GroupResult : Container for the returned outcomes.
    """
    from tit.telemetry import track_operation
    from tit import constants as _const

    if analysis_type == "mask":
        from tit.opt.masks import validate_mask

        if coordinate_space.lower() != "mni":
            raise ValueError("Group mask analysis requires an MNI-space mask")
        validate_mask(mask_path)

    with track_operation(_const.TELEMETRY_OP_GROUP_ANALYSIS):
        out = _resolve_output_dir(output_dir)

        # File handler so group analysis logs are persisted
        timestamp = time.strftime("%Y%m%d_%H%M%S")
        log_file = out / f"group_analysis_{timestamp}.log"
        add_file_handler(log_file)

        logger.info(
            "Group analysis started: %d subjects, space=%s, type=%s",
            len(subject_ids),
            space,
            analysis_type,
        )

        dispatch: dict[str, callable] = {
            "spherical": lambda a: (
                a.analyze_spheres(
                    spheres=spheres,
                    coordinate_space=coordinate_space,
                    visualize=visualize,
                )
                if spheres
                else a.analyze_sphere(
                    center=center,
                    radius=radius,
                    coordinate_space=coordinate_space,
                    visualize=visualize,
                )
            ),
            "mask": lambda a: a.analyze_mask(
                mask_path=mask_path,
                coordinate_space=coordinate_space,
                visualize=visualize,
            ),
            "cortical": lambda a: a.analyze_cortex(
                atlas=atlas,
                region=region,
                visualize=visualize,
            ),
        }
        analyze_fn = dispatch[analysis_type]

        results: dict[str, AnalysisResult] = {}
        for sid in subject_ids:
            logger.info("Analyzing subject %s", sid)
            results[sid] = analyze_fn(
                Analyzer(sid, simulation, space, tissue_type, field=field)
            )

        df = _build_summary_df(results)
        csv_path = out / "group_summary.csv"
        df.to_csv(csv_path, index=False, float_format="%.3f")
        logger.info("Summary CSV written to %s", csv_path)

        plot_path = _generate_comparison_plot(df, out)
        logger.info("Comparison plot written to %s", plot_path)

        return GroupResult(
            subject_results=results,
            summary_csv_path=csv_path,
            comparison_plot_path=plot_path,
        )