Skip to content

catalog

tit.catalog

Project catalog: subject and simulation discovery for the UI.

Built only on :class:tit.paths.PathManager (plus the existing per-domain helpers it composes: :mod:tit.sim.utils montage I/O, :mod:tit.atlas discovery, :mod:tit.opt.ex.roi/:mod:tit.opt.leadfield ROI and leadfield helpers, :mod:tit.opt.flex.manifest); the server routes (tit.server.routes.catalog, tit.server.routes.catalog_v1) serialise these dicts as-is, so the BIDS / derivative layout rules are never re-implemented outside tit.

v1 additions (this module is extended, not replaced, for Stage 1 / lane B2 — see docs/dev/DECISIONS.md § 2026-08-27 (Electron and the job server) §3 "Catalog"): subject/simulation detail, montage CRUD, EEG nets, atlases + regions, ROI CRUD, leadfields, flex/ex/mex runs, analyses, reports, freehand configs, the project-level group catalog and Quick Notes. A run/analysis directory without the manifest file that marks it complete (flex_meta.json, run_config.json, or the analysis.json+results.csv pair) is ignored, so a cancelled or in-progress run never appears as a result.

subject_ids

subject_ids(pm: PathManager) -> list[str]

Union of raw-BIDS, FastSurfer, legacy-FreeSurfer and m2m subjects.

Naturally sorted. derivatives/freesurfer is still unioned in so a project processed before FastSurfer replaced recon-all keeps listing its subjects.

Deliberately excludes subjects known only from sourcedata/ (DICOMs staged, nothing converted yet) -- this is the onboarded set every other catalog function gates on (montages, ROIs, EEG nets, ...), where a subject with no derivative of any kind would be meaningless. See :func:sourcedata_only_subject_ids and, for the two routes that do need to see a not-yet-onboarded subject, :func:list_subjects / :func:subject_detail.

Source code in tit/catalog.py
def subject_ids(pm: PathManager) -> list[str]:
    """Union of raw-BIDS, FastSurfer, legacy-FreeSurfer and m2m subjects.

    Naturally sorted. ``derivatives/freesurfer`` is still unioned in so a
    project processed before FastSurfer replaced ``recon-all`` keeps listing
    its subjects.

    Deliberately excludes subjects known only from ``sourcedata/`` (DICOMs
    staged, nothing converted yet) -- this is the *onboarded* set every other
    catalog function gates on (montages, ROIs, EEG nets, ...), where a
    subject with no derivative of any kind would be meaningless. See
    :func:`sourcedata_only_subject_ids` and, for the two routes that do need
    to see a not-yet-onboarded subject, :func:`list_subjects` /
    :func:`subject_detail`.
    """
    if not pm.project_dir:
        return []
    ids = set()
    for root, needs_m2m in (
        (pm.project_dir, False),
        (pm.fastsurfer(), False),
        (pm.freesurfer(), False),
        (pm.simnibs(), True),
    ):
        if not root:
            continue
        for name in _metadata_names(root, pm.project_dir):
            sid = name.removeprefix("sub-")
            if not name.startswith("sub-") or not is_valid_subject_id(sid):
                continue
            if os.path.isdir(os.path.join(root, name)) and (
                not needs_m2m or _project_isdir(pm, pm.m2m(sid))
            ):
                ids.add(sid)
    return sorted(ids, key=natural_key)

sourcedata_only_subject_ids

sourcedata_only_subject_ids(pm: PathManager) -> list[str]

Subject ids with raw data staged under sourcedata/ and nowhere else.

:func:subject_ids would never mention these -- no BIDS directory, no m2m, no FastSurfer/FreeSurfer -- so a project's own newly-arrived DICOMs (Dataset 000's sub-102, before its DICOM-conversion stage has ever run) were invisible to every page built on the catalog (lane FX5, docs/dev/DECISIONS.md § 2026-09-03 (One Docker image and a real development loop)). Naturally sorted; a subject that already appears in :func:subject_ids is never repeated here even if sourcedata/ also holds a copy of its DICOMs.

Source code in tit/catalog.py
def sourcedata_only_subject_ids(pm: PathManager) -> list[str]:
    """Subject ids with raw data staged under ``sourcedata/`` and nowhere else.

    :func:`subject_ids` would never mention these -- no BIDS directory, no
    m2m, no FastSurfer/FreeSurfer -- so a project's own newly-arrived DICOMs
    (Dataset 000's ``sub-102``, before its DICOM-conversion stage has ever
    run) were invisible to every page built on the catalog (lane FX5,
    ``docs/dev/DECISIONS.md § 2026-09-03 (One Docker image and a real development loop)``). Naturally sorted; a subject that
    already appears in :func:`subject_ids` is never repeated here even if
    ``sourcedata/`` also holds a copy of its DICOMs.
    """
    if not pm.project_dir:
        return []
    try:
        entries = _metadata_names(pm.sourcedata(), pm.project_dir)
    except OSError:
        return []
    known = set(subject_ids(pm))
    ids = []
    for name in entries:
        if not name.startswith("sub-"):
            continue
        sid = name[len("sub-") :]
        if not is_valid_subject_id(sid) or sid in known:
            continue
        if _has_sourcedata_raw(pm, sid):
            ids.append(sid)
    return sorted(ids, key=natural_key)

list_subjects

list_subjects(pm: PathManager) -> list[dict]

One entry per subject: presence flags and the simulation count.

Also lists subjects known only from sourcedata/ (DICOMs staged, no BIDS directory yet -- see :func:sourcedata_only_subject_ids): the Subjects page and Pre-processing's own subjects table are exactly where a project's newest subject needs to be plannable, before anything else about them exists. An entry carries has_sourcedata: True when staged raw data is present (so a client can tell "staged, not yet converted" from has_raw False); the key is left out entirely otherwise -- the overwhelming common case (a subject with no sourcedata copy at all), and the shape every existing caller/test built before this field existed stays byte-identical (/api/catalog/subjects is served with response_model_exclude_unset, tit/server/routes/catalog.py).

Source code in tit/catalog.py
def list_subjects(pm: PathManager) -> list[dict]:
    """One entry per subject: presence flags and the simulation count.

    Also lists subjects known only from ``sourcedata/`` (DICOMs staged, no
    BIDS directory yet -- see :func:`sourcedata_only_subject_ids`): the
    Subjects page and Pre-processing's own subjects table are exactly where
    a project's newest subject needs to be plannable, before anything else
    about them exists. An entry carries ``has_sourcedata: True`` when staged
    raw data is present (so a client can tell "staged, not yet converted"
    from ``has_raw`` False); the key is left out entirely otherwise -- the
    overwhelming common case (a subject with no sourcedata copy at all), and
    the shape every existing caller/test built before this field existed
    stays byte-identical (``/api/catalog/subjects`` is served with
    ``response_model_exclude_unset``, ``tit/server/routes/catalog.py``).
    """
    ids = sorted(
        set(subject_ids(pm)) | set(sourcedata_only_subject_ids(pm)), key=natural_key
    )
    rows = []
    for sid in ids:
        row = {
            "id": sid,
            "has_raw": _project_isdir(pm, pm.bids_subject(sid)),
            "has_fastsurfer": _has_fastsurfer(pm, sid),
            "has_freesurfer": _project_isdir(pm, pm.freesurfer_subject(sid)),
            "has_m2m": _has_m2m(pm, sid),
            "n_simulations": len(_simulation_names(pm, sid)),
        }
        if _has_sourcedata_raw(pm, sid):
            row["has_sourcedata"] = True
        rows.append(row)
    return rows

list_simulations

list_simulations(pm: PathManager, sid: str) -> list[dict] | None

Simulations of sid (TI/ and mTI/ presence), None if unknown.

Source code in tit/catalog.py
def list_simulations(pm: PathManager, sid: str) -> list[dict] | None:
    """Simulations of *sid* (``TI/`` and ``mTI/`` presence), ``None`` if unknown."""
    if sid not in subject_ids(pm):
        return None
    items: list[dict] = []
    for name in _simulation_names(pm, sid):
        item = {
            "name": name,
            "path": pm.simulation(sid, name),
            "has_ti": _project_isdir(pm, os.path.dirname(pm.ti_mesh_dir(sid, name))),
            "has_mti": _project_isdir(pm, os.path.dirname(pm.mti_mesh_dir(sid, name))),
        }
        # The v1 contract enriches every list item with the SimulationDetail keys (fields,
        # montages, niftis, meshes, ...); the v0 keys above always win. Found on real data:
        # the Results page crashed on ``fields`` missing from the list shape.
        try:
            detail = simulation_detail(pm, sid, name) or {}
        except (
            Exception
        ):  # pragma: no cover - a broken run dir must not hide the others
            detail = {}
        for key, value in detail.items():
            item.setdefault(key, value)
        items.append(item)
    return items

output_files

output_files(root: str, *, project_root: str | None = None) -> list[dict]

Every file under a run's output folder as {path, kind, label, bytes} rows.

The one filesystem read behind a finished job's Artifacts tab (GET /api/jobs/{id}/artifacts): what is on disk, not what a runner remembered to register. label is the path relative to root; bytes is the file size (None if it cannot be stat'ed). Same bounded walk and project jail as :func:_dir_artifacts.

Source code in tit/catalog.py
def output_files(root: str, *, project_root: str | None = None) -> list[dict]:
    """Every file under a run's output folder as ``{path, kind, label, bytes}`` rows.

    The one filesystem read behind a finished job's Artifacts tab
    (``GET /api/jobs/{id}/artifacts``): what is on disk, not what a runner remembered to
    register. ``label`` is the path relative to *root*; ``bytes`` is the file size (``None``
    if it cannot be stat'ed). Same bounded walk and project jail as :func:`_dir_artifacts`.
    """
    out: list[dict] = []
    for path in _walk_files(root, project_root=project_root):
        name = os.path.basename(path)
        lowered = name.lower()
        if lowered.endswith(".tetravox.json"):
            kind = "scene"
        elif lowered.endswith(".nii.gz"):
            kind = "nifti"
        else:
            kind = _OUTPUT_KIND_BY_EXT.get(os.path.splitext(lowered)[1], "file")
        try:
            size: int | None = os.path.getsize(path)
        except OSError:
            size = None
        out.append(
            {
                "path": path,
                "kind": kind,
                "label": os.path.relpath(path, root),
                "bytes": size,
            }
        )
    return out

classify_view_file

classify_view_file(path: str) -> str | None

What path is, as one of the seven kinds a scene understands — or None.

volume · label-volume · surface · mesh · annotation · morph · surface-data.

Decided from the name and its neighbours only. No file is opened: this runs once per row of a menu that is redrawn on every keystroke, and a menu that reads its way through a FreeSurfer surf/ directory is a menu that stutters. The one filesystem touch is :func:_has_lut_sidecar, a single os.path.isfile on a sibling.

