Skip to content

Analysis

The analysis module evaluates simulation results by computing statistics within regions of interest (ROIs). It supports spherical and cortical atlas ROIs in both mesh and voxel space.

graph LR
    FIELD([Field Data]) --> ANALYZER[Analyzer]
    ROI([ROI Specification]) --> ANALYZER
    ANALYZER --> STATS([ROI Statistics])
    ANALYZER --> VIZ([Visualizations])
    ANALYZER --> CSV([CSV Export])
    style FIELD fill:#1a3a5c,stroke:#48a,color:#fff
    style ROI fill:#1a3a5c,stroke:#48a,color:#fff
    style ANALYZER fill:#2d5a27,stroke:#4a8,color:#fff
    style STATS fill:#1a5c4a,stroke:#4a8,color:#fff
    style VIZ fill:#1a5c4a,stroke:#4a8,color:#fff
    style CSV fill:#1a5c4a,stroke:#4a8,color:#fff

Single-Subject Analysis

Spherical ROI

from tit.analyzer import Analyzer

analyzer = Analyzer(
    subject_id="001",
    simulation="motor_cortex",
    space="mesh",
    tissue_type="GM",  # "GM", "WM", or "both" (voxel only; mesh always uses GM)
)
result = analyzer.analyze_sphere(
    center=(-42, -20, 55),
    radius=10,
    coordinate_space="MNI",
    visualize=True,
)

# Access metrics
print(f"ROI Mean:     {result.roi_mean:.4f} V/m")
print(f"ROI Max:      {result.roi_max:.4f} V/m")
print(f"ROI Min:      {result.roi_min:.4f} V/m")
print(f"Focality:     {result.roi_focality:.2f}")
print(f"GM Mean:      {result.gm_mean:.4f} V/m")
print(f"GM Max:       {result.gm_max:.4f} V/m")
print(f"N elements:   {result.n_elements}")

Cortical Atlas ROI

The region parameter accepts a single region name or a list of region names whose masks are combined into one ROI.

result = analyzer.analyze_cortex(atlas="DK40", region="lh.precentral")

# Multiple regions combined into a single ROI
result = analyzer.analyze_cortex(atlas="DK40", region=["lh.precentral", "lh.postcentral"])

Analysis Spaces

Space Method Description
"mesh" Surface-based Analysis on the cortical mesh, weighted by node areas
"voxel" Volume-based Analysis on NIfTI data, weighted by voxel volumes

When to Use Each Space

Use mesh space for cortical targets where surface geometry matters (e.g., normal/tangential field components). Use voxel space for deep brain targets or when you need MNI-aligned volumetric analysis.

ROI Types

Define a sphere by center coordinates and radius. Works in both MNI and subject coordinate spaces.

result = analyzer.analyze_sphere(
    center=(-42, -20, 55),
    radius=10,
    coordinate_space="MNI",  # or "subject" (default)
)

Use a predefined cortical atlas parcellation (e.g., Desikan-Killiany DK40, HCP_MMP1). A single region or a list of regions can be provided.

result = analyzer.analyze_cortex(
    atlas="DK40",
    region="lh.precentral",
)

# Or combine multiple regions into one ROI
result = analyzer.analyze_cortex(
    atlas="HCP_MMP1",
    region=["lh.4", "lh.3a", "lh.3b"],
)

Result Metrics

Each analysis returns an AnalysisResult dataclass with these fields:

