"""../report/index.html: the v6 work (worktree ../fcp_devel_v6, branch v6), from the CSVs of this folder's scripts.

    env -u PYTHONPATH .venv/bin/python ~/code/data/meshes/fcp_2910_v6/scripts/report.py
"""
import csv
import html
import sys
import time
from collections import defaultdict
from pathlib import Path

import numpy as np

sys.path.insert(0, "/home/joao/code/data/meshes/fcp_2810_partition/scripts")
from report import CSS, cls  # noqa: E402  (the partition study's page style)

REPORT = Path(__file__).resolve().parent.parent / "report"
INPUTS = ["mesh", "clean_10000", "clean_2048", "noise0p6_10000", "pn0p6_10000"]
LABEL = {"mesh": "mesh", "clean_10000": "cloud 10k", "clean_2048": "sparse cloud 2048", "noise0p6_10000": "noisy mesh, 10k samples",
         "pn0p6_10000": "per-point noise 0.6 %, 10k"}


def rows(name):
    p = REPORT / name
    return list(csv.DictReader(p.open())) if p.exists() else []


def gm(a, b, k):
    r = [float(a[o][k]) / float(b[o][k]) for o in a if o in b and float(b[o][k]) > 0]
    return float(np.exp(np.mean(np.log(r)))) if r else float("nan")


def eval_section(ver="v6"):
    R = rows("eval_v6.csv")
    if not R:
        return "<p>not run yet</p>"
    by = defaultdict(dict)
    for r in R:
        by[r["version"], r["input"], r["atoms"]][r["object"]] = r
    if (ver, "mesh", "256") not in by:
        return f"<p>{html.escape(ver)}: not run yet</p>"
    out = ["<div class='wrap'><table><tr><th class='l'>input</th><th>parts<br>v5.31 → v6</th>"
           + "".join(f"<th>Chamfer %<br>v5.31 {T}</th><th>× v6<br>{T}</th>" for T in ("256", "1024", "6144"))
           + "".join(f"<th>&gt;30° ×<br>{T}</th>" for T in ("256", "1024", "6144"))
           + "<th>auto: bytes ×<br>(vs 6144)</th><th>auto: Chamfer ×<br>(vs 6144)</th></tr>"]
    for inp in INPUTS:
        if (ver, inp, "256") not in by or ("v5.31", inp, "256") not in by:
            continue
        cells = []
        for T in ("256", "1024", "6144"):
            a, b = by[ver, inp, T], by["v5.31", inp, T]
            g = gm(a, b, "chamfer")
            cells.append(f"<td>{np.mean([float(r['chamfer']) for r in b.values()]):.3f}</td><td class='{cls(g)}'>{g:.3f}</td>")
        for T in ("256", "1024", "6144"):
            g = gm(by[ver, inp, T], by["v5.31", inp, T], "n_over30")
            cells.append(f"<td class='{cls(g, 0.05)}'>{g:.3f}</td>")
        au, b6 = by.get((ver, inp, "-1"), {}), by["v5.31", inp, "6144"]
        cells.append(f"<td class='good'>{gm(au, b6, 'nbytes'):.2f}</td><td>{gm(au, b6, 'chamfer'):.3f}</td>" if au else "<td></td><td></td>")
        pp = (sum(int(r["parts"]) for r in by["v5.31", inp, "256"].values()), sum(int(r["parts"]) for r in by[ver, inp, "256"].values()))
        out.append(f"<tr><td class='l'>{LABEL[inp]}</td><td>{pp[0]} → {pp[1]}</td>{''.join(cells)}</tr>")
    out.append("</table></div>")
    return "\n".join(out)