None means "not something a scene can use" and is returned for everything unrecognised — .mat, .txt, .geo, .sigma, .label, .ctab, a log, and the derived FreeSurfer files (lh.pial.T1, lh.smoothwm.K.crv) that are recon-all's bookkeeping rather than anyone's input. Refusing to guess is the point: a file offered under the wrong kind fails at Open, which is a much worse moment to find out than not being offered at all.

Source code in tit/catalog.py
def classify_view_file(path: str) -> str | None:
    """What *path* is, as one of the seven kinds a scene understands — or ``None``.

    ``volume`` · ``label-volume`` · ``surface`` · ``mesh`` · ``annotation`` · ``morph`` ·
    ``surface-data``.

    Decided from the **name and its neighbours only**. No file is opened: this runs once per row
    of a menu that is redrawn on every keystroke, and a menu that reads its way through a
    FreeSurfer `surf/` directory is a menu that stutters. The one filesystem touch is
    :func:`_has_lut_sidecar`, a single `os.path.isfile` on a sibling.

    ``None`` means "not something a scene can use" and is returned for everything unrecognised —
    `.mat`, `.txt`, `.geo`, `.sigma`, `.label`, `.ctab`, a log, and the derived FreeSurfer files
    (`lh.pial.T1`, `lh.smoothwm.K.crv`) that are recon-all's bookkeeping rather than anyone's
    input. Refusing to guess is the point: a file offered under the wrong kind fails at Open,
    which is a much worse moment to find out than not being offered at all.
    """
    basename = os.path.basename(path)
    lowered = basename.lower()

    # A tetrahedral FEM mesh. The only extension that earns the word.
    if lowered.endswith(".msh"):
        return "mesh"

    if lowered.endswith(".annot"):
        return "annotation"

    if lowered.endswith(".gii"):
        # GIfTI says what it carries in its own second-to-last suffix. `.func`/`.shape`/`.time`
        # are per-vertex numbers *for* a surface; `.surf` and a bare `.gii` are the geometry.
        # SimNIBS writes the geometry bare (`lh.central.gii`), which is why the bare case is
        # geometry and not the other way round.
        if re.search(r"\.(func|shape|time)\.gii$", lowered):
            return "surface-data"
        return "surface"

    if lowered.endswith(_TRIANGLE_EXTS):
        return "surface"

    if lowered.endswith(_VOLUME_EXTS):
        stem = _strip_view_ext(basename)
        if _has_lut_sidecar(path) or any(
            pattern.match(stem) for pattern in _LABEL_VOLUME_PATTERNS
        ):
            return "label-volume"
        return "volume"

    # FreeSurfer's extensionless binaries, which is where the distinction is easiest to get wrong:
    # `lh.pial` and `lh.thickness` look identical to a filename matcher that only splits on dots.
    hemi = _hemi_split(basename)
    if hemi is not None:
        _, rest = hemi
        if rest in _FS_SURFACE_STEMS:
            return "surface"
        if rest in _FS_MORPH_STEMS:
            return "morph"

    return None

surface_attachments

surface_attachments(surface_path: str, *, extra_dirs: tuple[str, ...] = (), project_root: str | None = None) -> list[str]

Every annotation / morph / data-GIfTI file that belongs on surface_path.

Matched by hemisphere and nothing else, because that is the only correspondence FreeSurfer actually promises: lh.thickness has one value per vertex of every lh.* surface, since they all share a vertex numbering. Which lh. surface you hang it on is the viewer's choice, not a fact about the file. (Tetravox's own worker checks the vertex count when the file is attached, so a genuine mismatch is refused there with both counts named — this side does not need to read a byte to be safe, only to be plausible.)

Searched in the surface's own directory plus extra_dirs — SimNIBS keeps the geometry in m2m_<sid>/surfaces/ but writes the parcellations it made into m2m_<sid>/segmentation/, two directories apart, which is exactly why nothing offered them before.

Source code in tit/catalog.py
def surface_attachments(
    surface_path: str,
    *,
    extra_dirs: tuple[str, ...] = (),
    project_root: str | None = None,
) -> list[str]:
    """Every annotation / morph / data-GIfTI file that belongs on *surface_path*.

    Matched by hemisphere and nothing else, because that is the only correspondence FreeSurfer
    actually promises: `lh.thickness` has one value per vertex of *every* `lh.*` surface, since
    they all share a vertex numbering. Which `lh.` surface you hang it on is the viewer's choice,
    not a fact about the file. (Tetravox's own worker checks the vertex count when the file is
    attached, so a genuine mismatch is refused there with both counts named — this side does not
    need to read a byte to be safe, only to be plausible.)

    Searched in the surface's own directory plus *extra_dirs* — SimNIBS keeps the geometry in
    `m2m_<sid>/surfaces/` but writes the parcellations it made into `m2m_<sid>/segmentation/`,
    two directories apart, which is exactly why nothing offered them before.
    """
    if project_root and not is_within(project_root, surface_path):
        return []
    hemi = _hemi_split(os.path.basename(surface_path))
    if hemi is None:
        return []
    prefix = f"{hemi[0]}."
    found: list[str] = []
    for directory in (os.path.dirname(surface_path), *extra_dirs):
        for name in _metadata_names(directory, project_root):
            if not name.startswith(prefix):
                continue
            candidate = os.path.join(directory, name)
            if not os.path.isfile(candidate):
                continue
            if classify_view_file(candidate) in VIEW_ATTACHMENT_KINDS:
                found.append(candidate)
    return found

subject_detail

subject_detail(pm: PathManager, sid: str) -> dict | None

Full detail for one subject, None if unknown.

"Unknown" also admits a sourcedata-only subject (see :func:sourcedata_only_subject_ids) -- the Subjects page fetches this for every row :func:list_subjects returns, sourcedata-only rows included, and a 404 there would just blank the detail pane rather than error, but there is real information to return (has_sourcedata) so it is returned rather than dropped.

Source code in tit/catalog.py
def subject_detail(pm: PathManager, sid: str) -> dict | None:
    """Full detail for one subject, ``None`` if unknown.

    "Unknown" also admits a sourcedata-only subject (see
    :func:`sourcedata_only_subject_ids`) -- the Subjects page fetches this
    for every row :func:`list_subjects` returns, sourcedata-only rows
    included, and a 404 there would just blank the detail pane rather than
    error, but there is real information to return (``has_sourcedata``) so
    it is returned rather than dropped.
    """
    if sid not in subject_ids(pm) and sid not in sourcedata_only_subject_ids(pm):
        return None
    has_m2m = _has_m2m(pm, sid)
    # Only caps that carry electrodes: see :func:`_eeg_caps_with_electrodes`.
    eeg_nets_list = (
        [n for n, _ in _eeg_caps_with_electrodes(pm, sid)] if has_m2m else []
    )
    has_leadfields = (
        sorted({f"{item['net']}.csv" for item in (list_leadfields(pm, sid) or [])})
        if has_m2m
        else []
    )
    return {
        "id": sid,
        "has_raw": _project_isdir(pm, pm.bids_subject(sid)),
        "has_fastsurfer": _has_fastsurfer(pm, sid),
        "has_freesurfer": _project_isdir(pm, pm.freesurfer_subject(sid)),
        "has_m2m": has_m2m,
        "n_simulations": len(_simulation_names(pm, sid)),
        "m2m_path": pm.m2m(sid) if has_m2m else None,
        "eeg_nets": eeg_nets_list,
        "has_leadfields": has_leadfields,
        "has_dwi": _project_isdir(pm, pm.bids_dwi(sid)),
        "has_ct": _has_ct(pm, sid),
        "has_sourcedata": _has_sourcedata_raw(pm, sid),
    }

classify_sim_nifti

classify_sim_nifti(basename: str, simulation: str | None = None) -> dict | None

What a simulation NIfTI holds, read from the name the simulator gives it.

