- Site config: site.example.json is committed; the local site.json (git-ignored) or $JFJOCH_BATTERY_SITE / --site names this machine's paths and the private manifest. Data roots are the arms' own directories, so copying them and editing the site config moves the battery. - Run states: manifest records the runner's pid; a run is complete, running, aborted (Ctrl-C/SIGTERM, or `abort RUN`) or unfinished. compare and report refuse a run that did not finish unless --allow-incomplete, and the report says so. - `list` shows runs and their state; `run` defaults its label to the rugnux version and its baseline to the site's persisted one; a baseline must be complete and of the same privacy as the run; a private report cannot be written into the repo. - XDS reference d_min: when the finest shell of XDS's table has no significant CC1/2, XDS merged past its own signal (no resolution cut, or a cut at the detector edge) and its limit is the detector's reach. The reference is then where XDS's CC1/2 falls through 0.30 (rugnux's own target), and that is also the range forced on rugnux. The manifest keeps XDS's limit (dmin_xds) and the rule (dmin_rule). Forcing the old 1.08 A on the weak insulin set turned rugnux's I23 into I222 (ISa 12.5); forced at the derived 1.81 A it is I23 again (ISa 18.0). Changed in the inhouse arm: insu_I_weak 1.08 -> 1.81, lyso_half_image 0.80 -> 2.60, cytc_eiger 2.04 -> 2.27, lyso_strong 1.18 -> 1.24; every other inhouse limit stands. - inhouse.json follows the directory rename (ids and inputs, data root /home/data/inhouse); `remap` applies such a rename and keeps the old ids as aliases, so runs from before it still compare set by set. - Optional open-arm model check (model_check.py, when present): R-free of the deposited model against each merge, recorded and plotted. - results.json has a fixed, documented row schema (results_schema 1); the HTML report gains inline-SVG plots (verdicts per arm, d_min and R-free ratios per set). - README.md: arms, data layout and provenance, prerequisites, running, run directory, results schema, report, comparing, protocol, adding datasets. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
188 lines
8.9 KiB
Python
188 lines
8.9 KiB
Python
"""One document, written twice: Markdown and a self-contained HTML page.
|
|
|
|
The report is built as a list of blocks (heading, paragraph, bullet list, table) and each writer
|
|
turns the same list into its format, so the two can never say different things.
|
|
"""
|
|
import html
|
|
|
|
CSS = """
|
|
:root { --bg:#fff; --fg:#1d1d1f; --mute:#6e6e73; --line:#d9d9de; --head:#f3f3f5;
|
|
--pass:#1a7f37; --fail:#c62828; --warn:#9a6700; --passbg:#e8f5ec; --failbg:#fdecea;
|
|
--series1:#2a78d6; --good:#0ca30c; --critical:#d03b3b; --warning:#fab219;
|
|
--neutral:#898781; --grid:#e1e0d9; --axis:#c3c2b7; }
|
|
@media (prefers-color-scheme: dark) {
|
|
:root { --bg:#161618; --fg:#e8e8ea; --mute:#9a9aa0; --line:#3a3a3e; --head:#232326;
|
|
--pass:#5cc97a; --fail:#ff7b72; --warn:#e3b341; --passbg:#16301f; --failbg:#3a1a1a;
|
|
--series1:#3987e5; --grid:#2c2c2a; --axis:#383835; }
|
|
}
|
|
figure { margin:12px 0 20px; max-width:900px; } figcaption { color:var(--mute); font-size:13px; }
|
|
svg.plot { width:100%; height:auto; display:block; }
|
|
svg.plot text { fill:var(--mute); font:11px system-ui, sans-serif; }
|
|
svg.plot .grid { stroke:var(--grid); stroke-width:1; } svg.plot .ref { stroke:var(--axis); stroke-width:2; }
|
|
svg.plot circle:hover, svg.plot rect:hover { stroke:var(--fg); stroke-width:2; }
|
|
body { background:var(--bg); color:var(--fg); margin:0 auto; max-width:1400px; padding:16px;
|
|
font:14px/1.45 system-ui, -apple-system, "Segoe UI", sans-serif; }
|
|
h1 { font-size:22px; margin:8px 0 4px; } h2 { font-size:18px; margin:28px 0 8px; }
|
|
h3 { font-size:15px; margin:20px 0 6px; }
|
|
p, li { max-width:900px; } .mute { color:var(--mute); }
|
|
.wrap { overflow-x:auto; margin:8px 0 16px; }
|
|
table { border-collapse:collapse; font-variant-numeric:tabular-nums; }
|
|
th, td { border-bottom:1px solid var(--line); padding:4px 8px; text-align:left; white-space:nowrap; }
|
|
th { background:var(--head); position:sticky; top:0; }
|
|
td.num, th.num { text-align:right; }
|
|
td.pass { color:var(--pass); font-weight:600; } td.fail { color:var(--fail); font-weight:600; }
|
|
td.warn { color:var(--warn); font-weight:600; }
|
|
tr.fail td { background:var(--failbg); }
|
|
code { font:12px ui-monospace, Menlo, monospace; }
|
|
"""
|
|
|
|
|
|
class Doc:
|
|
def __init__(self, title):
|
|
self.title = title
|
|
self.blocks = []
|
|
|
|
def h(self, level, text):
|
|
self.blocks.append(("h", level, text))
|
|
|
|
def p(self, text):
|
|
self.blocks.append(("p", text))
|
|
|
|
def ul(self, items):
|
|
self.blocks.append(("ul", list(items)))
|
|
|
|
def svg(self, markup, caption):
|
|
"""An inline SVG plot (HTML only; the Markdown names it and points at the HTML)."""
|
|
self.blocks.append(("svg", markup, caption))
|
|
|
|
def table(self, head, rows, num=(), row_class=None, cell_class=None):
|
|
"""head: column titles; rows: lists of str; num: indices of right-aligned columns;
|
|
row_class(row) and cell_class(i, value) give optional CSS classes (HTML only)."""
|
|
self.blocks.append(("table", head, rows, set(num), row_class, cell_class))
|
|
|
|
def markdown(self):
|
|
out = [f"# {self.title}", ""]
|
|
for b in self.blocks:
|
|
if b[0] == "h":
|
|
out += ["#" * b[1] + " " + b[2], ""]
|
|
elif b[0] == "p":
|
|
out += [b[1], ""]
|
|
elif b[0] == "ul":
|
|
out += ["- " + x for x in b[1]] + [""]
|
|
elif b[0] == "svg":
|
|
out += [f"_Plot in report.html: {b[2]}_", ""]
|
|
else:
|
|
_, head, rows, num, _, _ = b
|
|
if not rows:
|
|
out += ["(none)", ""]
|
|
continue
|
|
out.append("| " + " | ".join(head) + " |")
|
|
out.append("|" + "|".join("--:" if i in num else ":--" for i in range(len(head))) + "|")
|
|
for r in rows:
|
|
out.append("| " + " | ".join(str(x).replace("|", "/") for x in r) + " |")
|
|
out.append("")
|
|
return "\n".join(out)
|
|
|
|
def html(self):
|
|
e = html.escape
|
|
out = ["<!DOCTYPE html>", '<html lang="en"><head><meta charset="utf-8">',
|
|
'<meta name="viewport" content="width=device-width, initial-scale=1">',
|
|
f"<title>{e(self.title)}</title><style>{CSS}</style></head><body>",
|
|
f"<h1>{e(self.title)}</h1>"]
|
|
for b in self.blocks:
|
|
if b[0] == "h":
|
|
out.append(f"<h{b[1]}>{e(b[2])}</h{b[1]}>")
|
|
elif b[0] == "p":
|
|
out.append(f"<p>{e(b[1])}</p>")
|
|
elif b[0] == "ul":
|
|
out.append("<ul>" + "".join(f"<li>{e(x)}</li>" for x in b[1]) + "</ul>")
|
|
elif b[0] == "svg":
|
|
out.append(f"<figure>{b[1]}<figcaption>{e(b[2])}</figcaption></figure>")
|
|
else:
|
|
_, head, rows, num, row_class, cell_class = b
|
|
if not rows:
|
|
out.append('<p class="mute">(none)</p>')
|
|
continue
|
|
t = ['<div class="wrap"><table><thead><tr>']
|
|
t += [f'<th class="num">{e(h)}</th>' if i in num else f"<th>{e(h)}</th>"
|
|
for i, h in enumerate(head)]
|
|
t.append("</tr></thead><tbody>")
|
|
for r in rows:
|
|
rc = row_class(r) if row_class else ""
|
|
t.append(f'<tr class="{rc}">' if rc else "<tr>")
|
|
for i, x in enumerate(r):
|
|
cls = ["num"] if i in num else []
|
|
if cell_class and cell_class(i, x):
|
|
cls.append(cell_class(i, x))
|
|
c = f' class="{" ".join(cls)}"' if cls else ""
|
|
t.append(f"<td{c}>{e(str(x))}</td>")
|
|
t.append("</tr>")
|
|
t.append("</tbody></table></div>")
|
|
out.append("".join(t))
|
|
out.append("</body></html>")
|
|
return "\n".join(out)
|
|
|
|
|
|
# ------------------------------------------------------------------ plots (inline SVG, no libraries)
|
|
|
|
STATUS = {"pass": "var(--good)", "fail": "var(--critical)", "unscored": "var(--neutral)",
|
|
"not_run": "var(--warning)"}
|
|
|
|
|
|
def verdict_bars(rows):
|
|
"""One horizontal bar per arm, split pass / fail / unscored / not run.
|
|
rows: [(arm, {verdict: count})]."""
|
|
e = html.escape
|
|
w, left, bar, gap = 900, 90, 22, 14
|
|
h = 30 + len(rows) * (bar + gap)
|
|
total = max(sum(c.values()) for _, c in rows) or 1
|
|
out = [f'<svg class="plot" viewBox="0 0 {w} {h}" role="img" aria-label="verdicts per arm">']
|
|
x = left
|
|
for v, col in STATUS.items(): # legend, above the bars
|
|
out.append(f'<rect x="{x}" y="4" width="10" height="10" rx="2" fill="{col}"/>'
|
|
f'<text x="{x + 14}" y="13">{v.replace("_", " ")}</text>')
|
|
x += 90
|
|
for i, (arm, cnt) in enumerate(rows):
|
|
y = 26 + i * (bar + gap)
|
|
out.append(f'<text x="0" y="{y + bar / 2 + 4}">{e(arm)}</text>')
|
|
x = left
|
|
for v, col in STATUS.items():
|
|
n = cnt.get(v, 0)
|
|
if not n:
|
|
continue
|
|
bw = (w - left - 10) * n / total
|
|
out.append(f'<rect x="{x:.1f}" y="{y}" width="{max(bw - 2, 1):.1f}" height="{bar}" rx="4" '
|
|
f'fill="{col}"><title>{e(arm)}: {n} {v.replace("_", " ")}</title></rect>')
|
|
if bw > 28:
|
|
out.append(f'<text x="{x + 6:.1f}" y="{y + bar / 2 + 4}" style="fill:#fff">{n}</text>')
|
|
x += bw
|
|
out.append("</svg>")
|
|
return "".join(out)
|
|
|
|
|
|
def ratio_dots(points, what):
|
|
"""Sets sorted by a ratio (ours / reference), on a log axis clipped to 0.5..2, with the line
|
|
at 1. points: [(set, ratio, verdict, tooltip)]. Failed sets are drawn in the fail colour."""
|
|
e = html.escape
|
|
import math
|
|
w, h, left, top, bottom = 900, 240, 44, 22, 18
|
|
lo, hi = math.log(0.5), math.log(2.0)
|
|
y = lambda r: top + (hi - math.log(min(max(r, 0.5), 2.0))) / (hi - lo) * (h - top - bottom)
|
|
pts = sorted(points, key=lambda p: p[1])
|
|
step = (w - left - 10) / max(len(pts), 1)
|
|
out = [f'<svg class="plot" viewBox="0 0 {w} {h}" role="img" aria-label="{e(what)}">']
|
|
for t in (0.5, 0.75, 1.0, 1.5, 2.0):
|
|
out.append(f'<line class="{"ref" if t == 1.0 else "grid"}" x1="{left}" x2="{w}" '
|
|
f'y1="{y(t):.1f}" y2="{y(t):.1f}"/><text x="0" y="{y(t) + 4:.1f}">{t:g}</text>')
|
|
out.append(f'<circle cx="{left + 6}" cy="10" r="4" fill="var(--series1)"/>'
|
|
f'<text x="{left + 14}" y="14">pass or unscored</text>'
|
|
f'<circle cx="{left + 136}" cy="10" r="4" fill="var(--critical)"/>'
|
|
f'<text x="{left + 144}" y="14">fail</text>'
|
|
f'<text x="{left + 190}" y="14">(clipped to 0.5-2; hover a point for the set)</text>')
|
|
for i, (name, r, verdict, tip) in enumerate(pts):
|
|
col = "var(--critical)" if verdict == "fail" else "var(--series1)"
|
|
out.append(f'<circle cx="{left + step * (i + 0.5):.1f}" cy="{y(r):.1f}" r="4" fill="{col}">'
|
|
f'<title>{e(name)}: {r:.3f} - {e(tip)}</title></circle>')
|
|
out.append("</svg>")
|
|
return "".join(out)
|