"""Figures, tables and the report fragment of the tradeoff study (tradeoffs.py, tradeoffs_sizes.py), one thread, CPU.

    env -u PYTHONPATH ~/code/fcp_devel/.venv/bin/python scripts/tradeoffs_figs.py
    -> sections/hull/tradeoffs_scatter.png, tradeoffs_sizes.png, tradeoffs_configs.csv, tradeoffs_section.html
"""
import sys
from pathlib import Path

import numpy as np
import pandas as pd

sys.argv = [sys.argv[0], "x"]
ROOT = Path(__file__).resolve().parent.parent
OUT = ROOT / "out"
SECTION = Path.home() / "code/data/meshes/fcp_report/sections/hull"
TASKS = [("hull", "convex hull", "qhull on the mesh"), ("volume", "volume", "tetrahedron sum over the mesh"),
         ("aabb", "AABB", "Open3D on the mesh vertices"), ("obb", "OBB", "the faster minimal box (trimesh / Open3D)"),
         ("sphere", "smallest sphere", "qhull + exact ball")]
LCOL = {64: "#E69F00", 128: "#009E73", 256: "#0072B2"}  # Okabe-Ito
AMARK = {512: "o", 2048: "s", 6144: "D"}


def load():
    g = pd.read_csv(OUT / "tr_grid.csv")
    b = pd.read_csv(OUT / "tr_base.csv").merge(pd.read_csv(OUT / "tr_o3d.csv").drop(columns="load"), on="obj")
    b["obb_ref_vol"] = b[["trimesh_obb_vol", "o3d_min_vol"]].min(axis=1)
    b["obb_ref_ms"] = b[["trimesh_obb_ms", "o3d_min_ms"]].min(axis=1)
    base_ms = {"hull": "qhull_ms", "volume": "tetra_ms", "aabb": "o3d_aabb_ms", "obb": "obb_ref_ms", "sphere": "sphere_exact_ms"}
    g = g.merge(b, on="obj", suffixes=("", "_base"))
    g.loc[g.task == "obb", "size_ratio"] = g.size_ratio / g.obb_ref_vol
    g["base_ms"] = [getattr(r, base_ms[r.task]) for r in g.itertuples()]
    g["time_ratio"] = g.ms / g.base_ms
    g["err"] = (g.size_ratio - 1).abs()
    return g, b


def configs(g):
    """Per task and configuration: geometric-mean time ratio, median size ratio and error, worst error and outside."""
    keys = ["task", "layers", "atoms", "M", "n_t", "frames"]
    c = g.fillna({"frames": ""}).groupby(keys).agg(
        time_ratio=("time_ratio", lambda x: float(np.exp(np.log(x).mean()))), ms_med=("ms", "median"),
        base_ms_med=("base_ms", "median"), size_med=("size_ratio", "median"), size_min=("size_ratio", "min"),
        size_max=("size_ratio", "max"), err_med=("err", "median"), err_max=("err", "max"),
        out_max=("out_pct", "max"), gap_max=("gap_max_pct", "max"), load_max=("load", "max")).reset_index()
    return c


WORST_LIM = {"hull": 2.0, "aabb": 1.0, "obb": 1.0, "sphere": 1.0, "volume": 1.0}  # for "balanced": % of the diagonal; volume: % error


def pick(c):
    """Named configurations per task: accurate (least median error), balanced (fastest within 1 % median error whose
    worst case stays within WORST_LIM: the farthest vertex outside a bound, or the hull's largest gap to the true hull),
    fast (fastest within 5 % median error, no worst-case limit)."""
    rows = []
    c = c.assign(worst=c.out_max.fillna(c.gap_max).fillna(100 * c.err_max))  # volume: its worst object's error, %
    for task, _, _ in TASKS:
        t = c[c.task == task]
        acc = t.sort_values(["err_med", "time_ratio"]).iloc[0]
        bal = t[(t.err_med <= 0.01) & (t.worst <= WORST_LIM[task])]
        for name, sel in (("fast", t[t.err_med <= 0.05]), ("balanced", bal), ("accurate", t[t.index == acc.name])):
            if len(sel):
                rows.append(dict(sel.sort_values("time_ratio").iloc[0], pick=name))
    P = pd.DataFrame(rows)
    keys = ["task", "layers", "atoms", "M", "n_t", "frames"]
    P["label"] = P.groupby(keys).pick.transform(lambda x: " = ".join(x))
    return P