The simulator names every volume <base>_subject_<field>.nii.gz or <base>_MNI_MNI_<field>.nii.gz (tit.tools.mesh2nii), where <base> is the mesh it came from: <tissue_><sim>_<TI|mTI> for the envelope fields (grey_/white_ for the volume mesh's tissue crops, tit.tools.mesh2nii) and <sid>_TDCS_<i>_<cond> for a carrier's magnE. Space and field are read from the tokens at the end and the tissue from the prefix before the simulation's own name, so a simulation called MNI_target, grey_matter or TDCS_vs_TI is classified like any other.

Parameters

basename : str File name, without directory. simulation : str or None, optional The simulation the file belongs to. With it the tissue prefix is read exactly (grey_matter_TI is the whole-head file of grey_matter); without it a known tissue prefix is stripped.

Returns

dict or None {"kind": "envelope" | "carrier", "field", "space": "subject" | "mni", "tissue": "grey" | "white" | None}, or None for a name the simulator does not write.

Source code in tit/catalog.py
def classify_sim_nifti(basename: str, simulation: str | None = None) -> dict | None:
    """What a simulation NIfTI holds, read from the name the simulator gives it.

    The simulator names every volume ``<base>_subject_<field>.nii.gz`` or
    ``<base>_MNI_MNI_<field>.nii.gz`` (``tit.tools.mesh2nii``), where ``<base>`` is
    the mesh it came from: ``<tissue_><sim>_<TI|mTI>`` for the envelope fields
    (``grey_``/``white_`` for the volume mesh's tissue crops, ``tit.tools.mesh2nii``) and ``<sid>_TDCS_<i>_<cond>`` for a
    carrier's ``magnE``. Space and field are read from the tokens at the end and the
    tissue from the prefix before the simulation's own name, so a simulation called
    ``MNI_target``, ``grey_matter`` or ``TDCS_vs_TI`` is classified like any other.

    Parameters
    ----------
    basename : str
        File name, without directory.
    simulation : str or None, optional
        The simulation the file belongs to. With it the tissue prefix is read exactly
        (``grey_matter_TI`` is the whole-head file of ``grey_matter``); without it a
        known tissue prefix is stripped.

    Returns
    -------
    dict or None
        ``{"kind": "envelope" | "carrier", "field", "space": "subject" | "mni",
        "tissue": "grey" | "white" | None}``, or ``None`` for a name the simulator
        does not write.
    """
    match = _SIM_NIFTI_RE.fullmatch(basename)
    if match is None:
        return None
    base, field = match["base"], match["field"]
    space = "mni" if match["space"] == "MNI_MNI" else "subject"
    if _CARRIER_BASE_RE.fullmatch(base):
        return {"kind": "carrier", "field": field, "space": space, "tissue": None}
    envelope = _ENVELOPE_BASE_RE.fullmatch(base)
    if envelope is None:
        return None
    rest = envelope["rest"]
    tissue = None
    if simulation is not None and rest in (
        simulation,
        f"grey_{simulation}",
        f"white_{simulation}",
    ):
        if rest != simulation:
            tissue = rest.split("_", 1)[0]
    else:
        tissue = _strip_tissue_prefix(rest)[1]
    return {"kind": "envelope", "field": field, "space": space, "tissue": tissue}

simulation_detail

simulation_detail(pm: PathManager, sid: str, sim: str) -> dict | None

Full detail for one simulation, None if unknown.

Source code in tit/catalog.py
def simulation_detail(pm: PathManager, sid: str, sim: str) -> dict | None:
    """Full detail for one simulation, ``None`` if unknown."""
    if sid not in subject_ids(pm) or sim not in _simulation_names(pm, sid):
        return None
    sim_dir = pm.simulation(sid, sim)
    if not _project_paths_safe(pm, sim_dir):
        return None
    has_ti = _project_isdir(pm, os.path.dirname(pm.ti_mesh_dir(sid, sim)))
    has_mti = _project_isdir(pm, os.path.dirname(pm.mti_mesh_dir(sid, sim)))
    config_path = os.path.join(sim_dir, "documentation", "config.json")
    config = (
        _read_json(config_path) if _project_paths_safe(pm, config_path) else None
    ) or {}
    montage_name = config.get("montage_name") or sim

    niftis: list[dict] = []
    meshes: list[dict] = []
    saved: set[str] = set()
    for mode in _MODE_DIRS:
        mode_dir = os.path.join(sim_dir, mode)
        if not _project_isdir(pm, mode_dir):
            continue
        # The field mesh's own fields: NIfTIs are opt-in, the mesh always holds what was saved.
        volume = os.path.join(mode_dir, "mesh", f"{sim}_{mode}.msh")
        if _project_paths_safe(pm, volume) and os.path.isfile(volume):
            saved |= msh_field_names(volume) or set()
        niftis.extend(
            _mode_niftis(mode_dir, simulation=sim, project_root=pm.project_dir)
        )
        meshes.extend(
            _dir_meshes(
                os.path.join(mode_dir, "mesh"), "field", project_root=pm.project_dir
            )
        )
        meshes.extend(
            _dir_meshes(
                os.path.join(mode_dir, "mesh", "surfaces"),
                "surface",
                project_root=pm.project_dir,
            )
        )
    hf_dir = os.path.join(sim_dir, "high_Frequency")
    if _project_isdir(pm, os.path.join(hf_dir, "niftis")):
        niftis.extend(
            _hf_niftis(os.path.join(hf_dir, "niftis"), project_root=pm.project_dir)
        )
    if _project_isdir(pm, os.path.join(hf_dir, "mesh")):
        meshes.extend(
            _dir_meshes(
                os.path.join(hf_dir, "mesh"),
                "high_frequency",
                project_root=pm.project_dir,
            )
        )

    # The fsaverage projection (tit.source.fsaverage): template-space surfaces, one per spacing.
    meshes.extend(
        _dir_meshes(
            pm.sim_fsaverage(sid, sim), "fsaverage", project_root=pm.project_dir
        )
    )

    fields = sorted(saved | {n["field"] for n in niftis if n["field"]})
    spaces = sorted({n["space"] for n in niftis})
    all_reports = reports(pm, sid) or []
    return {
        "name": sim,
        "path": sim_dir,
        "has_ti": has_ti,
        "has_mti": has_mti,
        "montages": [montage_name],
        "fields": fields,
        # A carrier-only control (ΔF = 0) has no envelope to analyse or compute.
        "carrier_only": config.get("carrier_only") is True,
        "space": spaces,
        "report_ids": _report_ids_for_simulation(all_reports, sim),
        "niftis": niftis,
        "meshes": meshes,
    }

simulation_figures

simulation_figures(pm: PathManager, sid: str, sim: str) -> list[dict] | None

Pictures a simulation run saved of itself; None if subject/simulation is unknown.

Today that is the montage visualisation tit.tools.montage_visualizer writes as <sim>/<TI|mTI>/montage_imgs/<name>_highlighted_visualization.png -- the EEG net with this run's electrodes highlighted. The Results pane shows it beside the channel chips, which name the same montage in text, and in the run's Figures grid.

Not folded into SimulationDetail: that response has a Pydantic response_model generated from the frozen v1 contract, so a new field there is a contract change. This is a small presence query beside the main read.

Source code in tit/catalog.py
def simulation_figures(pm: PathManager, sid: str, sim: str) -> list[dict] | None:
    """Pictures a simulation run saved of itself; ``None`` if subject/simulation is unknown.

    Today that is the montage visualisation ``tit.tools.montage_visualizer`` writes as
    ``<sim>/<TI|mTI>/montage_imgs/<name>_highlighted_visualization.png`` -- the EEG net with
    this run's electrodes highlighted. The Results pane shows it beside the channel chips,
    which name the same montage in text, and in the run's Figures grid.

    Not folded into ``SimulationDetail``: that response has a Pydantic ``response_model``
    generated from the frozen v1 contract, so a new field there is a contract change. This is
    a small presence query beside the main read.
    """
    if sid not in subject_ids(pm) or sim not in _simulation_names(pm, sid):
        return None
    sim_dir = pm.simulation(sid, sim)
    out: list[dict] = []
    for mode in _MODE_DIRS:
        path = _jailed(
            pm,
            os.path.join(
                sim_dir, mode, "montage_imgs", f"{sim}_highlighted_visualization.png"
            ),
        )
        if path and os.path.isfile(path):
            out.append(
                {
                    "path": path,
                    "kind": "image",
                    "label": f"{mode} montage",
                }
            )
    return out

get_montages

get_montages(pm: PathManager) -> dict

montage_list.json, reshaped to the v1 Montages schema.

Reads the explicitly selected project's montage file through the shared writer API.

Source code in tit/catalog.py
def get_montages(pm: PathManager) -> dict:
    """``montage_list.json``, reshaped to the v1 ``Montages`` schema.

    Reads the explicitly selected project's montage file through the shared writer API.
    """
    from tit.sim.utils import load_montage_data

    if not _project_paths_safe(pm, pm.montage_config()):
        return {"nets": {}}
    data = load_montage_data(pm=pm)
    nets = {
        net: {
            "uni_polar": entry.get("uni_polar_montages", {}),
            "multi_polar": entry.get("multi_polar_montages", {}),
        }
        for net, entry in data.get("nets", {}).items()
    }
    return {"nets": nets}

put_montage

put_montage(pm: PathManager, net: str, kind: str, name: str, pairs: list[list[str]]) -> list[list[str]]

Create or overwrite one montage; returns pairs back.

pairs must be a montage the simulator can run (:func:tit.sim.config.check_electrode_pairs: pairs of two distinct labels, an even count from 2 to 26) and fit kind: uni_polar is 2 pairs (TI), multi_polar 4 or more (mTI). Anything else raises ValueError before the file is touched.

Source code in tit/catalog.py
def put_montage(
    pm: PathManager, net: str, kind: str, name: str, pairs: list[list[str]]
) -> list[list[str]]:
    """Create or overwrite one montage; returns *pairs* back.

    *pairs* must be a montage the simulator can run
    (:func:`tit.sim.config.check_electrode_pairs`: pairs of two distinct
    labels, an even count from 2 to 26) and fit *kind*: ``uni_polar`` is 2
    pairs (TI), ``multi_polar`` 4 or more (mTI).  Anything else raises
    ``ValueError`` before the file is touched.
    """
    if not is_safe_name(name):
        raise ValueError(
            "name must match ^[A-Za-z0-9_-]{1,64}$ (no path separators or '..')"
        )
    from tit import constants as const
    from tit.sim.config import check_electrode_pairs
    from tit.sim.utils import upsert_montage

    if kind not in ("uni_polar", "multi_polar"):
        raise ValueError("kind must be uni_polar or multi_polar")
    if not isinstance(pairs, list):
        raise ValueError("pairs must be a list of electrode pairs")
    check_electrode_pairs(pairs, xyz=False)
    if (kind == "uni_polar") != (len(pairs) == const.TI_ELECTRODE_PAIRS):
        raise ValueError(
            f"{len(pairs)} pairs cannot be saved as {kind}: uni_polar is "
            f"{const.TI_ELECTRODE_PAIRS} pairs (TI), multi_polar 4 or more (mTI)."
        )
    if not _project_paths_safe(pm, pm.montage_config()):
        raise ValueError("Montage path must remain inside the project")
    mode = "U" if kind == "uni_polar" else "M"
    upsert_montage(
        eeg_net=net,
        montage_name=name,
        electrode_pairs=[list(pair) for pair in pairs],
        mode=mode,
        pm=pm,
    )
    return pairs

delete_montage

delete_montage(pm: PathManager, net: str, kind: str, name: str) -> bool

Delete one montage; False if it did not exist.

Source code in tit/catalog.py
def delete_montage(pm: PathManager, net: str, kind: str, name: str) -> bool:
    """Delete one montage; ``False`` if it did not exist."""
    from tit.sim.utils import load_montage_data, save_montage_data

    if not _project_paths_safe(pm, pm.montage_config()):
        return False
    data = load_montage_data(pm=pm)
    key = "uni_polar_montages" if kind == "uni_polar" else "multi_polar_montages"
    montages = data.get("nets", {}).get(net, {}).get(key, {})
    if name not in montages:
        return False
    del montages[name]
    save_montage_data(data, pm=pm)
    return True

eeg_nets

eeg_nets(pm: PathManager, sid: str) -> list[dict] | None

EEG nets available to sid, with electrode labels; None if unknown.

Source code in tit/catalog.py
def eeg_nets(pm: PathManager, sid: str) -> list[dict] | None:
    """EEG nets available to *sid*, with electrode labels; ``None`` if unknown."""
    if sid not in subject_ids(pm):
        return None
    return [
        {"name": name, "electrodes": electrodes, "n": len(electrodes)}
        for name, electrodes in _eeg_caps_with_electrodes(pm, sid)
    ]

atlases

atlases(pm: PathManager, sid: str, space: str | None = None, kind: str | None = None) -> list[dict] | None

Atlases available to sid; None if the subject is unknown.

Every entry carries kind: "surface" for a FreeSurfer .annot parcellation, "volume" for a label volume. For MNI space the list, its order and each entry's kind, template and licence come from resources/atlas/manifest.json (:mod:tit.atlas.manifest) rather than from a filename list, so an atlas is only ever offered to the targeting mode that can read it: the cortical mode asks for kind="cortical" and gets surfaces, the subcortical mode asks for kind="subcortical" and gets volumes. Before the manifest existed nothing recorded the kind of an MNI atlas and the subcortical mode was handed every shipped MNI file whatever it was.

Source code in tit/catalog.py
def atlases(
    pm: PathManager, sid: str, space: str | None = None, kind: str | None = None
) -> list[dict] | None:
    """Atlases available to *sid*; ``None`` if the subject is unknown.

    Every entry carries ``kind``: ``"surface"`` for a FreeSurfer ``.annot``
    parcellation, ``"volume"`` for a label volume. For MNI space the list, its
    order and each entry's kind, template and licence come from
    ``resources/atlas/manifest.json`` (:mod:`tit.atlas.manifest`) rather than
    from a filename list, so an atlas is only ever offered to the targeting mode
    that can read it: the cortical mode asks for ``kind="cortical"`` and gets
    surfaces, the subcortical mode asks for ``kind="subcortical"`` and gets
    volumes. Before the manifest existed nothing recorded the kind of an MNI
    atlas and the subcortical mode was handed every shipped MNI file whatever it
    was.
    """
    from tit.atlas.manifest import KIND_FOR_MODE, kind_for_path, mni_atlas_entries

    if sid not in subject_ids(pm):
        return None
    space = (space or "subject").lower()
    wanted = KIND_FOR_MODE.get(kind) if kind else None
    out: list[dict] = []

    if kind in (None, "cortical") and space == "subject":
        seg_dir = os.path.join(pm.m2m(sid), "segmentation")
        mesh_mgr = MeshAtlasManager(seg_dir if _project_paths_safe(pm, seg_dir) else "")
        for name in mesh_mgr.list_atlases():
            lh_path = mesh_mgr.find_atlas_file(name, "lh")
            if lh_path and not _project_paths_safe(pm, lh_path):
                continue
            out.append(
                {
                    "id": name,
                    "name": name,
                    "path": lh_path or "",
                    "kind": "surface",
                    "hemispheres": ["lh", "rh"],
                }
            )

    if space == "mni":
        # The manifest decides, not the mode: a volume atlas is offered to the
        # subcortical/volumetric flow and a surface one to the cortical flow,
        # and neither mode is ever handed an atlas it cannot read.
        for entry in mni_atlas_entries(mni_resources_dir()):
            if wanted is not None and entry["kind"] != wanted:
                continue
            out.append(
                {
                    "id": entry["id"],
                    "name": entry.get("name") or entry["id"],
                    "path": entry["path"],
                    "kind": entry["kind"],
                    "template": entry.get("template", ""),
                    "license": entry.get("license", ""),
                    "citation": entry.get("citation", ""),
                }
            )
    elif kind in (None, "subcortical"):
        seg_dir = os.path.join(pm.m2m(sid), "segmentation")
        voxel_mgr = VoxelAtlasManager(
            fastsurfer_mri_dir=(
                pm.fastsurfer_mri(sid)
                if _project_paths_safe(pm, pm.fastsurfer_mri(sid))
                else ""
            ),
            freesurfer_mri_dir=(
                pm.freesurfer_mri(sid)
                if _project_paths_safe(pm, pm.freesurfer_mri(sid))
                else ""
            ),
            seg_dir=seg_dir if _project_paths_safe(pm, seg_dir) else "",
            masks_dir=(pm.masks(sid) if _project_paths_safe(pm, pm.masks(sid)) else ""),
        )
        for display_name, path in voxel_mgr.list_atlases():
            if not _project_paths_safe(pm, path):
                continue
            entry: dict = {
                "id": display_name,
                "name": display_name,
                "path": path,
                "kind": kind_for_path(path),
            }
            hemi = VOXEL_ATLASES.get(display_name)
            if hemi in ("lh", "rh"):
                entry["hemispheres"] = [hemi]
            out.append(entry)

    return out

atlas_regions

atlas_regions(pm: PathManager, sid: str, atlas_id: str, hemi: str | None = None) -> list[dict] | None

Regions of atlas_id for sid; None if the subject/atlas is unknown.

Per the reconciled v1 Region schema: for a cortical (surface/annotation) atlas, id is the FreeSurfer .annot label index within that region's own hemisphere file -- exactly the integer FlexConfig.AtlasROI.label / ExConfig's equivalent needs, resolved the same way :func:tit.opt.roi_spec.resolve_cortical_region_index_map does -- and hemi is that region's hemisphere. For a subcortical (volumetric) atlas, id is the voxel label value and hemi is None (a region whose label cannot be parsed as an integer is dropped rather than returned with a non-conforming id).

Source code in tit/catalog.py
def atlas_regions(
    pm: PathManager, sid: str, atlas_id: str, hemi: str | None = None
) -> list[dict] | None:
    """Regions of *atlas_id* for *sid*; ``None`` if the subject/atlas is unknown.

    Per the reconciled v1 ``Region`` schema: for a cortical (surface/annotation)
    atlas, ``id`` is the FreeSurfer ``.annot`` label index *within that
    region's own hemisphere file* -- exactly the integer
    ``FlexConfig.AtlasROI.label`` / ``ExConfig``'s equivalent needs, resolved
    the same way :func:`tit.opt.roi_spec.resolve_cortical_region_index_map`
    does -- and ``hemi`` is that region's hemisphere. For a subcortical
    (volumetric) atlas, ``id`` is the voxel label value and ``hemi`` is
    ``None`` (a region whose label cannot be parsed as an integer is dropped
    rather than returned with a non-conforming id).
    """
    if hemi not in (None, "both", "lh", "rh") or sid not in subject_ids(pm):
        return None
    seg_dir = os.path.join(pm.m2m(sid), "segmentation")

    mesh_mgr = MeshAtlasManager(seg_dir if _project_paths_safe(pm, seg_dir) else "")
    if atlas_id in mesh_mgr.list_atlases():
        hemis = ("lh", "rh") if hemi in (None, "both") else (hemi,)
        out: list[dict] = []
        for h in hemis:
            annot_path = mesh_mgr.find_atlas_file(atlas_id, h)
            if not annot_path:
                continue
            if not is_within(pm.project_dir, annot_path):
                return None
            for index, name in mesh_mgr.list_annot_regions(annot_path):
                if name == "unknown":
                    continue
                out.append({"id": index, "name": name, "hemi": h})
        return out

    voxel_mgr = VoxelAtlasManager(
        fastsurfer_mri_dir=(
            pm.fastsurfer_mri(sid)
            if _project_paths_safe(pm, pm.fastsurfer_mri(sid))
            else ""
        ),
        freesurfer_mri_dir=(
            pm.freesurfer_mri(sid)
            if _project_paths_safe(pm, pm.freesurfer_mri(sid))
            else ""
        ),
        seg_dir=seg_dir if _project_paths_safe(pm, seg_dir) else "",
        masks_dir=pm.masks(sid) if _project_paths_safe(pm, pm.masks(sid)) else "",
    )
    atlas_path = dict(voxel_mgr.list_atlases()).get(atlas_id)
    atlas_root = pm.project_dir
    if atlas_path is None:
        atlas_root = mni_resources_dir()
        atlas_path = {
            os.path.basename(path): path
            for path in VoxelAtlasManager.detect_mni_atlases(atlas_root)
        }.get(atlas_id)
    if atlas_path is None:
        return None
    atlas_path = _jailed_atlas_path(atlas_root, atlas_path)
    if atlas_path is None:
        return None

    entries = voxel_mgr.list_regions(atlas_path)
    out = []
    for entry in entries:
        try:
            region_id = parse_region_label(entry)
        except ValueError:
            continue  # non-integer label: cannot satisfy Region.id: integer
        name = re.sub(r"\s*\(ID:\s*-?\d+\)\s*$", "", entry)
        out.append({"id": region_id, "name": name, "hemi": None})
    return out

region_roi

region_roi(atlas: dict, regions: list[dict], tissues: str = 'GM', space: str = 'subject') -> dict

The FlexConfig ROI targeting regions (atlas_regions rows) of atlas (an :func:atlases entry): an AtlasROI for a surface atlas, each region read from its own hemisphere's file (the lh. of the atlas path swapped for the region's hemi), a SubcorticalROI for a volume, the atlas path once per region.