def scans_section():
    R = [r for r in rows("scans_rd.csv") if r["status"] == "ok"]
    bad = [r for r in rows("scans_rd.csv") if r["status"] != "ok"]
    if not R:
        return "<p>not run yet</p>"
    by = defaultdict(dict)
    for r in R:
        by[r["config"], r["atoms"]][int(r["i"])] = r
    common = set(by["package", "256"]) & set(by.get(("rdT256", "256"), {}))
    out = [f"<p class='mut'>{len(common)} scans with both (of 251; {len({r['i'] for r in bad})} did not encode or load).</p>",
           "<div class='wrap'><table><tr><th class='l'>atoms</th><th>Chamfer % median<br>v6</th><th>rdT256</th>"
           "<th>ratio<br>(geo. mean)</th><th>scans<br>better</th><th>worst<br>ratio</th><th>&gt;30° %<br>median v6</th>"
           "<th>rdT256</th><th>ratio<br>(geo. mean)</th></tr>"]
    for T in ("256", "1024", "6144"):
        a, b = by["rdT256", T], by["package", T]
        rc = np.array([float(a[i]["chamfer"]) / float(b[i]["chamfer"]) for i in common])
        rn = np.array([max(float(a[i]["n_over30"]), 1e-3) / max(float(b[i]["n_over30"]), 1e-3) for i in common])
        g, gn = float(np.exp(np.log(rc).mean())), float(np.exp(np.log(rn).mean()))
        out.append(f"<tr><td class='l'>{T}</td><td>{np.median([float(b[i]['chamfer']) for i in common]):.3f}</td>"
                   f"<td>{np.median([float(a[i]['chamfer']) for i in common]):.3f}</td><td class='{cls(g)}'>{g:.3f}</td>"
                   f"<td>{(rc < 1).mean() * 100:.0f} %</td><td>{rc.max():.2f}</td>"
                   f"<td>{np.median([float(b[i]['n_over30']) for i in common]):.1f}</td><td>{np.median([float(a[i]['n_over30']) for i in common]):.1f}</td>"
                   f"<td class='{cls(gn, 0.05)}'>{gn:.3f}</td></tr>")
    out.append("</table></div>")
    pa = sum(int(by["package", "256"][i]["parts"]) for i in common)
    pr = sum(int(by["rdT256", "256"][i]["parts"]) for i in common)
    ta = np.median([float(by["package", "256"][i]["seconds"]) for i in common])
    tr = np.median([float(by["rdT256", "256"][i]["seconds"]) for i in common])
    out.append(f"<p>Parts {pa} → {pr}; encode (6144 atoms, CUDA) median {ta * 1000:.0f} → {tr * 1000:.0f} ms.</p>")
    return "\n".join(out)


def noise_section():
    R = rows("eval_v6.csv")
    by = defaultdict(dict)
    for r in R:
        by[r["version"], r["input"], r["atoms"]][r["object"]] = r
    lab = {"pn0p12_10000": "per-point noise 0.12 %", "pn0p6_10000": "per-point noise 0.6 %", "pn1p2_10000": "per-point noise 1.2 %",
           "noise0p6_10000": "noisy mesh samples 0.6 %", "noise1_10000": "noisy mesh samples 1 %", "dgrad_10000": "density gradient"}
    out = ["<div class='wrap'><table><tr><th class='l'>cloud (10k)</th><th>parts<br>v6 → rdT256</th>"
           + "".join(f"<th>Chamfer ×<br>{T}</th>" for T in ("256", "1024", "6144"))
           + "".join(f"<th>objects<br>better {T}</th>" for T in ("256", "1024", "6144"))
           + "".join(f"<th>&gt;30° ×<br>{T}</th>" for T in ("256", "1024", "6144")) + "</tr>"]
    for inp, name in lab.items():
        if ("v6+rdT256", inp, "256") not in by or ("v6", inp, "256") not in by:
            continue
        a = {T: by["v6+rdT256", inp, T] for T in ("256", "1024", "6144")}
        b = {T: by["v6", inp, T] for T in ("256", "1024", "6144")}
        ch = [gm(a[T], b[T], "chamfer") for T in a]
        bt = [np.mean([float(a[T][o]["chamfer"]) < float(b[T][o]["chamfer"]) for o in b[T]]) * 100 for T in a]
        nn = [gm(a[T], b[T], "n_over30") for T in a]
        pp = (sum(int(r["parts"]) for r in b["256"].values()), sum(int(r["parts"]) for r in a["256"].values()))
        out.append(f"<tr><td class='l'>{name}</td><td>{pp[0]} → {pp[1]}</td>" + "".join(f"<td class='{cls(g)}'>{g:.3f}</td>" for g in ch)
                   + "".join(f"<td>{x:.0f} %</td>" for x in bt) + "".join(f"<td class='{cls(g, 0.05)}'>{g:.3f}</td>" for g in nn) + "</tr>")
    out.append("</table></div>")
    return "\n".join(out)


