Skip to content

common

tit.reporting.generators.common

What the simulator, flex-search and ex-search reports share: the montage on the EEG cap, the ROI's name and writing the file.

The cap figure is the app's own overlay (:func:tit.tools.montage_visualizer.visualize_montage, the image a simulation writes to montage_imgs/), converted to WebP; nothing here draws a head.

per_channel

per_channel(mA: list[float]) -> str

"1" when every channel carries the same current, else "0.7 / 1.3" (mA).

Source code in tit/reporting/generators/common.py
def per_channel(mA: list[float]) -> str:
    """``"1"`` when every channel carries the same current, else ``"0.7 / 1.3"`` (mA)."""
    return fmt(mA[0]) if len(set(mA)) == 1 else " / ".join(fmt(v) for v in mA)

electrode_name

electrode_name(electrode: str | list[float]) -> str

An electrode as the reports name it: its cap label, or its position (subject-space mm, rounded to the millimetre) as "(74, -19, 39)" for a flex-free or free-hand montage.

Source code in tit/reporting/generators/common.py
def electrode_name(electrode: str | list[float]) -> str:
    """An electrode as the reports name it: its cap label, or its position (subject-space mm,
    rounded to the millimetre) as ``"(74, -19, 39)"`` for a flex-free or free-hand montage.
    """
    if isinstance(electrode, (list, tuple)):
        return "(" + ", ".join(str(round(float(v))) for v in electrode) + ")"
    return str(electrode)

polar_pair

polar_pair(pair: list[str], mA: float = 1.0, sep: str = '–') -> str

"F7(+)–P7(−)": tit.sim gives a channel's first electrode +I and its second −I (tdcs.currents = [I, -I]), so a negative current swaps the signs.

Source code in tit/reporting/generators/common.py
def polar_pair(pair: list[str], mA: float = 1.0, sep: str = "–") -> str:
    """``"F7(+)–P7(−)"``: tit.sim gives a channel's first electrode +I and its second −I
    (``tdcs.currents = [I, -I]``), so a negative current swaps the signs."""
    a, b = ("+", "−") if mA >= 0 else ("−", "+")
    return f"{pair[0]}({a}){sep}{pair[1]}({b})"

and_list

and_list(items: list[str]) -> str

"a, b and c".

Source code in tit/reporting/generators/common.py
def and_list(items: list[str]) -> str:
    """``"a, b and c"``."""
    return " and ".join(filter(None, [", ".join(items[:-1]), *items[-1:]]))

number_word

number_word(n: int) -> str

"four" for 4: numbers under ten as words, larger ones as digits.

Source code in tit/reporting/generators/common.py
def number_word(n: int) -> str:
    """``"four"`` for 4: numbers under ten as words, larger ones as digits."""
    words = (
        "zero",
        "one",
        "two",
        "three",
        "four",
        "five",
        "six",
        "seven",
        "eight",
        "nine",
    )
    return words[n] if 0 <= n < 10 else str(n)

channel_electrodes

channel_electrodes(pair: list[str], channel: int) -> str

"E1+ AF3 · E1- PO10" for channel index 0: channel k is electrodes Ek+ and Ek-, the one naming the app and every report use (never "A"/"B").

Source code in tit/reporting/generators/common.py
def channel_electrodes(pair: list[str], channel: int) -> str:
    """``"E1+ AF3 · E1- PO10"`` for channel index 0: channel k is electrodes Ek+ and Ek-, the one
    naming the app and every report use (never "A"/"B")."""
    k = channel + 1
    return f"E{k}+ {pair[0]} · E{k}- {pair[1]}"

channel_rows

channel_rows(pairs: list[list[str]], currents_mA: list[float]) -> list[tuple[str, str]]

("Channel 1 (blue)", "E1+ AF3 · E1- PO10, 1 mA") per channel, named and coloured as the cap image's legend names them.

Source code in tit/reporting/generators/common.py
def channel_rows(
    pairs: list[list[str]], currents_mA: list[float]
) -> list[tuple[str, str]]:
    """``("Channel 1 (blue)", "E1+ AF3 · E1- PO10, 1 mA")`` per channel, named and coloured as the
    cap image's legend names them."""
    rows = []
    for i, (pair, mA) in enumerate(zip(pairs, currents_mA)):
        colour = CAP_COLOURS[i % len(CAP_COLOURS)]
        rows.append(
            (
                f"Channel {i + 1} ({colour})",
                f"{esc(channel_electrodes(pair, i))}, {fmt(mA)} mA",
            )
        )
    return rows

cap_figure

cap_figure(num: int, pairs: list[list[str]], eeg_net: str, currents_mA: list[float], caption: str = '') -> str

The montage on the app's EEG-cap overlay, beside the channel list; the list alone when the net has no cap template.

Source code in tit/reporting/generators/common.py
def cap_figure(
    num: int,
    pairs: list[list[str]],
    eeg_net: str,
    currents_mA: list[float],
    caption: str = "",
) -> str:
    """The montage on the app's EEG-cap overlay, beside the channel list; the list alone when
    the net has no cap template."""
    from tit.tools.montage_visualizer import montage_webp

    listing = c.kv(channel_rows(pairs, currents_mA))
    image = montage_webp(pairs, eeg_net)
    if image is None:
        return listing
    net = esc(Path(str(eeg_net)).stem)
    body = (
        f'<div class="split"><div class="cap">{c.img(image, f"Montage on the {net} cap: " + "; ".join(polar_pair(p, mA, " to ") for p, mA in zip(pairs, currents_mA)))}</div>'
        f"<div>{listing}</div></div>"
    )
    return c.figure(
        num,
        "Montage on the EEG cap",
        body,
        caption
        or f"Top view, nose up. Each colour is one channel, drawn on the {net} layout the app uses.",
    )