The desktop's picker builds the same object from its selection (roiToConfig in desktop/src/renderer/pages/_shared/roi/types.ts); tests/fixtures/region_rois.json pins both to one table.

Source code in tit/catalog.py
def region_roi(
    atlas: dict, regions: list[dict], tissues: str = "GM", space: str = "subject"
) -> dict:
    """The ``FlexConfig`` ROI targeting *regions* (``atlas_regions`` rows) of *atlas* (an
    :func:`atlases` entry): an ``AtlasROI`` for a surface atlas, each region read from its own
    hemisphere's file (the ``lh.`` of the atlas path swapped for the region's ``hemi``), a
    ``SubcorticalROI`` for a volume, the atlas path once per region.

    The desktop's picker builds the same object from its selection (``roiToConfig`` in
    ``desktop/src/renderer/pages/_shared/roi/types.ts``); ``tests/fixtures/region_rois.json``
    pins both to one table.
    """
    labels = [r["id"] for r in regions]
    if atlas.get("kind") == "surface":
        hemis = [r.get("hemi") or "lh" for r in regions]
        return {
            "_type": "AtlasROI",
            "atlas_path": [
                re.sub(r"(^|/)lh\.", rf"\g<1>{h}.", atlas["path"], count=1)
                for h in hemis
            ],
            "label": labels,
            "hemisphere": hemis,
        }
    return {
        "_type": "SubcorticalROI",
        "atlas_path": [atlas["path"]] * len(regions),
        "label": labels,
        "tissues": tissues,
        "atlas_space": space,
    }

find_regions

find_regions(pm: PathManager, sid: str, query: str) -> list[dict] | None

Every region of sid's atlases whose name has each word of query as a whole word (:func:_name_matches), per atlas, with ready ROIs: rois.all (every match, i.e. both sides) and rois.left / rois.right when a side matched. Side words ("left", "bilateral", ...) are ignored; None for an unknown subject, :class:ValueError when query names no structure.

The atlases searched are the ones the Optimizer's ROI picker offers: the subject's own (space: "subject") and then the shipped MNI volume atlases (space: "mni", atlases(..., space="mni", kind="subcortical")), whose ROI is a SubcorticalROI with atlas_space: "mni" -- the object the picker builds for an MNI selection.

