Skip to content

pareto

tit.opt.flex.pareto

Pareto sweep module for focality threshold grid optimization.

This module provides the data structures and functions needed to run a Cartesian-product sweep over (ROI%, non-ROI%) threshold combinations for TI stimulation focality optimization. After all combinations are run, the results are saved as JSON, a PNG scatter plot, and an ASCII summary table.

All thresholds are expressed as percentages of achievable_ROI_mean -- the mean field in the target ROI at the mean-optimal electrode configuration obtained in step 1 of the adaptive workflow.

SweepPoint dataclass

SweepPoint(roi_pct: float, nonroi_pct: float, roi_threshold: float, nonroi_threshold: float, run_index: int, output_folder: str, focality_score: float | None = None, status: str = 'pending')

One (roi_pct, nonroi_pct) combination in the sweep grid.

Attributes:

Name Type Description
roi_pct float

ROI threshold expressed as a percentage (e.g. 80.0).

nonroi_pct float

Non-ROI threshold expressed as a percentage (e.g. 20.0).

roi_threshold float

Absolute ROI threshold in V/m (= roi_pct / 100 * achievable_ROI_mean).

nonroi_threshold float

Absolute non-ROI threshold in V/m.

run_index int

0-based index; determines the subfolder name.

output_folder str

Absolute path to this run's output directory.

focality_score float | None

optim_funvalue returned by SimNIBS (negative float; values closer to 0 are better focality). None until the run completes successfully.

status str

One of "pending", "running", "done", "failed".

ParetoSweepConfig dataclass

ParetoSweepConfig(roi_pcts: list, nonroi_pcts: list, achievable_roi_mean: float, base_output_folder: str)

Configuration for a full Pareto threshold sweep.

Attributes:

Name Type Description
roi_pcts list

List of ROI% values to sweep (e.g. [80.0, 70.0]).

nonroi_pcts list

List of non-ROI% values to sweep (e.g. [20.0, 30.0, 40.0]).

achievable_roi_mean float

Mean field strength in V/m from step-1 mean opt.

base_output_folder str

Parent directory for all sweep run subdirectories.

ParetoSweepResult dataclass

ParetoSweepResult(config: ParetoSweepConfig, points: list)

Result container for a completed (or in-progress) Pareto sweep.

Attributes:

Name Type Description
config ParetoSweepConfig

The configuration used for this sweep.

points list

Ordered list of :class:SweepPoint objects (one per combo).

compute_sweep_grid

compute_sweep_grid(roi_pcts: list, nonroi_pcts: list, achievable_roi_mean: float, base_output_folder: str) -> list

Return the Cartesian product of (roi_pct, nonroi_pct) as SweepPoints.

Ordering: roi_pcts outer loop, nonroi_pcts inner loop.

Invalid combinations (nonroi_pct >= roi_pct) are included here; call :func:validate_grid before starting any runs to reject them early.

Parameters:

Name Type Description Default
roi_pcts list

Sequence of ROI threshold percentages.

required
nonroi_pcts list

Sequence of non-ROI threshold percentages.

required
achievable_roi_mean float

Mean field strength (V/m) from step-1 mean opt.

required
base_output_folder str

Parent directory; each point gets a numbered subdirectory named {idx+1:02d}_roi{roi_pct}_nonroi{nonroi_pct}.

required

Returns:

Type Description
list

List of :class:SweepPoint objects, one per combination.