PICK = [("fast", "#E69F00", "o"), ("balanced", "#009E73", "s"), ("accurate", "#0072B2", "D")]
BETTER = "#2e7d32"


def accuracy(ratio):
    """Accuracy in %: 100 is exact, whichever way the size is off (better is always up)."""
    return 100 * (1 - np.abs(np.asarray(ratio, float) - 1))


def better_arrow(ax, y=1.015):
    ax.text(1.0, y, "better ↗", transform=ax.transAxes, ha="right", va="bottom", color=BETTER, fontsize=9, fontweight="bold")


def scatter(c, b, P):
    import matplotlib

    matplotlib.use("Agg")
    import matplotlib.pyplot as plt
    from matplotlib.lines import Line2D
    from matplotlib.ticker import FuncFormatter, LogLocator, NullFormatter

    vp = pd.read_csv(OUT / "vs_poisson.csv").merge(pd.read_csv(OUT / "vs_fcp.csv")[["obj", "exact", "exact_us"]], on="obj")
    gm = lambda x: float(np.exp(np.log(x).mean()))  # noqa: E731
    ref_ms = b[["trimesh_obb_ms", "o3d_min_ms"]].min(axis=1)
    ref_vol = b[["trimesh_obb_vol", "o3d_min_vol"]].min(axis=1)
    other = {  # standard routes other than the baseline: (label, speed-up, accuracy %)
        "aabb": [("NumPy min/max", gm(b.o3d_aabb_ms / b.aabb_np_ms), 100.0)],
        "obb": [("Open3D principal axes", gm(ref_ms / b.o3d_pca_ms), float(np.median(accuracy(b.o3d_pca_vol / ref_vol))))],
        "sphere": [("trimesh minimum_nsphere", gm(b.sphere_exact_ms / b.trimesh_sphere_ms), float(np.median(accuracy(b.trimesh_sphere_r / b.sphere_r))))],
        "volume": [("Poisson, true normals", gm((vp.exact_us / 1e3) / vp.poisson_true_ms), float(np.median(accuracy(vp.poisson_true_vol / vp.exact)))),
                   ("Poisson, estimated normals", gm((vp.exact_us / 1e3) / vp.poisson_est_ms), float(np.median(accuracy(vp.poisson_est_vol / vp.exact))))],
    }
    fig, axs = plt.subplots(2, 3, figsize=(14, 8.6))
    for ax, (task, label, base) in zip(axs.ravel(), TASKS):
        t = P[P.task == task].set_index("pick").loc[[k for k, _, _ in PICK]]
        xs, ys = 1 / t.time_ratio.values, 100 * (1 - t.err_med.values)  # per-object error, then the median: as in the tables
        worst = 100 * (1 - t.err_max.values)
        ax.plot(xs, ys, color="0.55", lw=1, zorder=1)
        for (k, col, mk), x, y, w in zip(PICK, xs, ys, worst):
            ax.plot([x, x], [w, y], color=col, lw=1.2, zorder=2)
            ax.plot([x], [w], marker="_", color=col, ms=9, zorder=2)
            ax.scatter([x], [y], c=col, marker=mk, s=80, zorder=4)
            ax.annotate(f"{1 / x:.0f}× slower" if x < 1 else f"{x:.1f}×" if x < 10 else f"{x:.0f}×", (x, y), xytext=(0, 9) if task == "aabb" and k == "accurate" else (7, -12) if task == "aabb" else (8, 4), textcoords="offset points",
                        ha="center" if task == "aabb" and k == "accurate" else "left", fontsize=8, color=col)
        ax.scatter([1], [100], marker="*", s=260, c="k", zorder=5)
        lo_x, hi_x = min(1, xs.min()) / 1.6, max(1, xs.max()) * 2.4
        if task == "aabb":
            lo_x, hi_x = xs.min() / 2, 2.0
        low = min(worst.min(), ys.min())
        items = other.get(task, [])
        lo_y = low
        for lab, x, y in items:
            if lo_x <= x <= hi_x and y >= 0.9 * low:
                lo_y = min(lo_y, y)
        pad = 0.08 * (100 - lo_y) + 0.02
        ax.set_xlim(lo_x, hi_x)
        ax.set_ylim(lo_y - pad, 100 + 1.5 * pad)
        for n, (lab, x, y) in enumerate(items):
            if lo_x <= x <= hi_x and lo_y <= y:
                ax.scatter([x], [y], marker="X", s=70, c="k", zorder=5)
                ax.annotate(lab, (x, y), xytext=(0, 8 if task == "aabb" else -13), textcoords="offset points", fontsize=8, ha="center")
            else:  # off scale: a note with its numbers
                speed = f"{1 / x:,.0f}× slower" if x < 1 else f"{x:.0f}× faster"
                ax.text(0.03, 0.05 + 0.08 * n, f"✕ off scale: {lab}, {speed}, {y:.1f} %", transform=ax.transAxes, fontsize=8)
        ax.axhline(100, color="0.6", lw=0.7, zorder=0)
        ax.axvline(1, color="0.6", lw=0.7, zorder=0)
        ax.set_xscale("log")
        ax.xaxis.set_major_locator(LogLocator(base=10, subs=(1.0,) if hi_x / lo_x > 300 else (1.0, 2.0, 5.0), numticks=15))
        ax.xaxis.set_major_formatter(FuncFormatter(lambda v, _: f"{v:g}×"))
        ax.xaxis.set_minor_formatter(NullFormatter())
        ax.set_title(f"{label}\nbaseline: {base}", fontsize=9.5, loc="left")
        ax.set_xlabel("speed-up over the baseline (1 thread)")
        ax.set_ylabel("accuracy, %")
        better_arrow(ax, 1.075)
    leg = axs.ravel()[-1]
    leg.axis("off")
    handles = [Line2D([], [], color="k", marker="*", ls="", ms=14, label="baseline: exact, 1× and 100 %")] + \
              [Line2D([], [], color=col, marker=mk, ls="", ms=8, label=f"FCP {k}") for k, col, mk in PICK] + \
              [Line2D([], [], color="0.4", marker="_", ms=9, lw=1.2, label="whisker: the worst object"),
               Line2D([], [], color="k", marker="X", ls="", label="other standard route")]
    leg.legend(handles=handles, loc="center", fontsize=11, frameon=False)
    fig.subplots_adjust(left=0.06, right=0.985, top=0.95, bottom=0.08, wspace=0.26, hspace=0.32)
    fig.savefig(SECTION / "tradeoffs_scatter.png", dpi=110, facecolor="white")