def mono_section():
    R = rows("monotonicity.csv")
    if not R:
        return "<p>not run yet</p>"
    sys.path.insert(0, str(Path(__file__).parent))
    from monotonicity import CONFIGS, INPUTS, LABEL, TOL, load, summary
    rs = summary(load())
    out = ["<div class='wrap'><table><tr><th class='l'>input</th><th class='l'>cut rule</th><th>Chamfer: steps worse</th>"
           "<th>worst</th><th>angle error: steps worse</th><th>worst</th></tr>"]
    for inp in INPUTS:
        for cfg in CONFIGS:
            c = next(r for r in rs if r["input"] == inp and r["config"] == cfg and r["metric"] == "chamfer")
            a = next(r for r in rs if r["input"] == inp and r["config"] == cfg and r["metric"] == "angle")
            out.append(f"<tr><td class='l'>{LABEL[inp]}</td><td class='l'>{CONFIGS[cfg][0]}</td><td>{c['violations']} of {c['steps']}</td>"
                       f"<td>+{c['worst'] * 100:.0f} %</td><td>{a['violations']} of {a['steps']}</td><td>+{a['worst'] * 100:.0f} %</td></tr>")
    out.append("</table></div>")
    for inp in INPUTS:
        out.append(f"<img src='img/monotonicity_{inp}.png' alt='error against atoms, {LABEL[inp]}' loading='lazy'>")
    return "\n".join(out)


def drop_section():
    R = rows("drop_short.csv")
    if not R:
        return "<p>not run yet</p>"
    by = defaultdict(dict)
    for r in R:
        by[r["input"], r["config"], r["atoms"]][r["object"]] = r
    configs = [c for c in dict.fromkeys(r["config"] for r in R) if c != "package"]
    out = ["<div class='wrap'><table><tr><th class='l'>cloud</th><th class='l'>drop (layers, area)</th><th>parts</th>"
           + "".join(f"<th>Chamfer ×<br>{T}</th>" for T in ("256", "1024", "6144")) + "</tr>"]
    for inp in INPUTS[1:]:
        for c in configs:
            if (inp, c, "256") not in by:
                continue
            pb = sum(int(r["parts"]) for r in by[inp, "package", "256"].values())
            pc = sum(int(r["parts"]) for r in by[inp, c, "256"].values())
            cells = "".join(f"<td class='{cls(g)}'>{g:.3f}</td>" for g in (gm(by[inp, c, T], by[inp, "package", T], "chamfer")
                                                                         for T in ("256", "1024", "6144")))
            out.append(f"<tr><td class='l'>{LABEL[inp]}</td><td class='l'>{c}</td><td>{pb} → {pc}</td>{cells}</tr>")
    out.append("</table></div>")
    return "\n".join(out)


def floor_section():
    R = rows("noise_floor.csv")
    if not R:
        return "<p>not run yet</p>"
    by = defaultdict(dict)
    for r in R:
        by[r["input"], r["config"]][r["object"]] = r
    out = ["<div class='wrap'><table><tr><th class='l'>input</th><th class='l'>stop at</th><th>atoms (mean)</th>"
           "<th>Chamfer ×<br>(vs 6144)</th><th>&gt;30° ×<br>(vs 6144)</th></tr>"]
    for inp in INPUTS:
        b = by.get((inp, "package-6144"), {})
        for c in ("floor-2", "floor-3", "floor-4"):
            cur = by.get((inp, c))
            if not cur or not b:
                continue
            out.append(f"<tr><td class='l'>{LABEL[inp]}</td><td class='l'>{c.replace('floor-', '')} × the noise level</td>"
                       f"<td>{np.mean([int(r['atoms']) for r in cur.values()]):.0f}</td><td>{gm(cur, b, 'chamfer'):.3f}</td>"
                       f"<td>{gm(cur, b, 'n_over30'):.3f}</td></tr>")
    out.append("</table></div>")
    return "\n".join(out)