Source code in tit/catalog.py
def find_regions(pm: PathManager, sid: str, query: str) -> list[dict] | None:
    """Every region of *sid*'s atlases whose name has each word of *query* as a whole word
    (:func:`_name_matches`), per atlas, with
    ready ROIs: ``rois.all`` (every match, i.e. both sides) and ``rois.left`` /
    ``rois.right`` when a side matched. Side words ("left", "bilateral", ...) are ignored;
    ``None`` for an unknown subject, :class:`ValueError` when *query* names no structure.

    The atlases searched are the ones the Optimizer's ROI picker offers: the subject's own
    (``space: "subject"``) and then the shipped MNI volume atlases (``space: "mni"``,
    ``atlases(..., space="mni", kind="subcortical")``), whose ROI is a ``SubcorticalROI`` with
    ``atlas_space: "mni"`` -- the object the picker builds for an MNI selection.
    """
    found = atlases(pm, sid)
    if found is None:
        return None
    words = [w for w in re.findall(r"[a-z0-9]+", query.lower()) if w not in _SIDE_WORDS]
    if not words:
        raise ValueError("query must name a structure, e.g. 'thalamus' or 'precentral'")
    searched = [(atlas, "subject") for atlas in found] + [
        (atlas, "mni") for atlas in atlases(pm, sid, "mni", "subcortical") or []
    ]
    out = []
    for atlas, space in searched:
        matches = [
            {**r, "side": _region_side(r)}
            for r in atlas_regions(pm, sid, atlas["id"]) or []
            if _name_matches(r["name"], words)
        ]
        if not matches:
            continue
        rois = {"all": region_roi(atlas, matches, space=space)}
        for side in ("left", "right"):
            picked = [r for r in matches if r["side"] == side]
            if picked:
                rois[side] = region_roi(atlas, picked, space=space)
        out.append(
            {
                "atlas": atlas["id"],
                "kind": atlas["kind"],
                "space": space,
                "matches": matches,
                "rois": rois,
            }
        )
    return out

nifti_labels

nifti_labels(pm: PathManager, sid: str, path: str | None = None) -> list[dict] | None

Unique integer labels present in one label volume, named where a LUT applies.

The 3D Visual Exporter's sub-cortical mode asks the user for label numbers (10,49); 2.5.0 answered that with a Qt dialog that parsed a FreeSurfer LUT itself. This is that browser's data, from the toolbox's own segstats path (:func:tit.atlas.segstats.compute_segstats + :func:~tit.atlas.segstats.resolve_lut_for_atlas), which also writes the <name>_labels.txt sidecar cache beside the volume -- so the first call on a subject pays for the scan and every later one reads the cache, same as :meth:VoxelAtlasManager.list_regions.

Parameters

pm : PathManager sid : str Subject whose m2m holds the default volume. path : str or None A specific label volume. None/empty means the subject's own <m2m>/segmentation/labeling.nii.gz -- the sub-cortical mode's default, and the only path the panel ever sends for an untouched form.

Returns

list of dict or None [{"id": int, "name": str, "n_voxels": int}] sorted by id; None for an unknown subject, a path outside the project jail, or a file that is not there. The caller (routes/catalog_v1) turns None into a 404 -- deliberately one status for all three, so probing this route cannot tell a file that exists outside the jail from one that does not exist at all.

Source code in tit/catalog.py
def nifti_labels(
    pm: PathManager, sid: str, path: str | None = None
) -> list[dict] | None:
    """Unique integer labels present in one label volume, named where a LUT applies.

    The 3D Visual Exporter's sub-cortical mode asks the user for label *numbers*
    (``10,49``); 2.5.0 answered that with a Qt dialog that parsed a FreeSurfer LUT
    itself. This is that browser's data, from the toolbox's own segstats path
    (:func:`tit.atlas.segstats.compute_segstats` +
    :func:`~tit.atlas.segstats.resolve_lut_for_atlas`), which also writes the
    ``<name>_labels.txt`` sidecar cache beside the volume -- so the first call on a
    subject pays for the scan and every later one reads the cache, same as
    :meth:`VoxelAtlasManager.list_regions`.

    Parameters
    ----------
    pm : PathManager
    sid : str
        Subject whose ``m2m`` holds the default volume.
    path : str or None
        A specific label volume. ``None``/empty means the subject's own
        ``<m2m>/segmentation/labeling.nii.gz`` -- the sub-cortical mode's default,
        and the only path the panel ever sends for an untouched form.

    Returns
    -------
    list of dict or None
        ``[{"id": int, "name": str, "n_voxels": int}]`` sorted by ``id``; ``None``
        for an unknown subject, a path outside the project jail, or a file that is
        not there. The caller (``routes/catalog_v1``) turns ``None`` into a 404 --
        deliberately one status for all three, so probing this route cannot tell a
        file that exists outside the jail from one that does not exist at all.
    """
    if sid not in subject_ids(pm):
        return None

    raw = (path or "").strip()
    if not raw:
        m2m = pm.m2m(sid)
        if not m2m:
            return None
        raw = os.path.join(m2m, "segmentation", "labeling.nii.gz")

    from tit.viewspec import jail_roots, resolve_jailed

    resolved = resolve_jailed(raw)
    if resolved is None:
        return None
    if not any(
        _jailed_atlas_path(str(root), str(resolved)) is not None
        for root in jail_roots()
    ):
        return None

    cached = _read_segstats_sum(_segstats_sidecar(str(resolved)))
    if cached is not None:
        return cached

    from tit.atlas.segstats import (
        compute_segstats,
        resolve_lut_for_atlas,
        write_segstats_sum,
    )

    try:
        lut = resolve_lut_for_atlas(str(resolved))
        stats = compute_segstats(str(resolved), lut)
    except (OSError, ValueError):
        # An unreadable or non-label volume is "nothing to browse", not a 500: the
        # path came from a text field the user can type anything into.
        return None
    try:
        write_segstats_sum(stats, _segstats_sidecar(str(resolved)))
    except OSError:
        # A read-only project still gets its answer -- it just pays for the scan again.
        pass
    return [
        {"id": int(s.seg_id), "name": s.name, "n_voxels": int(s.n_voxels)}
        for s in stats
    ]

is_safe_name

is_safe_name(name: Any) -> bool

True if name is safe to use as a filename component.

Used for ROI names, free-hand config names, and montage names on every write path -- never trust a client-supplied string used to build a path.

Source code in tit/catalog.py
def is_safe_name(name: Any) -> bool:
    """``True`` if *name* is safe to use as a filename component.

    Used for ROI names, free-hand config names, and montage names on every
    write path -- never trust a client-supplied string used to build a path.
    """
    # fullmatch, not match: ``$`` also matches before a trailing newline, so ``"x\n"`` passed.
    return isinstance(name, str) and bool(_SAFE_NAME_RE.fullmatch(name))

as_float

as_float(value: Any, field_name: str) -> float

Coerce value to float; raises ValueError naming the field on failure.

bool is rejected even though isinstance(True, int) is True in Python -- a checkbox value has no business landing in a coordinate.

Source code in tit/catalog.py
def as_float(value: Any, field_name: str) -> float:
    """Coerce *value* to ``float``; raises ``ValueError`` naming the field on failure.

    ``bool`` is rejected even though ``isinstance(True, int)`` is ``True`` in
    Python -- a checkbox value has no business landing in a coordinate.
    """
    if isinstance(value, bool) or not isinstance(value, (int, float, str)):
        raise ValueError(f"{field_name} must be a number")
    try:
        number = float(value)
    except (TypeError, ValueError) as exc:
        raise ValueError(f"{field_name} must be a number") from exc
    # "nan", "inf" and 1e309 all parse; none is a coordinate, and JSON cannot carry them back.
    if not math.isfinite(number):
        raise ValueError(f"{field_name} must be a finite number")
    return number

list_rois

list_rois(pm: PathManager, sid: str) -> list[dict] | None

Saved ROIs for sid; None if the subject is unknown.

Source code in tit/catalog.py
def list_rois(pm: PathManager, sid: str) -> list[dict] | None:
    """Saved ROIs for *sid*; ``None`` if the subject is unknown."""
    if sid not in subject_ids(pm):
        return None
    from tit.opt.ex.roi import read_roi_center

    roi_dir = pm.rois(sid)
    if not _project_paths_safe(pm, roi_dir) or not os.path.isdir(roi_dir):
        return []
    out = []
    for name in sorted(os.listdir(roi_dir)):
        if not name.endswith(".csv") or not _project_paths_safe(
            pm, os.path.join(roi_dir, name)
        ):
            continue
        try:
            x, y, z = read_roi_center(os.path.join(roi_dir, name))[:3]
        except (OSError, ValueError):
            continue
        if not all(math.isfinite(v) for v in (x, y, z)):
            continue  # a "nan" saved before the writer refused it is no centre
        roi_name = name[: -len(".csv")]
        item = {"name": roi_name, "x": x, "y": y, "z": z}
        item["space"] = "mni" if roi_name.upper().endswith("_MNI") else "subject"
        # create_roi's sidecar holds what the CSV cannot (space, radius). It counts only
        # while it describes this centre: another writer (the Ex-search engine) rewrites the
        # CSV without one, and a stale space must not stick to a new centre.
        sidecar = os.path.join(roi_dir, f"{roi_name}.json")
        meta = _read_json(sidecar) if _project_paths_safe(pm, sidecar) else None
        if (
            isinstance(meta, dict)
            and [meta.get(k) for k in ("x", "y", "z")] == [x, y, z]
            and meta.get("space") in ("subject", "mni")
        ):
            item["space"] = meta["space"]
            radius = meta.get("radius")
            if (
                isinstance(radius, (int, float))
                and math.isfinite(radius)
                and radius > 0
            ):
                item["radius"] = float(radius)
        out.append(item)
    return out

create_roi

create_roi(pm: PathManager, sid: str, roi: dict) -> dict

Save a new spherical ROI centre for sid.

Writes a plain x,y,z row (what :func:tit.opt.ex.roi.read_roi_center reads) plus a roi_list.txt entry; this module is the single writer of the CRUD shape the v1 contract expects. The CSV format has never carried the space or the radius, so they go in a <name>.json sidecar beside it, which :func:list_rois reads back: the listing returns what this function answered. Without a space the _MNI name suffix decides, as it always has.

Raises

ValueError If the name is unsafe, a coordinate or the radius is not a finite number, the radius is not positive, or the space is not subject or mni. Nothing is written then.