SWEEP = [("fast", 64, 512, "low", "#E69F00", "o"), ("default", 128, 2048, "default", "#009E73", "s"), ("accurate", 256, 6144, "high", "#0072B2", "D")]


def sizes_fig(s):
    import matplotlib

    matplotlib.use("Agg")
    import matplotlib.pyplot as plt
    from matplotlib.ticker import FuncFormatter

    cols = [("hull", "convex hull", "qhull_ms", "qhull"), ("volume", "volume", "tetra_ms", "tetrahedron sum"),
            ("obb", "OBB", "trimesh_obb_ms", "trimesh minimum-volume box"), ("sphere", "smallest sphere", "sphere_exact_ms", "qhull + exact ball")]
    fig, axes = plt.subplots(2, len(cols), figsize=(4.6 * len(cols), 7), sharex=True)
    for j, (task, label, base_col, base_name) in enumerate(cols):
        a0, a1 = axes[0, j], axes[1, j]
        tg = sorted(s.target.unique())
        a0.plot(tg, [1] * len(tg), "k-", lw=1.6, marker="*", ms=9, label=f"baseline: {base_name}")
        a1.plot(tg, [100] * len(tg), "k-", lw=1.6, marker="*", ms=9)
        for key, L, A, name, col, mk in SWEEP:
            q = s[(s.layers == L) & (s.atoms == A)].groupby("target")
            b = q[base_col].median()
            if task == "volume":
                tm, rc = q.volume_ms.median(), "volume_ratio"
            else:
                tm, rc = q[f"{key}_{task}_ms"].median(), f"{key}_{task}_ratio"
            sel = s[(s.layers == L) & (s.atoms == A)]
            acc = pd.Series(accuracy(sel[rc].values), index=sel.target.values).groupby(level=0).median()  # per object, then the median
            a0.plot(tm.index, b / tm, "-", marker=mk, color=col, ms=4, label=f"FCP {name} ({L} layers, {A} atoms)")
            a1.plot(acc.index, acc.values, "-", marker=mk, color=col, ms=4)
        a0.set_xscale("log")
        a0.set_yscale("log")
        a0.yaxis.set_major_formatter(FuncFormatter(lambda v, _: f"{v:g}×"))
        a0.set_title(label, loc="left")
        a0.set_ylabel("speed-up over the baseline" if j == 0 else "")
        a1.set_ylabel("accuracy, %" if j == 0 else "")
        a1.set_xlabel("mesh vertices")
        a0.legend(fontsize=7, loc="upper left")
        better_arrow(a1)
        better_arrow(a0)
    fig.tight_layout()
    fig.savefig(SECTION / "tradeoffs_sizes.png", dpi=110, facecolor="white")