Source code in tit/opt/flex/pareto.py
def compute_sweep_grid(
    roi_pcts: list,
    nonroi_pcts: list,
    achievable_roi_mean: float,
    base_output_folder: str,
) -> list:
    """Return the Cartesian product of (roi_pct, nonroi_pct) as SweepPoints.

    Ordering: ``roi_pcts`` outer loop, ``nonroi_pcts`` inner loop.

    Invalid combinations (``nonroi_pct >= roi_pct``) are *included* here;
    call :func:`validate_grid` before starting any runs to reject them early.

    Args:
        roi_pcts: Sequence of ROI threshold percentages.
        nonroi_pcts: Sequence of non-ROI threshold percentages.
        achievable_roi_mean: Mean field strength (V/m) from step-1 mean opt.
        base_output_folder: Parent directory; each point gets a numbered
            subdirectory named ``{idx+1:02d}_roi{roi_pct}_nonroi{nonroi_pct}``.

    Returns:
        List of :class:`SweepPoint` objects, one per combination.
    """
    points = []
    for idx, (roi_pct, nonroi_pct) in enumerate(product(roi_pcts, nonroi_pcts)):
        roi_thr = (roi_pct / 100.0) * achievable_roi_mean
        nonroi_thr = (nonroi_pct / 100.0) * achievable_roi_mean
        folder_name = f"{idx + 1:02d}_roi{int(roi_pct)}_nonroi{int(nonroi_pct)}"
        points.append(
            SweepPoint(
                roi_pct=float(roi_pct),
                nonroi_pct=float(nonroi_pct),
                roi_threshold=roi_thr,
                nonroi_threshold=nonroi_thr,
                run_index=idx,
                output_folder=os.path.join(base_output_folder, folder_name),
            )
        )
    return points

validate_grid

validate_grid(roi_pcts: list, nonroi_pcts: list) -> None

Raise ValueError if any combination has nonroi_pct >= roi_pct.

Rejects the entire grid rather than silently skipping bad rows, so the user is aware of all problematic combinations before any subprocess starts.

Parameters:

Name Type Description Default
roi_pcts list

Sequence of ROI threshold percentages.

required
nonroi_pcts list

Sequence of non-ROI threshold percentages.

required

Raises:

Type Description
ValueError

Lists all invalid (roi_pct, nonroi_pct) pairs.

Source code in tit/opt/flex/pareto.py
def validate_grid(roi_pcts: list, nonroi_pcts: list) -> None:
    """Raise ``ValueError`` if any combination has ``nonroi_pct >= roi_pct``.

    Rejects the *entire* grid rather than silently skipping bad rows, so the
    user is aware of all problematic combinations before any subprocess starts.

    Args:
        roi_pcts: Sequence of ROI threshold percentages.
        nonroi_pcts: Sequence of non-ROI threshold percentages.

    Raises:
        ValueError: Lists all invalid (roi_pct, nonroi_pct) pairs.
    """
    bad = [(r, n) for r, n in product(roi_pcts, nonroi_pcts) if n >= r]
    if bad:
        raise ValueError(
            "Non-ROI % must be strictly less than ROI % for all combinations. "
            f"Invalid pairs: {bad}"
        )

build_focality_config

build_focality_config(base_config: FlexConfig, point: SweepPoint) -> FlexConfig

Return a new FlexConfig with focality goal and thresholds from point.

Creates a shallow copy of base_config and sets:

  • goal to "focality"
  • thresholds to "<nonroi_threshold>,<roi_threshold>"
  • output_folder to point.output_folder

This decouples the Pareto sweep from subprocess command-line building and allows calling run_flex_search() directly.

Parameters:

Name Type Description Default
base_config FlexConfig

The template FlexConfig (unchanged).

required
point SweepPoint

The sweep point whose thresholds should be applied.

required

Returns:

Type Description
FlexConfig

A new :class:FlexConfig instance configured for focality.

Source code in tit/opt/flex/pareto.py
def build_focality_config(base_config: FlexConfig, point: SweepPoint) -> FlexConfig:
    """Return a new FlexConfig with focality goal and thresholds from *point*.

    Creates a shallow copy of *base_config* and sets:

    - ``goal`` to ``"focality"``
    - ``thresholds`` to ``"<nonroi_threshold>,<roi_threshold>"``
    - ``output_folder`` to ``point.output_folder``

    This decouples the Pareto sweep from subprocess command-line building
    and allows calling ``run_flex_search()`` directly.

    Args:
        base_config: The template FlexConfig (unchanged).
        point: The sweep point whose thresholds should be applied.

    Returns:
        A new :class:`FlexConfig` instance configured for focality.
    """
    cfg = copy.copy(base_config)
    cfg.goal = "focality"
    cfg.thresholds = f"{point.nonroi_threshold:.4f},{point.roi_threshold:.4f}"
    cfg.output_folder = point.output_folder
    return cfg