Source code in tit/catalog.py
def create_roi(pm: PathManager, sid: str, roi: dict) -> dict:
    """Save a new spherical ROI centre for *sid*.

    Writes a plain ``x,y,z`` row (what :func:`tit.opt.ex.roi.read_roi_center`
    reads) plus a ``roi_list.txt`` entry; this module is the single writer of
    the CRUD shape the v1 contract expects. The CSV format has never carried the space or the radius, so they
    go in a ``<name>.json`` sidecar beside it, which :func:`list_rois` reads
    back: the listing returns what this function answered. Without a
    ``space`` the ``_MNI`` name suffix decides, as it always has.

    Raises
    ------
    ValueError
        If the name is unsafe, a coordinate or the radius is not a finite
        number, the radius is not positive, or the space is not ``subject``
        or ``mni``. Nothing is written then.
    """
    name = str(roi["name"])
    if not is_safe_name(name):
        raise ValueError(
            "name must match ^[A-Za-z0-9_-]{1,64}$ (no path separators or '..')"
        )
    x = as_float(roi["x"], "x")
    y = as_float(roi["y"], "y")
    z = as_float(roi["z"], "z")
    radius = roi.get("radius")
    if radius is not None:
        radius = as_float(radius, "radius")
        if radius <= 0:
            raise ValueError("radius must be greater than 0 mm")
    if "space" in roi:
        space = roi["space"]
        if space not in ("subject", "mni"):
            raise ValueError("space must be subject or mni")
    else:
        space = "mni" if name.upper().endswith("_MNI") else "subject"

    roi_dir = pm.rois(sid)
    filename = f"{name}.csv"
    base_name = name
    roi_path = _jailed(pm, os.path.join(roi_dir, filename))
    roi_list = _jailed(pm, os.path.join(roi_dir, "roi_list.txt"))
    sidecar = _jailed(pm, os.path.join(roi_dir, f"{name}.json"))
    if roi_path is None or roi_list is None or sidecar is None:
        raise ValueError("ROI paths must remain inside the project")
    os.makedirs(roi_dir, exist_ok=True)

    saved = {"name": base_name, "x": x, "y": y, "z": z, "space": space}
    if radius is not None:
        saved["radius"] = radius
    with open(sidecar, "w") as f:
        json.dump(saved, f, indent=2)
    with open(roi_path, "w", newline="") as f:
        csv.writer(f).writerow([x, y, z])

    existing = []
    if os.path.isfile(roi_list):
        existing = [line.strip() for line in open(roi_list) if line.strip()]
    if filename not in existing:
        with open(roi_list, "a") as f:
            f.write(f"{filename}\n")

    # No "radius": null -- the contract's Roi.radius is a number when present.
    return saved

delete_roi

delete_roi(pm: PathManager, sid: str, name: str) -> bool

Delete one saved ROI; False if it did not exist (also if name is unsafe).

Source code in tit/catalog.py
def delete_roi(pm: PathManager, sid: str, name: str) -> bool:
    """Delete one saved ROI; ``False`` if it did not exist (also if *name* is unsafe)."""
    if not is_safe_name(name):
        return False
    roi_dir = pm.rois(sid)
    filename = name if name.endswith(".csv") else f"{name}.csv"
    roi_path = _jailed(pm, os.path.join(roi_dir, filename))
    roi_list = _jailed(pm, os.path.join(roi_dir, "roi_list.txt"))
    if roi_path is None or roi_list is None:
        return False
    existed = os.path.isfile(roi_path)
    if existed:
        os.remove(roi_path)

    if os.path.isfile(roi_list):
        lines = [line.strip() for line in open(roi_list) if line.strip()]
        if filename in lines:
            lines.remove(filename)
            with open(roi_list, "w") as f:
                f.write("\n".join(lines) + ("\n" if lines else ""))
    return existed

list_leadfields

list_leadfields(pm: PathManager, sid: str) -> list[dict] | None

Precomputed leadfields for sid; None if the subject is unknown.

Source code in tit/catalog.py
def list_leadfields(pm: PathManager, sid: str) -> list[dict] | None:
    """Precomputed leadfields for *sid*; ``None`` if the subject is unknown."""
    if sid not in subject_ids(pm):
        return None
    from tit.opt.leadfield import leadfield_net, read_leadfield_electrode

    root = pm.leadfields(sid)
    out = []
    names = _metadata_names(root, pm.project_dir)
    for name in names:
        if not name.endswith(".hdf5"):
            continue
        # The electrode it was computed with, from its jailed sidecar; ``None`` = not recorded.
        sidecar = name.removesuffix(".hdf5") + ".json"
        electrode = (
            read_leadfield_electrode(os.path.join(root, sidecar))
            if sidecar in names
            else None
        )
        path = os.path.join(root, name)
        # LeadfieldGenerator's net naming, without its unjailed stat.
        net = leadfield_net(name, sid)
        try:
            size = os.path.getsize(path)
        except OSError:
            size = 0
        out.append(
            {
                "net": net,
                "path": path,
                "exists": os.path.isfile(path),
                "size_bytes": size,
                "electrode": electrode,
            }
        )
    return sorted(out, key=lambda item: (item["net"], item["path"]))

pair_by_channel

pair_by_channel(electrodes: list, channel_array_indices: list | None) -> list[list]

Group electrodes into [a, b] pairs, one pair per stimulation channel.

The one electrode-pairing rule for a flex-search result: the Simulator's flex rows (via :func:flex_runs), POST /api/plan's flex montage sources, the run-mapping route and agent sim_from_flex steps (all via :func:tit.sim.montage_sources.resolve_flex_montage) pair through it. channel_array_indices is the optimiser's own [[channel, array], ...] bookkeeping (_save_optimized_positions in resources/map-electrodes/tes_flex_optimization.py writes one entry per electrode, carried through electrode_mapping_*.json by the Hungarian mapping): entry i says which channel and which of that channel's two arrays electrode i belongs to, so the pairs follow the channels whatever order the electrodes are listed in. When it is missing or unusable the electrodes are paired consecutively.

Source code in tit/catalog.py
def pair_by_channel(electrodes: list, channel_array_indices: list | None) -> list[list]:
    """Group *electrodes* into ``[a, b]`` pairs, one pair per stimulation channel.

    The one electrode-pairing rule for a flex-search result: the Simulator's flex rows (via
    :func:`flex_runs`), ``POST /api/plan``'s flex montage sources, the run-mapping route and
    agent ``sim_from_flex`` steps (all via
    :func:`tit.sim.montage_sources.resolve_flex_montage`) pair through it.
    ``channel_array_indices`` is the optimiser's own ``[[channel, array], ...]`` bookkeeping
    (``_save_optimized_positions`` in ``resources/map-electrodes/tes_flex_optimization.py``
    writes one entry per electrode, carried through ``electrode_mapping_*.json`` by the
    Hungarian mapping): entry *i* says which channel and which of that channel's two arrays
    electrode *i* belongs to, so the pairs follow the channels whatever order the electrodes
    are listed in. When it is missing or unusable the electrodes are paired consecutively.
    """
    if isinstance(channel_array_indices, list) and len(channel_array_indices) == len(
        electrodes
    ):
        by_channel: dict = {}
        ok = True
        for electrode, idx in zip(electrodes, channel_array_indices):
            if not isinstance(idx, (list, tuple)) or len(idx) != 2:
                ok = False
                break
            by_channel.setdefault(idx[0], []).append((idx[1], electrode))
        if ok:
            pairs = []
            for channel in sorted(by_channel):
                members = [
                    e for _, e in sorted(by_channel[channel], key=lambda t: t[0])
                ]
                if len(members) != 2:
                    pairs = []
                    break
                pairs.append(list(members))
            if pairs:
                return pairs
    return [
        [electrodes[i], electrodes[i + 1]] for i in range(0, len(electrodes) - 1, 2)
    ]

read_flex_mapping

read_flex_mapping(path: str) -> dict | None

One electrode_mapping_<net>.json as {eeg_net, pairs[, max_snap_mm]}, or None when unusable (unreadable, fewer than four labels, or a label used twice -- two optimised electrodes on one site is not a montage).

max_snap_mm is the largest distance (mm, Euclidean) an optimised electrode moved to its net site, from the file's distances; it is left out when those are missing, not one finite number per label, so an unknown snap is never shown as a small one.

Source code in tit/catalog.py
def read_flex_mapping(path: str) -> dict | None:
    """One ``electrode_mapping_<net>.json`` as ``{eeg_net, pairs[, max_snap_mm]}``, or
    ``None`` when unusable (unreadable, fewer than four labels, or a label used twice --
    two optimised electrodes on one site is not a montage).

    ``max_snap_mm`` is the largest distance (mm, Euclidean) an optimised electrode moved
    to its net site, from the file's ``distances``; it is left out when those are missing,
    not one finite number per label, so an unknown snap is never shown as a small one.
    """
    data = _read_json(path)
    if data is None:
        return None
    labels = [
        label for label in data.get("mapped_labels") or [] if isinstance(label, str)
    ]
    if len(labels) < 4 or len(set(labels)) != len(labels):
        return None
    net = data.get("eeg_net")
    if not isinstance(net, str) or not net:
        stem = os.path.basename(path).removeprefix("electrode_mapping_")
        if stem == os.path.basename(path):  # no net in the name or the file: unusable
            return None
        net = stem.removesuffix(".json") + ".csv"
    mapping = {
        "eeg_net": net,
        "pairs": pair_by_channel(labels, data.get("channel_array_indices")),
    }
    distances = data.get("distances")
    if (
        isinstance(distances, list)
        and len(distances) == len(labels)
        and all(
            isinstance(d, (int, float)) and not isinstance(d, bool) and math.isfinite(d)
            for d in distances
        )
    ):
        mapping["max_snap_mm"] = float(max(distances))
    return mapping

flex_optimized_pairs

flex_optimized_pairs(run_dir: str, *, project_root: str | None = None) -> list[list] | None

The run's free (un-mapped) XYZ electrode pairs, or None.

electrode_positions.json is written by every flex-search run, which is what makes a run selectable in the Simulator even when it has never been mapped onto an EEG net (Montage.Mode.FLEX_FREE).

Source code in tit/catalog.py
def flex_optimized_pairs(
    run_dir: str, *, project_root: str | None = None
) -> list[list] | None:
    """The run's free (un-mapped) XYZ electrode pairs, or ``None``.

    ``electrode_positions.json`` is written by every flex-search run, which is
    what makes a run selectable in the Simulator even when it has never been
    mapped onto an EEG net (``Montage.Mode.FLEX_FREE``).
    """
    path = os.path.join(run_dir, "electrode_positions.json")
    if project_root and not is_within(project_root, path):
        return None
    data = _read_json(path)
    if data is None:
        return None
    positions = [
        p
        for p in data.get("optimized_positions") or []
        if isinstance(p, (list, tuple)) and len(p) == 3
    ]
    if len(positions) < 4:
        return None
    return pair_by_channel(
        [list(p) for p in positions], data.get("channel_array_indices")
    )

flex_runs

flex_runs(pm: PathManager, sid: str) -> list[dict] | None

Flex-search runs for sid; None if the subject is unknown.