Field Description
field_name SimNIBS field name (e.g., "TI_max")
region_name Human-readable ROI identifier
space "mesh" or "voxel"
analysis_type "spherical", "cortical", "mask" or "subcortical"
roi_mean Weighted mean field intensity within the ROI
roi_max Maximum field intensity within the ROI
roi_min Minimum field intensity within the ROI
roi_focality Ratio of ROI mean intensity to whole-tissue mean
gm_mean Mean field intensity across all gray matter (or tissue)
gm_max Maximum field intensity across all gray matter (or tissue)
normal_mean Mean of the normal-component field in the ROI (mesh only, optional)
normal_max Max of the normal-component field in the ROI (mesh only, optional)
normal_focality Focality of the normal-component field (mesh only, optional)
percentile_95 95th percentile of field intensity (area/volume-weighted)
percentile_99 99th percentile of field intensity
percentile_99_9 99.9th percentile of field intensity
focality_50_area Area (cm^2, mesh) or volume (cm^3, voxel) above 50% of 99.9th percentile
focality_75_area Area (cm^2, mesh) or volume (cm^3, voxel) above 75% of 99.9th percentile
focality_90_area Area (cm^2, mesh) or volume (cm^3, voxel) above 90% of 99.9th percentile
focality_95_area Area (cm^2, mesh) or volume (cm^3, voxel) above 95% of 99.9th percentile
n_elements Number of mesh nodes or voxels in the ROI
total_area_or_volume Total area (mm^2, mesh) or volume (mm^3, voxel) of the ROI
hf_peak_roi_mean, hf_peak_gm_mean Weighted mean hf_peak (V/m) over the same ROI and GM elements (modulation fields only, optional)
hf_peak_focality hf_peak_roi_mean / hf_peak_gm_mean: focality of the stimulation (carrier) intensity
hf_rms_roi_mean, hf_rms_gm_mean, hf_rms_focality The same on the RMS carrier field hf_rms (V/m)
ti_selectivity roi_focality / hf_peak_focality; above 1 the modulation is more focal than its carriers
hf_peak_focality_50_area ... hf_peak_focality_95_area focality_*_area computed on hf_peak
carrier_note Why a carrier metric is None (a carrier field has no usable values on the analysed elements, or the analysed field is a carrier field)
on_demand_note What the analysis computed or wrote because the simulation had not saved it (None when everything was saved)

Group Analysis

Compare results across multiple subjects:

from tit.analyzer import run_group_analysis

group_result = run_group_analysis(
    subject_ids=["001", "002", "003"],
    simulation="motor_cortex",
    space="mesh",
    tissue_type="GM",
    analysis_type="spherical",
    center=(-42, -20, 55),
    radius=10,
    coordinate_space="MNI",
    visualize=True,
)

# group_result.subject_results: dict of per-subject AnalysisResult
# group_result.summary_csv_path: path to group_summary.csv
# group_result.comparison_plot_path: path to comparison bar chart PDF

The run_group_analysis function also supports cortical atlas ROIs:

group_result = run_group_analysis(
    subject_ids=["001", "002", "003"],
    simulation="motor_cortex",
    space="mesh",
    analysis_type="cortical",
    atlas="DK40",
    region="lh.precentral",
    visualize=True,
)

Statistical Testing

For formal group comparisons (e.g., responders vs non-responders), use the tit.stats module:

from tit.stats import GroupComparisonConfig, run_group_comparison

# Load subjects from CSV (columns: subject_id, simulation_name, response)
subjects = GroupComparisonConfig.load_subjects("/data/my_project/subjects.csv")

config = GroupComparisonConfig(
    analysis_name="responder_comparison",
    subjects=subjects,
    test_type=GroupComparisonConfig.TestType.UNPAIRED,
    n_permutations=5000,
    alpha=0.05,
    cluster_threshold=0.05,
)

result = run_group_comparison(config)
print(f"Significant clusters: {result.n_significant_clusters}")
print(f"Significant voxels:   {result.n_significant_voxels}")

The tit.stats module also supports voxel-wise correlation analysis via CorrelationConfig and run_correlation.

API Reference