build_pareto_manifest_data

build_pareto_manifest_data(result: ParetoSweepResult) -> dict

Build the pareto section for flex_meta.json from a sweep result.

Parameters:

Name Type Description Default
result ParetoSweepResult

Completed pareto sweep result.

required

Returns:

Type Description
dict

Dict suitable for the 'pareto_data' parameter of write_manifest().

Source code in tit/opt/flex/pareto.py
def build_pareto_manifest_data(result: ParetoSweepResult) -> dict:
    """Build the pareto section for flex_meta.json from a sweep result.

    Args:
        result: Completed pareto sweep result.

    Returns:
        Dict suitable for the 'pareto_data' parameter of write_manifest().
    """
    done = [
        p for p in result.points if p.status == "done" and p.focality_score is not None
    ]
    best_point = None
    if done:
        best = min(done, key=lambda p: p.focality_score)
        best_point = {
            "roi_pct": best.roi_pct,
            "nonroi_pct": best.nonroi_pct,
            "focality_score": best.focality_score,
        }

    return {
        "roi_pcts": result.config.roi_pcts,
        "nonroi_pcts": result.config.nonroi_pcts,
        "achievable_roi_mean_vm": result.config.achievable_roi_mean,
        "best_point": best_point,
        "points": [
            {
                "roi_pct": p.roi_pct,
                "nonroi_pct": p.nonroi_pct,
                "roi_threshold_vm": p.roi_threshold,
                "nonroi_threshold_vm": p.nonroi_threshold,
                "focality_score": p.focality_score,
                "status": p.status,
            }
            for p in result.points
        ],
    }

generate_pareto_plot

generate_pareto_plot(result: ParetoSweepResult, output_path: str) -> None

Save a Pareto trade-off scatter plot as a PNG.

  • x-axis: non-ROI threshold as % of achievable ROI mean
  • y-axis: focality score (optim_funvalue; higher/closer to 0 = better)
  • One line/series per unique ROI% value, coloured by series
  • Each point labelled with (roi%, nonroi%)
  • Points with status != "done" are skipped

Parameters:

Name Type Description Default
result ParetoSweepResult

The sweep result containing all :class:SweepPoint objects.

required
output_path str

Absolute path where the PNG should be written.

required
Source code in tit/opt/flex/pareto.py
def generate_pareto_plot(result: ParetoSweepResult, output_path: str) -> None:
    """Save a Pareto trade-off scatter plot as a PNG.

    - x-axis: non-ROI threshold as % of achievable ROI mean
    - y-axis: focality score (optim_funvalue; higher/closer to 0 = better)
    - One line/series per unique ROI% value, coloured by series
    - Each point labelled with ``(roi%, nonroi%)``
    - Points with ``status != "done"`` are skipped

    Args:
        result: The sweep result containing all :class:`SweepPoint` objects.
        output_path: Absolute path where the PNG should be written.
    """
    fig, ax = plt.subplots(figsize=(8, 5))
    roi_pcts_unique = sorted(set(p.roi_pct for p in result.points))
    colors = plt.cm.tab10.colors  # type: ignore[attr-defined]

    for ci, rp in enumerate(roi_pcts_unique):
        pts = [p for p in result.points if p.roi_pct == rp and p.status == "done"]
        if not pts:
            continue
        xs = [p.nonroi_pct for p in pts]
        ys = [p.focality_score for p in pts]
        ax.plot(
            xs,
            ys,
            marker="o",
            color=colors[ci % len(colors)],
            label=f"ROI {int(rp)}%",
            linewidth=1.5,
        )
        for p in pts:
            ax.annotate(
                f"({int(p.roi_pct)}%,{int(p.nonroi_pct)}%)",
                xy=(p.nonroi_pct, p.focality_score),
                fontsize=7,
                ha="left",
                va="bottom",
            )

    ax.set_xlabel("Non-ROI threshold (% of achievable ROI mean)")
    ax.set_ylabel("Focality score (higher = better)")
    ax.set_title("Focality\u2013Threshold Trade-off Sweep")
    ax.legend()
    fig.tight_layout()
    fig.savefig(output_path, dpi=150)
    plt.close(fig)