Source code in tit/catalog.py
def flex_runs(pm: PathManager, sid: str) -> list[dict] | None:
    """Flex-search runs for *sid*; ``None`` if the subject is unknown."""
    if sid not in subject_ids(pm):
        return None
    from tit.opt.flex.manifest import read_manifest

    out = []
    if not _project_paths_safe(pm, pm.flex_search(sid)):
        return out
    for name in pm.list_flex_search_runs(sid):
        run_dir = pm.flex_search_run(sid, name)
        if not _project_paths_safe(pm, run_dir, pm.flex_manifest(sid, name)):
            continue
        manifest = read_manifest(run_dir)
        if manifest is None:  # no flex_meta.json: ignore, per the plan
            continue
        out.append(
            {
                "name": name,
                "path": run_dir,
                "goal": manifest.get("goal", ""),
                "roi": manifest.get("roi") or {},
                "created": manifest.get("created") or _mtime_iso(run_dir),
                "manifest": manifest,
                # The electrodes the run actually produced. `flex_meta.json` records
                # none of them, so a client that reads only `manifest` has no way to
                # turn a run into a `Montage` for submission (simulator PARITY.md #4).
                "mappings": _flex_mappings(run_dir, project_root=pm.project_dir),
                "optimized": flex_optimized_pairs(run_dir, project_root=pm.project_dir),
                "artifacts": _dir_artifacts(run_dir, project_root=pm.project_dir),
            }
        )
    return out

ex_runs

ex_runs(pm: PathManager, sid: str, kind: str = 'ex') -> list[dict] | None

Ex/mEx-search runs for sid; None if the subject is unknown.

Source code in tit/catalog.py
def ex_runs(pm: PathManager, sid: str, kind: str = "ex") -> list[dict] | None:
    """Ex/mEx-search runs for *sid*; ``None`` if the subject is unknown."""
    if sid not in subject_ids(pm):
        return None
    root = pm.ex_search(sid) if kind == "ex" else pm.m_ex_search(sid)
    if not _project_paths_safe(pm, root) or not os.path.isdir(root):
        return []
    out = []
    for entry in sorted(os.scandir(root), key=lambda e: e.name):
        if not _project_paths_safe(
            pm, entry.path, os.path.join(entry.path, "run_config.json")
        ):
            continue
        if not entry.is_dir() or entry.name.startswith("."):
            continue
        run_config = _read_json(os.path.join(entry.path, "run_config.json"))
        if run_config is None:  # no run_config.json: ignore, per the plan
            continue
        out.append(
            {
                "run_name": entry.name,
                "path": entry.path,
                "eeg_net": _net_from_leadfield_hdf(run_config.get("leadfield_hdf", "")),
                "created": _mtime_iso(entry.path),
                "best": _ex_best(entry.path, project_root=pm.project_dir),
                "artifacts": _dir_artifacts(entry.path, project_root=pm.project_dir),
            }
        )
    return out

analyses

analyses(pm: PathManager, sid: str, sim: str) -> list[dict] | None

Analyzer runs for sid/sim; None if either is unknown.

Source code in tit/catalog.py
def analyses(pm: PathManager, sid: str, sim: str) -> list[dict] | None:
    """Analyzer runs for *sid*/*sim*; ``None`` if either is unknown."""
    if sid not in subject_ids(pm) or sim not in _simulation_names(pm, sid):
        return None
    out = []
    for path, name, standard in _find_analysis_dirs(
        pm.simulation(sid, sim), project_root=pm.project_dir
    ):
        # A standard run's space is the folder PathManager.analysis_output_dir wrote it into.
        space = os.path.basename(os.path.dirname(path)).lower() if standard else None
        item = _analysis_entry(
            path, name, analysis_space=space, project_root=pm.project_dir
        )
        if item:
            out.append(item)
    return out

analysis_summary

analysis_summary(pm: PathManager, sid: str, sim: str, name: str) -> dict | None

results.csv of one analysis, as TableData.

name is what :func:analyses listed; a caller holding the run's own path relative to the simulation (Analyses/Custom/run1) resolves too, so the one string a script already has does not have to be translated into the listed form.

Source code in tit/catalog.py
def analysis_summary(pm: PathManager, sid: str, sim: str, name: str) -> dict | None:
    """``results.csv`` of one analysis, as ``TableData``.

    *name* is what :func:`analyses` listed; a caller holding the run's own path relative
    to the simulation (``Analyses/Custom/run1``) resolves too, so the one string a script
    already has does not have to be translated into the listed form.
    """
    if sid not in subject_ids(pm) or sim not in _simulation_names(pm, sid):
        return None
    sim_dir = pm.simulation(sid, sim)
    for path, found_name, _standard in _find_analysis_dirs(
        sim_dir, project_root=pm.project_dir
    ):
        relative = os.path.relpath(path, sim_dir).replace(os.sep, "/")
        if name in (found_name, relative):
            return _read_csv_table(os.path.join(path, "results.csv"))
    return None

reports

reports(pm: PathManager, sid: str) -> list[dict] | None

Generated HTML reports for sid; None if the subject is unknown.