def lobes_section():
    out = []
    for p in sorted(REPORT.glob("lobes_check_*.csv")):
        R = list(csv.DictReader(p.open()))
        inp = p.stem.replace("lobes_check_", "")
        by = defaultdict(dict)
        for r in R:
            by[r["object"], r["config"]][r["atoms"]] = r
        objs = list(dict.fromkeys(r["object"] for r in R))
        out.append(f"<h3>{html.escape(LABEL.get(inp, inp))}</h3><div class='wrap'><table><tr><th class='l'>object</th><th>parts<br>off → on</th>"
                   + "".join(f"<th>Chamfer %<br>off {T}</th><th>on {T}</th>" for T in ("256", "1024", "6144"))
                   + "".join(f"<th>&gt;30° %<br>off {T}</th><th>on {T}</th>" for T in ("256", "1024", "6144")) + "</tr>")
        for o in objs:
            off, on = by.get((o, "off"), {}), by.get((o, "lobes"), {})
            if not off or not on:
                continue
            cells = []
            for k, fmt, tol in (("chamfer", ".3f", 0.02), ("n_over30", ".1f", 0.05)):
                for T in ("256", "1024", "6144"):
                    a, b = float(off[T][k]), float(on[T][k])
                    cells.append(f"<td>{a:{fmt}}</td><td class='{cls(b / a, tol) if a > 0 else ''}'>{b:{fmt}}</td>")
            out.append(f"<tr><td class='l'>{o}</td><td>{off['256']['parts']} → {on['256']['parts']}</td>{''.join(cells)}</tr>")
        out.append("</table></div>")
    for f in sorted((REPORT / "img").glob("lobes_*_a6144.png")):
        out.append(f"<img src='img/{f.name}' alt='{f.stem}' loading='lazy'>")
    return "\n".join(out) if out else "<p>not run yet</p>"