tit.analyzer.analyzer.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, hf_peak_roi_mean: float | None = None, hf_peak_gm_mean: float | None = None, hf_peak_focality: float | None = None, hf_rms_roi_mean: float | None = None, hf_rms_gm_mean: float | None = None, hf_rms_focality: float | None = None, ti_selectivity: float | None = None, hf_peak_focality_50_area: float | None = None, hf_peak_focality_75_area: float | None = None, hf_peak_focality_90_area: float | None = None, hf_peak_focality_95_area: float | None = None, carrier_note: str | None = None, on_demand_note: str | None = None)

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 the whole selected tissue's mean (> 1 means the ROI is stronger than that tissue's average). gm_mean : float Mean field value across all positive elements of the whole selected tissue: the GM surface in mesh space; the GM, WM or GM+WM voxels of the tissue selection in voxel space (results.csv says which in its tissue row). The gm_ name is kept for every tissue. gm_max : float Maximum field value across the same 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 selected tissue's field distribution. percentile_99 : float or None 99th percentile of the same distribution. percentile_99_9 : float or None 99.9th percentile of the same 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 ROI mesh nodes or voxels with a positive field value -- the elements every roi_* statistic and total_area_or_volume describe, defined identically in both spaces. total_area_or_volume : float Total surface area (mm^2) or volume (mm^3) of positive-valued ROI elements. hf_peak_roi_mean, hf_peak_gm_mean : float or None Area/volume-weighted mean hf_peak (peak carrier field, V/m) over the same ROI and GM elements as roi_mean / gm_mean. hf_peak_focality : float or None hf_peak_roi_mean / hf_peak_gm_mean: focality of the stimulation (carrier) intensity, the carrier counterpart of roi_focality. hf_rms_roi_mean, hf_rms_gm_mean : float or None Weighted mean of the saved hf_rms field (RMS carrier field over time, sqrt(hf_power), V/m) over the same elements. hf_rms_focality : float or None hf_rms_roi_mean / hf_rms_gm_mean. ti_selectivity : float or None roi_focality / hf_peak_focality: above 1 the modulation is more focal than the stimulation, at or below 1 a plain kHz field targets as well. hf_peak_focality_50_area ... hf_peak_focality_95_area : float or None focality_*_area computed on hf_peak (same units, same 99.9th-percentile convention, same GM elements). carrier_note : str or None Why a carrier metric is None: the analysed field is itself a carrier field, or a carrier field has no usable values on the analysed elements. None when every carrier metric is present. on_demand_note : str or None What this analysis produced because the simulation had not saved it (a field computed from the channel meshes, a NIfTI written from the field mesh), in the words it logged; None when everything was saved. What it produced stays in the simulation's outputs.

See Also

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

tit.analyzer.analyzer.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. A volume field the simulation did not save -- the analysed field, or the hf_peak / hf_rms a modulation field's carrier metrics read -- is produced first, from the simulation's channel meshes, and saved into its outputs (:mod:tit.volume_fields); so is the NIfTI a voxel analysis reads. on_demand_note on the result says what was.

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), or a field it needs was not saved and cannot be produced (:class:tit.volume_fields.FieldUnavailableError, naming what is missing). 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"

    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, the notices of
    # what is produced on demand below included.
    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)

    try:
        self._on_demand = tuple(self._produce_missing_fields(field))
        field_path, field_name = select_field_file(
            subject_id,
            simulation,
            space,
            tissue_type=self.tissue_type,
            field=field,
        )
    except BaseException:
        logging.getLogger("tit").removeHandler(self._log_handler)
        self._log_handler.close()
        raise
    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

    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

volume_fields_read staticmethod

volume_fields_read(space: str, field: str | None) -> list[str]

The volume fields an analysis of field in space reads (job preflight asks too).

The analysed field itself (not TI_normal, a surface field) and, for a modulation field, the hf_peak / hf_rms its carrier metrics read. A request :func:select_field_file will refuse (unknown field or space, TI_normal in voxel space) reads none.

Source code in tit/analyzer/analyzer.py
@staticmethod
def volume_fields_read(space: str, field: str | None) -> list[str]:
    """The volume fields an analysis of *field* in *space* reads (job preflight asks too).

    The analysed field itself (not ``TI_normal``, a surface field) and, for a modulation
    field, the ``hf_peak`` / ``hf_rms`` its carrier metrics read. A request
    :func:`select_field_file` will refuse (unknown field or space, ``TI_normal`` in voxel
    space) reads none.
    """
    name = field or const.FIELD_TI_MAX
    if space not in ("mesh", "voxel") or name not in const.get_field_names():
        return []
    if space == "voxel" and name == const.FIELD_TI_NORMAL:
        return []
    needed = [] if name == const.FIELD_TI_NORMAL else [name]
    if name in MODULATION_FIELDS:
        needed += [const.FIELD_HF_PEAK, const.FIELD_HF_RMS]
    return needed

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 Also write the ROI overlay, its scene and histogram.png (results.csv and analysis.json are written either way).