A subject known only from staged DICOMs (:func:sourcedata_only_subject_ids) is known: :func:list_subjects shows it, and its pre-processing report (Dataset 000's sub-102) was unreachable while this answered "unknown subject" for it.

Source code in tit/catalog.py
def reports(pm: PathManager, sid: str) -> list[dict] | None:
    """Generated HTML reports for *sid*; ``None`` if the subject is unknown.

    A subject known only from staged DICOMs (:func:`sourcedata_only_subject_ids`) is known:
    :func:`list_subjects` shows it, and its pre-processing report (Dataset 000's sub-102)
    was unreachable while this answered "unknown subject" for it.
    """
    if sid not in subject_ids(pm) and sid not in sourcedata_only_subject_ids(pm):
        return None
    root = _jailed(pm, os.path.join(pm.reports(), f"sub-{sid}"))
    if root is None or not os.path.isdir(root):
        return []
    entries = []
    for name in sorted(os.listdir(root)):
        path = _jailed(pm, os.path.join(root, name)) if name.endswith(".html") else None
        if path:
            entries.append(_report_entry(path, sid))
    return entries

find_report_path

find_report_path(pm: PathManager, report_id: str) -> str | None

Resolve a reports() id back to its file path (used by /api/files/report).

Source code in tit/catalog.py
def find_report_path(pm: PathManager, report_id: str) -> str | None:
    """Resolve a ``reports()`` ``id`` back to its file path (used by ``/api/files/report``)."""
    if "/" not in report_id:
        return None
    sid, stem = report_id.split("/", 1)
    if any(c in stem for c in ("/", "\\", "..")):
        return None
    path = _jailed(pm, os.path.join(pm.reports(), f"sub-{sid}", f"{stem}.html"))
    return path if path and os.path.isfile(path) else None

freehand_configs

freehand_configs(pm: PathManager, sid: str) -> list[dict] | None

Saved free-hand electrode configs for sid; None if unknown.

Reads m2m_<id>/stim_configs/*.json. The on-disk format, written by the free-hand electrode placement UI, is {"name", "type": "U"|"M", "electrode_positions": {label: [x, y, z]}}. The contract's FreehandConfig.type enum is [U, M] (unipolar/ multipolar), matching this on-disk value exactly (fixed from an earlier xyz/label enum that matched nothing real -- see contracts/CHANGES.md); type is passed through verbatim.

Source code in tit/catalog.py
def freehand_configs(pm: PathManager, sid: str) -> list[dict] | None:
    """Saved free-hand electrode configs for *sid*; ``None`` if unknown.

    Reads ``m2m_<id>/stim_configs/*.json``. The on-disk format, written by the
    free-hand electrode placement UI, is
    ``{"name", "type": "U"|"M", "electrode_positions": {label: [x, y, z]}}``.
    The contract's ``FreehandConfig.type`` enum is ``[U, M]`` (unipolar/
    multipolar), matching this on-disk value exactly (fixed from an earlier
    ``xyz``/``label`` enum that matched nothing real -- see
    ``contracts/CHANGES.md``); ``type`` is passed through verbatim.
    """
    if sid not in subject_ids(pm):
        return None
    stim_dir = os.path.join(pm.m2m(sid), "stim_configs")
    if not _project_paths_safe(pm, stim_dir) or not os.path.isdir(stim_dir):
        return []
    out = []
    for name in sorted(os.listdir(stim_dir)):
        if not name.endswith(".json") or not _project_paths_safe(
            pm, os.path.join(stim_dir, name)
        ):
            continue
        cfg = _read_freehand_file(os.path.join(stim_dir, name))
        if cfg:
            out.append(cfg)
    return out

put_freehand_config

put_freehand_config(pm: PathManager, sid: str, name: str, config: dict) -> dict

Create or overwrite one free-hand electrode configuration.

Positions are paired consecutively, as the simulator reads them (:func:tit.sim.montage_sources.resolve_freehand_montage), so the placement must be one it can run: type "U" with 4 positions (TI) or "M" with 8, 12, ... (mTI), every coordinate finite and every electrode at least 1 mm from the others (:func:tit.sim.config.check_electrode_pairs). Anything else raises ValueError before the file is touched.

Source code in tit/catalog.py
def put_freehand_config(pm: PathManager, sid: str, name: str, config: dict) -> dict:
    """Create or overwrite one free-hand electrode configuration.

    Positions are paired consecutively, as the simulator reads them
    (:func:`tit.sim.montage_sources.resolve_freehand_montage`), so the
    placement must be one it can run: ``type`` ``"U"`` with 4 positions (TI)
    or ``"M"`` with 8, 12, ... (mTI), every coordinate finite and every
    electrode at least 1 mm from the others
    (:func:`tit.sim.config.check_electrode_pairs`).  Anything else raises
    ``ValueError`` before the file is touched.
    """
    from tit import constants as const
    from tit.sim.config import check_electrode_pairs

    if not is_safe_name(name):
        raise ValueError(
            "name must match ^[A-Za-z0-9_-]{1,64}$ (no path separators or '..')"
        )
    stim_dir = os.path.join(pm.m2m(sid), "stim_configs")
    path = _jailed(pm, os.path.join(stim_dir, f"{name}.json"))
    if path is None:
        raise ValueError("Free-hand config path must remain inside the project")
    kind = config.get("type", "U")
    if kind not in ("U", "M"):
        raise ValueError(f"type must be U or M; got {kind!r}")
    entries = config.get("electrode_positions", [])
    if not isinstance(entries, list) or not all(isinstance(e, dict) for e in entries):
        raise ValueError("electrode_positions must be a list of {label, x, y, z}")
    if not all(isinstance(e.get("label") or "", str) for e in entries):
        raise ValueError("electrode_positions[].label must be a string")
    # The file keys positions by label, so a repeated label would silently drop a position.
    labels = [entry.get("label") or f"E{i + 1}" for i, entry in enumerate(entries)]
    repeated = sorted({label for label in labels if labels.count(label) > 1})
    if repeated:
        raise ValueError(
            f"electrode labels must be unique; repeated: {', '.join(repeated)}"
        )
    positions = {
        label: [
            as_float(entry["x"], "electrode_positions[].x"),
            as_float(entry["y"], "electrode_positions[].y"),
            as_float(entry["z"], "electrode_positions[].z"),
        ]
        for label, entry in zip(labels, entries)
    }
    n = len(entries)
    if n % 2 or not const.is_valid_pair_count(n // 2):
        raise ValueError(
            f"{n} electrode positions; a placement needs 4 (TI) or a multiple of 4 "
            "from 8 (mTI)."
        )
    if (kind == "U") != (n == 2 * const.TI_ELECTRODE_PAIRS):
        raise ValueError(
            f"type {kind} does not fit {n} positions: U is 4 positions (TI), "
            "M is 8 or more (mTI)."
        )
    points = list(positions.values())
    check_electrode_pairs(list(zip(points[0::2], points[1::2])), xyz=True)
    os.makedirs(stim_dir, exist_ok=True)
    data = {
        "name": name,
        "type": kind,
        "electrode_positions": positions,
    }
    with open(path, "w") as f:
        json.dump(data, f, indent=2)
    return {
        "name": name,
        "type": data["type"],
        "electrode_positions": [
            {"label": label, "x": xyz[0], "y": xyz[1], "z": xyz[2]}
            for label, xyz in positions.items()
        ],
    }

delete_freehand_config

delete_freehand_config(pm: PathManager, sid: str, name: str) -> bool

Remove a saved placement definition, leaving simulation outputs intact.

Source code in tit/catalog.py
def delete_freehand_config(pm: PathManager, sid: str, name: str) -> bool:
    """Remove a saved placement definition, leaving simulation outputs intact."""
    if not is_safe_name(name):
        raise ValueError("name must match ^[A-Za-z0-9_-]{1,64}$")
    if sid not in subject_ids(pm):
        return False
    path = _jailed(pm, os.path.join(pm.m2m(sid), "stim_configs", f"{name}.json"))
    if path is None or not os.path.isfile(path):
        return False
    try:
        os.unlink(path)
    except FileNotFoundError:
        return False
    return True

group_catalog

group_catalog(pm: PathManager) -> dict

Project-level group catalog: one row per run of every project-level writer.

stats (derivatives/ti-toolbox/stats/<type>/<name>/, :meth:PathManager.stats_output), nilearn (derivatives/ti-toolbox/nilearn_visuals/<subdir_name>/, the tit.plotting.nilearn runner), group_analyses (one folder per group analyzer run under derivatives/ti-toolbox/group_analysis/, :meth:PathManager.group_analysis) and nifti_average (derivatives/ti-toolbox/nifti_average/<output_name>/, the tit.stats.nifti_average runner, with the files it wrote as artifacts). tests/test_group_catalog_writers.py pins each reader against its writer.

Source code in tit/catalog.py
def group_catalog(pm: PathManager) -> dict:
    """Project-level group catalog: one row per run of every project-level writer.

    ``stats`` (``derivatives/ti-toolbox/stats/<type>/<name>/``, :meth:`PathManager.stats_output`),
    ``nilearn`` (``derivatives/ti-toolbox/nilearn_visuals/<subdir_name>/``, the
    ``tit.plotting.nilearn`` runner), ``group_analyses`` (one folder per group analyzer run under
    ``derivatives/ti-toolbox/group_analysis/``, :meth:`PathManager.group_analysis`) and
    ``nifti_average`` (``derivatives/ti-toolbox/nifti_average/<output_name>/``, the
    ``tit.stats.nifti_average`` runner, with the files it wrote as ``artifacts``).
    ``tests/test_group_catalog_writers.py`` pins each reader against its writer.
    """

    def _subdirs(root: str) -> list[str]:
        if not _project_paths_safe(pm, root):
            return []
        try:
            return sorted(
                n
                for n in os.listdir(root)
                if _project_paths_safe(pm, os.path.join(root, n))
                and os.path.isdir(os.path.join(root, n))
            )
        except OSError:
            return []

    stats = []
    stats_root = os.path.join(pm.ti_toolbox(), "stats")
    for analysis_type in _subdirs(stats_root):
        if analysis_type == "data":
            continue
        type_dir = os.path.join(stats_root, analysis_type)
        for name in _subdirs(type_dir):
            run_dir = os.path.join(type_dir, name)
            stats.append(
                {
                    "type": analysis_type,
                    "name": name,
                    "path": run_dir,
                    "created": _mtime_iso(run_dir),
                }
            )

    nilearn = []
    nilearn_root = os.path.join(pm.ti_toolbox(), "nilearn_visuals")
    for name in _subdirs(nilearn_root):
        path = os.path.join(nilearn_root, name)
        nilearn.append({"name": name, "path": path, "created": _mtime_iso(path)})

    group_analyses = []
    group_root = os.path.join(pm.ti_toolbox(), "group_analysis")
    for name in _subdirs(group_root):
        path = os.path.join(group_root, name)
        group_analyses.append({"name": name, "path": path, "created": _mtime_iso(path)})

    nifti_average = []
    average_root = os.path.join(pm.ti_toolbox(), "nifti_average")
    for name in _subdirs(average_root):
        path = os.path.join(average_root, name)
        nifti_average.append(
            {
                "name": name,
                "path": path,
                "created": _mtime_iso(path),
                "artifacts": [
                    {
                        "path": f["path"],
                        "kind": f["kind"],
                        "label": f["label"]
                        .removesuffix(".nii.gz")
                        .removesuffix(".json")
                        .replace("_", " "),
                    }
                    for f in output_files(path, project_root=pm.project_dir)
                ],
            }
        )

    return {
        "stats": stats,
        "nilearn": nilearn,
        "group_analyses": group_analyses,
        "nifti_average": nifti_average,
    }

group_stats_detail

group_stats_detail(pm: PathManager, analysis_type: str, name: str) -> dict | None

One derivatives/ti-toolbox/stats/<type>/<name>/ run, read for the Results pane.

None when the run directory does not exist. status is "ok" once the run has written something besides its log, and "empty" when it has not -- the state the maintainer hit, where a failed 2-vs-1 group comparison left a bare .log and the pane could say nothing about it. reason is then the log's own ERROR line, so the UI reports why instead of showing an empty file list.

Source code in tit/catalog.py
def group_stats_detail(pm: PathManager, analysis_type: str, name: str) -> dict | None:
    """One ``derivatives/ti-toolbox/stats/<type>/<name>/`` run, read for the Results pane.

    ``None`` when the run directory does not exist. ``status`` is ``"ok"`` once the run has
    written something besides its log, and ``"empty"`` when it has not -- the state the
    maintainer hit, where a failed 2-vs-1 group comparison left a bare ``.log`` and the pane
    could say nothing about it. ``reason`` is then the log's own ERROR line, so the UI
    reports why instead of showing an empty file list.
    """
    if "/" in analysis_type or "/" in name or ".." in (analysis_type, name):
        return None
    run_dir = _jailed(pm, os.path.join(pm.ti_toolbox(), "stats", analysis_type, name))
    if run_dir is None or not os.path.isdir(run_dir):
        return None

    try:
        names = sorted(os.listdir(run_dir))
    except OSError:
        names = []

    artifacts: list[dict] = []
    for filename in names:
        path = _jailed(pm, os.path.join(run_dir, filename))
        # A Gmsh options file rides beside its .msh; there is nothing in it to read.
        if path is None or not os.path.isfile(path) or filename.endswith(".opt"):
            continue
        if filename.endswith(".nii.gz") or filename.endswith(".nii"):
            kind = "nifti"
        else:
            kind = _STATS_KIND_BY_EXT.get(os.path.splitext(filename)[1], "file")
        artifacts.append(
            {
                "path": path,
                "kind": kind,
                "label": _STATS_FILE_LABELS.get(
                    filename, os.path.splitext(filename)[0].replace("_", " ")
                ),
            }
        )
    # The run's own files first, in the order the labels table lists them; anything else after.
    order = list(_STATS_FILE_LABELS)
    artifacts.sort(
        key=lambda a: (
            (
                order.index(os.path.basename(a["path"]))
                if os.path.basename(a["path"]) in order
                else len(order)
            ),
            a["path"],
        )
    )

    log_path = _stats_log_path(run_dir)
    log_path = _jailed(pm, log_path) if log_path else None
    parsed = {
        "config": [],
        "results": [],
        "groups": [],
        "image_shape": None,
        "clusters": None,
        "error": None,
    }
    if log_path:
        try:
            with open(log_path, encoding="utf-8", errors="replace") as f:
                parsed = _parse_stats_log(f.read())
        except OSError:
            pass
    surface = _surface_stats_summary(run_dir, pm)
    if surface:
        # The run's own machine-readable record wins over whatever its log happened to say.
        parsed.update({key: value for key, value in surface.items() if value})

    clusters = parsed["clusters"]
    csv_path = _jailed(pm, os.path.join(run_dir, "significant_clusters.csv"))
    if clusters is None and csv_path and os.path.isfile(csv_path):
        try:
            clusters = _read_csv_table(csv_path)
        except OSError:
            clusters = None

    produced = [a for a in artifacts if a["kind"] != "log"]
    status = "ok" if produced else "empty"
    reason = parsed["error"]
    if status == "empty" and not reason:
        reason = (
            "This run wrote no output files. Its log is the only record; open it for the "
            "last step it reached."
        )

    return {
        "type": analysis_type,
        "name": name,
        "path": run_dir,
        "created": _mtime_iso(run_dir),
        "status": status,
        "reason": reason,
        "config": parsed["config"],
        "results": parsed["results"],
        "groups": parsed["groups"],
        "image_shape": parsed["image_shape"],
        "clusters": clusters,
        "log": log_path,
        "artifacts": artifacts,
    }

read_notes

read_notes(pm: PathManager) -> dict

Quick Notes content (derivatives/ti-toolbox/notes.txt).

Source code in tit/catalog.py
def read_notes(pm: PathManager) -> dict:
    """Quick Notes content (``derivatives/ti-toolbox/notes.txt``)."""
    path = os.path.join(pm.ti_toolbox(), "notes.txt")
    if not _project_paths_safe(pm, path) or not os.path.isfile(path):
        return {"text": "", "updated_at": None}
    with open(path, encoding="utf-8") as f:
        text = f.read()
    return {"text": text, "updated_at": _mtime_iso(path)}

write_notes

write_notes(pm: PathManager, text: str) -> dict

Replace Quick Notes content, atomically.

Source code in tit/catalog.py
def write_notes(pm: PathManager, text: str) -> dict:
    """Replace Quick Notes content, atomically."""
    path = os.path.join(pm.ti_toolbox(), "notes.txt")
    tmp = f"{path}.tmp"
    if not _project_paths_safe(pm, path, tmp):
        raise ValueError("Notes paths must remain inside the project")
    os.makedirs(os.path.dirname(path), exist_ok=True)
    with open(tmp, "w", encoding="utf-8") as f:
        f.write(text)
    os.replace(tmp, path)
    return {"text": text, "updated_at": _mtime_iso(path)}