roi_summary

roi_summary(run_dir: str | Path) -> dict | None

The target as the run's ROI confirmation recorded it (roi.tetravox.json), with label names from the atlas's lookup table: {name, atlas, volume_mm3, centroid, gm_overlap}.

Source code in tit/reporting/generators/common.py
def roi_summary(run_dir: str | Path) -> dict | None:
    """The target as the run's ROI confirmation recorded it (``roi.tetravox.json``), with label
    names from the atlas's lookup table: ``{name, atlas, volume_mm3, centroid, gm_overlap}``.
    """
    scene = Path(run_dir) / "roi.tetravox.json"
    if not scene.is_file():
        return None
    meta = json.loads(scene.read_text()).get("meta") or {}
    name, atlas = meta.get("roi") or "", ""
    sources, labels = meta.get("source"), meta.get("label")
    if sources and labels is not None:
        from tit.opt.roi_spec import region_name

        sources = sources if isinstance(sources, list) else [sources]
        labels = labels if isinstance(labels, list) else [labels]
        spaces = meta.get("space")
        spaces = spaces if isinstance(spaces, list) else [spaces] * len(sources)
        names = []
        stems = {}
        for src, lab, space in zip(sources, labels, spaces):
            src = str(src)
            if src.endswith(".annot"):  # a cortical surface target, not a volume
                hemi, _, rest = Path(src).name.partition(".")
                names.append(region_name(src, lab))
                stems[f"{rest.removesuffix('.annot')} ({hemi})"] = None
                continue
            names.append(region_name(src, lab, space or "subject").replace("-", " "))
            stems[
                Path(src).name.split(".nii")[0] + (" (MNI)" if space == "mni" else "")
            ] = None
        name = " + ".join(dict.fromkeys(names))
        atlas = ", ".join(stems)
    for s in meta.get("spheres") or []:
        x, y, z = s["centre_ras"]
        name = (
            name
            or f"sphere at ({x:.0f}, {y:.0f}, {z:.0f}) mm, radius {s['radius_mm']:g} mm"
        )
    return {
        "name": name or "target ROI",
        "atlas": atlas,
        "volume_mm3": meta.get("volume_mm3"),
        "vertices": meta["voxels"] if meta.get("unit") == "vertices" else None,
        "centroid": meta.get("centroid_ras"),
        "gm_overlap": meta.get("gm_overlap"),
    }

write_report

write_report(html: str, project_dir: str | Path, subject_id: str, prefix: str, budget: int, out_dir: str | Path | None = None, t0: float | None = None, label: str = 'Report') -> Path

Write <reports>/sub-<id>/<prefix>_<timestamp>.html (or into out_dir), log its size and time, and record it as a report artifact of the running job (a no-op outside a job).

One file per run: when the second's name is taken (two ex-search jobs for one subject finishing together), the next free second is used, so the name keeps the <prefix>_<YYYYMMDD>_<HHMMSS> grammar :func:tit.catalog.reports parses. The file is created exclusively, so an existing report is never overwritten, and a write that fails removes its partial file before the error propagates.

Source code in tit/reporting/generators/common.py
def write_report(
    html: str,
    project_dir: str | Path,
    subject_id: str,
    prefix: str,
    budget: int,
    out_dir: str | Path | None = None,
    t0: float | None = None,
    label: str = "Report",
) -> Path:
    """Write ``<reports>/sub-<id>/<prefix>_<timestamp>.html`` (or into *out_dir*), log its size and
    time, and record it as a ``report`` artifact of the running job (a no-op outside a job).

    One file per run: when the second's name is taken (two ex-search jobs for one subject
    finishing together), the next free second is used, so the name keeps the
    ``<prefix>_<YYYYMMDD>_<HHMMSS>`` grammar :func:`tit.catalog.reports` parses. The file is
    created exclusively, so an existing report is never overwritten, and a write that fails
    removes its partial file before the error propagates.
    """
    from tit.jobs import events
    from tit.paths import get_path_manager

    out = (
        Path(out_dir)
        if out_dir
        else Path(get_path_manager(str(project_dir)).reports()) / f"sub-{subject_id}"
    )
    out.mkdir(parents=True, exist_ok=True)
    stamp = datetime.now()
    while True:
        path = out / f"{prefix}_{stamp.strftime('%Y%m%d_%H%M%S')}.html"
        try:
            f = path.open("x", encoding="utf-8")
        except FileExistsError:
            stamp += timedelta(seconds=1)
            continue
        try:
            with f:
                f.write(html)
        except BaseException:
            # No empty or partial report under a name the Reports list would show.
            path.unlink(missing_ok=True)
            raise
        break
    size = path.stat().st_size
    took = f", {time.time() - t0:.1f} s" if t0 else ""
    logger.info(f"{label}: {path} ({size / 1e6:.2f} MB{took})")
    if size > budget:
        logger.warning(
            f"{label} is {size / 1e6:.2f} MB, over its {budget / 1e6:.1f} MB budget"
        )
    events.emit_artifact(str(path), kind="report", label=label)
    return path