generate_summary_text

generate_summary_text(result: ParetoSweepResult) -> str

Return a formatted ASCII table summarising all sweep points.

Columns: ROI% | NonROI% | ROI thr (V/m) | NR thr (V/m) | Score | Status

Parameters:

Name Type Description Default
result ParetoSweepResult

The sweep result to summarise.

required

Returns:

Type Description
str

Multi-line string ready to write to a text file or print.

Source code in tit/opt/flex/pareto.py
def generate_summary_text(result: ParetoSweepResult) -> str:
    """Return a formatted ASCII table summarising all sweep points.

    Columns: ROI% | NonROI% | ROI thr (V/m) | NR thr (V/m) | Score | Status

    Args:
        result: The sweep result to summarise.

    Returns:
        Multi-line string ready to write to a text file or print.
    """
    header = (
        f"{'ROI%':>6} {'NonROI%':>8} {'ROI thr(V/m)':>14} "
        f"{'NR thr(V/m)':>12} {'Score':>10} {'Status'}"
    )
    sep = "=" * len(header)
    lines = [sep, header, sep]
    for p in result.points:
        score_str = (
            f"{p.focality_score:.3f}" if p.focality_score is not None else "\u2014"
        )
        lines.append(
            f"{p.roi_pct:>6.0f} {p.nonroi_pct:>8.0f} "
            f"{p.roi_threshold:>14.4f} {p.nonroi_threshold:>12.4f} "
            f"{score_str:>10} {p.status}"
        )
    lines.append(sep)
    return "\n".join(lines)

save_results

save_results(result: ParetoSweepResult, output_folder: str) -> tuple

Persist the sweep results to disk and promote the best run's output.

Steps:

  1. Copy the best run's optimizer output files into output_folder.
  2. Delete all numbered sweep subdirectories.
  3. Write pareto_results.json and pareto_sweep_plot.png.

Parameters:

Name Type Description Default
result ParetoSweepResult

The completed (or partially completed) sweep result.

required
output_folder str

Parent pareto-sweep directory (created if necessary).

required

Returns:

Type Description
tuple

(json_path, plot_path) -- absolute paths to the two written files.

Source code in tit/opt/flex/pareto.py
def save_results(result: ParetoSweepResult, output_folder: str) -> tuple:
    """Persist the sweep results to disk and promote the best run's output.

    Steps:

    1. Copy the best run's optimizer output files into *output_folder*.
    2. Delete all numbered sweep subdirectories.
    3. Write ``pareto_results.json`` and ``pareto_sweep_plot.png``.

    Args:
        result: The completed (or partially completed) sweep result.
        output_folder: Parent pareto-sweep directory (created if necessary).

    Returns:
        ``(json_path, plot_path)`` -- absolute paths to the two written files.
    """
    os.makedirs(output_folder, exist_ok=True)

    # Promote best run's files and clean up numbered subdirs
    _promote_best_run(result, output_folder)

    json_path = os.path.join(output_folder, "pareto_results.json")
    plot_path = os.path.join(output_folder, "pareto_sweep_plot.png")

    done = [
        p for p in result.points if p.status == "done" and p.focality_score is not None
    ]
    best_run = None
    if done:
        best_pt = min(done, key=lambda p: p.focality_score)
        best_run = {
            "roi_pct": best_pt.roi_pct,
            "nonroi_pct": best_pt.nonroi_pct,
            "focality_score": best_pt.focality_score,
        }

    data = {
        "achievable_roi_mean_vm": result.config.achievable_roi_mean,
        "roi_pcts": result.config.roi_pcts,
        "nonroi_pcts": result.config.nonroi_pcts,
        "best_run": best_run,
        "points": [
            {
                "roi_pct": p.roi_pct,
                "nonroi_pct": p.nonroi_pct,
                "roi_threshold_vm": p.roi_threshold,
                "nonroi_threshold_vm": p.nonroi_threshold,
                "focality_score": p.focality_score,
                "status": p.status,
            }
            for p in result.points
        ],
    }
    with open(json_path, "w") as f:
        json.dump(data, f, indent=2)

    generate_pareto_plot(result, plot_path)

    return json_path, plot_path