def crossover(s, task, name, L, A):
    """Vertices at which FCP's query (config name, encode L/A) becomes faster than the baseline: log-log interpolation
    of the median curves over the four objects; None if never in range, 0 if always."""
    base_col = {"hull": "qhull_ms", "volume": "tetra_ms", "obb": "trimesh_obb_ms", "sphere": "sphere_exact_ms"}[task]
    q = s[(s.layers == L) & (s.atoms == A)].groupby("target")
    b = q[base_col].median()
    f = q.volume_ms.median() if task == "volume" else q[f"{name}_{task}_ms"].median()
    x, dlt = np.log(b.index.values.astype(float)), np.log(f.values) - np.log(b.values)
    if dlt[0] < 0:
        return 0
    for i in range(1, len(x)):
        if dlt[i] < 0:
            return float(np.exp(x[i - 1] + (x[i] - x[i - 1]) * dlt[i - 1] / (dlt[i - 1] - dlt[i])))
    return None


def section(c, P, b):
    import os

    s = pd.read_csv(OUT / "tr_sizes.csv")
    so = pd.read_csv(OUT / "tr_sizes_o3d.csv") if (OUT / "tr_sizes_o3d.csv").exists() else None
    g = pd.read_csv(OUT / "tr_grid.csv")
    f = lambda v, n=2: "&ndash;" if v is None or v != v else f"{v:.{n}f}"  # noqa: E731
    speed = lambda r: f"{1 / r:.1f}&times; faster" if r < 1 else f"{r:.1f}&times; slower"  # noqa: E731
    gm = lambda x: float(np.exp(np.log(x).mean()))  # noqa: E731
    names = dict((t, l) for t, l, _ in TASKS)
    base_name = {"hull": "qhull (scipy) on the mesh vertices", "volume": "tetrahedron sum over the mesh (numba)",
                 "aabb": "Open3D on the mesh vertices", "obb": "the faster of trimesh's <code>oriented_bounds</code> and Open3D's minimal box",
                 "sphere": "qhull, then the exact smallest ball of its vertices"}
    base_ms = {"hull": b.qhull_ms, "volume": b.tetra_ms, "aabb": b.o3d_aabb_ms,
               "obb": b[["trimesh_obb_ms", "o3d_min_ms"]].min(axis=1), "sphere": b.sphere_exact_ms}
    acc_col = {"hull": ("hull volume", "gap_max", "largest gap to the true hull"), "volume": ("volume", None, None),
               "aabb": ("box volume", "out_max", "farthest vertex outside"), "obb": ("box volume", "out_max", "farthest vertex outside"),
               "sphere": ("radius", "out_max", "farthest vertex outside")}
    tables = []
    for task, label, _ in TASKS:
        what, wc, wlab = acc_col[task]
        rows = [f"<tr><td style='text-align:left'>baseline: {base_name[task]}</td><td>&ndash;</td><td>{f(base_ms[task].median(), 3)}</td><td>1</td>"
                f"<td>exact</td><td>&ndash;</td>" + (f"<td>0</td>" if wc else "") + "</tr>"]
        for r in P[P.task == task].drop_duplicates("label").itertuples():
            cfg = f"{r.layers} layers, {r.atoms} atoms" + (f", M = {r.M}, {r.n_t} samples" if r.M else "") + (f", frames: {r.frames}" if r.frames else "")
            rows.append(f"<tr><td style='text-align:left'>FCP <b>{r.label}</b></td><td style='text-align:left'>{cfg}</td><td>{f(r.ms_med, 3)}</td>"
                        f"<td>{f(r.time_ratio, 3)} ({speed(r.time_ratio)})</td>"
                        f"<td>{f(r.size_med, 4)} ({f(r.size_min, 3)}&ndash;{f(r.size_max, 3)})</td><td>{f(100 * r.err_med, 2)} % / {f(100 * r.err_max, 2)} %</td>"
                        + (f"<td>{f(getattr(r, wc), 2)} %</td>" if wc else "") + "</tr>")
        head = (f"<tr><th style='text-align:left'>{label}</th><th style='text-align:left'>configuration</th><th>time, ms (median)</th>"
                f"<th>time / baseline (geometric mean)</th><th>{what} / exact, median (range)</th><th>error, median / worst</th>"
                + (f"<th>{wlab}, worst, % diagonal</th>" if wc else "") + "</tr>")
        tables.append(f"<div class='tablewrap'><table><thead>{head}</thead><tbody>{''.join(rows)}</tbody></table></div>")
        if task == "obb":
            q = P[P.task == "obb"].set_index("pick")
            pca_t = gm(b.o3d_pca_ms / b[["trimesh_obb_ms", "o3d_min_ms"]].min(axis=1))
            pca_s = float(np.median(b.o3d_pca_vol / b[["trimesh_obb_vol", "o3d_min_vol"]].min(axis=1)))
            tables.append(
                f"<p><b>OBB cases.</b> Much faster, losing accuracy: <i>fast</i> is {f(1 / q.loc['fast'].time_ratio, 0)}&times; faster than the minimal box "
                f"at {f(100 * q.loc['fast'].err_med, 1)} % median volume and up to {f(q.loc['fast'].out_max, 1)} % of the diagonal outside (64 layers are too coarse "
                f"for bounds). Nearly as accurate and still faster: <i>accurate</i> is {f(1 / q.loc['accurate'].time_ratio, 0)}&times; faster at "
                f"{f(100 * q.loc['accurate'].err_med, 2)} % median volume, {f(q.loc['accurate'].out_max, 2)} % outside. Against the quick standard route: Open3D's "
                f"principal-axes box is {f(1 / pca_t, 0)}&times; faster than the minimal box but {f(100 * (pca_s - 1), 0)} % too large at the median; "
                f"<i>balanced</i> is faster still ({f(1 / q.loc['balanced'].time_ratio, 0)}&times;) and within {f(100 * q.loc['balanced'].err_med, 1)} %.</p>")
    # crossovers on the sweep
    combos = [("fast", 64, 512), ("default", 128, 2048), ("accurate", 256, 6144)]
    xr = []
    for task in ("hull", "volume", "obb", "sphere"):
        cells = []
        for name, L, A in combos:
            v = crossover(s, task, name, L, A)
            cells.append("always faster" if v == 0 else ("not within 1M" if v is None else f"{v / 1e3:.1f}k"))
        xr.append(f"<tr><td style='text-align:left'>{names[task]}</td>" + "".join(f"<td>{x}</td>" for x in cells) + "</tr>")
    # absolute times on the sweep: the figure shows speed-ups
    tg = [1000, 30000, 1000000]
    thead = "".join(f"<th>{'1M' if t >= 1e6 else f'{t // 1000}k'} vertices</th>" for t in tg)
    tr = []
    dflt = s[(s.layers == 128) & (s.atoms == 2048)].groupby("target")
    for task, lab, col in (("hull", "convex hull", "qhull_ms"), ("volume", "volume", "tetra_ms"), ("obb", "OBB", "trimesh_obb_ms"), ("sphere", "smallest sphere", "sphere_exact_ms")):
        bm = dflt[col].median()
        fm = dflt.volume_ms.median() if task == "volume" else dflt[f"default_{task}_ms"].median()
        tr.append(f"<tr><td style='text-align:left'>{lab}: baseline</td>" + "".join(f"<td>{bm[t]:.3g}</td>" for t in tg) + "</tr>")
        tr.append(f"<tr><td style='text-align:left'>{lab}: FCP default</td>" + "".join(f"<td>{fm[t]:.3g}</td>" for t in tg) + "</tr>")
    trows = "".join(tr)
    # encoding table: median over the four objects, per mesh size
    enc = s.groupby(["layers", "atoms", "target"]).encode_ms.median().unstack("target")
    ehead = "".join(f"<th>{'1M' if t >= 1e6 else f'{int(t / 1e3)}k'} vertices</th>" for t in enc.columns)
    erows = "".join(f"<tr><td>{L}</td><td>{A}</td>" + "".join(f"<td>{f(v / 1e3 if v >= 1e3 else v, 2 if v >= 1e3 else 0)}{' s' if v >= 1e3 else ' ms'}</td>" for v in row) + "</tr>"
                    for (L, A), row in enc.iterrows())
    loads = (g.load.max(), s.load.max())
    cpu = [l.split(":", 1)[1].strip() for l in open("/proc/cpuinfo") if l.startswith("model name")][0]
    verdict = []
    for task, label, _ in TASKS:
        t = P[P.task == task].set_index("pick")
        best = t.loc["balanced"] if "balanced" in t.index else t.iloc[0]
        verdict.append((label, best))
    wl = {"convex hull": "largest gap to the true hull", "volume": None}
    vt = "".join(f"<tr><td style='text-align:left'>{l}</td><td>{speed(r.time_ratio)}</td>"
                 f"<td>{f(100 * r.err_med, 2)} % (worst object {f(100 * r.err_max, 1)} %)</td>"
                 f"<td>{'&ndash;' if l == 'volume' else f(r.gap_max if l == 'convex hull' else r.out_max, 2) + ' % (' + ('largest gap to the true hull' if l == 'convex hull' else 'farthest vertex outside') + ')'}</td></tr>"
                 for l, r in verdict)
    html = f"""<h2>Tradeoffs: speed against accuracy, one thread</h2>
<p>Each geometric query from the descriptor against the standard route from the mesh, on the CPU with <b>one thread for
every method</b> (numba, OpenMP and BLAS pinned to one thread; {cpu}). Twelve objects (the collision section's), encoded
from their meshes at 64, 128 and 256 layers and 512, 2048 and 6144 atoms (package v5.31, commit db3e6ceb); for the hull and
the bounds also 16, 32, 64 and 128 directions M and 64, 128 and 256 samples per ring, and for the OBB the descriptor's own
frame or its own and the world's: {len(c)} configurations over the five tasks, every one on every object. Exact references from the mesh:
the convex hull's volume (qhull), the mesh's volume, its AABB, the smaller of trimesh's and Open3D's minimal boxes, the exact
smallest ball. Encoding is not in these times (its own table below). Times are medians after a warm-up, taken while
the load average was logged; objects first timed under another job's load (above 3) were timed again
on a quiet machine (load average at most {f(loads[0], 1)} for the times shown).</p>
<figure><img src="sections/hull/tradeoffs_scatter.png" alt="Five plots, one per task: accuracy against speed-up over the baseline, with the baseline at 1× and 100 % and better towards the top right">
<figcaption>Baseline first: the exact standard route is the star at 1× and 100 %, and <b>better is always towards the top
right</b>. x: speed-up over the baseline (geometric mean over the 12 objects, log scale; left of 1× is slower). y: accuracy,
100 % minus the size error (each object's error, then the median over the objects; the whisker reaches the worst object). FCP is shown at its three settings
for each task, those of the tables below: <i>accurate</i> (the least median error of the {len(c)} configurations),
<i>balanced</i> (the fastest within 1 % median error whose worst case stays within 1 % of the diagonal: the farthest vertex
outside a bound; for the hull its largest gap to the true hull, within 2 %, since no setting reaches 1 %; for the volume the
worst object's error, within 1 %), <i>fast</i> (the fastest within 5 % median error, with no limit on the worst case). Crosses
are other standard routes; those off the scale are noted in the panel with their numbers.</figcaption></figure>
{''.join(tables)}
<p><b>Mesh size.</b> The standard routes' cost grows with the mesh; the descriptor's does not (its size is set by the
layers and atoms). Four objects (mug, dino, horse, knot) resampled to 1k&ndash;1M vertices (quadric decimation, Loop
subdivision); per size the exact references are recomputed. FCP at three fixed settings: low (64 layers, 512 atoms, M = 16,
64 samples), default (128, 2048, 64, 128) and high (256, 6144, 128, 256).</p>
<figure><img src="sections/hull/tradeoffs_sizes.png" alt="Speed-up over the baseline and accuracy against mesh vertices for the hull, volume, OBB and sphere">
<figcaption>Baseline first, the black line at 1× and 100 %; better is always up and right. Top: FCP's speed-up over the
baseline against the mesh's vertices (median over the four objects, log-log, encoding excluded): above 1× FCP is faster. Bottom:
FCP's accuracy at each mesh size (each object's error, then the median). The OBB's baseline here is trimesh's minimum-volume box alone (Open3D's minimal box took
29 ms to 3.8 s on the 12 objects, 3.8 s on the 14k-vertex knot, too slow for the sweep); trimesh's is not always minimal.</figcaption></figure>
<div class="tablewrap"><table><thead><tr><th style='text-align:left'>time, ms</th>{thead}</tr></thead><tbody>{trows}</tbody></table></div>
<div class="tablewrap"><table><thead><tr><th style='text-align:left'>FCP faster from (vertices)</th><th>low</th><th>default</th><th>high</th></tr></thead>
<tbody>{''.join(xr)}</tbody></table></div>
<p><b>Encoding</b> (one thread, from the mesh; median over the four objects; once per object, then every query above):</p>
<div class="tablewrap"><table><thead><tr><th>layers</th><th>atoms</th>{ehead}</tr></thead><tbody>{erows}</tbody></table></div>
<p><b>Verdict</b> at the <i>balanced</i> setting of each task (12 objects, one thread):</p>
<div class="tablewrap"><table><thead><tr><th style='text-align:left'>task</th><th>speed against the exact standard route</th><th>size error, median (worst object)</th><th>worst case, % of the diagonal</th></tr></thead>
<tbody>{vt}</tbody></table></div>
"""
    (SECTION / "tradeoffs_section.html").write_text(html)
    readme = (SECTION / "README.txt").read_text()
    note = ("tradeoffs_*: ~/code/data/meshes/fcp_3010_hull/scripts/: tradeoffs.py (base, o3d, grid, retime: out/tr_base.csv, tr_o3d.csv, "
            "tr_grid.csv), tradeoffs_sizes.py (make, o3d, run: out/tr_sizes*.csv), then tradeoffs_figs.py (tradeoffs_scatter.png, "
            "tradeoffs_sizes.png, tradeoffs_configs.csv, tradeoffs_section.html). One thread everywhere.")
    lines = [l for l in readme.splitlines() if not l.startswith("tradeoffs_*")] + [note]
    (SECTION / "README.txt").write_text("\n".join(lines).rstrip("\n") + "\n")


if __name__ == "__main__":
    g, b = load()
    c = configs(g)
    P = pick(c)
    c.to_csv(SECTION / "tradeoffs_configs.csv", index=False)
    scatter(c, b, P)
    if (OUT / "tr_sizes.csv").exists():
        sizes_fig(pd.read_csv(OUT / "tr_sizes.csv"))
        section(c, P, b)
    print(P[["task", "pick", "layers", "atoms", "M", "n_t", "frames", "time_ratio", "ms_med", "base_ms_med", "size_med", "err_med", "err_max", "out_max", "load_max"]].round(4).to_string())