def main():
    page = f"""<!doctype html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1"><title>FCP v6</title><style>{CSS}</style></head><body>
<h1>FCP v6</h1>
<p class="mut">{time.strftime('%Y-%m-%d %H:%M')} · worktree <code>~/code/fcp_devel_v6</code>, branch <code>v6</code> (decision 27;
lobe parts in progress) · scripts in <code>fcp_2910_v6/scripts</code>, the shared helpers (objects, measures, renders) in
<code>fcp_2810_partition/scripts</code> · the partition study that led here:
<a href="../../fcp_2810_partition/report/index.html"><code>fcp_2810_partition/report</code></a></p>
<div class="box"><b>v6 as committed.</b> (1) Point clouds drop their short, small tracks (at most 3 layers, under 5 % of the
largest slice): the slicers' blobs on noisy and sparse clouds; a mesh keeps them. (2) <code>encode(atoms="auto")</code> stops at
twice the noise level read at the top height degrees of the long parts (the atoms are orthonormal: noise spreads evenly over
them; median |score| / 0.6745); a prefix like any other. The format and the decoder are v5.31's; meshes encode to the same
bytes. 287 tests pass (6 new).</div>
<h2>v5.31 against v6</h2>
<p class="mut"><code>scripts/eval_v6.py</code>: the ten meshes and per object four clouds of <code>fcp_2710_tests/pointclouds</code>;
reference the clean mesh; ratios: geometric mean over the objects; green / red: 2 % better / worse (normals 5 %); "auto":
the <code>atoms="auto"</code> descriptor against v5.31's 6144.</p>
{eval_section()}
<h2>The rate-distortion partition (prototype)</h2>
<div class="box"><b>One rule instead of tolerances.</b> With orthonormal atoms, keeping those above a level lam is the best
descriptor of its size; its error plus lam² per atom kept is the sum over atoms of min(score², lam²). The partition is chosen
by the same rule: each track cut into the pieces of least total cost (every piece's rings projected on its own height basis,
plus the energy the basis cannot hold, plus lam² × 0.5 for a part's header), by dynamic programming over cut positions. lam is
the level of the T0-th atom at the reference budget T0 (iterated with the partition), so the partition depends on the data
and one constant, and prefixes stay exact. It replaces FIT_TOL, FIT_GAIN, STEP_SLACK, FIT_MIN and the clouds' spacing
tolerance; the same rule on meshes and clouds. A level from the noise alone (2 σ or the universal threshold) is optimal for
large budgets and cuts 2-3 times more parts, worse at 256 atoms. <code>scripts/rd_split.py</code> (swapped in for the
package's cut rule); CPU NumPy, 0.2-1 s per object, not optimized. Tracks are not dropped by it (a ring's energy includes its
position, which is not the geometric cost of leaving it out): v6's short-track filter stays.</div>
<p><b>Speed</b> (1024 atoms, best of 3; projections once per track in float32, each level only re-sums): mug / elephant / dino
/ horse / dress / shoe encode on CUDA in 62 / 77 / 69 / 68 / 171 / 89 ms with the package's rule, 187 / 110 / 142 / 172 / 255 / 153
ms with rdT256 (CPU 117-586 → 199-670 ms); the same parts and values as the first version.</p>
<h3>On the 251 scans (not used to develop it): v6's rule against rdT256</h3>
<p class="mut"><code>scripts/scans_rd.py</code>: the bench's scans, loaded and repaired as <code>api/bench/scans.py</code> does, each
scored against itself; ratio rdT256 / v6.</p>
{scans_section()}
<h3>Noise and density (ten objects): v6's rule against rdT256</h3>
<p class="mut">The cloud kinds of <code>fcp_2710_tests/pointclouds</code> at 10k points (per-point noise encoded with
<code>noise=</code>); ratio rdT256 / v6.</p>
{noise_section()}
<h3>Is more atoms always better? (monotonicity)</h3>
<p class="mut"><code>scripts/monotonicity.py</code>: each descriptor encoded once at 6144 atoms, decoded at its prefixes 16 ... 6144;
a step "worse" when the error grows by more than 0.5 % from the previous count; ten objects, 9 steps each.</p>
<div class="box">The medians fall with every step for both rules, on meshes, clean and noisy clouds; per object, rate-distortion
has fewer or as many increases (meshes, Chamfer: none against one). The increases are at small budgets and on a few objects:
the open box, the GSO bowl, the battery, all with cavities or thin walls. Two causes, neither the cut rule: (1) the angle
error is one-sided (decoded surface to the nearest true normal), so a decode that draws less of the object can score better
normals; the open box at 128 atoms has good normals (11°) and poor Chamfer (4.2 %), at 256 it is drawn more fully (Chamfer
2.1 %) with noisy walls (35°). (2) On the noisy clouds the slicer leaves thin walls as one ring around both faces (no hole
part for the cavity over most of the height); its first harmonics are a filled ellipse, so the cavity fills as atoms arrive
before later atoms carve it out (the bowl from 1.6 to 4.0 % at 32 → 64 atoms; no single part's atoms cause it). That is the
point slicers' thin-wall problem (OPEN.md 9). Tested and rejected: parts drawn without both mean coordinates (dropping them is
worse everywhere).</div>
{mono_section()}
<h3>v6 + rdT256 against v5.31</h3>
{eval_section("v6+rdT256")}
<h3>v6 + rdT1024 against v5.31</h3>
{eval_section("v6+rdT1024")}
<h2>The short-track filter: settings</h2>
<p class="mut"><code>scripts/drop_short.py</code>: ratios to the package without it. v6 takes 3 layers / 5 %: 4 / 10 % loses real
detail at 6144, 2 / 5 % keeps more blobs.</p>
{drop_section()}
<h2>The noise floor: where to stop</h2>
<p class="mut"><code>scripts/noise_floor.py</code>: the descriptor cut where the atoms fall under c times the level, against the
6144 prefix. It does not denoise (the slicers' noise is smooth along the height and sits in the low degrees too); it is a
stopping point: v6 takes c = 2.</p>
{floor_section()}
<h2>Lobe parts (branch <code>v6-lobes</code>, experimental)</h2>
<div class="box">What joins a long part on one side (a handle's root, a limb) is peeled into a part that overlaps the body, so the
body's part goes on through it; the decoder takes the union (the distance at a layer to the region's boundary only: the same
bits on NumPy, numba and CUDA). <code>fcp/_lobes.py</code>: a robust fit along the height locates lobes (each round keeps at
least the better half of the layers); each ring is cut on its own outline, walked off the lobe's tip and down to the fillets;
the body is closed across the gap by its own wall (the ring either side of the gap in polar form, a cubic in angle, matched to
both ends); a peeled run is extended along the moving cut until nothing sticks out; a run goes to the track it touches, and is
not peeled when it touches none (free lobe parts added 6 parts on the elephant and the horse for nothing).
<b>Results (ten meshes, on against off):</b> the mug's body is one part through the lower handle root, and the mug is better at
every budget (256 atoms: Chamfer 0.264 against 0.282, normals over 30° 7.7 % against 12.7 %; with its upper root a part of its
own, 0.246 and 4.0 %); the dino slightly better; the dress, shoe and horse worse at 256 / 1024 atoms; overall Chamfer 1.019 /
1.014 / 0.998 times, normals 0.97 / 1.02 / 1.00 at 256 / 1024 / 6144. The boundary flags cost 100-500 ms per decode on the
host (the CUDA decode is 30-40 ms without them). Not in v6. 287 tests pass on the branch.
<b>Why the dress, shoe and horse lose.</b> Their lobes are where a leg or an arm joins the body (the dress at 41-49: a leg's
track ends at 40 and its remnant is peeled and given back to it); the body is cut at those joints anyway, so the peel only adds
an overlapping part. <b>The decisive test</b> (second commit on the branch): keep a run only when the cut rule then makes fewer
parts of the body and of the track its lobe joins. No run on the ten meshes passes: mug 6 → 7, dress 11 → 13 and 10 → 10,
horse 8 → 9 and 4 → 4, shoe 12 → 12, dino 3 → 3, 6 → 6, 7 → 7 (the other runs touch no track). Peeling a lobe never makes
the body's parts longer; the mug's gain at small budgets came from where the cuts fall and one more part. <b>Conclusion:</b>
lobe parts do not make parts bigger; left on the branch as a record.
<b>Speed</b> (1024 atoms, best of 3): v6 encodes the mug / dino / shoe in 62 / 75 / 91 ms on CUDA (118 / 129 / 168 on the CPU)
and decodes in 13 / 16 / 17 ms (45 / 56 / 58); with lobes, encode about 1 s and decode 252 / 71 / 174 ms (host NumPy: the robust
fit, the cuts, the boundary flags on every decode).</div>
<p class="mut"><code>scripts/lobes_check.py</code>: the v6 package with lobes on and off.</p>
{lobes_section()}
<h2>Tried and left out</h2>
<div class="box"><b>Offset patches over an angle range</b> (an offset along a smooth base's normal over a height and an angle
range, Chebyshev × the height basis): a protrusion's sides run along the normal, so the offset is a step in angle and a
polynomial rings (the mug's handle root: 0.3-2.6 layer heights off, against 0.15-0.38 for the parts). <b>A cut tolerance raised
with the noise</b>: no fewer parts and a worse Chamfer (the clouds' extra parts are tracks, not cuts). <b>Spike cut
placement</b>: meshes 0.96 times the Chamfer at 256 atoms, not validated on clouds. The partition rules of the study before
(higher degree, cut tests in shape, minimal partitions): <code>fcp_2810_partition</code>.</div>
</body></html>"""
    (REPORT / "index.html").write_text(page)


if __name__ == "__main__":
    main()