Returns

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

Raises

FileNotFoundError If the required field or surface mesh file does not exist. ValueError If center is not three finite numbers or radius is not a positive finite number, or if the sphere holds no positive field value (an empty ROI has no statistics).

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
        Also write the ROI overlay, its scene and ``histogram.png``
        (``results.csv`` and ``analysis.json`` are written either way).

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

    Raises
    ------
    FileNotFoundError
        If the required field or surface mesh file does not exist.
    ValueError
        If *center* is not three finite numbers or *radius* is not a positive
        finite number, or if the sphere holds no positive field value (an
        empty ROI has no statistics).

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

    spheres = self._checked_spheres([(*center, radius)])
    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_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 Also write the ROI overlay, its scene and histogram.png (results.csv and analysis.json are written either way).

Returns

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

Raises

ValueError If spheres is empty, a sphere lacks a finite centre or a positive finite radius, or their union holds no positive field value.

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
        Also write the ROI overlay, its scene and ``histogram.png``
        (``results.csv`` and ``analysis.json`` are written either way).

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

    Raises
    ------
    ValueError
        If *spheres* is empty, a sphere lacks a finite centre or a positive
        finite radius, or their union holds no positive field value.

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

    spheres = self._checked_spheres(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 Also write the ROI overlay, its scene and histogram.png (results.csv and analysis.json are written either way).

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
        Also write the ROI overlay, its scene and ``histogram.png``
        (``results.csv`` and ``analysis.json`` are written either way).

    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 Also write the ROI overlay, its scene and histogram.png (results.csv and analysis.json are written either way).

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
        Also write the ROI overlay, its scene and ``histogram.png``
        (``results.csv`` and ``analysis.json`` are written either way).

    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.
    """
    from tit.analyzer.masks import mask_region_name

    coordinate_space = coordinate_space.lower()
    return self._analyze_mask_file(
        mask_path,
        coordinate_space,
        visualize,
        region_name=mask_region_name(mask_path, coordinate_space),
        analysis_type="mask",
        what="Mask",
    )

analyze_subcortical

analyze_subcortical(atlas: str, region: str | list[str], coordinate_space: str = 'subject', visualize: bool = False) -> AnalysisResult

Analyze regions of a volumetric (label) atlas, in subject or MNI space.

The selected labels (islands removed, as for every volumetric ROI) become one binary mask in the atlas's own space, which is then measured exactly as :meth:analyze_mask measures a mask: an MNI atlas goes through the subject's nonlinear m2m registration, a mesh analysis samples the mask onto the GM surface, a voxel analysis onto the field grid within the selected tissue.

Parameters

atlas : str Subject space: an atlas name the catalog lists for the subject ("labeling.nii.gz", a FreeSurfer volume such as "aparc.DKTatlas+aseg.mgz", "masks/<file>") or a file path. MNI space: a shipped MNI atlas id (its file name) or a file path. region : str or list of str Region name(s) or integer label(s); several are unioned into one ROI. coordinate_space : str, optional "subject" (default) or "mni" -- the space atlas is in. visualize : bool, optional Also write the ROI overlay, its scene and histogram.png (results.csv and analysis.json are written either way).

Returns

AnalysisResult ROI statistics with analysis_type="subcortical".

Raises

FileNotFoundError If the atlas cannot be found. ValueError If a region is not in the atlas, or the ROI does not overlap the GM surface (mesh; a deep structure has none) or the selected tissue (voxel).

Source code in tit/analyzer/analyzer.py
def analyze_subcortical(
    self,
    atlas: str,
    region: str | list[str],
    coordinate_space: str = "subject",
    visualize: bool = False,
) -> AnalysisResult:
    """Analyze regions of a volumetric (label) atlas, in subject or MNI space.

    The selected labels (islands removed, as for every volumetric ROI) become one
    binary mask in the atlas's own space, which is then measured exactly as
    :meth:`analyze_mask` measures a mask: an MNI atlas goes through the subject's
    nonlinear m2m registration, a mesh analysis samples the mask onto the GM
    surface, a voxel analysis onto the field grid within the selected tissue.

    Parameters
    ----------
    atlas : str
        Subject space: an atlas name the catalog lists for the subject
        (``"labeling.nii.gz"``, a FreeSurfer volume such as
        ``"aparc.DKTatlas+aseg.mgz"``, ``"masks/<file>"``) or a file path.
        MNI space: a shipped MNI atlas id (its file name) or a file path.
    region : str or list of str
        Region name(s) or integer label(s); several are unioned into one ROI.
    coordinate_space : str, optional
        ``"subject"`` (default) or ``"mni"`` -- the space *atlas* is in.
    visualize : bool, optional
        Also write the ROI overlay, its scene and ``histogram.png``
        (``results.csv`` and ``analysis.json`` are written either way).

    Returns
    -------
    AnalysisResult
        ROI statistics with ``analysis_type="subcortical"``.

    Raises
    ------
    FileNotFoundError
        If the atlas cannot be found.
    ValueError
        If a region is not in the atlas, or the ROI does not overlap the GM
        surface (mesh; a deep structure has none) or the selected tissue (voxel).
    """
    import tempfile
    import nibabel as nib
    from tit.atlas.islands import keep_main_components

    space = coordinate_space.lower()
    atlas_path = (
        self._resolve_mni_atlas(atlas)
        if space == "mni"
        else self._resolve_voxel_atlas(atlas)
    )
    atlas_img = nib.load(str(atlas_path))
    labels = self._squeeze_4d(np.asanyarray(atlas_img.dataobj))
    regions = [str(r) for r in (region if isinstance(region, list) else [region])]
    if not any(r.strip() for r in regions):
        raise ValueError("A subcortical analysis needs at least one region")
    roi = np.zeros(labels.shape, dtype=bool)
    for name in regions:
        rid = self._find_voxel_region_id(atlas_path, name)
        one, _, _ = keep_main_components(labels == rid, what=name)
        self._require_label_voxels(one, name, rid, atlas_path)
        roi |= one
    region_name = analysis_region_key(regions)
    with tempfile.TemporaryDirectory() as scratch:
        mask_path = str(Path(scratch) / "subcortical_roi.nii")
        nib.save(nib.Nifti1Image(roi.astype(np.uint8), atlas_img.affine), mask_path)
        return self._analyze_mask_file(
            mask_path,
            space,
            visualize,
            region_name=region_name,
            analysis_type="subcortical",
            what=f"{region_name} ({Path(str(atlas)).name})",
            atlas=atlas,
            region_labels=regions,
            coordinate_space=space,
        )

voxel_atlas_candidates classmethod

voxel_atlas_candidates(pm, sid: str, atlas: str) -> list[Path]

Every path :meth:_resolve_voxel_atlas tries for atlas name, in search order.

Shared with :mod:tit.jobs.preflight, so the submit-time check and the runner cannot disagree about where a subject's volume atlas lives.

Source code in tit/analyzer/analyzer.py
@classmethod
def voxel_atlas_candidates(cls, pm, sid: str, atlas: str) -> list[Path]:
    """Every path :meth:`_resolve_voxel_atlas` tries for *atlas* name, in search order.

    Shared with :mod:`tit.jobs.preflight`, so the submit-time check and the runner
    cannot disagree about where a subject's volume atlas lives.
    """
    fs_mri = Path(pm.fastsurfer_mri(sid))
    legacy_mri = Path(pm.freesurfer_mri(sid))
    seg_dir = Path(pm.segmentation(sid))
    if atlas in cls._SURFACE_ATLAS_VOLUMES:
        return [
            mri / name
            for name in cls._SURFACE_ATLAS_VOLUMES[atlas]
            for mri in (fs_mri, legacy_mri)
        ]
    return [
        fs_mri / atlas,
        legacy_mri / atlas,
        seg_dir / atlas,
        fs_mri / f"{atlas}.mgz",
        fs_mri / f"{atlas}.nii.gz",
        fs_mri / f"{atlas}.nii",
        legacy_mri / f"{atlas}.mgz",
        legacy_mri / f"{atlas}.nii.gz",
        legacy_mri / f"{atlas}.nii",
        seg_dir / f"{atlas}.nii.gz",
        seg_dir / f"{atlas}.nii",
        # The catalog lists a custom label volume as "masks/<file>" (VoxelAtlasManager).
        Path(pm.masks(sid)).parent / atlas,
    ]

surface_regions_without_volume classmethod

surface_regions_without_volume(atlas: str, regions) -> list[str]

The names in regions a voxel analysis of surface atlas atlas cannot measure.

Shared with :mod:tit.jobs.preflight, so such a region is refused at submit, not after the job started. Only DK40 (read from the DKT volume) has any.

Source code in tit/analyzer/analyzer.py
@classmethod
def surface_regions_without_volume(cls, atlas: str, regions) -> list[str]:
    """The names in *regions* a voxel analysis of surface atlas *atlas* cannot measure.

    Shared with :mod:`tit.jobs.preflight`, so such a region is refused at submit, not
    after the job started. Only ``DK40`` (read from the DKT volume) has any.
    """
    if atlas != "DK40":
        return []
    return [
        str(r)
        for r in regions
        if re.sub(r"^(lh|rh)[._-]", "", str(r).strip().lower())
        in cls.DKT_VOLUME_LACKS
    ]

match_region_label staticmethod

match_region_label(region: str, labels) -> int | None

The label id whose name region names, or None.

labels is (id, name) pairs in ascending id order. The exact name (any case) wins, then a surface-style hemisphere name's volume spelling, then the first name containing region. Shared with :mod:tit.jobs.preflight, which matches against the atlas's whole lookup table -- names the volume may not carry -- so it only asks whether region names anything; the runner (:meth:_find_voxel_region_id) refuses a name several present labels contain.

Source code in tit/analyzer/analyzer.py
@staticmethod
def match_region_label(region: str, labels) -> int | None:
    """The label id whose name *region* names, or ``None``.

    *labels* is ``(id, name)`` pairs in ascending id order. The exact name (any case)
    wins, then a surface-style hemisphere name's volume spelling, then the first name
    containing *region*. Shared with :mod:`tit.jobs.preflight`, which matches against
    the atlas's whole lookup table -- names the volume may not carry -- so it only
    asks whether *region* names anything; the runner
    (:meth:`_find_voxel_region_id`) refuses a name several present labels contain.
    """
    hits = Analyzer._region_label_matches(region, labels)
    return hits[0][0] if hits else None

tit.analyzer.group.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.

tit.analyzer.group.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", "subcortical" 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, subcortical and mask): the space of the sphere centre, the volume atlas or the mask. Default "subject"; a group spherical or mask analysis requires "MNI" (one target for every subject). 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 and subcortical). region : str, list of str, or None, optional Region name or list of region names (cortical and subcortical). visualize : bool, optional Generate per-subject visualization artifacts. Default False. output_dir : str, pathlib.Path, or None, optional Override output directory. If None, each run writes to its own derivatives/ti-toolbox/group_analysis/<simulation>_<space>_<target>[_<field>][_<tissue>]_<timestamp>/ (the field and tissue suffixes as :func:group_run_name adds them; a second run of the same name started in the same second gets _2, _3, ...). The per-subject analyses always go to each subject's own default folder. 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", "subcortical" or "mask"; or if a subject's atlas lacks a region, and then the message starts with sub-<id>:. ValueError If coordinate_space is not "subject"/"MNI"; if analysis_type="mask" or "spherical" with a non-MNI coordinate_space, or an unreadable mask_path; if a sphere is missing or lacks a finite centre or a positive, finite radius (checked once, before any subject); or if a subject's analysis is refused (for example its ROI is empty), and then the message starts with sub-<id>:. 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"``, ``"subcortical"`` 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, subcortical and mask): the
        space of the sphere centre, the volume atlas or the mask. Default
        ``"subject"``; a group spherical or mask analysis requires ``"MNI"``
        (one target for every subject).
    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 and subcortical).
    region : str, list of str, or None, optional
        Region name or list of region names (cortical and subcortical).
    visualize : bool, optional
        Generate per-subject visualization artifacts. Default ``False``.
    output_dir : str, pathlib.Path, or None, optional
        Override output directory. If ``None``, each run writes to its own
        ``derivatives/ti-toolbox/group_analysis/<simulation>_<space>_<target>[_<field>][_<tissue>]_<timestamp>/``
        (the field and tissue suffixes as :func:`group_run_name` adds them; a second
        run of the same name started in the same second gets ``_2``, ``_3``, ...). The
        per-subject analyses always go to each subject's own default folder.
    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"``,
        ``"subcortical"`` or ``"mask"``; or if a subject's atlas lacks a
        region, and then the message starts with ``sub-<id>:``.
    ValueError
        If *coordinate_space* is not ``"subject"``/``"MNI"``; if
        ``analysis_type="mask"`` or ``"spherical"`` with a non-MNI
        *coordinate_space*, or an unreadable *mask_path*; if a sphere is
        missing or lacks a finite centre or a positive, finite radius
        (checked once, before any subject); or if a
        subject's analysis is refused (for example its ROI is empty), and
        then the message starts with ``sub-<id>:``.
    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 coordinate_space.lower() not in ("subject", "mni"):
        raise ValueError(
            f"coordinate_space must be 'subject' or 'MNI'; got {coordinate_space!r}"
        )
    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)
    if analysis_type == "spherical":
        # The same sphere for every subject: refuse it once, blaming no subject.
        if coordinate_space.lower() != "mni":
            raise ValueError(
                "A group spherical analysis needs coordinate_space 'MNI': one centre "
                "is used for every subject"
            )
        if not spheres and (center is None or radius is None):
            raise ValueError(
                "center and radius (or spheres) are required for "
                "analysis_type='spherical'"
            )
        Analyzer._checked_spheres(spheres or [(*center, radius)])

    with track_operation(_const.TELEMETRY_OP_GROUP_ANALYSIS):
        run_name = group_run_name(
            simulation,
            space,
            analysis_type,
            center=center,
            radius=radius,
            coordinate_space=coordinate_space,
            spheres=spheres,
            region=region,
            mask_path=mask_path,
            field=field,
            tissue_type=tissue_type,
        )
        out = _resolve_output_dir(output_dir, run_name)

        # 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"
        group_log = add_file_handler(log_file)
        try:
            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,
                ),
                "subcortical": lambda a: a.analyze_subcortical(
                    atlas=atlas,
                    region=region,
                    coordinate_space=coordinate_space,
                    visualize=visualize,
                ),
            }
            analyze_fn = dispatch[analysis_type]

            results: dict[str, AnalysisResult] = {}
            tissue = ""
            for sid in subject_ids:
                logger.info("Analyzing subject %s", sid)
                analyzer = None
                try:
                    analyzer = Analyzer(
                        sid, simulation, space, tissue_type, field=field
                    )
                    results[sid] = analyze_fn(analyzer)
                    tissue = "+".join(analyzer._tissue_type_list())
                except ValueError as exc:
                    # An empty or invalid target is per subject (anatomy differs): say whose.
                    raise ValueError(f"sub-{sid}: {exc}") from exc
                except KeyError as exc:  # a region missing from this subject's atlas
                    raise KeyError(
                        f"sub-{sid}: {exc.args[0] if exc.args else exc}"
                    ) from exc
                finally:
                    # Each subject's own log holds its own analysis only, not the next one's.
                    handler = getattr(analyzer, "_log_handler", None)
                    if handler is not None:
                        _detach_log_handler(handler)

            df = _build_summary_df(results, tissue)
            csv_path = out / "group_summary.csv"
            df.to_csv(csv_path, index=False, float_format="%.6g")
            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,
            )
        finally:
            _detach_log_handler(group_log)